[1]) {
+ try {
+ await api.updateActivity(id, update);
+ setPendingMetadataRetry(null);
+ return true;
+ } catch {
+ setPendingMetadataRetry(update);
+ setSaveMessage({ tone: "warning", text: "Your activity was saved, but its details were not. Try saving again." });
+ return false;
+ }
+ }
+
+ async function handleSave() {
+ setSaveMessage(null);
+ if (form.subjects.length === 0) {
+ setSubjectError(true);
+ setTab("details");
+ setSaveMessage({ tone: "error", text: "Choose a subject before saving." });
+ return;
+ }
+ setSubjectError(false);
+ setSaving(true);
+ const metadata = metadataFrom(form);
+ try {
+ // A metadata save that failed after the content saved is retried alone.
+ if (pendingMetadataRetry && activityIDRef.current) {
+ if (await saveMetadata(activityIDRef.current, { ...pendingMetadataRetry, ...metadata })) {
+ setSavedForm(form);
+ setLastSaved(new Date());
+ addNotification({ type: "success", message: "Activity saved." });
+ queryClient.invalidateQueries(["activity-authoring", activityIDRef.current]);
+ } else {
+ addNotification({ type: "error", message: "Your activity's details could not be saved." });
+ }
+ return;
+ }
+
+ let id = activityIDRef.current;
+ if (!id) {
+ const created = await api.createActivity(metadata);
+ id = created.data.activity.activityID;
+ activityIDRef.current = id;
+ setActivityID(id);
+ bridge.sendToken(created.data.token);
+ }
+
+ const saved = await bridge.save(options);
+ const ok = await saveMetadata(id, {
+ ...metadata,
+ contentSaved: {
+ title: saved.title,
+ library: saved.library,
+ libraryTitle: saved.libraryTitle,
+ license: saved.license,
+ licenseVersion: saved.licenseVersion,
+ authors: saved.authors,
+ },
+ });
+ setSavedOptions(saved.displayOptions ?? options);
+ if (ok) {
+ setSavedForm(form);
+ setLastSaved(new Date());
+ addNotification({ type: "success", message: "Activity saved." });
+ } else {
+ addNotification({ type: "error", message: "Your activity was saved, but its details were not." });
+ }
+ queryClient.invalidateQueries(["my-activities"]);
+ queryClient.invalidateQueries(["activity-authoring", id]);
+ if (!routeActivityID || routeActivityID !== id) history.replace(`/h5p/${id}/edit`);
+ } catch (err) {
+ const reason = err instanceof EditorSaveError ? err.reason : "server";
+ addNotification({ type: "error", message: "Your activity could not be saved." });
+ setSaveMessage({
+ tone: "error",
+ text:
+ reason === "validation"
+ ? "The editor found problems with this activity. Check the highlighted fields and save again."
+ : "Your activity could not be saved. Try again in a moment.",
+ });
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete() {
+ if (!activityIDRef.current) return;
+ setDeleting(true);
+ try {
+ await api.deleteActivity(activityIDRef.current);
+ queryClient.invalidateQueries(["my-activities"]);
+ history.push("/h5p");
+ } catch {
+ setDeleting(false);
+ setConfirmDelete(false);
+ setSaveMessage({ tone: "error", text: "This activity could not be deleted. Try again in a moment." });
+ }
+ }
+
+ function handleCancel() {
+ if (dirty) setConfirmLeave(true);
+ else history.push("/h5p");
+ }
+
+ // ------------------------------------------------------------- states
+ const unavailable = loadError && [403, 404].includes((loadError as any)?.response?.status);
+ if (unavailable || (!configLoading && (!editorOrigin || !authoringEnabled))) {
+ return (
+
+
+ }
+ title={unavailable ? "This activity isn't available to edit" : "The H5P editor isn't available"}
+ description={
+ unavailable
+ ? "It may have been deleted, or you may not be its owner or a collaborator."
+ : "Editing H5P activities is not set up on this site yet."
+ }
+ action={
+
+ Back to My H5P
+
+ }
+ />
+
+
+ );
+ }
+ if (isCreate && !canCreate) {
+ return (
+
+
+ }
+ title="Creating H5P activities is open to verified instructors"
+ description="You can still edit activities other authors share with you. To create your own, request instructor verification."
+ action={
+
+
+ Back to My H5P
+
+ {clientConfig?.instructor_verification_url && (
+ }>
+ Request verification
+
+ )}
+
+ }
+ />
+
+
+ );
+ }
+ if (loadError) {
+ return (
+
+
+ }
+ title="This activity couldn't be loaded"
+ description="Something went wrong on our end. Try again in a moment."
+ action={ window.location.reload()}>Try again }
+ />
+
+
+ );
+ }
+
+ const loadingActivity = Boolean(initialActivityID) && isLoading;
+ const editorSrc = editorOrigin
+ ? initialActivityID
+ ? data && `${editorOrigin}/h5p/${data.activity.h5pContentID}/edit`
+ : `${editorOrigin}/h5p/new/edit`
+ : undefined;
+ const statusBadge =
+ form.visibility === "private" || isCreate ? (
+
+ ) : (
+
+ );
+
+ // ------------------------------------------------------------- parts
+ // H5P's editor stylesheet caps its own page at 960px. From 1600px the
+ // column is wider than that, so the frame is centred with padding around
+ // it and the spare width goes to the details sidebar instead.
+ const editorPanel = (
+
+ {!bridge.loaded && (
+
+
+
+ )}
+ {bridge.error && (
+
+ )}
+ {editorSrc && (
+
+ )}
+
+ );
+
+ const displayOptionsCard = (
+
+
+ Display options
+
+ {DISPLAY_OPTION_CHOICES.map((o) => (
+ setOptions({ ...options, [o.key]: checked })}
+ />
+ ))}
+
+
+
+ );
+
+ const detailsFields = (
+
+
+ );
+
+ const sharingFields = (
+
+
setForm({ ...form, visibility })} />
+
+
Collaborators
+
+ {(people.length ? people : [{ uuid: "me", name: "You", role: "owner" as const }]).map((p) => (
+
+ {p.name}
+ {p.role === "owner" ? "Owner" : "Can edit"}
+
+ ))}
+
+
+
+ );
+
+ const licenseFields = (
+
+
+ License and author
+
+ {licenseInfo ? licenseInfo.text : "Not set"}
+ {authors.length ? ` · ${authors.join(", ")}` : ""}
+
+
+ Set these in the editor with the Metadata button beside the title. They update here when you save, and will follow the activity wherever it is reused.
+
+
+
setForm({ ...form, publisher: e.target.value })}
+ />
+
+ Attribution preview
+ {attribution}
+
+
+ );
+
+ const metadataSummary = {
+ details: [form.subjects.length ? form.subjects.join(", ") : "No subject", form.tags.length ? `${form.tags.length} tag${form.tags.length === 1 ? "" : "s"}` : null].filter(Boolean).join(" · "),
+ sharing: `${VISIBILITY_CHOICES.find((c) => c.value === form.visibility)?.label} · ${Math.max(people.length, 1)} ${Math.max(people.length, 1) === 1 ? "person" : "people"}`,
+ license: [licenseInfo?.text ?? "Not set", authors[0]].filter(Boolean).join(" · "),
+ };
+
+ const section = (key: string, label: string, summary: string, body: JSX.Element, open = false) => (
+
+
+
+ {label}
+ {summary}
+
+
+ {body}
+
+ );
+
+ const deleteButton =
+ !isCreate && role === "owner" ? (
+ } onClick={() => setConfirmDelete(true)}>
+ Delete H5P
+
+ ) : null;
+
+ const saveButton = (size: "md" | "lg") => (
+ }
+ >
+ {isCreate ? "Create" : "Save"}
+
+ );
+ const cancelButton = (size: "md" | "lg") => (
+
+ Cancel
+
+ );
+
+ const messages = (
+ <>
+ {saveMessage && (
+
+ )}
+ {saving ? "Saving" : saveStatus}
+ >
+ );
+
+ const dialogs = (
+ <>
+ !deleting && setConfirmDelete(false)} size="md">
+
+ Delete this H5P activity?
+
+
+
+ "{title}" will be deleted permanently. Any page that embeds it, in textbooks or in ADAPT, will show it as unavailable.
+
+
+
+ setConfirmDelete(false)} softDisabled={deleting}>
+ Keep it
+
+ }>
+ Delete H5P
+
+
+
+ setConfirmLeave(false)} size="sm">
+
+ Leave without saving?
+ Your unsaved changes will be lost.
+
+
+ setConfirmLeave(false)}>
+ Keep editing
+
+ history.push("/h5p")}>
+ Leave
+
+
+
+ >
+ );
+
+ // ------------------------------------------------------------- desktop
+ if (isDesktop) {
+ return (
+
+
+ My H5P
+ {loadingActivity ? : title}
+
+
+
+ {loadingActivity ? (
+
+ ) : (
+ {title}
+ )}
+ {!loadingActivity && statusBadge}
+
+
+ {saveStatus}
+ {cancelButton("md")}
+ {saveButton("md")}
+
+
+
{messages}
+
+
+ {loadingActivity ? (
+
+
+
+ ) : (
+ editorPanel
+ )}
+ {displayOptionsCard}
+
+
+ {loadingActivity ? (
+
+
+
+ ) : (
+
+
+ {section("details", "Details", metadataSummary.details, detailsFields, true)}
+ {section("sharing", "Sharing", metadataSummary.sharing, sharingFields, true)}
+ {section("license", "License and attribution", metadataSummary.license, licenseFields)}
+
+
+ )}
+ {deleteButton && {deleteButton}
}
+
+
+ {dialogs}
+
+ );
+ }
+
+ // ------------------------------------------------------------- mobile
+ const tabs: { key: typeof tab; label: string }[] = [
+ { key: "content", label: "Content" },
+ { key: "details", label: "Details" },
+ { key: "sharing", label: "Sharing" },
+ ];
+ return (
+
+
+
+ ‹ My H5P
+
+
+ {loadingActivity ? : {title} }
+ {!loadingActivity && statusBadge}
+
+
+
+ {tabs.map((t, i) => (
+ setTab(t.key)}
+ onKeyDown={(e) => {
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
+ const next = tabs[(i + (e.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length];
+ setTab(next.key);
+ document.getElementById(`edit-tab-${next.key}`)?.focus();
+ }
+ }}
+ className={[
+ "min-h-[44px] flex-1 px-3 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500",
+ tab === t.key ? "border-b-2 border-primary-500 text-primary-600" : "text-neutral-700",
+ ].join(" ")}
+ >
+ {t.label}
+
+ ))}
+
+
{messages}
+ {/* Hidden rather than unmounted: unmounting would reload the editor and lose work. */}
+
+
+ {loadingActivity ? (
+
+
+
+ ) : (
+ editorPanel
+ )}
+ {displayOptionsCard}
+
+
+
{detailsFields}
+
+ {section("license", "License and attribution", metadataSummary.license, licenseFields)}
+
+
+
+
{sharingFields}
+ {deleteButton &&
{deleteButton}
}
+
+
+
{saveStatus}
+
+ {cancelButton("lg")}
+ {saveButton("lg")}
+
+
+ {dialogs}
+
+ );
+};
+
+export default EditH5P;
diff --git a/client/src/screens/conductor/H5P/index.test.tsx b/client/src/screens/conductor/H5P/index.test.tsx
new file mode 100644
index 000000000..85f11580a
--- /dev/null
+++ b/client/src/screens/conductor/H5P/index.test.tsx
@@ -0,0 +1,177 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter } from "react-router-dom";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import MyH5P from "./index";
+import { MyActivitiesResult } from "../../../types";
+
+const mockGetMine = vi.hoisted(() => vi.fn());
+const mockMediaQuery = vi.hoisted(() => vi.fn(() => false));
+
+vi.mock("../../../api", () => ({
+ default: { getMyActivities: mockGetMine },
+}));
+
+vi.mock("../../../state/hooks", () => ({
+ useTypedSelector: (select: (state: any) => unknown) => select({ user: { verifiedInstructor: true } }),
+}));
+vi.mock("usehooks-ts", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useMediaQuery: mockMediaQuery,
+}));
+
+const twoActivities: MyActivitiesResult = {
+ activities: [
+ {
+ activityID: "1001",
+ title: "Regulations on Fintech",
+ library: "H5P.InteractiveVideo 1.27",
+ libraryTitle: "Interactive Video",
+ description: "A short video on fintech rules.
",
+ authors: ["Sophie K"],
+ subjects: ["Business", "Economics"],
+ license: { name: "CC BY", version: "4.0" },
+ language: "en",
+ tags: ["fintech", "law", "banking", "policy", "regulation"],
+ visibility: "public",
+ role: "owner",
+ updatedAt: new Date(Date.now() - 2 * 86400000).toISOString(),
+ },
+ {
+ activityID: "1002",
+ title: "Shared Quiz",
+ library: "H5P.QuestionSet 1.20",
+ visibility: "unlisted",
+ role: "collaborator",
+ },
+ ],
+ counts: { all: 2, public: 1, unlisted: 1, private: 0 },
+ overallTotal: 2,
+ total_items: 2,
+};
+
+const reply = (data: MyActivitiesResult) => ({ data });
+
+function renderPage() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+
+
+
+ );
+}
+
+describe("My H5P", () => {
+ beforeEach(() => {
+ mockGetMine.mockReset();
+ mockMediaQuery.mockReturnValue(false);
+ });
+
+ it("shows cards with content type, visibility, role and a link to the public page", async () => {
+ mockGetMine.mockResolvedValue(reply(twoActivities));
+ renderPage();
+
+ const link = await screen.findByRole("link", { name: "Regulations on Fintech" });
+ expect(link).toHaveAttribute("href", "/interactive/1001");
+ expect(screen.getByText("Interactive Video")).toBeInTheDocument();
+ expect(screen.getByText("Question Set")).toBeInTheDocument();
+ expect(screen.getByText("Collaborator")).toBeInTheDocument();
+ const sharedCard = screen.getByRole("link", { name: "Shared Quiz" }).closest("article") as HTMLElement;
+ expect(within(sharedCard).getByText("Unlisted")).toBeInTheDocument();
+ expect(screen.getByText(/Updated 2 days ago/)).toBeInTheDocument();
+ expect(screen.getByText(/2 activities · Sorted by last updated/)).toBeInTheDocument();
+ // Authoring has not shipped, so nothing links to the editor yet.
+ expect(screen.queryByRole("link", { name: /New H5P|Edit/ })).toBeNull();
+ });
+
+ it("shows each card's description, author, subjects, license, language and tags, and leaves out what is unset", async () => {
+ mockGetMine.mockResolvedValue(reply(twoActivities));
+ renderPage();
+
+ const card = (await screen.findByRole("link", { name: "Regulations on Fintech" })).closest("article") as HTMLElement;
+ expect(within(card).getByText("A short video on fintech rules.")).toBeInTheDocument();
+ expect(within(card).getByText("Sophie K")).toBeInTheDocument();
+ expect(within(card).getByText("Business, Economics")).toBeInTheDocument();
+ expect(within(card).getByText("CC BY 4.0")).toBeInTheDocument();
+ expect(within(card).getByText("English")).toBeInTheDocument();
+ expect(within(card).getByText("Subjects")).toHaveClass("sr-only");
+ const tags = within(card).getByRole("list", { name: "Tags" });
+ expect(within(tags).getAllByRole("listitem")).toHaveLength(4);
+ expect(within(tags).getByText("+2")).toBeInTheDocument();
+
+ const shared = screen.getByRole("link", { name: "Shared Quiz" }).closest("article") as HTMLElement;
+ expect(within(shared).queryByRole("term")).toBeNull();
+ expect(within(shared).queryByRole("list", { name: "Tags" })).toBeNull();
+ });
+
+ it("labels each visibility option with its count", async () => {
+ mockGetMine.mockResolvedValue(reply(twoActivities));
+ renderPage();
+ const group = await screen.findByRole("group", { name: "Visibility" });
+ expect(within(group).getByRole("radio", { name: "All 2" })).toBeChecked();
+ expect(within(group).getByRole("radio", { name: "Private 0" })).toBeInTheDocument();
+ });
+
+ it("asks the server for the chosen filter and a debounced search", async () => {
+ mockGetMine.mockResolvedValue(reply(twoActivities));
+ renderPage();
+ await userEvent.click(await screen.findByRole("radio", { name: "Unlisted 1" }));
+ await waitFor(() =>
+ expect(mockGetMine).toHaveBeenLastCalledWith(expect.objectContaining({ visibility: "unlisted", page: 1 }))
+ );
+ await userEvent.type(screen.getByRole("searchbox", { name: "Search" }), "quiz");
+ await waitFor(() =>
+ expect(mockGetMine).toHaveBeenLastCalledWith(expect.objectContaining({ query: "quiz", visibility: "unlisted" }))
+ );
+ // Debounced: typing four letters does not send four searches.
+ const queries = mockGetMine.mock.calls.map(([p]) => p.query).filter(Boolean);
+ expect(queries).toEqual(["quiz"]);
+ });
+
+ it("shows the no-activities state without a toolbar", async () => {
+ mockGetMine.mockResolvedValue(
+ reply({ activities: [], counts: { all: 0, public: 0, unlisted: 0, private: 0 }, overallTotal: 0, total_items: 0 })
+ );
+ renderPage();
+ expect(await screen.findByText("No H5P activities yet")).toBeInTheDocument();
+ expect(screen.queryByRole("searchbox")).toBeNull();
+ });
+
+ it("explains an empty search and clears it", async () => {
+ mockGetMine.mockImplementation(async (p) =>
+ p.query
+ ? reply({ activities: [], counts: { all: 0, public: 0, unlisted: 0, private: 0 }, overallTotal: 8, total_items: 0 })
+ : reply(twoActivities)
+ );
+ renderPage();
+ await userEvent.type(await screen.findByRole("searchbox", { name: "Search" }), "photosynthesis");
+ expect(await screen.findByText('No activities match "photosynthesis"')).toBeInTheDocument();
+ expect(screen.getByText(/clear filters to search all 8 of your activities/)).toBeInTheDocument();
+ expect(screen.getByText(/0 of 8 activities/)).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole("button", { name: "Clear filters" }));
+ expect(await screen.findByRole("link", { name: "Shared Quiz" })).toBeInTheDocument();
+ expect(screen.getByRole("searchbox", { name: "Search" })).toHaveValue("");
+ });
+
+ it("uses compact rows with a more-actions menu on phones", async () => {
+ mockMediaQuery.mockReturnValue(true);
+ mockGetMine.mockResolvedValue(reply(twoActivities));
+ renderPage();
+ expect(await screen.findByRole("link", { name: "Shared Quiz" })).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "More actions for Shared Quiz" }));
+ expect(await screen.findByRole("menuitem", { name: /View public page/ })).toBeInTheDocument();
+ });
+
+ it("offers a retry when loading fails", async () => {
+ mockGetMine.mockRejectedValueOnce(new Error("boom"));
+ renderPage();
+ expect(await screen.findByText("Your activities couldn't be loaded")).toBeInTheDocument();
+ mockGetMine.mockResolvedValue(reply(twoActivities));
+ await userEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(await screen.findByRole("link", { name: "Shared Quiz" })).toBeInTheDocument();
+ });
+});
diff --git a/client/src/screens/conductor/H5P/index.tsx b/client/src/screens/conductor/H5P/index.tsx
new file mode 100644
index 000000000..781b5d4b2
--- /dev/null
+++ b/client/src/screens/conductor/H5P/index.tsx
@@ -0,0 +1,489 @@
+import { useEffect, useState } from "react";
+import { Link, useHistory } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import { useDebounceValue, useDocumentTitle, useMediaQuery } from "usehooks-ts";
+import { formatDistanceToNow, parseISO } from "date-fns";
+import {
+ Badge,
+ Button,
+ Card,
+ EmptyState,
+ Heading,
+ Input,
+ Menu,
+ Pagination,
+ Select,
+ Skeleton,
+ Spinner,
+ Text,
+ VisuallyHidden,
+} from "@libretexts/davis-react";
+import {
+ IconAlertTriangle,
+ IconBox,
+ IconDotsVertical,
+ IconEdit,
+ IconExternalLink,
+ IconFilterOff,
+ IconLink,
+ IconPlus,
+ IconSearch,
+ IconSearchOff,
+ IconUpload,
+} from "@tabler/icons-react";
+import api from "../../../api";
+import useH5PAuthoringEnabled from "../../../hooks/useH5PAuthoringEnabled";
+import useCanCreateActivities from "../../../hooks/useCanCreateActivities";
+import { ActivityVisibilityLevel, MyActivitiesParams, MyActivity } from "../../../types";
+import {
+ getContentTypeIcon,
+ getContentTypeName,
+ toPlainText,
+} from "../../../utils/activityHelpers";
+import ActivityCardDetails from "../../../components/commons/ActivityCardDetails";
+
+const PAGE_SIZE = 24;
+
+/** Matches Tailwind's `sm`: below it, cards become compact rows. */
+const ROWS_QUERY = "(max-width: 639px)";
+
+type VisibilityFilter = MyActivitiesParams["visibility"];
+
+const VISIBILITY_OPTIONS: { value: VisibilityFilter; label: string }[] = [
+ { value: "all", label: "All" },
+ { value: "public", label: "Public" },
+ { value: "unlisted", label: "Unlisted" },
+ { value: "private", label: "Private" },
+];
+
+const VISIBILITY_BADGES: Record = {
+ public: { label: "Public", variant: "success" },
+ unlisted: { label: "Unlisted", variant: "warning" },
+ private: { label: "Private", variant: "default" },
+};
+
+const SORT_OPTIONS = [
+ { value: "updated", label: "Last updated" },
+ { value: "title", label: "Title" },
+ { value: "created", label: "Date created" },
+];
+
+function updatedLabel(activity: MyActivity): string | undefined {
+ if (!activity.updatedAt) return undefined;
+ return `Updated ${formatDistanceToNow(parseISO(activity.updatedAt), { addSuffix: true })}`;
+}
+
+/**
+ * The Visibility filter, drawn as a segmented control. Native radio inputs
+ * give it arrow-key navigation and radio-group semantics for free; each
+ * option shows how many results choosing it would give.
+ */
+const VisibilityFilterControl = ({
+ value,
+ counts,
+ onChange,
+ compact,
+}: {
+ value: VisibilityFilter;
+ counts?: Record;
+ onChange: (value: VisibilityFilter) => void;
+ compact: boolean;
+}) => (
+
+ Visibility
+
+ {VISIBILITY_OPTIONS.map((option, i) => {
+ const checked = value === option.value;
+ const count = counts?.[option.value];
+ return (
+ 0 ? "border-l border-neutral-500" : "",
+ checked
+ ? "border-primary-500 bg-primary-500 text-white"
+ : "border-neutral-500 bg-white text-neutral-900 hover:bg-neutral-100",
+ ].join(" ")}
+ >
+ onChange(option.value)}
+ className="sr-only"
+ />
+ {/* The space keeps the accessible name "All 8", not "All8". */}
+ {count !== undefined ? `${option.label} ` : option.label}
+ {count !== undefined && {count} }
+
+ );
+ })}
+
+
+);
+
+const ContentTypeIcon = ({ activity, size }: { activity: MyActivity; size: number }) => {
+ const TypeIcon = getContentTypeIcon(activity.library);
+ return ;
+};
+
+const ActivityBadges = ({ activity, size }: { activity: MyActivity; size?: "sm" | "md" }) => {
+ const visibility = VISIBILITY_BADGES[activity.visibility];
+ return (
+
+
+ {activity.role === "collaborator" && }
+
+ );
+};
+
+/** A grid card, frame 3a. The title is the link; Edit appears once authoring ships. */
+const ActivityCard = ({ activity }: { activity: MyActivity }) => {
+ const editingAvailable = useH5PAuthoringEnabled();
+ const typeName = getContentTypeName(activity);
+ const description = toPlainText(activity.description);
+ const updated = updatedLabel(activity);
+ return (
+
+
+
+
+ {typeName && (
+
+ {typeName}
+
+ )}
+
+
+ {activity.title}
+
+
+ {description &&
{description} }
+
+
+
+ {(updated || editingAvailable) && (
+
+ {updated && (
+
+ {updated}
+
+ )}
+ {editingAvailable && (
+ }
+ aria-label={`Edit ${activity.title}`}
+ >
+ Edit
+
+ )}
+
+ )}
+
+
+ );
+};
+
+/** A compact row for phones, frame 4d. Secondary actions move to the ⋯ menu. */
+const ActivityRow = ({ activity }: { activity: MyActivity }) => {
+ const history = useHistory();
+ const editingAvailable = useH5PAuthoringEnabled();
+ const typeName = getContentTypeName(activity);
+ const description = toPlainText(activity.description);
+ const updated = activity.updatedAt
+ ? formatDistanceToNow(parseISO(activity.updatedAt), { addSuffix: true })
+ : undefined;
+ return (
+
+
+
+
+
+
+ {activity.title}
+
+ {(typeName || updated) && (
+
+ {[typeName, updated].filter(Boolean).join(" · ")}
+
+ )}
+ {description &&
{description} }
+
+
+
+
+
+
+
+
+ } onClick={() => history.push(`/interactive/${activity.activityID}`)}>
+ View public page
+
+ }
+ onClick={() => navigator.clipboard?.writeText(`${window.location.origin}/interactive/${activity.activityID}`)}
+ >
+ Copy link
+
+ {editingAvailable && (
+ } onClick={() => history.push(`/h5p/${activity.activityID}/edit`)}>
+ Edit
+
+ )}
+
+
+
+ );
+};
+
+const CreateActions = ({ stacked }: { stacked?: boolean }) => {
+ const canCreate = useCanCreateActivities();
+ return canCreate ? (
+
+ } fullWidth={stacked}>
+ Upload .h5p file
+
+ } fullWidth={stacked}>
+ New H5P
+
+
+ ) : null;
+};
+
+const LoadingGrid = ({ rows }: { rows: boolean }) =>
+ rows ? (
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ ) : (
+
+ {Array.from({ length: 8 }, (_, i) => (
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+
+/**
+ * My H5P: the activities the signed-in user created or collaborates on.
+ *
+ * Search, the visibility filter, sorting and paging all run on the server, so
+ * the counts and pages stay right for users with hundreds of activities.
+ */
+const MyH5P = () => {
+ useDocumentTitle("LibreTexts Conductor | My H5P");
+ const authoringEnabled = useH5PAuthoringEnabled();
+ const canCreate = useCanCreateActivities();
+ const rows = useMediaQuery(ROWS_QUERY);
+
+ const [search, setSearch] = useState("");
+ const [debouncedSearch] = useDebounceValue(search.trim(), 300);
+ const [visibility, setVisibility] = useState("all");
+ const [sort, setSort] = useState("updated");
+ const [page, setPage] = useState(1);
+
+ // A new search, filter or sort starts again from the first page.
+ useEffect(() => setPage(1), [debouncedSearch, visibility, sort]);
+
+ const params: MyActivitiesParams = {
+ query: debouncedSearch || undefined,
+ visibility,
+ sort,
+ page,
+ limit: PAGE_SIZE,
+ };
+ const { data, isLoading, isFetching, isError, refetch } = useQuery({
+ queryKey: ["my-activities", params],
+ queryFn: async () => (await api.getMyActivities(params)).data,
+ keepPreviousData: true,
+ refetchOnWindowFocus: false,
+ });
+
+ const filtered = Boolean(debouncedSearch) || visibility !== "all";
+ const noActivitiesAtAll = data && data.overallTotal === 0;
+ const totalPages = data ? Math.max(1, Math.ceil(data.total_items / PAGE_SIZE)) : 1;
+ const sortLabel = SORT_OPTIONS.find((o) => o.value === sort)?.label.toLowerCase();
+
+ const clearFilters = () => {
+ setSearch("");
+ setVisibility("all");
+ };
+
+ const noResultsTitle = (() => {
+ const level = visibility === "all" ? "" : `${visibility} `;
+ return debouncedSearch
+ ? `No ${level}activities match "${debouncedSearch}"`
+ : `No ${level}activities`;
+ })();
+
+ return (
+
+
+
+ {isError ? (
+
+ }
+ title="Your activities couldn't be loaded"
+ description="Something went wrong on our end. Try again in a moment."
+ action={
+ refetch()}>
+ Try again
+
+ }
+ />
+
+ ) : noActivitiesAtAll ? (
+
+ }
+ title="No H5P activities yet"
+ description={
+ canCreate
+ ? rows
+ ? "Create an interactive activity, or upload an .h5p file you already have."
+ : "Create an interactive activity, or upload an .h5p file you already have. Activities shared with you will also appear here."
+ : authoringEnabled
+ ? "Activities shared with you appear here. Creating activities is open to verified instructors."
+ : "Activities you create or that are shared with you appear here."
+ }
+ action={ }
+ />
+
+ ) : (
+ <>
+
+
+ setSearch(e.target.value)}
+ placeholder={rows ? "Search my H5P" : "Search by title, tag, or content type"}
+ leftIcon={ }
+ className="-mt-1.5!"
+ />
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ setSort(e.target.value as MyActivitiesParams["sort"])}
+ />
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+ data && (
+
+ {filtered
+ ? `${data.total_items} of ${data.overallTotal} activities`
+ : `${data.total_items} ${data.total_items === 1 ? "activity" : "activities"}`}
+ {` · Sorted by ${sortLabel}`}
+
+ )
+ )}
+ {isFetching && !isLoading && Updating results }
+
+
+
+ {isLoading ? (
+
+ ) : data && data.activities.length === 0 ? (
+
+ }
+ title={noResultsTitle}
+ description={`Try a different keyword, or clear filters to search all ${data.overallTotal} of your activities.`}
+ action={
+ } onClick={clearFilters}>
+ Clear filters
+
+ }
+ />
+
+ ) : rows ? (
+
+ {data?.activities.map((a) => )}
+
+ ) : (
+
+ {data?.activities.map((a) => )}
+
+ )}
+
+
+ {data && totalPages > 1 && (
+
+
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default MyH5P;
diff --git a/client/src/screens/conductor/H5P/useEditorBridge.ts b/client/src/screens/conductor/H5P/useEditorBridge.ts
new file mode 100644
index 000000000..55144448c
--- /dev/null
+++ b/client/src/screens/conductor/H5P/useEditorBridge.ts
@@ -0,0 +1,151 @@
+import { RefObject, useCallback, useEffect, useRef, useState } from "react";
+import { ActivityDisplayOptions, EditorSavedContent } from "../../../types";
+
+/**
+ * Conductor's side of the editor protocol with the H5P service's editor page
+ * (H5P_AUTHORING_DESIGN.md, section 3). The editor runs in an iframe served by
+ * the H5P service; everything crosses postMessage, checked against that
+ * service's origin and the iframe's own window.
+ */
+
+export type EditorSaved = EditorSavedContent & {
+ contentId: string;
+ displayOptions?: ActivityDisplayOptions;
+};
+
+export class EditorSaveError extends Error {
+ constructor(readonly reason: string, readonly status?: number) {
+ super(`Editor save failed: ${reason}`);
+ }
+}
+
+type Pending = {
+ resolve: (saved: EditorSaved) => void;
+ reject: (err: EditorSaveError) => void;
+ timer: ReturnType;
+};
+
+/** A save that has not answered in this long is reported as failed. */
+const SAVE_TIMEOUT_MS = 90_000;
+
+export function useEditorBridge({
+ iframeRef,
+ editorOrigin,
+ fetchToken,
+}: {
+ iframeRef: RefObject;
+ editorOrigin?: string;
+ /** Returns a fresh token for whatever the screen is editing right now. */
+ fetchToken: () => Promise;
+}) {
+ const [loaded, setLoaded] = useState(false);
+ const [title, setTitle] = useState("");
+ const [dirty, setDirty] = useState(false);
+ const [height, setHeight] = useState(null);
+ const [displayOptions, setDisplayOptions] = useState(null);
+ const [error, setError] = useState(null);
+
+ const fetchTokenRef = useRef(fetchToken);
+ fetchTokenRef.current = fetchToken;
+ const pending = useRef(new Map());
+
+ const post = useCallback(
+ (message: Record) => {
+ if (!editorOrigin) return;
+ iframeRef.current?.contentWindow?.postMessage(message, editorOrigin);
+ },
+ [editorOrigin, iframeRef]
+ );
+
+ const sendToken = useCallback((token: string) => post({ type: "token", token }), [post]);
+
+ const sendFreshToken = useCallback(async () => {
+ try {
+ sendToken(await fetchTokenRef.current());
+ } catch {
+ setError("token");
+ }
+ }, [sendToken]);
+
+ useEffect(() => {
+ if (!editorOrigin) return;
+ const onMessage = (event: MessageEvent) => {
+ if (event.origin !== editorOrigin) return;
+ if (event.source !== iframeRef.current?.contentWindow) return;
+ const m = event.data;
+ if (!m || typeof m !== "object") return;
+ switch (m.type) {
+ case "ready":
+ case "token-expiring":
+ void sendFreshToken();
+ break;
+ case "loaded":
+ setLoaded(true);
+ if (typeof m.title === "string") setTitle(m.title);
+ if (m.displayOptions) setDisplayOptions(m.displayOptions);
+ break;
+ case "title":
+ if (typeof m.title === "string") setTitle(m.title);
+ break;
+ case "dirty":
+ setDirty(Boolean(m.dirty));
+ break;
+ case "height":
+ if (typeof m.height === "number" && m.height > 0) setHeight(m.height);
+ break;
+ case "saved": {
+ const p = pending.current.get(m.requestId);
+ if (!p) break;
+ clearTimeout(p.timer);
+ pending.current.delete(m.requestId);
+ setDirty(false);
+ if (m.displayOptions) setDisplayOptions(m.displayOptions);
+ p.resolve(m as EditorSaved);
+ break;
+ }
+ case "save-failed": {
+ const p = pending.current.get(m.requestId);
+ if (!p) break;
+ clearTimeout(p.timer);
+ pending.current.delete(m.requestId);
+ p.reject(new EditorSaveError(String(m.reason ?? "unknown"), m.status));
+ break;
+ }
+ case "error":
+ setError(String(m.reason ?? "unknown"));
+ break;
+ }
+ };
+ window.addEventListener("message", onMessage);
+ return () => window.removeEventListener("message", onMessage);
+ }, [editorOrigin, iframeRef, sendFreshToken]);
+
+ // Reject anything still waiting if the screen goes away.
+ useEffect(() => {
+ const waiting = pending.current;
+ return () => {
+ waiting.forEach((p) => {
+ clearTimeout(p.timer);
+ p.reject(new EditorSaveError("unmounted"));
+ });
+ waiting.clear();
+ };
+ }, []);
+
+ /** Asks the editor to validate and save; resolves with what it saved. */
+ const save = useCallback(
+ (options?: ActivityDisplayOptions) =>
+ new Promise((resolve, reject) => {
+ const requestId = crypto.randomUUID();
+ const timer = setTimeout(() => {
+ pending.current.delete(requestId);
+ reject(new EditorSaveError("timeout"));
+ }, SAVE_TIMEOUT_MS);
+ pending.current.set(requestId, { resolve, reject, timer });
+ post({ type: "save", requestId, ...(options ? { displayOptions: options } : {}) });
+ }),
+ [post]
+ );
+
+ return { loaded, title, dirty, height, displayOptions, error, save, sendToken };
+}
diff --git a/client/src/types/Activity.ts b/client/src/types/Activity.ts
new file mode 100644
index 000000000..3d67244ad
--- /dev/null
+++ b/client/src/types/Activity.ts
@@ -0,0 +1,139 @@
+/**
+ * An H5P activity as the public activity page receives it. See
+ * `getActivity` in server/api/activities.ts for what is left out and why.
+ */
+export type CommonsActivity = {
+ activityID: string;
+ h5pContentID: number;
+ title: string;
+ description?: string;
+ /** H5P machine name and version, e.g. "H5P.InteractiveVideo 1.27". */
+ library?: string;
+ /** H5P library display title, e.g. "Interactive Video". */
+ libraryTitle?: string;
+ language?: string;
+ /** Credit to display: H5P metadata authors, else the owning user. */
+ authors: string[];
+ source?: string;
+ /** Institution or publisher credited for the activity. */
+ publisher?: string;
+ subjects?: string[];
+ tags?: string[];
+ visibility: "public" | "private";
+ includeInLibrary?: boolean;
+ /** H5P license code, e.g. "CC BY", with a version such as "4.0". */
+ license?: {
+ name?: string;
+ version?: string;
+ additionalTerms?: string;
+ };
+ createdAt?: string;
+ updatedAt?: string;
+};
+
+/** What an activity card shows below its title. Each field is optional. */
+export type ActivityCardFields = Partial<
+ Pick
+>;
+
+/**
+ * One row of the public listing. The server sends the stored document minus
+ * collaborators and migration bookkeeping, plus `authors`, so only the fields
+ * the Interactives page reads are typed here.
+ */
+export type CommonsActivitySummary = Pick<
+ CommonsActivity,
+ "activityID" | "title" | "library" | "libraryTitle" | "updatedAt"
+> &
+ ActivityCardFields;
+
+export type CommonsActivitiesParams = {
+ query?: string;
+ sort: "updated" | "title" | "created";
+ sortDirection: "ascending" | "descending";
+ page: number;
+ limit: number;
+};
+
+/** The three visibility levels the UI shows. See `getMyActivities` on the server. */
+export type ActivityVisibilityLevel = "public" | "unlisted" | "private";
+
+/** One card on My H5P. */
+export type MyActivity = ActivityCardFields & {
+ activityID: string;
+ title: string;
+ library?: string;
+ libraryTitle?: string;
+ visibility: ActivityVisibilityLevel;
+ role: "owner" | "collaborator";
+ createdAt?: string;
+ updatedAt?: string;
+};
+
+export type MyActivitiesParams = {
+ query?: string;
+ visibility: "all" | ActivityVisibilityLevel;
+ sort: "updated" | "title" | "created";
+ page: number;
+ limit: number;
+};
+
+export type MyActivitiesResult = {
+ activities: MyActivity[];
+ /** Counts for the current search, per visibility level. */
+ counts: Record<"all" | ActivityVisibilityLevel, number>;
+ /** Everything the user can see on My H5P, ignoring search and filters. */
+ overallTotal: number;
+ total_items: number;
+};
+
+/** An activity as its create/edit screen loads it. Owners and collaborators only. */
+export type EditableActivity = {
+ activityID: string;
+ h5pContentID: number;
+ title: string;
+ description?: string;
+ library?: string;
+ libraryTitle?: string;
+ language?: string;
+ /** H5P metadata authors, as the JSON text Studio stored. */
+ h5pAuthors?: string;
+ source?: string;
+ publisher?: string;
+ subjects?: string[];
+ tags?: string[];
+ visibilityLevel: ActivityVisibilityLevel;
+ license?: CommonsActivity["license"];
+ pendingFirstSave?: boolean;
+ createdAt?: string;
+ updatedAt?: string;
+};
+
+export type ActivityPerson = { uuid: string; name: string; role: "owner" | "collaborator" };
+
+/** Metadata the create/edit screen sends. Every field optional on update. */
+export type ActivityMetadataInput = {
+ title?: string;
+ description?: string;
+ subjects?: string[];
+ tags?: string[];
+ language?: string;
+ publisher?: string;
+ visibility?: ActivityVisibilityLevel;
+};
+
+/** What the H5P editor's `saved` message reports, passed on to Conductor. */
+export type EditorSavedContent = {
+ title: string;
+ library: string;
+ /** The library.json title, e.g. "Interactive Video". Absent if h5p-service couldn't read it. */
+ libraryTitle?: string;
+ license?: string;
+ licenseVersion?: string;
+ authors?: { name: string; role?: string }[];
+};
+
+export type EditorToken = { token: string; expiresAt: string; ttlSeconds: number };
+
+/** The four Display options checkboxes. `true` shows the feature. */
+export type ActivityDisplayOptions = { frame: boolean; download: boolean; embed: boolean; copyright: boolean };
diff --git a/client/src/types/ClientConfig.ts b/client/src/types/ClientConfig.ts
index c0db9b0c4..f7712a205 100644
--- a/client/src/types/ClientConfig.ts
+++ b/client/src/types/ClientConfig.ts
@@ -3,4 +3,6 @@ export type ClientConfig = {
main_commons_url: string;
instructor_verification_url: string;
central_identity_base_url: string;
+ h5p_embed_origin: string;
+ h5p_authoring_enabled?: boolean;
};
\ No newline at end of file
diff --git a/client/src/types/index.ts b/client/src/types/index.ts
index 58caaea14..b7eea8ad3 100644
--- a/client/src/types/index.ts
+++ b/client/src/types/index.ts
@@ -1,6 +1,7 @@
export * from "./Announcement";
export * from "./AICoAuthor";
export * from "./Author";
+export * from "./Activity";
export * from "./AssetTagging";
export * from "./a11y";
export * from "./BaseDocument";
diff --git a/client/src/utils/activityHelpers.test.ts b/client/src/utils/activityHelpers.test.ts
new file mode 100644
index 000000000..eb72cbf96
--- /dev/null
+++ b/client/src/utils/activityHelpers.test.ts
@@ -0,0 +1,77 @@
+import { describe, it, expect } from "vitest";
+import { IconBox, IconPlayerPlay } from "@tabler/icons-react";
+import {
+ buildActivityAttribution,
+ buildActivityEmbedCode,
+ getActivityEmbedURL,
+ getActivityLicense,
+ getContentTypeIcon,
+ getContentTypeName,
+} from "./activityHelpers";
+
+describe("getActivityLicense", () => {
+ it("builds a Creative Commons deed link from H5P's code and version", () => {
+ expect(getActivityLicense({ name: "CC BY-NC-SA", version: "4.0" })).toEqual({
+ text: "CC BY-NC-SA 4.0",
+ url: "https://creativecommons.org/licenses/by-nc-sa/4.0/",
+ });
+ });
+
+ it("shows nothing for undisclosed or missing licenses", () => {
+ expect(getActivityLicense({ name: "U" })).toBeUndefined();
+ expect(getActivityLicense(undefined)).toBeUndefined();
+ expect(getActivityLicense({ name: " " })).toBeUndefined();
+ });
+
+ it("marks public domain codes", () => {
+ expect(getActivityLicense({ name: "PD" })?.publicDomain).toBe(true);
+ expect(getActivityLicense({ name: "CC0 1.0" })?.url).toContain("publicdomain/zero");
+ });
+});
+
+describe("buildActivityAttribution", () => {
+ const base = { title: "Regulations on Fintech", authors: ["Sophie K"], publisher: "WWU" };
+
+ it("reads as the designed sentence", () => {
+ expect(
+ buildActivityAttribution({ ...base, license: { name: "CC BY", version: "4.0" } })
+ ).toBe(
+ "Regulations on Fintech by Sophie K, WWU, is shared under a CC BY 4.0 license, except where otherwise noted."
+ );
+ });
+
+ it("drops the clauses it has no data for", () => {
+ expect(buildActivityAttribution({ title: "Quiz", authors: [] })).toBe("Quiz.");
+ expect(
+ buildActivityAttribution({ title: "Quiz", authors: [], license: { name: "PD" } })
+ ).toBe("Quiz, is in the public domain (Public Domain).");
+ });
+});
+
+describe("content types", () => {
+ it("maps by machine name and falls back to the generic icon", () => {
+ expect(getContentTypeIcon("H5P.InteractiveVideo 1.27")).toBe(IconPlayerPlay);
+ expect(getContentTypeIcon("H5P.SomethingNew 1.0")).toBe(IconBox);
+ expect(getContentTypeIcon(undefined)).toBe(IconBox);
+ });
+
+ it("prefers Studio's library title, else humanises the machine name", () => {
+ expect(getContentTypeName({ libraryTitle: "Interactive Video", library: "H5P.InteractiveVideo 1.27" })).toBe(
+ "Interactive Video"
+ );
+ expect(getContentTypeName({ library: "H5P.DragQuestion 1.14" })).toBe("Drag Question");
+ expect(getContentTypeName({})).toBeUndefined();
+ });
+});
+
+describe("embed code", () => {
+ it("uses the public embed URL contract and the canonical resizer path", () => {
+ expect(getActivityEmbedURL("https://studio.libretexts.org/", 31483)).toBe(
+ "https://studio.libretexts.org/h5p/31483/embed"
+ );
+ const code = buildActivityEmbedCode("https://studio.libretexts.org", 31483, 'Say "hi" & ');
+ expect(code).toContain('src="https://studio.libretexts.org/h5p/31483/embed"');
+ expect(code).toContain('title="Say "hi" & <go>"');
+ expect(code).toContain('src="https://studio.libretexts.org/h5p/core/js/h5p-resizer.js"');
+ });
+});
diff --git a/client/src/utils/activityHelpers.ts b/client/src/utils/activityHelpers.ts
new file mode 100644
index 000000000..029a1a0e4
--- /dev/null
+++ b/client/src/utils/activityHelpers.ts
@@ -0,0 +1,206 @@
+import {
+ Icon,
+ IconBook,
+ IconBox,
+ IconCards,
+ IconCircleCheck,
+ IconClipboardList,
+ IconColumns,
+ IconDragDrop,
+ IconDragDrop2,
+ IconForms,
+ IconHighlight,
+ IconLayoutList,
+ IconListCheck,
+ IconListDetails,
+ IconMapPin,
+ IconMathSymbols,
+ IconMicrophone,
+ IconPhoto,
+ IconPlayerPlay,
+ IconPresentation,
+ IconSlideshow,
+ IconTimeline,
+ IconToggleRight,
+ IconVolume,
+ IconWriting,
+} from "@tabler/icons-react";
+import { CommonsActivity } from "../types";
+
+/**
+ * Tabler icon per H5P content type, keyed by library machine name because
+ * display titles vary between library versions. Anything unmapped gets the
+ * generic box.
+ */
+const CONTENT_TYPE_ICONS: Record = {
+ "H5P.InteractiveVideo": IconPlayerPlay,
+ "H5P.QuestionSet": IconListCheck,
+ "H5P.CoursePresentation": IconPresentation,
+ "H5P.InteractiveBook": IconBook,
+ "H5P.MultiChoice": IconCircleCheck,
+ "H5P.SingleChoiceSet": IconCircleCheck,
+ "H5P.TrueFalse": IconToggleRight,
+ "H5P.Blanks": IconForms,
+ "H5P.DragText": IconDragDrop,
+ "H5P.DragQuestion": IconDragDrop2,
+ "H5P.MarkTheWords": IconHighlight,
+ "H5P.Essay": IconWriting,
+ "H5P.Accordion": IconLayoutList,
+ "H5P.Column": IconColumns,
+ "H5P.Summary": IconListDetails,
+ "H5P.Flashcards": IconCards,
+ "H5P.Dialogcards": IconCards,
+ "H5P.ImageHotspots": IconMapPin,
+ "H5P.ImageHotspotQuestion": IconMapPin,
+ "H5P.Timeline": IconTimeline,
+ "H5P.Questionnaire": IconClipboardList,
+ "H5P.ArithmeticQuiz": IconMathSymbols,
+ "H5P.SpeakTheWords": IconMicrophone,
+ "H5P.SpeakTheWordsSet": IconMicrophone,
+ "H5P.Audio": IconVolume,
+ "H5P.ImageSlider": IconSlideshow,
+ "H5P.Agamotto": IconSlideshow,
+ "H5P.Image": IconPhoto,
+};
+
+/** "H5P.InteractiveVideo 1.27" -> "H5P.InteractiveVideo". */
+function machineName(library?: string): string {
+ return (library ?? "").split(" ")[0];
+}
+
+export function getContentTypeIcon(library?: string): Icon {
+ return CONTENT_TYPE_ICONS[machineName(library)] ?? IconBox;
+}
+
+/**
+ * Display name of the content type. Studio's library title when the import
+ * carried one, else a readable form of the machine name.
+ */
+export function getContentTypeName(
+ activity: Pick
+): string | undefined {
+ if (activity.libraryTitle) return activity.libraryTitle;
+ const name = machineName(activity.library).replace(/^H5P\./, "");
+ return name ? name.replace(/([a-z])([A-Z])/g, "$1 $2") : undefined;
+}
+
+/**
+ * Migrated Studio descriptions are Drupal rich text. Cards show them as plain
+ * text, so markup never reaches a listing. DOMParser builds an inert document
+ * and runs no scripts.
+ */
+export function toPlainText(value?: string): string {
+ if (!value) return "";
+ if (!/<[a-z][\s\S]*>/i.test(value)) return value.trim();
+ return new DOMParser().parseFromString(value, "text/html").body.textContent?.trim() ?? "";
+}
+
+type LicenseInfo = { text: string; url?: string; publicDomain?: boolean };
+
+const CC_PATHS: Record = {
+ "CC BY": "by",
+ "CC BY-SA": "by-sa",
+ "CC BY-ND": "by-nd",
+ "CC BY-NC": "by-nc",
+ "CC BY-NC-SA": "by-nc-sa",
+ "CC BY-NC-ND": "by-nc-nd",
+};
+
+/**
+ * Presentation of an H5P license. H5P uses its own codes ("CC BY" with a
+ * separate version, "PD", "C", "U"), not Conductor's book license keys, so the
+ * book helpers do not apply. "U" (undisclosed) and an empty value show nothing.
+ */
+export function getActivityLicense(
+ license?: CommonsActivity["license"]
+): LicenseInfo | undefined {
+ const code = license?.name?.trim();
+ if (!code || code === "U") return undefined;
+ const version = license?.version?.trim();
+
+ if (CC_PATHS[code]) {
+ const v = version || "4.0";
+ return {
+ text: `${code} ${v}`,
+ url: `https://creativecommons.org/licenses/${CC_PATHS[code]}/${v}/`,
+ };
+ }
+ switch (code) {
+ case "CC0 1.0":
+ return {
+ text: "CC0 1.0",
+ url: "https://creativecommons.org/publicdomain/zero/1.0/",
+ publicDomain: true,
+ };
+ case "CC PDM":
+ return {
+ text: "Public Domain Mark",
+ url: "https://creativecommons.org/publicdomain/mark/1.0/",
+ publicDomain: true,
+ };
+ case "PD":
+ return { text: "Public Domain", publicDomain: true };
+ case "ODC PDDL":
+ return {
+ text: "ODC Public Domain Dedication and License",
+ url: "https://opendatacommons.org/licenses/pddl/",
+ publicDomain: true,
+ };
+ case "GNU GPL":
+ return {
+ text: version ? `GNU GPL ${version}` : "GNU GPL",
+ url: "https://www.gnu.org/licenses/gpl.html",
+ };
+ case "C":
+ return { text: "All Rights Reserved" };
+ default:
+ return { text: version ? `${code} ${version}` : code };
+ }
+}
+
+/**
+ * The attribution sentence for an activity, as plain text for copying:
+ * "{title} by {authors}, {institution}, is shared under a {license} license,
+ * except where otherwise noted." Clauses with no data are left out.
+ */
+export function buildActivityAttribution(
+ activity: Pick
+): string {
+ const credit = [activity.authors.join(", "), activity.publisher]
+ .filter(Boolean)
+ .join(", ");
+ const license = getActivityLicense(activity.license);
+
+ let sentence = credit ? `${activity.title} by ${credit}` : activity.title;
+ if (!license) return `${sentence}.`;
+ if (license.publicDomain) return `${sentence}, is in the public domain (${license.text}).`;
+ if (license.text === "All Rights Reserved") return `${sentence}. All rights reserved.`;
+ sentence += `, is shared under a ${license.text} license, except where otherwise noted.`;
+ return sentence;
+}
+
+/**
+ * The URL h5p-service serves an activity's embed page at. This shape is a
+ * public contract that published textbooks and ADAPT depend on.
+ */
+export function getActivityEmbedURL(origin: string, h5pContentID: number): string {
+ return `${origin.replace(/\/+$/, "")}/h5p/${h5pContentID}/embed`;
+}
+
+/**
+ * Embed code for pasting into a page or LMS: the iframe plus the H5P resizer,
+ * which grows the iframe to fit the activity.
+ */
+export function buildActivityEmbedCode(
+ origin: string,
+ h5pContentID: number,
+ title: string
+): string {
+ const base = origin.replace(/\/+$/, "");
+ const safeTitle = title.replace(/&/g, "&").replace(/"/g, """).replace(/` +
+ ``
+ );
+}
diff --git a/server/api.js b/server/api.js
index 1059d7074..d7ab0e1dd 100644
--- a/server/api.js
+++ b/server/api.js
@@ -22,6 +22,11 @@ import orgsAPI from "./api/organizations.js";
import alertsAPI from "./api/alerts.js";
import adoptionReportAPI from "./api/adoptionreports.js";
import collectionsAPI from "./api/collections.js";
+import activitiesAPI from "./api/activities.js";
+import studioLegacyAPI from "./api/studio-legacy.js";
+import activityAuthoringAPI from "./api/activity-authoring.js";
+import studioCompatAPI from "./api/studio-compat.js";
+import { requireAdaptIntegration } from "./util/adapt-integration.js";
import booksAPI from "./api/books.js";
import homeworkAPI from "./api/homework.js";
import librariesAPI from "./api/libraries.js";
@@ -50,6 +55,8 @@ import * as publishAPI from "./api/publish.js";
import * as storeValidators from "./api/validators/store.js";
import * as centralIdentityValidators from "./api/validators/central-identity.js";
import * as collectionValidators from "./api/validators/collections.js";
+import * as activityValidators from "./api/validators/activities.js";
+import * as studioLegacyValidators from "./api/validators/studio-legacy.js";
import * as kbValidators from "./api/validators/kb.js";
import * as LibraryValidators from "./api/validators/libraries.js";
import * as supportValidators from "./api/validators/support.js";
@@ -1106,6 +1113,141 @@ router
collectionsAPI.deleteCollection
);
+/* Commons Activities (H5P) */
+router
+ .route("/commons/activities")
+ .get(
+ middleware.validateZod(activityValidators.getCommonsActivitiesSchema),
+ activitiesAPI.getCommonsActivities
+ );
+
+// Declared before the :activityID route because of the nature in which route
+// params fall-through.
+router
+ .route("/commons/activities/h5p/:h5pContentID")
+ .get(
+ middleware.validateZod(activityValidators.getActivityByH5PIDSchema),
+ activitiesAPI.getActivityByH5PID
+ );
+
+router
+ .route("/commons/activities/author/:uuid")
+ .get(
+ middleware.validateZod(activityValidators.getActivitiesByAuthorSchema),
+ activitiesAPI.getActivitiesByAuthor
+ );
+// end
+
+// Replacements for the Studio API paths ADAPT calls, in Studio's exact
+// shapes. The studio.libretexts.org Worker forwards each path here once it
+// passes its diff against the captured production responses. Anonymous, as
+// Studio's were, except author lookups, which kept Studio's Basic auth.
+// Production only; see util/adapt-integration.ts.
+router.route("/studio/h5p/:idOrAll").get(middleware.checkLibreCommons, requireAdaptIntegration, studioCompatAPI.getStudioH5P);
+router
+ .route("/studio/author/:email")
+ .get(middleware.checkLibreCommons, requireAdaptIntegration, studioCompatAPI.requireStudioBasicAuth, studioCompatAPI.getStudioAuthorH5P);
+// "list" is declared before ":cid" so it is not read as a collection id.
+router.route("/studio/collections/list").get(middleware.checkLibreCommons, requireAdaptIntegration, studioCompatAPI.getStudioCollectionsList);
+router.route("/studio/collections/:cid").get(middleware.checkLibreCommons, requireAdaptIntegration, studioCompatAPI.getStudioCollection);
+
+// H5P authoring. Conductor decides who may edit what and signs capability
+// tokens for the H5P service, which owns the content. Owners and
+// collaborators edit; only owners delete. See H5P_AUTHORING_DESIGN.md.
+router
+ .route("/activities")
+ .post(
+ authAPI.verifyRequest,
+ middleware.validateZod(activityValidators.createActivitySchema),
+ activityAuthoringAPI.createActivity
+ );
+
+router
+ .route("/activities/subjects")
+ .get(authAPI.verifyRequest, activityAuthoringAPI.listActivitySubjects);
+
+router
+ .route("/activities/:activityID/authoring")
+ .get(
+ authAPI.verifyRequest,
+ middleware.validateZod(activityValidators.activityIDOnlySchema),
+ activityAuthoringAPI.getActivityForEditing
+ );
+
+router
+ .route("/activities/editor-token")
+ .post(authAPI.verifyRequest, activityAuthoringAPI.issueNewActivityEditorToken);
+
+// Daily, triggered externally like /alerts/processdaily.
+router
+ .route("/activities/automation/cleanup")
+ .put(middleware.checkLibreAPIKey, activityAuthoringAPI.cleanupAbandonedActivities);
+
+// Hourly, triggered externally. Production only, replaces Studio's ADAPT id cron.
+router
+ .route("/activities/automation/adapt-ids")
+ .put(middleware.checkLibreAPIKey, activityAuthoringAPI.syncAdaptIDsJob);
+
+router
+ .route("/activities/:activityID")
+ .patch(
+ authAPI.verifyRequest,
+ middleware.validateZod(activityValidators.updateActivitySchema),
+ activityAuthoringAPI.updateActivity
+ )
+ .delete(
+ authAPI.verifyRequest,
+ middleware.validateZod(activityValidators.activityIDOnlySchema),
+ activityAuthoringAPI.deleteActivity
+ );
+
+router
+ .route("/activities/:activityID/editor-token")
+ .post(
+ authAPI.verifyRequest,
+ middleware.validateZod(activityValidators.activityIDOnlySchema),
+ activityAuthoringAPI.issueEditorToken
+ );
+
+// The signed-in user's own activities, for My H5P.
+router
+ .route("/activities/mine")
+ .get(
+ authAPI.verifyRequest,
+ middleware.validateZod(activityValidators.getMyActivitiesSchema),
+ activitiesAPI.getMyActivities
+ );
+
+router
+ .route("/commons/activities/:activityID")
+ .get(
+ middleware.validateZod(activityValidators.getActivitySchema),
+ activitiesAPI.getActivity
+ );
+
+// ADAPT question-bank reconciliation feed, replacing Studio's /api/v2/nodes.
+// Anonymous by design: Studio served it without authentication and ADAPT pulls
+// it unauthenticated today. Restricted to the LibreTexts instance.
+router
+ .route("/commons/activities-adapt-feed")
+ .get(
+ middleware.checkLibreCommons,
+ requireAdaptIntegration,
+ middleware.validateZod(activityValidators.getAdaptFeedSchema),
+ activitiesAPI.getAdaptFeed
+ );
+
+// Old studio.libretexts.org page URLs, sent here by Cloudflare Single
+// Redirects after cutover. Always answers with a redirect. Anonymous, and
+// restricted to the LibreTexts instance, which is the only one Studio fed.
+router
+ .route("/commons/studio-legacy/:kind(h5p|node|collection)/:value")
+ .get(
+ middleware.checkLibreCommons,
+ middleware.validateZod(studioLegacyValidators.resolveStudioLegacyPathSchema),
+ studioLegacyAPI.resolveStudioLegacyPath
+ );
+
// Data export endpoint for 3rd-party content hosts
router
.route("/commons/kbexport")
diff --git a/server/api/activities.ts b/server/api/activities.ts
new file mode 100644
index 000000000..337451346
--- /dev/null
+++ b/server/api/activities.ts
@@ -0,0 +1,531 @@
+import { Response } from "express";
+import { FilterQuery } from "mongoose";
+import { z } from "zod";
+import logger, { childLogger } from "../logger.js";
+import Activity, { ActivityInterface } from "../models/activity.js";
+import User from "../models/user.js";
+import { conductor404Err, conductor500Err } from "../util/errorutils.js";
+import { getPaginationOffset } from "../util/helpers.js";
+import {
+ getActivitiesByAuthorSchema,
+ getActivityByH5PIDSchema,
+ getActivitySchema,
+ getAdaptFeedSchema,
+ getCommonsActivitiesSchema,
+ getMyActivitiesSchema,
+} from "./validators/activities.js";
+import { ZodReqWithOptionalUser, ZodReqWithUser } from "../types";
+import { adaptDataFilter } from "../util/adapt-integration.js";
+
+const adaptLog = childLogger("adapt-feed");
+
+/**
+ * Fields returned for a catalog row. Deliberately narrower than the stored
+ * document so a collaborator list never rides along with a public listing.
+ */
+const ACTIVITY_LIST_PROJECTION = {
+ _id: 0,
+ __v: 0,
+ collaborators: 0,
+ legacyNodeID: 0,
+};
+
+/**
+ * Escapes user input for use inside a `$regex`, so a search for "C++" or "("
+ * matches literally instead of failing or backtracking.
+ */
+function _escapeRegex(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/**
+ * Restricts a query to activities the caller is allowed to see.
+ *
+ * Public activities are visible to everyone. A signed-in user additionally sees
+ * their own private activities and any private activity they collaborate on.
+ */
+function _buildVisibilityFilter(
+ userUUID?: string
+): FilterQuery {
+ if (!userUUID) return { visibility: "public" };
+ return {
+ $or: [
+ { visibility: "public" },
+ { createdBy: userUUID },
+ { collaborators: userUUID },
+ ],
+ };
+}
+
+/**
+ * Returns activities listed in the public catalog for this instance's
+ * organization. Safe to call anonymously.
+ */
+async function getCommonsActivities(
+ req: ZodReqWithOptionalUser>,
+ res: Response
+) {
+ try {
+ const { query, library, subject, license, sort, sortDirection } = req.query;
+ const page = parseInt(req.query.page.toString()) || 1;
+ const limit = parseInt(req.query.limit.toString()) || 12;
+
+ const matchConds: FilterQuery[] = [
+ { orgID: process.env.ORG_ID },
+ { includeInLibrary: true },
+ _buildVisibilityFilter(req.user?.decoded?.uuid),
+ ];
+
+ if (library) matchConds.push({ library });
+ if (subject) matchConds.push({ subjects: subject });
+ if (license) matchConds.push({ "license.name": license });
+ if (query) {
+ const pattern = _escapeRegex(query);
+ matchConds.push({
+ $or: [
+ { title: { $regex: pattern, $options: "i" } },
+ { description: { $regex: pattern, $options: "i" } },
+ { tags: { $regex: pattern, $options: "i" } },
+ ],
+ });
+ }
+
+ const sortField =
+ sort === "title"
+ ? "title"
+ : sort === "created"
+ ? "createdAt"
+ : "updatedAt";
+ const sortOrder = sortDirection === "ascending" ? 1 : -1;
+
+ const offset = getPaginationOffset(page, limit);
+
+ const [activities, total] = await Promise.all([
+ Activity.find({ $and: matchConds }, ACTIVITY_LIST_PROJECTION)
+ .sort({ [sortField]: sortOrder })
+ .skip(offset)
+ .limit(limit)
+ .lean(),
+ Activity.countDocuments({ $and: matchConds }),
+ ]);
+
+ return res.send({
+ err: false,
+ activities: await _withAuthors(activities),
+ cursor: total > offset + limit ? offset + limit : undefined,
+ total_items: total,
+ });
+ } catch (err) {
+ logger.error({ err }, "getCommonsActivities failed");
+ return conductor500Err(res);
+ }
+}
+
+/**
+ * Fields the public activity page reads. Collaborators, migration bookkeeping
+ * and the ADAPT id stay server-side: the page is public and Studio's own
+ * public page did not show them either.
+ */
+const ACTIVITY_PAGE_PROJECTION = {
+ _id: 0,
+ activityID: 1,
+ h5pContentID: 1,
+ title: 1,
+ description: 1,
+ library: 1,
+ libraryTitle: 1,
+ language: 1,
+ h5pAuthors: 1,
+ source: 1,
+ publisher: 1,
+ subjects: 1,
+ tags: 1,
+ visibility: 1,
+ includeInLibrary: 1,
+ license: 1,
+ createdBy: 1,
+ createdAt: 1,
+ updatedAt: 1,
+};
+
+/**
+ * Names credited in the H5P package metadata. Studio stored the H5P `authors`
+ * array as JSON text, so a malformed value yields no names rather than an error.
+ */
+function _parseH5PAuthorNames(raw?: string): string[] {
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed
+ .map((a) => (typeof a?.name === "string" ? a.name.trim() : ""))
+ .filter(Boolean);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Adds `authors`, the credit to display, to each activity: the H5P metadata
+ * authors when the package names any, otherwise the owning Conductor user.
+ * Owners are looked up in one query for the whole page.
+ */
+async function _withAuthors(
+ activities: T[]
+): Promise<(T & { authors: string[] })[]> {
+ const credited = activities.map((a) => _parseH5PAuthorNames(a.h5pAuthors));
+ const ownerUUIDs = [
+ ...new Set(
+ activities
+ .filter((a, i) => !credited[i].length && a.createdBy)
+ .map((a) => a.createdBy as string)
+ ),
+ ];
+ const owners = ownerUUIDs.length
+ ? await User.find(
+ { uuid: { $in: ownerUUIDs } },
+ { _id: 0, uuid: 1, firstName: 1, lastName: 1 }
+ ).lean()
+ : [];
+ const nameByUUID = new Map(
+ owners.map((u) => [u.uuid, [u.firstName, u.lastName].filter(Boolean).join(" ")])
+ );
+ return activities.map((a, i) => {
+ const owner = a.createdBy ? nameByUUID.get(a.createdBy) : undefined;
+ return { ...a, authors: credited[i].length ? credited[i] : owner ? [owner] : [] };
+ });
+}
+
+/**
+ * Returns a single activity by its Conductor identifier, shaped for the public
+ * activity page.
+ *
+ * `authors` is the credit to display: the H5P metadata authors when the
+ * package names any, otherwise the owning Conductor user. A private activity
+ * the caller cannot see is a plain 404, the same as a missing one, so the page
+ * never reveals that it exists.
+ */
+async function getActivity(
+ req: ZodReqWithOptionalUser>,
+ res: Response
+) {
+ try {
+ const activity = await Activity.findOne(
+ {
+ activityID: req.params.activityID,
+ orgID: process.env.ORG_ID,
+ ...(_buildVisibilityFilter(req.user?.decoded?.uuid)),
+ },
+ ACTIVITY_PAGE_PROJECTION
+ ).lean();
+
+ if (!activity) return conductor404Err(res);
+
+ const [credited] = await _withAuthors([activity]);
+ const { h5pAuthors: _h5pAuthors, createdBy: _createdBy, ...rest } = credited;
+ return res.send({ err: false, activity: rest });
+ } catch (err) {
+ logger.error({ err }, "getActivity failed");
+ return conductor500Err(res);
+ }
+}
+
+/**
+ * Resolves an activity from the H5P content id.
+ *
+ * This is what lets a legacy Studio URL (`/h5p/{id}`) redirect to its Conductor
+ * page without a lookup table.
+ */
+async function getActivityByH5PID(
+ req: ZodReqWithOptionalUser>,
+ res: Response
+) {
+ try {
+ const activity = await Activity.findOne(
+ {
+ // Number() so only a scalar reaches the query, whatever the validator lets through.
+ h5pContentID: Number(req.params.h5pContentID),
+ orgID: process.env.ORG_ID,
+ ...(_buildVisibilityFilter(req.user?.decoded?.uuid)),
+ },
+ { _id: 0, __v: 0 }
+ ).lean();
+
+ if (!activity) return conductor404Err(res);
+
+ return res.send({ err: false, activity });
+ } catch (err) {
+ logger.error({ err }, "getActivityByH5PID failed");
+ return conductor500Err(res);
+ }
+}
+
+/**
+ * Returns the public activities an author has chosen to show on their profile.
+ */
+async function getActivitiesByAuthor(
+ req: ZodReqWithOptionalUser>,
+ res: Response
+) {
+ try {
+ const page = parseInt(req.query.page.toString()) || 1;
+ const limit = parseInt(req.query.limit.toString()) || 12;
+ const offset = getPaginationOffset(page, limit);
+
+ const matchConds: FilterQuery = {
+ orgID: process.env.ORG_ID,
+ createdBy: req.params.uuid,
+ includeInProfile: true,
+ visibility: "public",
+ };
+
+ const [activities, total] = await Promise.all([
+ Activity.find(matchConds, ACTIVITY_LIST_PROJECTION)
+ .sort({ updatedAt: -1 })
+ .skip(offset)
+ .limit(limit)
+ .lean(),
+ Activity.countDocuments(matchConds),
+ ]);
+
+ return res.send({
+ err: false,
+ activities,
+ cursor: total > offset + limit ? offset + limit : undefined,
+ total_items: total,
+ });
+ } catch (err) {
+ logger.error({ err }, "getActivitiesByAuthor failed");
+ return conductor500Err(res);
+ }
+}
+
+/**
+ * The UI's three visibility levels, derived from the stored fields. An
+ * activity with no `includeInLibrary` value counts as listed, matching the
+ * schema default and Studio's.
+ */
+const VISIBILITY_LEVEL_EXPR = {
+ $cond: [
+ { $eq: ["$visibility", "private"] },
+ "private",
+ { $cond: [{ $eq: ["$includeInLibrary", false] }, "unlisted", "public"] },
+ ],
+};
+
+const VISIBILITY_LEVEL_FILTERS: Record> = {
+ public: { visibility: "public", includeInLibrary: { $ne: false } },
+ unlisted: { visibility: "public", includeInLibrary: false },
+ private: { visibility: "private" },
+};
+
+type MyActivityRow = Record & { h5pAuthors?: string; createdBy?: string };
+
+const MY_ACTIVITIES_SORTS: Record> = {
+ updated: { updatedAt: -1, _id: 1 },
+ created: { createdAt: -1, _id: 1 },
+ title: { title: 1, _id: 1 },
+};
+
+/**
+ * The signed-in user's activities for My H5P: those they own and those they
+ * collaborate on, each marked with the caller's role.
+ *
+ * One aggregation returns the page of results plus the counts the visibility
+ * filter shows. `counts` respects the search but not the visibility filter,
+ * so each option shows how many results choosing it would give.
+ * `overallTotal` ignores both, for the "search all N" message.
+ */
+async function getMyActivities(
+ req: ZodReqWithUser>,
+ res: Response
+) {
+ try {
+ const userUUID = req.user.decoded.uuid;
+ const { query, visibility, sort } = req.query;
+ const page = parseInt(req.query.page.toString()) || 1;
+ const limit = parseInt(req.query.limit.toString()) || 24;
+ const offset = getPaginationOffset(page, limit);
+
+ const searchMatch: FilterQuery = {};
+ if (query) {
+ const pattern = _escapeRegex(query);
+ searchMatch.$or = [
+ { title: { $regex: pattern, $options: "i" } },
+ { tags: { $regex: pattern, $options: "i" } },
+ { libraryTitle: { $regex: pattern, $options: "i" } },
+ ];
+ }
+ const visibilityMatch =
+ visibility === "all" ? {} : VISIBILITY_LEVEL_FILTERS[visibility];
+
+ const [result] = await Activity.aggregate([
+ {
+ $match: {
+ orgID: process.env.ORG_ID,
+ $or: [{ createdBy: userUUID }, { collaborators: userUUID }],
+ },
+ },
+ {
+ $facet: {
+ overall: [{ $count: "n" }],
+ counts: [
+ { $match: searchMatch },
+ { $group: { _id: VISIBILITY_LEVEL_EXPR, n: { $sum: 1 } } },
+ ],
+ items: [
+ { $match: searchMatch },
+ { $match: visibilityMatch },
+ { $sort: MY_ACTIVITIES_SORTS[sort] },
+ { $skip: offset },
+ { $limit: limit },
+ {
+ $project: {
+ _id: 0,
+ activityID: 1,
+ title: 1,
+ description: 1,
+ library: 1,
+ libraryTitle: 1,
+ language: 1,
+ license: 1,
+ subjects: 1,
+ tags: 1,
+ h5pAuthors: 1,
+ createdBy: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ visibility: VISIBILITY_LEVEL_EXPR,
+ role: {
+ $cond: [{ $eq: ["$createdBy", userUUID] }, "owner", "collaborator"],
+ },
+ },
+ },
+ ],
+ },
+ },
+ ]).collation({ locale: "en", strength: 2 });
+
+ const counts = { all: 0, public: 0, unlisted: 0, private: 0 };
+ for (const c of result.counts as { _id: keyof typeof counts; n: number }[]) {
+ counts[c._id] = c.n;
+ counts.all += c.n;
+ }
+ const totalItems = visibility === "all" ? counts.all : counts[visibility];
+
+ // The card shows the credit, not the raw H5P metadata or the owner's id.
+ const activities = (await _withAuthors(result.items as MyActivityRow[])).map(
+ ({ h5pAuthors: _h5pAuthors, createdBy: _createdBy, ...rest }) => rest
+ );
+
+ return res.send({
+ err: false,
+ activities,
+ counts,
+ overallTotal: result.overall[0]?.n ?? 0,
+ total_items: totalItems,
+ cursor: totalItems > offset + limit ? offset + limit : undefined,
+ });
+ } catch (err) {
+ logger.error({ err }, "getMyActivities failed");
+ return conductor500Err(res);
+ }
+}
+
+/**
+ * Escapes one CSV field per RFC 4180.
+ */
+function _csvField(value: unknown): string {
+ if (value === null || value === undefined) return "";
+ const str = String(value);
+ if (/[",\r\n]/.test(str)) {
+ return '"' + str.replace(/"/g, '""') + '"';
+ }
+ return str;
+}
+
+/**
+ * The ADAPT reconciliation feed, replacing Studio's `/api/v2/nodes`.
+ *
+ * ADAPT pulls this as an unpaginated CSV to map its question bank onto H5P
+ * activities. Column names and order match Studio's output exactly, and every
+ * activity is included regardless of visibility, because Studio's view applied
+ * no publication filter and ADAPT relies on seeing the full set.
+ *
+ * ADAPT has since confirmed it does not call this endpoint and has no knowledge
+ * of it, so this is no longer a contract with an external consumer. It is kept
+ * because Studio served it, the shape is cheap to reproduce, and Studio's
+ * `api_user` role and `api_for_adapt` view suggest something once relied on it.
+ * Check the access logs before assuming nothing does.
+ *
+ * One deliberate difference from Studio's output: the `uid` column carried
+ * Drupal's integer user id, which has no Conductor equivalent. It emits the
+ * author's LibreOne UUID (`User.centralID`) instead, the one author identifier
+ * meaningful in Studio, Conductor and ADAPT alike. With no consumer, that
+ * difference no longer needs anyone's sign-off.
+ */
+async function getAdaptFeed(
+ req: z.infer,
+ res: Response
+) {
+ try {
+ const activities = await Activity.find(
+ { orgID: process.env.ORG_ID, ...adaptDataFilter() },
+ { _id: 0, h5pContentID: 1, adaptID: 1, authorCentralID: 1 }
+ )
+ .sort({ h5pContentID: 1 })
+ .lean();
+
+ // `uid` is read straight off the activity. Joining through a local `User`
+ // on `createdBy` would blank the column for the 87 authors who hold a valid
+ // LibreOne identity but have no Conductor account yet.
+ const centralIDs = [
+ ...new Set(activities.map((a) => a.authorCentralID).filter(Boolean)),
+ ] as string[];
+
+ // Email is the only column that still needs the local record.
+ const authors = await User.find(
+ { centralID: { $in: centralIDs } },
+ { _id: 0, centralID: 1, email: 1 }
+ ).lean();
+ const emailByCentralID = new Map(authors.map((u) => [u.centralID, u.email]));
+
+ const rows = activities.map((a) => ({
+ h5p_id: a.h5pContentID,
+ adapt_id: a.adaptID ?? "",
+ uid: a.authorCentralID ?? "",
+ email: a.authorCentralID
+ ? emailByCentralID.get(a.authorCentralID) ?? ""
+ : "",
+ }));
+
+ adaptLog.info({ rows: rows.length }, "Served ADAPT reconciliation feed");
+
+ if (req.query.format === "json") {
+ return res.send({ err: false, nodes: rows });
+ }
+
+ const header = "h5p_id,adapt_id,uid,email";
+ const body = rows
+ .map((r) =>
+ [r.h5p_id, r.adapt_id, r.uid, r.email].map(_csvField).join(",")
+ )
+ .join("\n");
+
+ res.setHeader("Content-Type", "text/csv; charset=utf-8");
+ res.setHeader("Content-Disposition", 'attachment; filename="nodes.csv"');
+ return res.send(rows.length ? header + "\n" + body + "\n" : header + "\n");
+ } catch (err) {
+ logger.error({ err }, "getAdaptFeed failed");
+ return conductor500Err(res);
+ }
+}
+
+export default {
+ getCommonsActivities,
+ getActivity,
+ getActivityByH5PID,
+ getActivitiesByAuthor,
+ getMyActivities,
+ getAdaptFeed,
+};
diff --git a/server/api/activity-authoring.ts b/server/api/activity-authoring.ts
new file mode 100644
index 000000000..0eb9d5b48
--- /dev/null
+++ b/server/api/activity-authoring.ts
@@ -0,0 +1,431 @@
+import { Request, Response } from "express";
+import { z } from "zod";
+import { childLogger } from "../logger.js";
+import Activity, { ActivityInterface } from "../models/activity.js";
+import ActivityCollection from "../models/activityCollection.js";
+import User from "../models/user.js";
+import {
+ conductor404Err,
+ conductor500Err,
+ conductorErr,
+} from "../util/errorutils.js";
+import {
+ AuthoringNotConfiguredError,
+ EDITOR_TOKEN_TTL_SECONDS,
+ allocateH5PContentId,
+ deleteH5PContent,
+ signCapabilityToken,
+} from "./services/h5p-authoring-service.js";
+import {
+ activityIDOnlySchema,
+ createActivitySchema,
+ updateActivitySchema,
+} from "./validators/activities.js";
+import { ZodReqWithUser } from "../types";
+import { isProductionEnvironment } from "../util/adapt-integration.js";
+import {
+ ADAPT_ID_SYNC_DEFAULT_LIMIT,
+ AdaptIdSyncNotConfiguredError,
+ syncAdaptIDs,
+} from "./services/adapt-id-sync-service.js";
+
+const log = childLogger("activity-authoring");
+
+/** How long an activity may sit with no saved content before cleanup. */
+const ABANDONED_AFTER_MS = 24 * 60 * 60 * 1000;
+
+type VisibilityLevel = "public" | "unlisted" | "private";
+
+/**
+ * The UI's three visibility levels as stored fields. Unlisted is public but
+ * left out of the catalog, which is exactly Studio's "include in library" off.
+ */
+function visibilityFields(level: VisibilityLevel): Pick {
+ if (level === "private") return { visibility: "private", includeInLibrary: true };
+ return { visibility: "public", includeInLibrary: level === "public" };
+}
+
+/**
+ * The content type title to store for a saved library, e.g. "Interactive
+ * Video". ADAPT reads it through the Studio compatibility API to recognise
+ * Interactive Video and Question Set, so the title Studio already gave that
+ * machine name wins: it is what ADAPT has always seen. The editor's reported
+ * library.json title covers content types no migrated activity uses.
+ */
+async function libraryTitleFor(library: string, reported?: string): Promise {
+ const machineName = library.split(" ")[0];
+ const known = await Activity.findOne(
+ {
+ orgID: process.env.ORG_ID,
+ // The validator limits machine names to letters, digits, "_", "." and "-".
+ library: { $regex: `^${machineName.replace(/[.-]/g, "\\$&")} ` },
+ libraryTitle: { $nin: [null, ""] },
+ },
+ { _id: 0, libraryTitle: 1 }
+ ).lean();
+ return known?.libraryTitle || reported || undefined;
+}
+
+function isOwner(activity: Pick, userUUID: string): boolean {
+ return Boolean(activity.createdBy) && activity.createdBy === userUUID;
+}
+
+function canEdit(activity: Pick, userUUID: string): boolean {
+ return isOwner(activity, userUUID) || (activity.collaborators ?? []).includes(userUUID);
+}
+
+/**
+ * Only verified instructors create activities. Editing an existing one, as
+ * owner or collaborator, needs no verification.
+ */
+async function isVerifiedInstructor(userUUID: string): Promise {
+ const user = await User.findOne({ uuid: userUUID }, { _id: 0, verifiedInstructor: 1 }).lean();
+ return user?.verifiedInstructor === true;
+}
+
+/** Answers for the two failures every authoring call can hit. */
+function authoringFailure(res: Response, err: unknown, what: string) {
+ if (err instanceof AuthoringNotConfiguredError) {
+ log.error({ err }, `${what}: authoring is not configured`);
+ return conductorErr(res, 503, "err93");
+ }
+ log.error({ err }, `${what} failed`);
+ return conductor500Err(res);
+}
+
+/** Metadata fields from a create or update body, in stored form. */
+function metadataUpdate(body: z.infer["body"]): Partial {
+ const update: Partial = {};
+ if (body.title !== undefined) update.title = body.title;
+ if (body.description !== undefined) update.description = body.description;
+ if (body.subjects !== undefined) update.subjects = body.subjects;
+ if (body.tags !== undefined) update.tags = body.tags;
+ if (body.language !== undefined) update.language = body.language;
+ if (body.publisher !== undefined) update.publisher = body.publisher;
+ if (body.license !== undefined) update.license = body.license as any;
+ if (body.visibility !== undefined) Object.assign(update, visibilityFields(body.visibility));
+ return update;
+}
+
+/**
+ * An activity as its create/edit screen needs it: every field an author
+ * edits, the caller's role, and the collaborators by name. Owners and
+ * collaborators only.
+ */
+async function getActivityForEditing(
+ req: ZodReqWithUser>,
+ res: Response
+) {
+ try {
+ const userUUID = req.user.decoded.uuid;
+ const activity = await Activity.findOne(
+ { activityID: { $eq: req.params.activityID }, orgID: process.env.ORG_ID },
+ {
+ _id: 0, activityID: 1, h5pContentID: 1, title: 1, description: 1, library: 1, libraryTitle: 1,
+ language: 1, h5pAuthors: 1, source: 1, publisher: 1, subjects: 1, tags: 1, visibility: 1,
+ includeInLibrary: 1, license: 1, createdBy: 1, collaborators: 1, pendingFirstSave: 1,
+ createdAt: 1, updatedAt: 1,
+ }
+ ).lean();
+ if (!activity) return conductor404Err(res);
+ if (!canEdit(activity, userUUID)) return conductorErr(res, 403, "err8");
+
+ const people = await User.find(
+ { uuid: { $in: [activity.createdBy, ...(activity.collaborators ?? [])].filter(Boolean) } },
+ { _id: 0, uuid: 1, firstName: 1, lastName: 1 }
+ ).lean();
+ const nameOf = (uuid?: string) => {
+ const u = people.find((p) => p.uuid === uuid);
+ return u ? [u.firstName, u.lastName].filter(Boolean).join(" ") : "Unknown user";
+ };
+ const level: VisibilityLevel =
+ activity.visibility === "private" ? "private" : activity.includeInLibrary === false ? "unlisted" : "public";
+
+ const { createdBy, collaborators, ...rest } = activity;
+ return res.send({
+ err: false,
+ activity: { ...rest, visibilityLevel: level },
+ role: isOwner(activity, userUUID) ? "owner" : "collaborator",
+ people: [
+ ...(createdBy ? [{ uuid: createdBy, name: nameOf(createdBy), role: "owner" }] : []),
+ ...(collaborators ?? []).map((uuid) => ({ uuid, name: nameOf(uuid), role: "collaborator" })),
+ ],
+ });
+ } catch (err) {
+ log.error({ err }, "getActivityForEditing failed");
+ return conductor500Err(res);
+ }
+}
+
+/**
+ * Subjects already in use, for the Subject field. Studio's subjects
+ * vocabulary arrived with the migrated activities, so it is the list.
+ */
+async function listActivitySubjects(_req: Request, res: Response) {
+ try {
+ const subjects = (await Activity.distinct("subjects", { orgID: process.env.ORG_ID }))
+ .filter((s): s is string => typeof s === "string" && s.trim().length > 0)
+ .sort((a, b) => a.localeCompare(b));
+ return res.send({ err: false, subjects });
+ } catch (err) {
+ log.error({ err }, "listActivitySubjects failed");
+ return conductor500Err(res);
+ }
+}
+
+/**
+ * Editor token for a new activity: opens the editor and allows uploads, but
+ * cannot save anything until `createActivity` has allocated a content id.
+ */
+async function issueNewActivityEditorToken(req: ZodReqWithUser<{}>, res: Response) {
+ try {
+ if (!(await isVerifiedInstructor(req.user.decoded.uuid))) return conductorErr(res, 403, "err94");
+ const { token, expiresAt } = await signCapabilityToken({
+ subject: req.user.decoded.uuid,
+ actions: ["create"],
+ });
+ return res.send({ err: false, token, expiresAt, ttlSeconds: EDITOR_TOKEN_TTL_SECONDS });
+ } catch (err) {
+ return authoringFailure(res, err, "issueNewActivityEditorToken");
+ }
+}
+
+/**
+ * Editor token for an existing activity, scoped to its H5P content. Owners and
+ * collaborators get one. Delete is never in an editor token: it goes through
+ * `deleteActivity`, which Conductor performs server to server.
+ */
+async function issueEditorToken(
+ req: ZodReqWithUser>,
+ res: Response
+) {
+ try {
+ const userUUID = req.user.decoded.uuid;
+ const activity = await Activity.findOne(
+ { activityID: { $eq: req.params.activityID }, orgID: process.env.ORG_ID },
+ { _id: 0, h5pContentID: 1, createdBy: 1, collaborators: 1 }
+ ).lean();
+ if (!activity) return conductor404Err(res);
+ if (!canEdit(activity, userUUID)) return conductorErr(res, 403, "err8");
+
+ const { token, expiresAt } = await signCapabilityToken({
+ subject: userUUID,
+ actions: ["edit"],
+ contentId: activity.h5pContentID,
+ });
+ return res.send({
+ err: false,
+ token,
+ expiresAt,
+ ttlSeconds: EDITOR_TOKEN_TTL_SECONDS,
+ h5pContentID: activity.h5pContentID,
+ });
+ } catch (err) {
+ return authoringFailure(res, err, "issueEditorToken");
+ }
+}
+
+/**
+ * Creates an activity at the first Save of a new one (authoring design,
+ * section 4). Conductor allocates the H5P content id first, so the link
+ * between Activity and content is Conductor's from the start, and returns an
+ * edit token for that id. The editor then saves its content into it, and the
+ * screen reports that back with `updateActivity`'s `contentSaved`.
+ *
+ * New activities are private until the author chooses otherwise. Only
+ * verified instructors create them.
+ */
+async function createActivity(
+ req: ZodReqWithUser>,
+ res: Response
+) {
+ try {
+ const userUUID = req.user.decoded.uuid;
+ const owner = await User.findOne({ uuid: userUUID }, { _id: 0, centralID: 1, verifiedInstructor: 1 }).lean();
+ if (owner?.verifiedInstructor !== true) return conductorErr(res, 403, "err94");
+
+ const h5pContentID = await allocateH5PContentId();
+
+ const activityID = String(h5pContentID);
+
+ const metadata = metadataUpdate(req.body);
+ const activity = await Activity.create({
+ activityID,
+ h5pContentID,
+ orgID: process.env.ORG_ID,
+ title: metadata.title ?? "Untitled activity",
+ ...metadata,
+ ...visibilityFields(req.body.visibility ?? "private"),
+ createdBy: userUUID,
+ ...(owner?.centralID ? { authorCentralID: owner.centralID } : {}),
+ collaborators: [],
+ pendingFirstSave: true,
+ });
+
+ const { token, expiresAt } = await signCapabilityToken({
+ subject: userUUID,
+ actions: ["edit"],
+ contentId: h5pContentID,
+ });
+ log.info({ activityID, h5pContentID }, "Activity created");
+ return res.status(201).send({
+ err: false,
+ activity: { activityID: activity.activityID, h5pContentID },
+ token,
+ expiresAt,
+ ttlSeconds: EDITOR_TOKEN_TTL_SECONDS,
+ });
+ } catch (err) {
+ return authoringFailure(res, err, "createActivity");
+ }
+}
+
+/**
+ * Updates an activity's metadata, and, with `contentSaved`, records what the
+ * H5P editor just saved. Owners and collaborators may update.
+ *
+ * Saving moves `updatedAt`, which is what ADAPT's `changed` poll reads, so
+ * an edit reaches ADAPT without anything else.
+ */
+async function updateActivity(
+ req: ZodReqWithUser>,
+ res: Response
+) {
+ try {
+ const userUUID = req.user.decoded.uuid;
+ const activity = await Activity.findOne(
+ { activityID: { $eq: req.params.activityID }, orgID: process.env.ORG_ID },
+ { _id: 0, createdBy: 1, collaborators: 1 }
+ ).lean();
+ if (!activity) return conductor404Err(res);
+ if (!canEdit(activity, userUUID)) return conductorErr(res, 403, "err8");
+
+ const update: Record = metadataUpdate(req.body);
+ const unset: Record = {};
+ const saved = req.body.contentSaved;
+ if (saved) {
+ // H5P's own title field is the single title (design handoff), so the
+ // saved content's title wins over one sent alongside it.
+ update.title = saved.title;
+ update.library = saved.library;
+ const libraryTitle = await libraryTitleFor(saved.library, saved.libraryTitle);
+ if (libraryTitle) update.libraryTitle = libraryTitle;
+ if (saved.license) {
+ update.license = { name: saved.license, ...(saved.licenseVersion ? { version: saved.licenseVersion } : {}) };
+ }
+ if (saved.authors) update.h5pAuthors = JSON.stringify(saved.authors);
+ unset.pendingFirstSave = 1;
+ }
+
+ const updated = await Activity.findOneAndUpdate(
+ { activityID: { $eq: req.params.activityID }, orgID: process.env.ORG_ID },
+ { $set: update, ...(Object.keys(unset).length ? { $unset: unset } : {}) },
+ { new: true, projection: { _id: 0, __v: 0, collaborators: 0 } }
+ ).lean();
+ return res.send({ err: false, activity: updated });
+ } catch (err) {
+ log.error({ err }, "updateActivity failed");
+ return conductor500Err(res);
+ }
+}
+
+/**
+ * Deletes an activity and its H5P content. Owner only.
+ *
+ * This breaks every published embed of the activity; the screen says so
+ * before calling. The content goes first, so a failure leaves the Activity in
+ * place to retry rather than an Activity pointing at nothing.
+ */
+async function deleteActivity(
+ req: ZodReqWithUser>,
+ res: Response
+) {
+ try {
+ const userUUID = req.user.decoded.uuid;
+ const activity = await Activity.findOne(
+ { activityID: { $eq: req.params.activityID }, orgID: process.env.ORG_ID },
+ { _id: 0, activityID: 1, h5pContentID: 1, createdBy: 1 }
+ ).lean();
+ if (!activity) return conductor404Err(res);
+ if (!isOwner(activity, userUUID)) return conductorErr(res, 403, "err8");
+
+ await removeActivity(activity.activityID, activity.h5pContentID);
+ log.info({ activityID: activity.activityID, h5pContentID: activity.h5pContentID }, "Activity deleted");
+ return res.send({ err: false });
+ } catch (err) {
+ return authoringFailure(res, err, "deleteActivity");
+ }
+}
+
+async function removeActivity(activityID: string, h5pContentID: number): Promise {
+ await deleteH5PContent(h5pContentID);
+ const contentID = Number(h5pContentID);
+ await ActivityCollection.updateMany(
+ { "subCollections.h5pContentIDs": contentID },
+ { $pull: { "subCollections.$[].h5pContentIDs": contentID } }
+ );
+ await Activity.deleteOne({ activityID: { $eq: activityID } });
+}
+
+/**
+ * Daily: deletes activities created in Conductor whose content was never
+ * saved, older than a day. Someone opened New H5P, got as far as an allocated
+ * id, and left. Triggered externally with the LibreTexts API key, like the
+ * other daily jobs.
+ */
+async function cleanupAbandonedActivities(_req: Request, res: Response) {
+ try {
+ const cutoff = new Date(Date.now() - ABANDONED_AFTER_MS);
+ const abandoned = await Activity.find(
+ { orgID: process.env.ORG_ID, pendingFirstSave: true, createdAt: { $lt: cutoff } },
+ { _id: 0, activityID: 1, h5pContentID: 1 }
+ ).lean();
+ let removed = 0;
+ for (const a of abandoned) {
+ try {
+ await removeActivity(a.activityID, a.h5pContentID);
+ removed += 1;
+ } catch (err) {
+ log.warn({ err, activityID: a.activityID }, "Could not remove abandoned activity; will retry tomorrow");
+ }
+ }
+ log.info({ found: abandoned.length, removed }, "Abandoned activity cleanup finished");
+ return res.send({ err: false, found: abandoned.length, removed });
+ } catch (err) {
+ return authoringFailure(res, err, "cleanupAbandonedActivities");
+ }
+}
+
+/**
+ * Looks up ADAPT question ids for activities that lack one. Triggered
+ * externally, hourly, like the cleanup. `?limit=` caps the batch, 500 at most.
+ * Answers 404 outside production, the same as the other ADAPT endpoints.
+ */
+async function syncAdaptIDsJob(req: Request, res: Response) {
+ if (!isProductionEnvironment()) return conductor404Err(res);
+ try {
+ const requested = Number(req.query.limit);
+ const limit = Number.isInteger(requested) && requested > 0 ? Math.min(requested, 500) : ADAPT_ID_SYNC_DEFAULT_LIMIT;
+ const result = await syncAdaptIDs(limit);
+ return res.send({ err: false, ...result });
+ } catch (err) {
+ if (err instanceof AdaptIdSyncNotConfiguredError) {
+ log.error({ err }, "ADAPT id sync is not configured");
+ return conductorErr(res, 503, "err92");
+ }
+ log.error({ err }, "ADAPT id sync failed");
+ return conductor500Err(res);
+ }
+}
+
+export default {
+ getActivityForEditing,
+ listActivitySubjects,
+ issueNewActivityEditorToken,
+ issueEditorToken,
+ createActivity,
+ updateActivity,
+ deleteActivity,
+ cleanupAbandonedActivities,
+ syncAdaptIDsJob,
+};
diff --git a/server/api/services/adapt-id-sync-service.ts b/server/api/services/adapt-id-sync-service.ts
new file mode 100644
index 000000000..719940790
--- /dev/null
+++ b/server/api/services/adapt-id-sync-service.ts
@@ -0,0 +1,105 @@
+import Activity from "../../models/activity.js";
+import { childLogger } from "../../logger.js";
+import { assertAdaptIntegrationAllowed } from "../../util/adapt-integration.js";
+
+const log = childLogger("adapt-id-sync");
+
+/**
+ * Fills in `Activity.adaptID` from ADAPT, replacing the Studio cron job that
+ * did the same (`adapt_cron` in Studio's `adapt` module).
+ *
+ * ADAPT's `PATCH /api/h5p-collections/get-adapt-question-id-by-h5p-id/{id}`
+ * is a lookup, not a write: it finds ADAPT questions with technology `h5p`
+ * and that content id. One match is the answer. Several are disambiguated by
+ * the question editor's email, which must equal the `email` in the body.
+ * Studio always sent one fixed staff address, so this does the same with
+ * `ADAPT_ID_SYNC_EMAIL`. ADAPT answers 200 with `type: "error"` for no
+ * match, an ambiguous match, or a wrong token.
+ *
+ * The Bearer token is ADAPT's `H5P_API_PASSWORD`, which Conductor already
+ * holds as `STUDIO_COMPAT_BASIC_PASSWORD` for `/studio/author`.
+ *
+ * Studio took the 50 most recently changed nodes without an id on every run,
+ * so a few activities ADAPT never imported held the queue forever. This
+ * records each attempt in `adaptIDCheckedAt` and takes the least recently
+ * checked first, so every activity gets its turn.
+ */
+
+const LOOKUP_URL = "https://adapt.libretexts.org/api/h5p-collections/get-adapt-question-id-by-h5p-id";
+const REQUEST_TIMEOUT_MS = 10_000;
+export const ADAPT_ID_SYNC_DEFAULT_LIMIT = 100;
+
+export class AdaptIdSyncNotConfiguredError extends Error {}
+
+export interface AdaptIdSyncResult {
+ checked: number;
+ found: number;
+ failed: number;
+}
+
+async function lookUpAdaptID(h5pContentID: number, token: string, email: string): Promise {
+ const res = await fetch(`${LOOKUP_URL}/${h5pContentID}`, {
+ method: "PATCH",
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json" },
+ body: JSON.stringify({ email }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ if (!res.ok) throw new Error(`ADAPT answered ${res.status}`);
+ const body: any = await res.json();
+ if (body?.type === "success" && body.adapt_question_id) return String(body.adapt_question_id);
+ if (body?.message === "Invalid bearer token.") throw new AdaptIdSyncNotConfiguredError("ADAPT refused the bearer token");
+ return null;
+}
+
+/**
+ * Looks up ADAPT ids for up to `limit` production activities that lack one.
+ * Throws outside production, before contacting ADAPT.
+ */
+export async function syncAdaptIDs(limit = ADAPT_ID_SYNC_DEFAULT_LIMIT): Promise {
+ assertAdaptIntegrationAllowed("ADAPT id sync");
+ const token = process.env.STUDIO_COMPAT_BASIC_PASSWORD;
+ const email = process.env.ADAPT_ID_SYNC_EMAIL;
+ if (!token || !email) {
+ throw new AdaptIdSyncNotConfiguredError("STUDIO_COMPAT_BASIC_PASSWORD or ADAPT_ID_SYNC_EMAIL is not set");
+ }
+
+ const batch = await Activity.find(
+ {
+ orgID: process.env.ORG_ID,
+ dataClass: "production",
+ adaptID: { $in: [null, ""] },
+ pendingFirstSave: { $ne: true },
+ },
+ { _id: 0, activityID: 1, h5pContentID: 1 }
+ )
+ .sort({ adaptIDCheckedAt: 1, updatedAt: -1 })
+ .limit(limit)
+ .lean();
+
+ const result: AdaptIdSyncResult = { checked: 0, found: 0, failed: 0 };
+ for (const activity of batch) {
+ let adaptID: string | null = null;
+ try {
+ adaptID = await lookUpAdaptID(activity.h5pContentID, token, email);
+ } catch (err) {
+ if (err instanceof AdaptIdSyncNotConfiguredError) throw err;
+ result.failed += 1;
+ log.warn({ err, h5pContentID: activity.h5pContentID }, "ADAPT id lookup failed; will retry on a later run");
+ continue;
+ }
+ // A found id moves `updatedAt`, as Studio's node save moved `changed`,
+ // so ADAPT's `/api/h5p/all?changed=` poll picks it up. A miss doesn't.
+ await Activity.updateOne(
+ { activityID: activity.activityID },
+ { $set: { adaptIDCheckedAt: new Date(), ...(adaptID ? { adaptID } : {}) } },
+ { timestamps: Boolean(adaptID) }
+ );
+ result.checked += 1;
+ if (adaptID) {
+ result.found += 1;
+ log.info({ h5pContentID: activity.h5pContentID, adaptID }, "Recorded ADAPT id");
+ }
+ }
+ log.info({ ...result, batch: batch.length }, "ADAPT id sync finished");
+ return result;
+}
diff --git a/server/api/services/client-config-service.ts b/server/api/services/client-config-service.ts
index 3d5191eaa..1a109c916 100644
--- a/server/api/services/client-config-service.ts
+++ b/server/api/services/client-config-service.ts
@@ -8,6 +8,10 @@ export default class ClientConfigService {
main_commons_url: process.env.CLIENT__MAIN_COMMONS_URL || "",
instructor_verification_url: process.env.CLIENT__INSTRUCTOR_VERIFICATION_URL || "",
central_identity_base_url: process.env.CLIENT__CENTRAL_IDENTITY_BASE_URL || "",
+ // Origin h5p-service serves embeds from, e.g. https://studio.libretexts.org.
+ h5p_embed_origin: process.env.CLIENT__H5P_EMBED_ORIGIN || "",
+ // Opens H5P authoring in the client. Off unless set, per environment.
+ h5p_authoring_enabled: process.env.CLIENT__H5P_AUTHORING_ENABLED === "true",
};
}
}
diff --git a/server/api/services/h5p-authoring-service.ts b/server/api/services/h5p-authoring-service.ts
new file mode 100644
index 000000000..52c09bcca
--- /dev/null
+++ b/server/api/services/h5p-authoring-service.ts
@@ -0,0 +1,159 @@
+import { SignJWT, importJWK, type KeyLike } from "jose";
+import { randomUUID } from "node:crypto";
+import { childLogger } from "../../logger.js";
+
+const log = childLogger("h5p-authoring");
+
+/**
+ * Conductor's half of H5P authoring: signing the capability tokens the H5P
+ * service accepts, and the two server-to-server calls Conductor makes to it.
+ *
+ * Conductor decides who may do what to which activity. The H5P service owns
+ * the content and verifies only that a token was signed by Conductor and
+ * grants the action on that content id. See H5P_AUTHORING_DESIGN.md in the
+ * migration workspace.
+ *
+ * Configuration, read when first needed rather than at boot, so an instance
+ * without authoring configured starts normally and answers authoring requests
+ * with a clear "not available":
+ *
+ * - `H5P_CAPABILITY_PRIVATE_KEY`: the Ed25519 signing key as a JWK, with a
+ * `kid`. A secret, injected from Secrets Manager. The H5P service holds the
+ * matching public key in `CAPABILITY_TOKEN_PUBLIC_KEYS`.
+ * - `H5P_SERVICE_URL`: the H5P service origin for server-to-server calls.
+ */
+
+const ISSUER = "conductor";
+const AUDIENCE = "h5p-service";
+
+/** Lifetime of an editor token. The editor asks for a new one before expiry. */
+export const EDITOR_TOKEN_TTL_SECONDS = 15 * 60;
+
+/** Lifetime of a token for one server-to-server call. */
+const SERVICE_TOKEN_TTL_SECONDS = 60;
+
+/** Subject of tokens Conductor's server uses for its own calls. */
+const SERVICE_SUBJECT = "conductor-service";
+
+const SERVICE_TIMEOUT_MS = 10_000;
+
+export type CapabilityAction = "edit" | "create" | "delete" | "allocate";
+
+export class AuthoringNotConfiguredError extends Error {}
+
+export class H5PServiceError extends Error {
+ constructor(message: string, readonly status?: number) {
+ super(message);
+ }
+}
+
+let signer: { key: KeyLike | Uint8Array; kid: string } | null = null;
+
+/** Whether this instance has what authoring needs. */
+export function isAuthoringConfigured(): boolean {
+ return Boolean(process.env.H5P_CAPABILITY_PRIVATE_KEY && process.env.H5P_SERVICE_URL);
+}
+
+async function getSigner(): Promise<{ key: KeyLike | Uint8Array; kid: string }> {
+ if (signer) return signer;
+ const raw = process.env.H5P_CAPABILITY_PRIVATE_KEY;
+ if (!raw) throw new AuthoringNotConfiguredError("H5P_CAPABILITY_PRIVATE_KEY is not set");
+ let jwk: any;
+ try {
+ jwk = JSON.parse(raw);
+ } catch {
+ throw new AuthoringNotConfiguredError("H5P_CAPABILITY_PRIVATE_KEY is not valid JSON");
+ }
+ if (jwk?.kty !== "OKP" || jwk?.crv !== "Ed25519" || typeof jwk?.d !== "string" || typeof jwk?.x !== "string") {
+ throw new AuthoringNotConfiguredError("H5P_CAPABILITY_PRIVATE_KEY must be an Ed25519 private JWK");
+ }
+ if (typeof jwk.kid !== "string" || !jwk.kid) {
+ throw new AuthoringNotConfiguredError("H5P_CAPABILITY_PRIVATE_KEY needs a kid matching the H5P service's public key");
+ }
+ signer = { key: await importJWK(jwk, "EdDSA"), kid: jwk.kid };
+ return signer;
+}
+
+function serviceUrl(): string {
+ const url = process.env.H5P_SERVICE_URL;
+ if (!url) throw new AuthoringNotConfiguredError("H5P_SERVICE_URL is not set");
+ return url.replace(/\/+$/, "");
+}
+
+/**
+ * Signs a capability token. `contentId` scopes `edit` and `delete` to one
+ * activity's H5P content; the H5P service refuses either without it.
+ */
+export async function signCapabilityToken(params: {
+ subject: string;
+ actions: CapabilityAction[];
+ contentId?: number;
+ ttlSeconds?: number;
+}): Promise<{ token: string; expiresAt: Date; tokenId: string }> {
+ const { key, kid } = await getSigner();
+ const now = Math.floor(Date.now() / 1000);
+ const ttl = params.ttlSeconds ?? EDITOR_TOKEN_TTL_SECONDS;
+ const tokenId = randomUUID();
+ const token = await new SignJWT({
+ act: params.actions,
+ ...(params.contentId !== undefined ? { cid: String(params.contentId) } : {}),
+ })
+ .setProtectedHeader({ alg: "EdDSA", kid })
+ .setIssuer(ISSUER)
+ .setAudience(AUDIENCE)
+ .setSubject(params.subject)
+ .setJti(tokenId)
+ .setIssuedAt(now)
+ .setExpirationTime(now + ttl)
+ .sign(key);
+ return { token, expiresAt: new Date((now + ttl) * 1000), tokenId };
+}
+
+async function callService(method: string, path: string, actions: CapabilityAction[], contentId?: number) {
+ const { token, tokenId } = await signCapabilityToken({
+ subject: SERVICE_SUBJECT,
+ actions,
+ contentId,
+ ttlSeconds: SERVICE_TOKEN_TTL_SECONDS,
+ });
+ try {
+ const res = await fetch(`${serviceUrl()}${path}`, {
+ method,
+ headers: { authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(SERVICE_TIMEOUT_MS),
+ });
+ return { res, tokenId };
+ } catch (err) {
+ if (err instanceof AuthoringNotConfiguredError) throw err;
+ throw new H5PServiceError(`H5P service unreachable: ${(err as Error).message}`);
+ }
+}
+
+/**
+ * Takes the next H5P content id for a new activity. The H5P service never
+ * hands out an id twice, so an id allocated and never saved is simply skipped.
+ */
+export async function allocateH5PContentId(): Promise {
+ const { res, tokenId } = await callService("POST", "/h5p/service/allocate", ["allocate"]);
+ const body: any = await res.json().catch(() => ({}));
+ const id = Number(body?.contentId);
+ if (res.status !== 201 || !Number.isInteger(id) || id <= 0) {
+ throw new H5PServiceError(`allocate returned ${res.status}`, res.status);
+ }
+ log.info({ contentId: id, jti: tokenId }, "Allocated H5P content id");
+ return id;
+}
+
+/**
+ * Deletes an activity's H5P content and files. A 404 counts as done: content
+ * that was allocated but never saved does not exist on the H5P service.
+ */
+export async function deleteH5PContent(contentId: number): Promise<"deleted" | "absent"> {
+ const { res, tokenId } = await callService("DELETE", `/h5p/service/content/${contentId}`, ["delete"], contentId);
+ if (res.status === 204) {
+ log.info({ contentId, jti: tokenId }, "Deleted H5P content");
+ return "deleted";
+ }
+ if (res.status === 404) return "absent";
+ throw new H5PServiceError(`delete returned ${res.status}`, res.status);
+}
diff --git a/server/api/studio-compat.ts b/server/api/studio-compat.ts
new file mode 100644
index 000000000..1dafa0381
--- /dev/null
+++ b/server/api/studio-compat.ts
@@ -0,0 +1,403 @@
+import { NextFunction, Request, Response } from "express";
+import { timingSafeEqual } from "node:crypto";
+import { childLogger } from "../logger.js";
+import Activity, { ActivityInterface } from "../models/activity.js";
+import ActivityCollection from "../models/activityCollection.js";
+import User from "../models/user.js";
+import CentralIdentityService from "./services/central-identity-service.js";
+import {
+ STUDIO_ORIGIN,
+ drupalBasicString,
+ drupalEscape,
+ drupalHtmlDatetimeSite,
+ drupalLabels,
+ drupalMediumSite,
+ drupalShortUTC,
+ studioDateFilterStart,
+} from "../util/studio-format.js";
+import { adaptDataFilter } from "../util/adapt-integration.js";
+
+const log = childLogger("studio-compat");
+
+/**
+ * Replacements for the five Studio API paths ADAPT calls. The Cloudflare
+ * Worker on studio.libretexts.org forwards each path here once it passes its
+ * diff against the responses captured from production (migration runbook,
+ * Phase 6 step 8). Until then the Worker sends them to frozen Studio.
+ *
+ * /api/h5p/all?changed=YYYY-MM-DD -> GET /api/v1/studio/h5p/all
+ * /api/h5p/{id} -> GET /api/v1/studio/h5p/{id}
+ * /api/author/{email} -> GET /api/v1/studio/author/{email}
+ * /api/collections/list -> GET /api/v1/studio/collections/list
+ * /api/collections/{cid} -> GET /api/v1/studio/collections/{cid}
+ *
+ * Every response is a bare JSON array, every value a string, missing values
+ * `""`, exactly as Studio's REST views produced them. ADAPT calls these with
+ * curl and no timeout, and treats anything it cannot decode as "no data", so
+ * shape matters more than elegance here. See `util/studio-format.ts` for the
+ * escaping and date formats.
+ *
+ * What cannot be reproduced, and does not matter to ADAPT:
+ * - `view_user` was Drupal's user page; there is no Drupal user id, so `""`.
+ * - Collection `thumbnail`s were Studio-hosted screenshots, not migrated, so
+ * `""`.
+ * - Studio's collection list repeated a collection once per subject/tag
+ * combination; here each appears once. ADAPT de-duplicates by `cid`.
+ */
+
+/** `api_h5p`'s REST display ran a 100-item pager, and ADAPT never pages. */
+const H5P_FEED_LIMIT = 100;
+
+const DAY = /^\d{4}-\d{2}-\d{2}$/;
+
+// ---------------------------------------------------------------- owners
+
+type Owner = { name: string; email: string };
+type OwnerRef = Pick;
+
+const libreOne = new CentralIdentityService();
+const libreOneCache = new Map();
+const LIBREONE_TTL_MS = 60 * 60 * 1000;
+const LIBREONE_CACHE_MAX = 5000;
+
+async function libreOneOwner(centralID: string): Promise {
+ const cached = libreOneCache.get(centralID);
+ if (cached && Date.now() - cached.at < LIBREONE_TTL_MS) return cached.owner;
+ let owner: Owner | null = null;
+ try {
+ const user = (await libreOne.getUser(centralID)).data?.data;
+ if (user) {
+ owner = {
+ name: [user.first_name, user.last_name].filter(Boolean).join(" "),
+ email: user.email ?? "",
+ };
+ }
+ } catch (err) {
+ log.debug({ err, centralID }, "LibreOne owner lookup failed");
+ }
+ if (libreOneCache.size >= LIBREONE_CACHE_MAX) libreOneCache.clear();
+ libreOneCache.set(centralID, { owner, at: Date.now() });
+ return owner;
+}
+
+/**
+ * Display name and email for each activity or collection owner, which Studio
+ * rendered from its user records. A local Conductor user first; for the
+ * authors with a LibreOne identity but no Conductor account, LibreOne.
+ */
+async function resolveOwners(refs: OwnerRef[]): Promise<(ref: OwnerRef) => Owner> {
+ const uuids = [...new Set(refs.map((r) => r.createdBy).filter(Boolean))] as string[];
+ const centralIDs = [...new Set(refs.map((r) => r.authorCentralID).filter(Boolean))] as string[];
+ const users = await User.find(
+ { $or: [{ uuid: { $in: uuids } }, { centralID: { $in: centralIDs } }] },
+ { _id: 0, uuid: 1, centralID: 1, firstName: 1, lastName: 1, email: 1 }
+ ).lean();
+ const byUUID = new Map();
+ const byCentral = new Map();
+ for (const u of users) {
+ const owner = { name: [u.firstName, u.lastName].filter(Boolean).join(" "), email: u.email ?? "" };
+ if (u.uuid) byUUID.set(u.uuid, owner);
+ if (u.centralID) byCentral.set(u.centralID, owner);
+ }
+ if (libreOne.isConfigured()) {
+ const missing = centralIDs.filter((id) => !byCentral.has(id));
+ for (let i = 0; i < missing.length; i += 5) {
+ await Promise.all(
+ missing.slice(i, i + 5).map(async (id) => {
+ const owner = await libreOneOwner(id);
+ if (owner) byCentral.set(id, owner);
+ })
+ );
+ }
+ }
+ const none: Owner = { name: "", email: "" };
+ return (ref) =>
+ (ref.createdBy && byUUID.get(ref.createdBy)) ||
+ (ref.authorCentralID && byCentral.get(ref.authorCentralID)) ||
+ none;
+}
+
+// ---------------------------------------------------------------- h5p
+
+const H5P_ROW_PROJECTION = {
+ _id: 0, h5pContentID: 1, title: 1, libraryTitle: 1, legacyLibraryID: 1, h5pAuthors: 1, license: 1,
+ source: 1, description: 1, createdAt: 1, updatedAt: 1, subjects: 1, tags: 1, legacyNodeUUID: 1,
+ legacyNodeID: 1, language: 1, createdBy: 1, authorCentralID: 1,
+};
+
+type ActivityRow = Pick<
+ ActivityInterface,
+ | "h5pContentID" | "title" | "libraryTitle" | "legacyLibraryID" | "h5pAuthors" | "license" | "source"
+ | "description" | "subjects" | "tags" | "legacyNodeUUID" | "legacyNodeID" | "language" | "createdBy"
+ | "authorCentralID"
+> & { createdAt?: Date; updatedAt?: Date };
+
+/** One row of Studio's `api/h5p` view, keys in Studio's order. */
+function h5pRow(a: ActivityRow, owner: Owner) {
+ const id = String(a.h5pContentID);
+ return {
+ id,
+ title_1: drupalEscape(a.title),
+ title: drupalEscape(a.title),
+ type: drupalEscape(a.libraryTitle),
+ type_id: a.legacyLibraryID !== undefined && a.legacyLibraryID !== null ? String(a.legacyLibraryID) : "",
+ uid: drupalEscape(owner.name),
+ authors: drupalBasicString(a.h5pAuthors),
+ license: drupalEscape(a.license?.name),
+ license_extras: drupalBasicString(a.license?.additionalTerms),
+ license_version: drupalEscape(a.license?.version),
+ h5p_source: drupalEscape(a.source),
+ body: a.description ?? "",
+ created: drupalShortUTC(a.createdAt),
+ changed: drupalShortUTC(a.updatedAt),
+ field_subject: drupalLabels(a.subjects),
+ field_tags: drupalLabels(a.tags),
+ uuid: a.legacyNodeUUID ?? "",
+ adapt_id: `h5p-${id}`,
+ view_node: `${STUDIO_ORIGIN}/h5p/${id}`,
+ mail: drupalBasicString(owner.email),
+ view_user: "",
+ default_language: drupalEscape(a.language),
+ };
+}
+
+/** Published in Studio's sense, with content saved, and fit for ADAPT to see. */
+const published = () => ({ visibility: "public", pendingFirstSave: { $ne: true }, ...adaptDataFilter() });
+
+/**
+ * `GET /studio/h5p/all?changed=YYYY-MM-DD` and `GET /studio/h5p/{id}`.
+ *
+ * Studio's `api/h5p/%` view: published activities, unlisted ones included,
+ * newest change first, 100 at most. `changed` is a lower bound measured from
+ * midnight US Eastern, which is how Drupal parsed it; ADAPT sends today's date,
+ * so it sees the newest 100 changes since midnight. `updatedAt` is Studio's
+ * `changed`: imported from it, and moved by every edit since.
+ */
+async function getStudioH5P(req: Request, res: Response) {
+ try {
+ const { idOrAll } = req.params;
+ const filter: Record = { orgID: process.env.ORG_ID, ...published() };
+ if (idOrAll !== "all") {
+ if (!/^\d+$/.test(idOrAll)) return res.json([]);
+ filter.h5pContentID = Number(idOrAll);
+ }
+ const changed = typeof req.query.changed === "string" ? req.query.changed : "";
+ if (DAY.test(changed)) filter.updatedAt = { $gte: studioDateFilterStart(changed) };
+
+ const activities = (await Activity.find(filter, H5P_ROW_PROJECTION)
+ .sort({ updatedAt: -1, h5pContentID: -1 })
+ .limit(H5P_FEED_LIMIT)
+ .lean()) as ActivityRow[];
+ const ownerOf = await resolveOwners(activities);
+ return res.json(activities.map((a) => h5pRow(a, ownerOf(a))));
+ } catch (err) {
+ log.error({ err }, "getStudioH5P failed");
+ return res.status(500).json([]);
+ }
+}
+
+// ---------------------------------------------------------------- author
+
+const basicUser = () => process.env.STUDIO_COMPAT_BASIC_USER;
+const basicPassword = () => process.env.STUDIO_COMPAT_BASIC_PASSWORD;
+
+function sameSecret(a: string, b: string): boolean {
+ const x = Buffer.from(a);
+ const y = Buffer.from(b);
+ return x.length === y.length && timingSafeEqual(x, y);
+}
+
+/**
+ * HTTP Basic auth for `/studio/author`, as Studio's view required. ADAPT
+ * sends `H5P_API_USERNAME` and `H5P_API_PASSWORD`; Conductor holds the same
+ * pair as `STUDIO_COMPAT_BASIC_USER` and `STUDIO_COMPAT_BASIC_PASSWORD`.
+ * Unset, the endpoint refuses everyone rather than serving unauthenticated.
+ */
+function requireStudioBasicAuth(req: Request, res: Response, next: NextFunction) {
+ const user = basicUser();
+ const password = basicPassword();
+ if (!user || !password) {
+ log.error("STUDIO_COMPAT_BASIC_USER or STUDIO_COMPAT_BASIC_PASSWORD is not set");
+ return res.status(503).json([]);
+ }
+ const header = req.headers.authorization ?? "";
+ const match = /^Basic ([A-Za-z0-9+/=]+)$/.exec(header);
+ if (match) {
+ const decoded = Buffer.from(match[1], "base64").toString("utf8");
+ const sep = decoded.indexOf(":");
+ if (sep > -1 && sameSecret(decoded.slice(0, sep), user) && sameSecret(decoded.slice(sep + 1), password)) {
+ return next();
+ }
+ }
+ res.setHeader("WWW-Authenticate", 'Basic realm="Studio API"');
+ return res.status(401).json([]);
+}
+
+/**
+ * `GET /studio/author/{email}`: Studio's `api/author/%` view. Published
+ * activities the user with that email owns, newest change first, as
+ * `[{ "h5p_id": "123" }]`. Activities they collaborate on are not included.
+ * ADAPT calls it on every question editor load.
+ */
+async function getStudioAuthorH5P(req: Request, res: Response) {
+ try {
+ const email = String(req.params.email ?? "").trim().toLowerCase();
+ if (!email || email.length > 254 || !email.includes("@")) return res.json([]);
+
+ const escaped = email.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const users = await User.find(
+ { email: { $regex: `^${escaped}$`, $options: "i" } },
+ { _id: 0, uuid: 1, centralID: 1 }
+ ).lean();
+ const uuids = users.map((u) => u.uuid).filter(Boolean);
+ const centralIDs = new Set(users.map((u) => u.centralID).filter(Boolean) as string[]);
+
+ // Authors with a LibreOne identity but no Conductor account own
+ // activities through `authorCentralID` only.
+ if (!users.length && libreOne.isConfigured()) {
+ try {
+ const found = (await libreOne.getUsers({ query: email, limit: 10 })).data?.data ?? [];
+ for (const u of found as any[]) {
+ if (typeof u?.email === "string" && u.email.toLowerCase() === email && u.uuid) centralIDs.add(u.uuid);
+ }
+ } catch (err) {
+ log.warn({ err }, "LibreOne email lookup failed");
+ }
+ }
+ if (!uuids.length && !centralIDs.size) return res.json([]);
+
+ const activities = await Activity.find(
+ {
+ orgID: process.env.ORG_ID,
+ ...published(),
+ $or: [{ createdBy: { $in: uuids } }, { authorCentralID: { $in: [...centralIDs] } }],
+ },
+ { _id: 0, h5pContentID: 1 }
+ )
+ .sort({ updatedAt: -1, h5pContentID: -1 })
+ .lean();
+ return res.json(activities.map((a) => ({ h5p_id: String(a.h5pContentID) })));
+ } catch (err) {
+ log.error({ err }, "getStudioAuthorH5P failed");
+ return res.status(500).json([]);
+ }
+}
+
+// ---------------------------------------------------------------- collections
+
+/**
+ * `GET /studio/collections/list`: Studio's `api/collections/list`. Collections
+ * that are published and included in the library, most recently changed
+ * first. `count` is how many activities the collection references.
+ */
+async function getStudioCollectionsList(_req: Request, res: Response) {
+ try {
+ const collections = await ActivityCollection.find(
+ {
+ orgID: process.env.ORG_ID,
+ visibility: "public",
+ includeInLibrary: { $ne: false },
+ legacyCollectionID: { $exists: true },
+ ...adaptDataFilter(),
+ },
+ {
+ _id: 0, legacyCollectionID: 1, title: 1, createdBy: 1, authorCentralID: 1, createdAt: 1, subjects: 1,
+ tags: 1, description: 1, oerReference: 1, oerReferenceTitle: 1, thumbnail: 1, subCollections: 1,
+ updatedAt: 1,
+ }
+ )
+ .sort({ updatedAt: -1, legacyCollectionID: -1 })
+ .lean();
+ const ownerOf = await resolveOwners(collections);
+ return res.json(
+ collections.map((c: any) => {
+ const cid = String(c.legacyCollectionID);
+ const count = (c.subCollections ?? []).reduce((n: number, s: any) => n + (s.h5pContentIDs?.length ?? 0), 0);
+ return {
+ title: drupalEscape(c.title),
+ url: `${STUDIO_ORIGIN}/collection/${cid}`,
+ cid,
+ author_name: drupalEscape(ownerOf(c).name),
+ date_created: drupalHtmlDatetimeSite(c.createdAt),
+ subject: drupalLabels(c.subjects),
+ tags: drupalLabels(c.tags),
+ description: drupalBasicString(c.description),
+ oer_reference: c.oerReference
+ ? `${drupalEscape(c.oerReferenceTitle || c.oerReference)} `
+ : "",
+ thumbnail: "",
+ count: String(count),
+ };
+ })
+ );
+ } catch (err) {
+ log.error({ err }, "getStudioCollectionsList failed");
+ return res.status(500).json([]);
+ }
+}
+
+/**
+ * `GET /studio/collections/{cid}`: Studio's `api/collections/%`. One row per
+ * activity reference, grouped by sub-collection in order, the activities in
+ * their order within it. ADAPT turns each `subcollection_title` into an
+ * assignment. A reference to an activity that is not in Conductor, which only
+ * happens after a sampled staging import, is left out.
+ */
+async function getStudioCollection(req: Request, res: Response) {
+ try {
+ const { cid } = req.params;
+ if (!/^\d+$/.test(cid)) return res.json([]);
+ const collection = await ActivityCollection.findOne(
+ { orgID: process.env.ORG_ID, legacyCollectionID: Number(cid), visibility: "public", ...adaptDataFilter() },
+ { _id: 0, subCollections: 1 }
+ ).lean();
+ if (!collection) return res.json([]);
+
+ const ids = [...new Set(collection.subCollections.flatMap((s) => s.h5pContentIDs))];
+ const activities = (await Activity.find(
+ { orgID: process.env.ORG_ID, h5pContentID: { $in: ids }, pendingFirstSave: { $ne: true }, ...adaptDataFilter() },
+ H5P_ROW_PROJECTION
+ ).lean()) as ActivityRow[];
+ const byID = new Map(activities.map((a) => [a.h5pContentID, a]));
+ const ownerOf = await resolveOwners(activities);
+
+ const rows = [];
+ for (const sub of collection.subCollections) {
+ for (const contentID of sub.h5pContentIDs) {
+ const a = byID.get(contentID);
+ if (!a) continue;
+ const id = String(contentID);
+ rows.push({
+ title: drupalEscape(a.title),
+ h5p_id: id,
+ uuid: a.legacyNodeUUID ?? "",
+ node_id: a.legacyNodeID !== undefined && a.legacyNodeID !== null ? String(a.legacyNodeID) : "",
+ adapt_id: `h5p-${id}`,
+ h5p_authors: drupalBasicString(a.h5pAuthors),
+ node_author: drupalEscape(ownerOf(a).name),
+ license: drupalEscape(a.license?.name),
+ type: drupalEscape(a.libraryTitle),
+ type_id: a.legacyLibraryID !== undefined && a.legacyLibraryID !== null ? String(a.legacyLibraryID) : "",
+ subcollection_title: drupalEscape(sub.title),
+ created: drupalMediumSite(a.createdAt),
+ changed: drupalMediumSite(a.updatedAt),
+ subject: drupalLabels(a.subjects),
+ tags: drupalLabels(a.tags),
+ view_node: `${STUDIO_ORIGIN}/h5p/${id}`,
+ });
+ }
+ }
+ return res.json(rows);
+ } catch (err) {
+ log.error({ err }, "getStudioCollection failed");
+ return res.status(500).json([]);
+ }
+}
+
+export default {
+ getStudioH5P,
+ getStudioAuthorH5P,
+ requireStudioBasicAuth,
+ getStudioCollectionsList,
+ getStudioCollection,
+};
diff --git a/server/api/studio-legacy.ts b/server/api/studio-legacy.ts
new file mode 100644
index 000000000..35c70f61f
--- /dev/null
+++ b/server/api/studio-legacy.ts
@@ -0,0 +1,106 @@
+import { Request, Response } from "express";
+import { z } from "zod";
+import { childLogger } from "../logger.js";
+import Activity from "../models/activity.js";
+import ActivityCollection from "../models/activityCollection.js";
+import StudioLegacyNode, {
+ normalizeStudioAlias,
+} from "../models/studioLegacyNode.js";
+import { resolveStudioLegacyPathSchema } from "./validators/studio-legacy.js";
+
+const log = childLogger("studio-legacy");
+
+/**
+ * Where resolved links land.
+ *
+ * The collection page is not designed yet, so collection links go to the
+ * Interactives listing for now. When `/interactives-collections/:collectionID`
+ * exists, set `COLLECTION_PAGE_LIVE` to true. Unresolved links also go to
+ * `/interactives`, the same page Cloudflare's catch-all sends every other
+ * Studio URL to.
+ */
+const COLLECTION_PAGE_LIVE = false;
+
+export const STUDIO_LEGACY_TARGETS = {
+ activity: (activityID: string) => `/interactive/${activityID}`,
+ collection: (collectionID: string) =>
+ COLLECTION_PAGE_LIVE ? `/interactives-collections/${collectionID}` : "/interactives",
+ fallback: "/interactives",
+};
+
+/**
+ * 302 until the Commons pages above exist and their paths are final. Browsers
+ * cache a 301 indefinitely, so a permanent redirect to a path that later
+ * changes could never be corrected for anyone who had followed it.
+ */
+const REDIRECT_STATUS = 302;
+
+const NUMERIC = /^\d+$/;
+
+/**
+ * Resolves an old `studio.libretexts.org` page URL to its Conductor page.
+ *
+ * Cloudflare Single Redirects send `/h5p/{alias}`, `/node/{nid}` and
+ * `/collection/{alias}` here as `/commons/studio-legacy/{kind}/{value}`. The
+ * alias table goes first because the number in a `/h5p/` URL is not reliably
+ * a content id (see `models/studioLegacyNode.ts`). Without a match, the URL
+ * is read the way Studio's current pathauto patterns built it.
+ *
+ * Always a redirect, never an error page: a dead Studio link lands on
+ * `/interactives`. Visibility is left to the destination page, which already enforces
+ * it, so a private activity's link reaches its page and that page decides.
+ */
+async function resolveStudioLegacyPath(
+ req: Request & z.infer,
+ res: Response
+) {
+ const { kind, value } = req.params;
+ const legacyPath = normalizeStudioAlias(`/${kind}/${value}`);
+ const orgID = process.env.ORG_ID;
+
+ try {
+ const node =
+ kind === "node" && NUMERIC.test(value)
+ ? await StudioLegacyNode.findOne({ nid: Number(value), orgID }).lean()
+ : await StudioLegacyNode.findOne({ aliases: legacyPath, orgID }).lean();
+
+ let h5pContentID: number | undefined;
+ let collectionNid: number | undefined;
+ if (node?.kind === "activity") h5pContentID = node.h5pContentID;
+ else if (node?.kind === "collection") collectionNid = node.nid;
+ else if (!node && kind === "h5p" && NUMERIC.test(value)) h5pContentID = Number(value);
+ else if (!node && kind === "collection" && NUMERIC.test(value)) collectionNid = Number(value);
+
+ let target: string | undefined;
+ if (h5pContentID) {
+ const activity = await Activity.findOne(
+ { h5pContentID, orgID },
+ { _id: 0, activityID: 1 }
+ ).lean();
+ if (activity) target = STUDIO_LEGACY_TARGETS.activity(activity.activityID);
+ } else if (collectionNid) {
+ const collection = await ActivityCollection.findOne(
+ { legacyCollectionID: collectionNid, orgID },
+ { _id: 0, collectionID: 1 }
+ ).lean();
+ if (collection) target = STUDIO_LEGACY_TARGETS.collection(collection.collectionID);
+ }
+
+ if (!target) {
+ log.info(
+ { legacyPath, matchedNid: node?.nid, h5pContentID, collectionNid },
+ "Studio legacy path did not resolve"
+ );
+ return res.redirect(REDIRECT_STATUS, STUDIO_LEGACY_TARGETS.fallback);
+ }
+ log.debug({ legacyPath, target }, "Resolved Studio legacy path");
+ return res.redirect(REDIRECT_STATUS, target);
+ } catch (err) {
+ log.error({ err, legacyPath }, "Studio legacy path resolution failed");
+ return res.redirect(REDIRECT_STATUS, STUDIO_LEGACY_TARGETS.fallback);
+ }
+}
+
+export default {
+ resolveStudioLegacyPath,
+};
diff --git a/server/api/validators/activities.ts b/server/api/validators/activities.ts
new file mode 100644
index 000000000..a5797d053
--- /dev/null
+++ b/server/api/validators/activities.ts
@@ -0,0 +1,165 @@
+import { z } from "zod";
+import conductorErrors from "../../conductor-errors.js";
+import { PaginationSchema, SortDirection } from "./misc.js";
+
+/**
+ * Conductor-side activity identifier: the H5P content id as a string, the
+ * number authors already know from Studio and from embed URLs.
+ */
+const activityIDSchema = z.string().regex(/^[1-9][0-9]{0,9}$/, { message: conductorErrors.err1 });
+
+/**
+ * The H5P content id.
+ *
+ * Accepted as a path parameter because it is the identifier published embeds and
+ * ADAPT already use. Coerced because it always arrives as a string on the wire,
+ * and bounded to a positive integer so a malformed embed URL fails validation
+ * rather than reaching a query.
+ */
+const h5pContentIDSchema = z.coerce
+ .number()
+ .int()
+ .positive({ message: conductorErrors.err1 });
+
+const activityIDParamsSchema = z.object({
+ activityID: activityIDSchema,
+});
+
+const h5pContentIDParamsSchema = z.object({
+ h5pContentID: h5pContentIDSchema,
+});
+
+/**
+ * Shared listing controls. `library` filters by H5P content type, which is the
+ * facet Studio's `/library` page exposed as its "H5P Type" checkbox group.
+ */
+const listActivitiesQuerySchema = z.intersection(
+ z.object({
+ query: z
+ .string()
+ .max(100, { message: conductorErrors.err1 })
+ .or(z.literal(""))
+ .optional(),
+ library: z.string().max(100, { message: conductorErrors.err1 }).optional(),
+ subject: z.string().max(100, { message: conductorErrors.err1 }).optional(),
+ license: z.string().max(100, { message: conductorErrors.err1 }).optional(),
+ sort: z.enum(["title", "updated", "created"]).optional().default("updated"),
+ sortDirection: SortDirection.optional().default("descending"),
+ }),
+ PaginationSchema
+);
+
+export const getCommonsActivitiesSchema = z.object({
+ query: listActivitiesQuerySchema,
+});
+
+export const getActivitySchema = z.object({
+ params: activityIDParamsSchema,
+});
+
+/**
+ * Resolves an activity by the identifier baked into published embeds.
+ */
+export const getActivityByH5PIDSchema = z.object({
+ params: h5pContentIDParamsSchema,
+});
+
+export const getActivitiesByAuthorSchema = z.object({
+ params: z.object({
+ uuid: z.string().uuid({ message: conductorErrors.err1 }),
+ }),
+ query: PaginationSchema,
+});
+
+/**
+ * The signed-in user's own activities, for My H5P.
+ *
+ * `visibility` uses the three levels the UI shows, which map onto the stored
+ * fields: public = public and in the library, unlisted = public and not in the
+ * library, private = private.
+ */
+export const getMyActivitiesSchema = z.object({
+ query: z.intersection(
+ z.object({
+ query: z
+ .string()
+ .max(100, { message: conductorErrors.err1 })
+ .or(z.literal(""))
+ .optional(),
+ visibility: z.enum(["all", "public", "unlisted", "private"]).optional().default("all"),
+ sort: z.enum(["updated", "title", "created"]).optional().default("updated"),
+ }),
+ PaginationSchema.extend({
+ limit: z.coerce.number().int().min(1).max(100).default(24),
+ })
+ ),
+});
+
+/**
+ * The ADAPT reconciliation feed.
+ *
+ * Studio served this as an unpaginated CSV over every H5P node regardless of
+ * publication state, and ADAPT consumes it that way, so the schema deliberately
+ * exposes no pagination or filtering. `format` exists only so the endpoint can
+ * also be read as JSON during cutover verification; it defaults to the CSV that
+ * ADAPT expects.
+ */
+export const getAdaptFeedSchema = z.object({
+ query: z.object({
+ format: z.enum(["csv", "json"]).optional().default("csv"),
+ }),
+});
+
+/**
+ * Metadata an author edits on the create/edit screen. Every field optional,
+ * so update can send only what changed. `visibility` is the UI's three
+ * levels; see `getMyActivitiesSchema` for how they map onto stored fields.
+ */
+const activityMetadataSchema = z.object({
+ title: z.string().trim().min(1, { message: conductorErrors.err1 }).max(255, { message: conductorErrors.err1 }).optional(),
+ description: z.string().max(10000, { message: conductorErrors.err1 }).optional(),
+ subjects: z.array(z.string().trim().min(1).max(100)).max(10, { message: conductorErrors.err1 }).optional(),
+ tags: z.array(z.string().trim().min(1).max(64)).max(30, { message: conductorErrors.err1 }).optional(),
+ language: z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, { message: conductorErrors.err1 }).optional(),
+ publisher: z.string().max(255, { message: conductorErrors.err1 }).optional(),
+ visibility: z.enum(["public", "unlisted", "private"]).optional(),
+ license: z
+ .object({
+ name: z.string().min(1).max(32),
+ version: z.string().max(10).optional(),
+ additionalTerms: z.string().max(2000).optional(),
+ })
+ .optional(),
+});
+
+export const createActivitySchema = z.object({
+ body: activityMetadataSchema,
+});
+
+/**
+ * `contentSaved` carries what the H5P editor reported after a successful
+ * save (its `saved` message), so the Activity's title, content type and
+ * license follow the content. Sending it also clears `pendingFirstSave`.
+ */
+export const updateActivitySchema = z.object({
+ params: activityIDParamsSchema,
+ body: activityMetadataSchema.extend({
+ contentSaved: z
+ .object({
+ title: z.string().trim().min(1).max(255),
+ library: z.string().regex(/^[A-Za-z][\w.-]* \d+\.\d+$/, { message: conductorErrors.err1 }),
+ libraryTitle: z.string().trim().min(1).max(255).optional(),
+ license: z.string().max(32).optional(),
+ licenseVersion: z.string().max(10).optional(),
+ authors: z
+ .array(z.object({ name: z.string().max(255), role: z.string().max(64).optional() }).passthrough())
+ .max(50)
+ .optional(),
+ })
+ .optional(),
+ }),
+});
+
+export const activityIDOnlySchema = z.object({
+ params: activityIDParamsSchema,
+});
diff --git a/server/api/validators/studio-legacy.ts b/server/api/validators/studio-legacy.ts
new file mode 100644
index 000000000..e7227c6e8
--- /dev/null
+++ b/server/api/validators/studio-legacy.ts
@@ -0,0 +1,17 @@
+import { z } from "zod";
+import conductorErrors from "../../conductor-errors.js";
+
+/**
+ * An old Studio page URL, split by the edge redirect into its first path
+ * segment and the rest. Only the three shapes the edge sends are accepted.
+ * `value` is one path segment: Studio's aliases never nest below these.
+ */
+export const resolveStudioLegacyPathSchema = z.object({
+ params: z.object({
+ kind: z.enum(["h5p", "node", "collection"]),
+ value: z
+ .string()
+ .min(1, { message: conductorErrors.err1 })
+ .max(200, { message: conductorErrors.err1 }),
+ }),
+});
diff --git a/server/conductor-errors.ts b/server/conductor-errors.ts
index 0aa325f2f..407e92564 100644
--- a/server/conductor-errors.ts
+++ b/server/conductor-errors.ts
@@ -99,6 +99,8 @@ const conductorErrors = {
err90: "Cannot get embed code for non-public resource.",
err91: "Sorry, you can't remove your own role(s). Please contact our Support Center for assistance.",
err92: "Sorry, an internal error occurred as a result of internal misconfiguration. Please contact our Support Center for assistance.",
+ err93: "Sorry, creating and editing H5P activities is not available right now. Please try again later.",
+ err94: "Sorry, only verified instructors can create H5P activities. You can request verification from your LibreOne account.",
};
export default conductorErrors;
diff --git a/server/migrations/StudioArchiveExport.ts b/server/migrations/StudioArchiveExport.ts
new file mode 100644
index 000000000..d3c285fad
--- /dev/null
+++ b/server/migrations/StudioArchiveExport.ts
@@ -0,0 +1,299 @@
+/**
+ * Studio learner-data cold archive. READ-ONLY against Studio.
+ *
+ * Exports the learner result data that is not being imported into Conductor,
+ * so the Studio host can be decommissioned without destroying it. ADAPT holds
+ * the grading of record, which makes these rows a by-product rather than a
+ * system of record, but they are not discardable either.
+ *
+ * What it exports (row counts from the 2026-10-22 production survey):
+ * h5p_points 1,131,666 rows learner results
+ * h5p_content_user_data 15,644 rows saved player state
+ * users 673,977 rows uid -> LibreOne UUID + email
+ *
+ * The users export is what makes the other two legible years from now, since
+ * both key on Drupal's integer uid, which stops meaning anything the moment
+ * Studio is gone. It is also the file that carries personal data: roughly 674k
+ * email addresses. It is written as a separate object so access to it can be
+ * controlled separately from the result rows, and the upload applies
+ * server-side encryption. Set a retention period on the prefix; this script
+ * deliberately does not choose one for you.
+ *
+ * STUDIO_MYSQL_HOST=... STUDIO_MYSQL_USER=... STUDIO_MYSQL_PASSWORD=... \
+ * STUDIO_MYSQL_DATABASE=studio_main \
+ * ARCHIVE_S3_BUCKET=libretexts-studio-archive \
+ * npx tsx server/migrations/StudioArchiveExport.ts
+ *
+ * Without ARCHIVE_S3_BUCKET it writes the files locally and stops, so the
+ * export can be inspected before anything leaves the machine.
+ *
+ * Rows are streamed from MySQL and gzipped on the way to disk. 1.1M rows must
+ * never be buffered in memory, and `mysql2`'s default query path would do
+ * exactly that.
+ */
+import fs from "node:fs";
+import path from "node:path";
+import crypto from "node:crypto";
+import zlib from "node:zlib";
+import { pipeline } from "node:stream/promises";
+import { Transform } from "node:stream";
+import mysql from "mysql2/promise";
+import {
+ PutObjectCommand,
+ S3Client,
+ type StorageClass,
+} from "@aws-sdk/client-s3";
+import logger, { childLogger } from "../logger.js";
+
+const log = childLogger("studio-archive");
+
+type ExportSpec = {
+ /** Base filename, without extension. */
+ name: string;
+ /** What this table holds, recorded in the manifest. */
+ description: string;
+ sql: string;
+ /** True when the export contains personal data. */
+ sensitive: boolean;
+};
+
+const EXPORTS: ExportSpec[] = [
+ {
+ name: "h5p_points",
+ description:
+ "Learner results: one row per (content, user) with score and timing.",
+ sql: `SELECT content_id, uid, started, finished, points, max_points
+ FROM h5p_points`,
+ sensitive: false,
+ },
+ {
+ name: "h5p_content_user_data",
+ description:
+ "Saved player state. Retained despite h5p_save_content_state being off, because rows exist.",
+ sql: `SELECT user_id, content_main_id, sub_content_id, data_id,
+ timestamp, data, preloaded, delete_on_content_change
+ FROM h5p_content_user_data`,
+ sensitive: false,
+ },
+ {
+ name: "users",
+ description:
+ "Drupal uid to LibreOne UUID and email. Required to interpret the other two exports. CONTAINS PERSONAL DATA.",
+ sql: `SELECT uid, name AS libreone_uuid, mail AS email, created, access, login
+ FROM users_field_data
+ WHERE default_langcode = 1`,
+ sensitive: true,
+ },
+];
+
+type ExportResult = {
+ name: string;
+ description: string;
+ sensitive: boolean;
+ file: string;
+ rows: number;
+ bytesGzipped: number;
+ sha256: string;
+};
+
+/**
+ * Streams one query to a gzipped JSON Lines file, hashing the compressed bytes
+ * as they are written so the manifest can record an integrity check without a
+ * second pass over the file.
+ */
+async function exportTable(
+ conn: mysql.Connection,
+ spec: ExportSpec,
+ outDir: string
+): Promise {
+ const file = path.join(outDir, `${spec.name}.jsonl.gz`);
+ let rows = 0;
+
+ // The raw connection exposes the streaming query API; the promise wrapper
+ // does not. Without this the whole result set is buffered.
+ const source = (conn as any).connection.query(spec.sql).stream();
+
+ const toJsonl = new Transform({
+ objectMode: true,
+ transform(row, _enc, cb) {
+ rows += 1;
+ if (rows % 100000 === 0) {
+ log.info({ table: spec.name, rows }, "Export progress");
+ }
+ cb(null, JSON.stringify(row) + "\n");
+ },
+ });
+
+ const hash = crypto.createHash("sha256");
+ const hashing = new Transform({
+ transform(chunk, _enc, cb) {
+ hash.update(chunk);
+ cb(null, chunk);
+ },
+ });
+
+ await pipeline(
+ source,
+ toJsonl,
+ zlib.createGzip({ level: 9 }),
+ hashing,
+ fs.createWriteStream(file)
+ );
+
+ const bytesGzipped = fs.statSync(file).size;
+ log.info(
+ { table: spec.name, rows, bytesGzipped },
+ "Export complete"
+ );
+
+ return {
+ name: spec.name,
+ description: spec.description,
+ sensitive: spec.sensitive,
+ file: path.basename(file),
+ rows,
+ bytesGzipped,
+ sha256: hash.digest("hex"),
+ };
+}
+
+/**
+ * Uploads one file to S3 under the archive prefix.
+ *
+ * `PutObject` with a read stream rather than a buffer, so the largest export
+ * never has to fit in memory. `ContentLength` is required because a streamed
+ * body cannot be sized by the SDK.
+ */
+async function upload(
+ s3: S3Client,
+ bucket: string,
+ prefix: string,
+ filePath: string,
+ storageClass: StorageClass
+): Promise {
+ const key = `${prefix}/${path.basename(filePath)}`;
+ const { size } = fs.statSync(filePath);
+
+ await s3.send(
+ new PutObjectCommand({
+ Bucket: bucket,
+ Key: key,
+ Body: fs.createReadStream(filePath),
+ ContentLength: size,
+ ContentType: filePath.endsWith(".gz")
+ ? "application/gzip"
+ : "application/json",
+ StorageClass: storageClass,
+ // Encrypt at rest. A KMS key is used when one is configured, so the
+ // personal-data export can be gated by key policy rather than only by
+ // bucket policy.
+ ...(process.env.ARCHIVE_KMS_KEY_ID
+ ? {
+ ServerSideEncryption: "aws:kms" as const,
+ SSEKMSKeyId: process.env.ARCHIVE_KMS_KEY_ID,
+ }
+ : { ServerSideEncryption: "AES256" as const }),
+ })
+ );
+
+ log.info({ key, bytes: size, storageClass }, "Uploaded archive object");
+}
+
+export async function runMigration(): Promise {
+ const mysqlConfig = {
+ host: process.env.STUDIO_MYSQL_HOST,
+ port: parseInt(process.env.STUDIO_MYSQL_PORT || "3306", 10),
+ user: process.env.STUDIO_MYSQL_USER,
+ password: process.env.STUDIO_MYSQL_PASSWORD,
+ database: process.env.STUDIO_MYSQL_DATABASE,
+ };
+
+ if (!mysqlConfig.host || !mysqlConfig.user || !mysqlConfig.database) {
+ throw new Error(
+ "Missing STUDIO_MYSQL_HOST, STUDIO_MYSQL_USER or STUDIO_MYSQL_DATABASE."
+ );
+ }
+
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const outDir = path.resolve("migration-reports", `studio-archive-${stamp}`);
+ fs.mkdirSync(outDir, { recursive: true });
+
+ log.info({ outDir }, "Starting Studio cold archive export");
+
+ const conn = await mysql.createConnection(mysqlConfig);
+ const results: ExportResult[] = [];
+ try {
+ await conn.query("SET SESSION TRANSACTION READ ONLY");
+ for (const spec of EXPORTS) {
+ results.push(await exportTable(conn, spec, outDir));
+ }
+ } finally {
+ await conn.end();
+ }
+
+ const manifest = {
+ archiveVersion: 1,
+ generatedAt: new Date().toISOString(),
+ source: {
+ host: mysqlConfig.host,
+ database: mysqlConfig.database,
+ },
+ purpose:
+ "Cold archive of LibreTexts Studio learner data, retained at decommission. ADAPT holds the grading of record; these rows are a by-product.",
+ readMe:
+ "Each .jsonl.gz holds one JSON object per line. h5p_points and h5p_content_user_data key on Drupal's integer uid, which is only interpretable via users.jsonl.gz.",
+ containsPersonalData: results.some((r) => r.sensitive),
+ retention:
+ "Set deliberately on the bucket prefix. This export does not choose one.",
+ exports: results,
+ };
+
+ const manifestPath = path.join(outDir, "manifest.json");
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
+
+ const totalRows = results.reduce((n, r) => n + r.rows, 0);
+ const totalBytes = results.reduce((n, r) => n + r.bytesGzipped, 0);
+ log.info({ totalRows, totalBytes, outDir }, "Archive written locally");
+
+ const bucket = process.env.ARCHIVE_S3_BUCKET;
+ if (!bucket) {
+ log.warn(
+ { outDir },
+ "ARCHIVE_S3_BUCKET not set; archive written locally and NOT uploaded"
+ );
+ process.stdout.write(
+ `\nArchive written to ${outDir}\n` +
+ `${totalRows} rows, ${(totalBytes / 1024 / 1024).toFixed(1)} MB gzipped.\n` +
+ `Set ARCHIVE_S3_BUCKET to upload.\n`
+ );
+ return;
+ }
+
+ // Deep Archive by default: this is data we are keeping precisely because we
+ // do not expect to read it. Retrieval takes hours, which is the right trade
+ // for a by-product dataset.
+ const storageClass = (process.env.ARCHIVE_S3_STORAGE_CLASS ||
+ "DEEP_ARCHIVE") as StorageClass;
+ const prefix = `studio-archive/${stamp}`;
+ const s3 = new S3Client({ region: process.env.AWS_REGION || "us-west-2" });
+
+ for (const r of results) {
+ await upload(s3, bucket, prefix, path.join(outDir, r.file), storageClass);
+ }
+ // The manifest stays instantly readable, so the archive can be inspected
+ // without a Deep Archive restore.
+ await upload(s3, bucket, prefix, manifestPath, "STANDARD" as StorageClass);
+
+ process.stdout.write(
+ `\nArchive uploaded to s3://${bucket}/${prefix}/\n` +
+ `${totalRows} rows, ${(totalBytes / 1024 / 1024).toFixed(1)} MB gzipped.\n` +
+ `Local copy: ${outDir}\n`
+ );
+}
+
+if (process.argv[1] && process.argv[1].includes("StudioArchiveExport")) {
+ runMigration().catch((err) => {
+ logger.error({ err }, "Studio archive export failed");
+ process.exitCode = 1;
+ });
+}
diff --git a/server/migrations/StudioImportActivities.ts b/server/migrations/StudioImportActivities.ts
new file mode 100644
index 000000000..4fb2dbed5
--- /dev/null
+++ b/server/migrations/StudioImportActivities.ts
@@ -0,0 +1,889 @@
+/**
+ * Studio -> Conductor activity import. DRY RUN BY DEFAULT.
+ *
+ * Creates the Conductor `Activity` records for H5P activities migrated from
+ * LibreTexts Studio. Reads Studio MySQL read-only and writes nothing unless
+ * `--commit` is passed; without it the script reports exactly what it would do
+ * and exits.
+ *
+ * This is one of three migration phases and the only one that touches
+ * Conductor:
+ *
+ * A. Content and library FILES -> `aws s3 sync` into the h5p-service bucket
+ * B. H5P content and library RECORDS -> h5p-service Mongo, via its own
+ * storage classes so ids are preserved through `addContent(..., id)`
+ * C. Conductor `Activity` records -> this script
+ *
+ * A and B make embeds work and are what lets the Studio host be shut down. C
+ * makes activities appear in the Commons catalog. They are independent and can
+ * run in any order.
+ *
+ * STUDIO_MYSQL_HOST=... STUDIO_MYSQL_USER=... STUDIO_MYSQL_PASSWORD=... \
+ * STUDIO_MYSQL_DATABASE=studio_main \
+ * npx tsx --env-file=.env server/migrations/StudioImportActivities.ts
+ * # ... --commit
+ *
+ * Decisions this implements, all settled by the 2026-10-22 production survey:
+ *
+ * Author resolution runs in four tiers. `authorCentralID` is the
+ * authoritative author field and is always set when known;
+ * `createdBy` (Conductor-local uuid) is filled in only when a local User
+ * exists, because 87 authors have a valid LibreOne identity and no Conductor
+ * account. Provisioning accounts here would be the wrong trade.
+ *
+ * 674 content ids are referenced by more than one live node. Studio already
+ * treats the content id as the identity (both the public page and the embed
+ * resolve by it), so one canonical node wins per content id and the rest are
+ * reported with their nids so inbound links can be redirected. Tie-break:
+ * published, then has an ADAPT id, then lowest nid.
+ *
+ * 258 content rows have no node and are deliberately NOT imported here. They
+ * stay embeddable via phase B, which is their current state in Studio, but
+ * there is no node to build a catalog record from. 66 nodes point at missing
+ * content and are skipped, since there is nothing to serve.
+ *
+ * Idempotent: records are upserted on `h5pContentID`. The `activityID` is
+ * the content id as a string, so a rerun always writes the same one, and
+ * rewrites any record still carrying an older random id.
+ */
+import fs from "node:fs";
+import path from "node:path";
+import mongoose from "mongoose";
+import mysql from "mysql2/promise";
+import logger, { childLogger } from "../logger.js";
+import Activity from "../models/activity.js";
+import User from "../models/user.js";
+import StudioPseudonymizer from "./StudioPseudonymizer.js";
+import dotenv from "dotenv";
+dotenv.config();
+
+const log = childLogger("studio-import");
+
+const COMMIT = process.argv.includes("--commit");
+const WITH_IDENTITIES = process.argv.includes("--with-identities");
+const FULL = process.argv.includes("--full");
+const IS_PRODUCTION = (process.env.APP_ENV ?? "production") === "production";
+
+/**
+ * Default sample size for non-production imports.
+ *
+ * Big enough to exercise pagination, search and the catalog realistically,
+ * small enough to re-seed quickly. `--full` overrides it.
+ */
+const DEFAULT_SAMPLE = 2500;
+
+function parseSampleSize(): number {
+ if (FULL) return Infinity;
+ const arg = process.argv.find((a) => a.startsWith("--sample="));
+ if (arg) {
+ const n = parseInt(arg.split("=")[1], 10);
+ if (!Number.isFinite(n) || n <= 0) {
+ throw new Error("--sample= requires a positive integer.");
+ }
+ return n;
+ }
+ return WITH_IDENTITIES ? Infinity : DEFAULT_SAMPLE;
+}
+
+/**
+ * Decides whether this run produces real or pseudonymised identities, and
+ * refuses the two combinations that would cross the dev/production line.
+ *
+ * `APP_ENV` defaults to "production" when unset, so it cannot be the only
+ * guard: an unconfigured box would read as production and happily import real
+ * identities. Real data therefore needs the explicit flag AND the environment,
+ * and the default with no flag is always pseudonymised.
+ */
+export function resolveMode(): { anonymize: boolean; dataClass: "production" | "synthetic" } {
+ if (WITH_IDENTITIES && !IS_PRODUCTION) {
+ throw new Error(
+ "Refusing to import real identities outside production. " +
+ `APP_ENV is "${process.env.APP_ENV ?? "(unset)"}". Drop --with-identities.`
+ );
+ }
+ if (!WITH_IDENTITIES && IS_PRODUCTION && COMMIT) {
+ throw new Error(
+ "Refusing to write synthetic records to production. " +
+ "Pass --with-identities for a production import, or set APP_ENV for a non-production target."
+ );
+ }
+ return WITH_IDENTITIES
+ ? { anonymize: false, dataClass: "production" }
+ : { anonymize: true, dataClass: "synthetic" };
+}
+
+/** Studio row shapes, as the queries below return them. */
+type StudioNode = {
+ nid: number;
+ uid: number;
+ status: number;
+ title: string | null;
+ created: number | null;
+ changed: number | null;
+ uuid: string | null;
+ h5p_content_id: number;
+};
+
+type StudioContent = {
+ id: number;
+ title: string | null;
+ library_id: number;
+ authors: string | null;
+ source: string | null;
+ license: string | null;
+ license_version: string | null;
+ license_extras: string | null;
+ default_language: string | null;
+ library_title: string | null;
+};
+
+export type AuthorResolution = {
+ centralID?: string;
+ conductorUUID?: string;
+ tier: 1 | 2 | 3 | 4 | 0;
+};
+
+/**
+ * Loads one Drupal field table into a map keyed by node id.
+ *
+ * Wrapped so a field table that does not exist, or whose column name differs
+ * from what was inferred, degrades to "this field is missing" rather than
+ * aborting an import of 30,000 records.
+ */
+export async function loadFieldMap(
+ conn: mysql.Connection,
+ label: string,
+ sql: string,
+ pick: (row: any) => [number, T]
+): Promise> {
+ const map = new Map();
+ try {
+ const [rows] = await conn.query(sql);
+ for (const row of rows as any[]) {
+ const [nid, value] = pick(row);
+ const list = map.get(nid);
+ if (list) list.push(value);
+ else map.set(nid, [value]);
+ }
+ log.info({ field: label, nodes: map.size }, "Loaded field table");
+ } catch (err) {
+ log.warn({ field: label, err }, "Field table unavailable; skipping");
+ }
+ return map;
+}
+
+/**
+ * Builds the Studio-uid to Conductor-identity map.
+ *
+ * Tier 1 is the LibreOne UUID stored as the Drupal username. Tier 2 recovers
+ * pre-CAS accounts through the one-time mapping file Studio shipped. Tier 3 is
+ * a valid LibreOne identity with no Conductor account yet. Tier 4 falls back to
+ * email. Tier 0 means unresolvable.
+ */
+export async function buildAuthorMap(
+ conn: mysql.Connection,
+ casCsvPath: string
+): Promise> {
+ const [userRows] = await conn.query(
+ `SELECT uid, name, mail FROM users_field_data WHERE default_langcode = 1`
+ );
+ const users = userRows as { uid: number; name: string; mail: string }[];
+
+ const isUUID = (v: string) =>
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ v || ""
+ );
+
+ // The pre-CAS username to LibreOne UUID mapping Studio shipped. Absent in
+ // most environments, which only costs tier 2.
+ const cas = new Map();
+ try {
+ const raw = fs.readFileSync(casCsvPath, "utf-8");
+ for (const line of raw.split(/\r?\n/)) {
+ const [name, uuid] = line.split(",");
+ if (name?.trim() && uuid?.trim()) {
+ cas.set(name.trim().toLowerCase(), uuid.trim());
+ }
+ }
+ log.info({ entries: cas.size, casCsvPath }, "Loaded CAS username map");
+ } catch {
+ log.warn({ casCsvPath }, "CAS username map not found; tier 2 unavailable");
+ }
+
+ // Candidate centralIDs, then one lookup against Conductor rather than
+ // 784 round trips.
+ const candidates = new Set();
+ const emails = new Set();
+ for (const u of users) {
+ if (isUUID(u.name)) candidates.add(u.name);
+ else {
+ const mapped = cas.get((u.name || "").toLowerCase());
+ if (mapped) candidates.add(mapped);
+ }
+ if (u.mail) emails.add(u.mail.toLowerCase());
+ }
+
+ const byCentralID = new Map();
+ const byEmail = new Map();
+ if (mongoose.connection.readyState === 1) {
+ const matched = await User.find(
+ {
+ $or: [
+ { centralID: { $in: [...candidates] } },
+ { email: { $in: [...emails] } },
+ ],
+ },
+ { _id: 0, uuid: 1, centralID: 1, email: 1 }
+ ).lean();
+ for (const u of matched) {
+ if (u.centralID) byCentralID.set(u.centralID, u.uuid);
+ if (u.email) {
+ byEmail.set(u.email.toLowerCase(), {
+ uuid: u.uuid,
+ centralID: u.centralID,
+ });
+ }
+ }
+ log.info({ matched: matched.length }, "Matched Conductor users");
+ } else {
+ log.warn("No Mongo connection; every author will resolve as tier 0");
+ }
+
+ const out = new Map();
+ for (const u of users) {
+ if (isUUID(u.name)) {
+ const uuid = byCentralID.get(u.name);
+ // A LibreOne id with no Conductor account is tier 3, not a failure: the
+ // attribution is still authoritative.
+ out.set(u.uid, uuid
+ ? { centralID: u.name, conductorUUID: uuid, tier: 1 }
+ : { centralID: u.name, tier: 3 });
+ continue;
+ }
+
+ const mapped = cas.get((u.name || "").toLowerCase());
+ if (mapped) {
+ const uuid = byCentralID.get(mapped);
+ out.set(u.uid, uuid
+ ? { centralID: mapped, conductorUUID: uuid, tier: 2 }
+ : { centralID: mapped, tier: 3 });
+ continue;
+ }
+
+ const viaEmail = u.mail ? byEmail.get(u.mail.toLowerCase()) : undefined;
+ if (viaEmail) {
+ out.set(u.uid, {
+ centralID: viaEmail.centralID,
+ conductorUUID: viaEmail.uuid,
+ tier: 4,
+ });
+ continue;
+ }
+
+ out.set(u.uid, { tier: 0 });
+ }
+ return out;
+}
+
+/**
+ * Chooses which node owns a content id when several reference it.
+ *
+ * Published beats unpublished, then having an ADAPT id, then the lowest nid as
+ * a stable final tie-break so reruns pick the same winner.
+ */
+function pickCanonical(
+ nodes: StudioNode[],
+ adaptIDs: Map
+): { winner: StudioNode; discarded: StudioNode[] } {
+ const scored = [...nodes].sort((a, b) => {
+ if (a.status !== b.status) return b.status - a.status;
+ const aAdapt = adaptIDs.get(a.nid)?.[0] ? 1 : 0;
+ const bAdapt = adaptIDs.get(b.nid)?.[0] ? 1 : 0;
+ if (aAdapt !== bAdapt) return bAdapt - aAdapt;
+ return a.nid - b.nid;
+ });
+ return { winner: scored[0], discarded: scored.slice(1) };
+}
+
+/**
+ * Picks a representative subset for a non-production environment.
+ *
+ * A random slice would under-represent exactly the cases worth testing, so
+ * every structural oddity is force-included first: content ids shared by
+ * several nodes, nodes whose content is gone, and at least a few activities
+ * from each author resolution tier. The remainder is filled proportionally by
+ * H5P library so the content-type mix matches production, which is what makes
+ * catalog filtering and the player behave realistically.
+ *
+ * Selection is deterministic (sorted by content id, no randomness) so
+ * re-seeding a dev environment produces the same subset.
+ */
+function stratifiedSample(
+ byContent: Map,
+ libraryOf: (contentID: number) => string | undefined,
+ tierOf: (contentID: number) => number,
+ limit: number
+): Set {
+ const all = [...byContent.keys()].sort((a, b) => a - b);
+ if (all.length <= limit) return new Set(all);
+
+ const picked = new Set();
+
+ // Structural edge cases first; these are the reason bugs survive to prod.
+ for (const id of all) {
+ if (picked.size >= limit) break;
+ if ((byContent.get(id)?.length ?? 0) > 1) picked.add(id);
+ }
+
+ // At least a handful from every author tier, including unresolvable.
+ const perTier = new Map();
+ for (const id of all) {
+ if (picked.size >= limit) break;
+ const tier = tierOf(id);
+ const seen = perTier.get(tier) ?? 0;
+ if (seen < 10) {
+ picked.add(id);
+ perTier.set(tier, seen + 1);
+ }
+ }
+
+ // Fill the rest proportionally by library.
+ const byLibrary = new Map();
+ for (const id of all) {
+ const lib = libraryOf(id) ?? "unknown";
+ const list = byLibrary.get(lib);
+ if (list) list.push(id);
+ else byLibrary.set(lib, [id]);
+ }
+ const remaining = limit - picked.size;
+ if (remaining > 0) {
+ for (const [, ids] of byLibrary) {
+ const share = Math.max(1, Math.round((ids.length / all.length) * remaining));
+ let taken = 0;
+ for (const id of ids) {
+ if (taken >= share || picked.size >= limit) break;
+ if (!picked.has(id)) {
+ picked.add(id);
+ taken += 1;
+ }
+ }
+ }
+ }
+
+ return picked;
+}
+
+export async function runMigration(): Promise {
+ const mysqlConfig = {
+ host: process.env.STUDIO_MYSQL_HOST,
+ port: parseInt(process.env.STUDIO_MYSQL_PORT || "3306", 10),
+ user: process.env.STUDIO_MYSQL_USER,
+ password: process.env.STUDIO_MYSQL_PASSWORD,
+ database: process.env.STUDIO_MYSQL_DATABASE,
+ };
+ if (!mysqlConfig.host || !mysqlConfig.user || !mysqlConfig.database) {
+ throw new Error(
+ "Missing STUDIO_MYSQL_HOST, STUDIO_MYSQL_USER or STUDIO_MYSQL_DATABASE."
+ );
+ }
+ const orgID = process.env.ORG_ID;
+ if (!orgID) throw new Error("Missing ORG_ID.");
+
+ const { anonymize, dataClass } = resolveMode();
+ const sampleLimit = parseSampleSize();
+ const seedUsers = anonymize && !process.argv.includes("--no-seed-users");
+
+ const pseudo = anonymize
+ ? new StudioPseudonymizer(process.env.STUDIO_PSEUDONYM_SALT ?? "")
+ : null;
+
+ log.info(
+ {
+ mode: COMMIT ? "COMMIT" : "DRY RUN",
+ orgID,
+ dataClass,
+ appEnv: process.env.APP_ENV ?? "(unset, treated as production)",
+ sampleLimit: sampleLimit === Infinity ? "full" : sampleLimit,
+ seedUsers,
+ },
+ COMMIT
+ ? `Importing Studio activities as ${dataClass} data`
+ : `Dry run (${dataClass}): no Conductor records will be written`
+ );
+
+ const conn = await mysql.createConnection(mysqlConfig);
+ const report = {
+ mode: COMMIT ? "commit" : "dry-run",
+ generatedAt: new Date().toISOString(),
+ nodes: 0,
+ contentRows: 0,
+ activitiesPlanned: 0,
+ created: 0,
+ updated: 0,
+ skippedMissingContent: [] as number[],
+ collapsedDuplicates: [] as {
+ h5pContentID: number;
+ canonicalNid: number;
+ discardedNids: number[];
+ }[],
+ authorTiers: { 1: 0, 2: 0, 3: 0, 4: 0, 0: 0 } as Record,
+ activitiesWithNoAuthor: [] as number[],
+ dataClass: "" as string,
+ anonymized: false,
+ sampled: null as null | { limit: number; selected: number; of: number },
+ syntheticUsersSeeded: 0,
+ };
+
+ try {
+ await conn.query("SET SESSION TRANSACTION READ ONLY");
+
+ const casCsvPath =
+ process.env.STUDIO_CAS_CSV ||
+ path.resolve("..", "studio_main", "scripts", "cas_users.csv");
+ const authorMap = await buildAuthorMap(conn, casCsvPath);
+
+ const [nodeRows] = await conn.query(
+ `SELECT n.nid, n.uid, n.status, n.title, n.created, n.changed, nb.uuid,
+ f.field_h5p_h5p_content_id AS h5p_content_id
+ FROM node_field_data n
+ JOIN node__field_h5p f
+ ON f.entity_id = n.nid AND f.deleted = 0
+ LEFT JOIN node nb ON nb.nid = n.nid
+ WHERE n.type = 'h5p' AND n.default_langcode = 1`
+ );
+ const nodes = nodeRows as StudioNode[];
+ report.nodes = nodes.length;
+
+ const [contentRows] = await conn.query(
+ `SELECT c.id, c.title, c.library_id, c.authors, c.source,
+ c.license, c.license_version, c.license_extras, c.default_language,
+ l.title AS library_title,
+ CONCAT(l.machine_name, ' ', l.major_version, '.', l.minor_version) AS library
+ FROM h5p_content c
+ LEFT JOIN h5p_libraries l ON l.library_id = c.library_id`
+ );
+ const content = new Map(
+ (contentRows as any[]).map((r) => [r.id, r])
+ );
+ report.contentRows = content.size;
+
+ const body = await loadFieldMap(
+ conn,
+ "body",
+ `SELECT entity_id, body_value FROM node__body WHERE bundle = 'h5p' AND deleted = 0`,
+ (r) => [r.entity_id, r.body_value]
+ );
+ const adaptIDs = await loadFieldMap(
+ conn,
+ "adapt_id",
+ `SELECT entity_id, field_adapt_id_value FROM node__field_adapt_id
+ WHERE deleted = 0 AND field_adapt_id_value IS NOT NULL AND field_adapt_id_value <> ''`,
+ (r) => [r.entity_id, r.field_adapt_id_value]
+ );
+ const subjects = await loadFieldMap(
+ conn,
+ "subject",
+ `SELECT f.entity_id, t.name FROM node__field_subject f
+ JOIN taxonomy_term_field_data t ON t.tid = f.field_subject_target_id AND t.default_langcode = 1
+ WHERE f.bundle = 'h5p' AND f.deleted = 0`,
+ (r) => [r.entity_id, r.name]
+ );
+ const tags = await loadFieldMap(
+ conn,
+ "tags",
+ `SELECT f.entity_id, t.name FROM node__field_tags f
+ JOIN taxonomy_term_field_data t ON t.tid = f.field_tags_target_id AND t.default_langcode = 1
+ WHERE f.bundle = 'h5p' AND f.deleted = 0`,
+ (r) => [r.entity_id, r.name]
+ );
+ const publishers = await loadFieldMap(
+ conn,
+ "publisher",
+ `SELECT f.entity_id, t.name FROM node__field_publisher f
+ JOIN taxonomy_term_field_data t ON t.tid = f.field_publisher_target_id AND t.default_langcode = 1
+ WHERE f.bundle = 'h5p' AND f.deleted = 0`,
+ (r) => [r.entity_id, r.name]
+ );
+ const collaborators = await loadFieldMap(
+ conn,
+ "collaborators",
+ `SELECT entity_id, field_collaborators_target_id FROM node__field_collaborators
+ WHERE bundle = 'h5p' AND deleted = 0`,
+ (r) => [r.entity_id, r.field_collaborators_target_id]
+ );
+ const inLibrary = await loadFieldMap(
+ conn,
+ "include_in_library",
+ `SELECT entity_id, field_include_in_library_value FROM node__field_include_in_library
+ WHERE bundle = 'h5p' AND deleted = 0`,
+ (r) => [r.entity_id, r.field_include_in_library_value]
+ );
+ const inProfile = await loadFieldMap(
+ conn,
+ "include_in_profile",
+ `SELECT entity_id, field_include_in_profile_value FROM node__field_include_in_profile
+ WHERE bundle = 'h5p' AND deleted = 0`,
+ (r) => [r.entity_id, r.field_include_in_profile_value]
+ );
+
+ // Group nodes by content id so duplicates can be collapsed deliberately
+ // rather than colliding on the unique index.
+ const byContent = new Map();
+ for (const n of nodes) {
+ const list = byContent.get(n.h5p_content_id);
+ if (list) list.push(n);
+ else byContent.set(n.h5p_content_id, [n]);
+ }
+
+ report.dataClass = dataClass;
+ report.anonymized = anonymize;
+
+ // Narrow to a representative subset before doing any per-record work.
+ let selected: Set | null = null;
+ if (sampleLimit !== Infinity) {
+ selected = stratifiedSample(
+ byContent,
+ (cid) => content.get(cid)?.library,
+ (cid) => {
+ const group = byContent.get(cid) ?? [];
+ const winner = pickCanonical(group, adaptIDs).winner;
+ return authorMap.get(winner?.uid ?? -1)?.tier ?? 0;
+ },
+ sampleLimit
+ );
+ report.sampled = {
+ limit: sampleLimit,
+ selected: selected.size,
+ of: byContent.size,
+ };
+ log.info(
+ { selected: selected.size, of: byContent.size },
+ "Stratified sample selected"
+ );
+ }
+
+ // Only for the created and updated counts in the report.
+ const existing = new Map();
+ if (mongoose.connection.readyState === 1) {
+ const rows = await Activity.find(
+ { orgID },
+ { _id: 0, activityID: 1, h5pContentID: 1 }
+ ).lean();
+ for (const r of rows) existing.set(r.h5pContentID, r.activityID);
+ log.info({ existing: existing.size }, "Existing activities found");
+ }
+
+ const ops: any[] = [];
+ const createdAtOps: any[] = [];
+
+ const syntheticUsers = new Map<
+ string,
+ {
+ uuid: string;
+ centralID: string;
+ email: string;
+ firstName: string;
+ lastName: string;
+ }
+ >();
+
+ for (const [contentID, group] of byContent) {
+ if (selected && !selected.has(contentID)) continue;
+ const c = content.get(contentID);
+ if (!c) {
+ // Node points at content that no longer exists. Nothing to serve.
+ report.skippedMissingContent.push(...group.map((n) => n.nid));
+ continue;
+ }
+
+ const { winner, discarded } = pickCanonical(group, adaptIDs);
+ if (discarded.length) {
+ report.collapsedDuplicates.push({
+ h5pContentID: contentID,
+ canonicalNid: winner.nid,
+ discardedNids: discarded.map((n) => n.nid),
+ });
+ }
+
+ const author = authorMap.get(winner.uid) ?? { tier: 0 as const };
+ report.authorTiers[String(author.tier)] += 1;
+ if (!author.centralID && !author.conductorUUID) {
+ report.activitiesWithNoAuthor.push(contentID);
+ }
+
+ const activityID = String(contentID);
+
+ const collabUIDs = collaborators.get(winner.nid) ?? [];
+
+ /**
+ * Identity for this record, real or pseudonymised.
+ *
+ * Anonymised mode must reproduce production's *shape*, not just hide
+ * names. In particular an author with a LibreOne identity but no
+ * Conductor account (tier 3, 87 real authors) keeps that shape here:
+ * `authorCentralID` is set and `createdBy` stays empty, so dev exercises
+ * the unowned-activity path and the webhook backfill that fills it in.
+ * Giving every synthetic author a local account would quietly delete
+ * that case from dev.
+ */
+ let authorCentralID = author.centralID;
+ let createdBy = author.conductorUUID;
+ let collabUUIDs = collabUIDs
+ .map((uid) => authorMap.get(uid)?.conductorUUID)
+ .filter((v): v is string => Boolean(v));
+
+ if (anonymize && pseudo) {
+ /**
+ * Registers a synthetic stand-in and returns its local uuid. The
+ * seeded user's `uuid` is this exact value, so `createdBy` resolves.
+ */
+ const synthesize = (real: AuthorResolution): string | undefined => {
+ if (!real.centralID || !real.conductorUUID) return undefined;
+ const fakeCentral = pseudo.centralID(real.centralID);
+ const fakeUUID = pseudo.localUUID(real.conductorUUID);
+ const { firstName, lastName } = pseudo.name(real.centralID);
+ syntheticUsers.set(fakeUUID, {
+ uuid: fakeUUID,
+ centralID: fakeCentral,
+ email: pseudo.email(real.centralID),
+ firstName,
+ lastName,
+ });
+ return fakeUUID;
+ };
+
+ authorCentralID = author.centralID
+ ? pseudo.centralID(author.centralID)
+ : undefined;
+ createdBy = seedUsers ? synthesize(author) : undefined;
+
+ // Iterate the source uids, not the already-filtered uuid list: those
+ // two arrays differ in length whenever a collaborator has no local
+ // account, and indexing one by the other's position mismatches them.
+ collabUUIDs = collabUIDs
+ .map((uid) => {
+ const real = authorMap.get(uid);
+ if (!real) return undefined;
+ return seedUsers
+ ? synthesize(real)
+ : real.conductorUUID
+ ? pseudo.localUUID(real.conductorUUID)
+ : undefined;
+ })
+ .filter((v): v is string => Boolean(v));
+ }
+
+ const doc = {
+ activityID,
+ h5pContentID: contentID,
+ orgID,
+ title: winner.title || c.title || `H5P Content ${contentID}`,
+ // Free text is scrubbed of addresses in anonymised mode; the H5P
+ // metadata author list is replaced outright, since it holds real names
+ // typed by content creators and has no safe partial redaction.
+ description: anonymize && pseudo
+ ? pseudo.scrubText(body.get(winner.nid)?.[0])
+ : body.get(winner.nid)?.[0],
+ library: c.library,
+ h5pAuthors: anonymize && pseudo
+ ? pseudo.h5pAuthors(c.authors, author.centralID ?? String(winner.uid))
+ : c.authors ?? undefined,
+ source: c.source ?? undefined,
+ adaptID: adaptIDs.get(winner.nid)?.[0],
+ authorCentralID,
+ createdBy,
+ collaborators: collabUUIDs,
+ publisher: publishers.get(winner.nid)?.[0],
+ subjects: subjects.get(winner.nid) ?? [],
+ tags: tags.get(winner.nid) ?? [],
+ // Studio's `private` flag is set on exactly one node site-wide and is
+ // readable by every authenticated user anyway, so it is not ported.
+ // Catalog visibility is publication status plus include_in_library.
+ visibility: winner.status === 1 ? "public" : "private",
+ includeInLibrary: (inLibrary.get(winner.nid)?.[0] ?? 1) === 1,
+ includeInProfile: (inProfile.get(winner.nid)?.[0] ?? 1) === 1,
+ ...(c.license
+ ? {
+ license: {
+ name: c.license,
+ ...(c.license_version ? { version: c.license_version } : {}),
+ // Free text typed by the author, so scrubbed like the body.
+ ...(c.license_extras
+ ? {
+ additionalTerms: anonymize && pseudo
+ ? pseudo.scrubText(c.license_extras)
+ : c.license_extras,
+ }
+ : {}),
+ },
+ }
+ : {}),
+ // The rest of what Studio's `/api/h5p/{id}` emits that is not derivable
+ // later: `default_language`, the library's display title (`type`, which
+ // ADAPT matches on), Studio's library id (`type_id`) and the node uuid.
+ ...(c.default_language ? { language: c.default_language } : {}),
+ ...(c.library_title ? { libraryTitle: c.library_title } : {}),
+ legacyLibraryID: c.library_id,
+ ...(winner.uuid ? { legacyNodeUUID: winner.uuid } : {}),
+ legacyNodeID: winner.nid,
+ dataClass,
+ // Studio's own timestamps, from the canonical node. ADAPT polls
+ // `/api/h5p/all?changed=` and Studio's author view sorts on
+ // `changed`, so stamping the import time would make every activity
+ // look changed on import day. Written with timestamps off for this op.
+ ...(winner.created ? { createdAt: new Date(winner.created * 1000) } : {}),
+ ...(winner.changed ? { updatedAt: new Date(winner.changed * 1000) } : {}),
+ };
+
+ if (existing.has(contentID)) report.updated += 1;
+ else report.created += 1;
+
+ ops.push({
+ updateOne: {
+ filter: { h5pContentID: contentID, orgID },
+ update: { $set: doc },
+ upsert: true,
+ timestamps: false,
+ overwriteImmutable: true,
+ },
+ });
+ // On an upsert Mongoose moves immutable paths into `$setOnInsert`
+ // before it consults `overwriteImmutable`, so `createdAt` only lands on
+ // new documents. Correct it on existing ones with a plain update.
+ if (existing.has(contentID) && doc.createdAt) {
+ createdAtOps.push({
+ updateOne: {
+ filter: { h5pContentID: contentID, orgID },
+ update: { $set: { createdAt: doc.createdAt } },
+ timestamps: false,
+ overwriteImmutable: true,
+ },
+ });
+ }
+ }
+
+ report.activitiesPlanned = ops.length;
+ report.syntheticUsersSeeded = syntheticUsers.size;
+
+ if (COMMIT) {
+ if (mongoose.connection.readyState !== 1) {
+ throw new Error("--commit requires an active Mongo connection.");
+ }
+ /**
+ * Seed the synthetic authors so a dev environment can exercise author
+ * profiles, ownership and collaborator access. Without these the import
+ * lands with every activity unowned, which is not what production looks
+ * like. Only ever written in anonymised mode, and the `@dev.invalid`
+ * address makes them identifiable at a glance.
+ */
+ if (syntheticUsers.size) {
+ if (!anonymize) {
+ throw new Error(
+ "Refusing to seed synthetic users outside anonymised mode."
+ );
+ }
+ const userOps = [...syntheticUsers.values()].map((u) => ({
+ updateOne: {
+ filter: { uuid: u.uuid },
+ update: {
+ $set: {
+ uuid: u.uuid,
+ centralID: u.centralID,
+ email: u.email,
+ firstName: u.firstName,
+ lastName: u.lastName,
+ },
+ },
+ upsert: true,
+ },
+ }));
+ for (let i = 0; i < userOps.length; i += 500) {
+ await User.bulkWrite(userOps.slice(i, i + 500), { ordered: false });
+ }
+ log.info({ users: userOps.length }, "Seeded synthetic authors");
+ }
+
+ const BATCH = 500;
+ for (let i = 0; i < ops.length; i += BATCH) {
+ const slice = ops.slice(i, i + BATCH);
+ await Activity.bulkWrite(slice, { ordered: false });
+ log.info(
+ { written: Math.min(i + BATCH, ops.length), total: ops.length },
+ "Import progress"
+ );
+ }
+ // Separate pass, after the upserts: unordered bulk writes may run in
+ // any order, so mixing these in could apply before their upsert.
+ for (let i = 0; i < createdAtOps.length; i += BATCH) {
+ await Activity.bulkWrite(createdAtOps.slice(i, i + BATCH), {
+ ordered: false,
+ });
+ }
+ log.info({ written: ops.length }, "Import complete");
+ } else {
+ log.info(
+ { wouldWrite: ops.length },
+ "Dry run complete; no records written"
+ );
+ }
+
+ const outDir = path.resolve("migration-reports");
+ fs.mkdirSync(outDir, { recursive: true });
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const reportPath = path.join(
+ outDir,
+ `studio-import-${report.mode}-${stamp}.json`
+ );
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
+
+ process.stdout.write(
+ `
+${COMMIT ? "IMPORT" : "DRY RUN"} [${report.dataClass}${
+ report.anonymized ? ", pseudonymised" : ", REAL IDENTITIES"
+ }]
+` +
+ (report.sampled
+ ? ` Stratified sample: ${report.sampled.selected} of ${report.sampled.of}
+`
+ : "") +
+ ` Studio nodes read: ${report.nodes}\n` +
+ ` H5P content rows: ${report.contentRows}\n` +
+ ` Activities planned: ${report.activitiesPlanned}\n` +
+ ` new: ${report.created}\n` +
+ ` updated: ${report.updated}\n` +
+ ` Duplicate ids collapsed: ${report.collapsedDuplicates.length}\n` +
+ ` Nodes w/ missing content: ${report.skippedMissingContent.length}\n` +
+ ` Author tiers 1/2/3/4/none: ${report.authorTiers["1"]}/${report.authorTiers["2"]}/${report.authorTiers["3"]}/${report.authorTiers["4"]}/${report.authorTiers["0"]}\n` +
+ ` Activities with no author: ${report.activitiesWithNoAuthor.length}\n` +
+ (report.syntheticUsersSeeded
+ ? ` Synthetic authors: ${report.syntheticUsersSeeded}
+`
+ : "") +
+ `\nReport: ${reportPath}\n` +
+ (COMMIT ? "" : "\nRe-run with --commit to write these records.\n")
+ );
+ } finally {
+ await conn.end();
+ }
+}
+
+if (process.argv[1] && process.argv[1].includes("StudioImportActivities")) {
+ const mongoURI = process.env.MONGOOSEURI;
+ const connected = mongoURI
+ ? mongoose
+ .connect(mongoURI)
+ .then(() => true)
+ .catch((err) => {
+ logger.warn({ err }, "Mongo connect failed");
+ return false;
+ })
+ : Promise.resolve(false);
+
+ connected
+ .then(() => runMigration())
+ .catch((err) => {
+ logger.error({ err }, "Studio activity import failed");
+ process.exitCode = 1;
+ })
+ .finally(() => mongoose.disconnect().catch(() => undefined));
+}
diff --git a/server/migrations/StudioImportCollections.ts b/server/migrations/StudioImportCollections.ts
new file mode 100644
index 000000000..674742e0c
--- /dev/null
+++ b/server/migrations/StudioImportCollections.ts
@@ -0,0 +1,545 @@
+/**
+ * Studio -> Conductor activity collection import. DRY RUN BY DEFAULT.
+ *
+ * Creates the Conductor `ActivityCollection` records for Studio's collection
+ * nodes. Reads Studio MySQL read-only and writes nothing unless `--commit` is
+ * passed.
+ *
+ * Part of phase C, run after `StudioImportActivities.ts`. ADAPT's bulk H5P
+ * importer reads collections through Studio's `/api/collections/list` and
+ * `/api/collections/{cid}`, and turns each sub-collection into an assignment
+ * named after its title, so three things here are contracts rather than
+ * presentation:
+ *
+ * - Studio's collection nid is kept as `legacyCollectionID`. ADAPT stores it
+ * as `cid`.
+ * - Sub-collections keep their titles and their order.
+ * - Activities inside each sub-collection keep their order.
+ *
+ * STUDIO_MYSQL_HOST=... STUDIO_MYSQL_USER=... STUDIO_MYSQL_PASSWORD=... \
+ * STUDIO_MYSQL_DATABASE=studio_main \
+ * npx tsx --env-file=.env migrations/StudioImportCollections.ts
+ * # ... --commit
+ *
+ * Studio stored a collection as a node whose `field_collections` paragraphs
+ * are the sub-collections, each with a title and an ordered list of H5P node
+ * references. Those references are to NODES, so each is resolved to its H5P
+ * content id here. The activity import collapsed nodes that share a content id
+ * into one activity, so resolving by content id lands every reference on the
+ * canonical activity. References to nodes whose content is gone are reported
+ * and left out.
+ *
+ * Every collection is imported in every environment, with no sampling: there
+ * are only 311. A staging activity import that used the default sample leaves
+ * most references without an `Activity`. The report counts them, and
+ * `StudioImportActivities.ts --full` closes the gap.
+ *
+ * Identities follow the activity import exactly: pseudonymised unless
+ * `--with-identities`, which is refused outside `APP_ENV=production`.
+ *
+ * Idempotent: records are upserted on `legacyCollectionID`, and an existing
+ * `collectionID` is reused so reruns do not churn public identifiers.
+ */
+import fs from "node:fs";
+import path from "node:path";
+import mongoose from "mongoose";
+import mysql from "mysql2/promise";
+import b62 from "base62-random";
+import logger, { childLogger } from "../logger.js";
+import Activity from "../models/activity.js";
+import ActivityCollection from "../models/activityCollection.js";
+import User from "../models/user.js";
+import StudioPseudonymizer from "./StudioPseudonymizer.js";
+import {
+ type AuthorResolution,
+ buildAuthorMap,
+ loadFieldMap,
+ resolveMode,
+} from "./StudioImportActivities.js";
+import dotenv from "dotenv";
+dotenv.config();
+
+const log = childLogger("studio-import-collections");
+
+const COMMIT = process.argv.includes("--commit");
+
+/** Studio row shapes, as the queries below return them. */
+type StudioCollectionNode = {
+ nid: number;
+ uid: number;
+ status: number;
+ title: string | null;
+ created: number | null;
+ changed: number | null;
+};
+
+type SubCollectionRef = { nid: number; delta: number; pid: number };
+type ResourceRef = { pid: number; delta: number; target: number };
+
+export async function runMigration(): Promise {
+ const mysqlConfig = {
+ host: process.env.STUDIO_MYSQL_HOST,
+ port: parseInt(process.env.STUDIO_MYSQL_PORT || "3306", 10),
+ user: process.env.STUDIO_MYSQL_USER,
+ password: process.env.STUDIO_MYSQL_PASSWORD,
+ database: process.env.STUDIO_MYSQL_DATABASE,
+ };
+ if (!mysqlConfig.host || !mysqlConfig.user || !mysqlConfig.database) {
+ throw new Error(
+ "Missing STUDIO_MYSQL_HOST, STUDIO_MYSQL_USER or STUDIO_MYSQL_DATABASE."
+ );
+ }
+ const orgID = process.env.ORG_ID;
+ if (!orgID) throw new Error("Missing ORG_ID.");
+
+ const { anonymize, dataClass } = resolveMode();
+ const seedUsers = anonymize && !process.argv.includes("--no-seed-users");
+
+ const pseudo = anonymize
+ ? new StudioPseudonymizer(process.env.STUDIO_PSEUDONYM_SALT ?? "")
+ : null;
+
+ log.info(
+ {
+ mode: COMMIT ? "COMMIT" : "DRY RUN",
+ orgID,
+ dataClass,
+ appEnv: process.env.APP_ENV ?? "(unset, treated as production)",
+ seedUsers,
+ },
+ COMMIT
+ ? `Importing Studio collections as ${dataClass} data`
+ : `Dry run (${dataClass}): no Conductor records will be written`
+ );
+
+ const conn = await mysql.createConnection(mysqlConfig);
+ const report = {
+ mode: COMMIT ? "commit" : "dry-run",
+ generatedAt: new Date().toISOString(),
+ dataClass,
+ anonymized: anonymize,
+ collections: 0,
+ created: 0,
+ updated: 0,
+ subCollections: 0,
+ activityReferences: 0,
+ /** Collection nids that have no sub-collections at all. */
+ collectionsWithNoSubCollections: [] as number[],
+ /** References to a node that is not an H5P node, or has no content id. */
+ unresolvedReferences: [] as { collectionNid: number; targetNid: number }[],
+ /** References whose node points at H5P content that no longer exists. */
+ missingContentReferences: [] as {
+ collectionNid: number;
+ targetNid: number;
+ h5pContentID: number;
+ }[],
+ /**
+ * Content ids referenced by a collection with no Conductor `Activity`.
+ * Expected after a sampled staging activity import, never in production.
+ */
+ referencesWithoutActivity: 0,
+ /** Collections whose Studio screenshots were not migrated. */
+ collectionsWithScreenshots: [] as number[],
+ authorTiers: { 1: 0, 2: 0, 3: 0, 4: 0, 0: 0 } as Record,
+ syntheticUsersSeeded: 0,
+ };
+
+ try {
+ await conn.query("SET SESSION TRANSACTION READ ONLY");
+
+ const casCsvPath =
+ process.env.STUDIO_CAS_CSV ||
+ path.resolve("..", "studio_main", "scripts", "cas_users.csv");
+ const authorMap = await buildAuthorMap(conn, casCsvPath);
+
+ const [collectionRows] = await conn.query(
+ `SELECT nid, uid, status, title, created, changed
+ FROM node_field_data
+ WHERE type = 'collection' AND default_langcode = 1
+ ORDER BY nid`
+ );
+ const collections = collectionRows as StudioCollectionNode[];
+ report.collections = collections.length;
+
+ // Sub-collection paragraphs per collection, in display order.
+ const [subRows] = await conn.query(
+ `SELECT entity_id AS nid, delta, field_collections_target_id AS pid
+ FROM node__field_collections
+ WHERE bundle = 'collection' AND deleted = 0
+ ORDER BY entity_id, delta`
+ );
+ const subsByCollection = new Map();
+ for (const r of subRows as SubCollectionRef[]) {
+ const list = subsByCollection.get(r.nid);
+ if (list) list.push(r);
+ else subsByCollection.set(r.nid, [r]);
+ }
+
+ const subTitles = await loadFieldMap(
+ conn,
+ "collection_title_name",
+ `SELECT entity_id, field_collection_title_name_value
+ FROM paragraph__field_collection_title_name
+ WHERE deleted = 0`,
+ (r) => [r.entity_id, r.field_collection_title_name_value]
+ );
+
+ // Activity node references per sub-collection paragraph, in display order.
+ const [resourceRows] = await conn.query(
+ `SELECT entity_id AS pid, delta, field_collection_resources_target_id AS target
+ FROM paragraph__field_collection_resources
+ WHERE deleted = 0
+ ORDER BY entity_id, delta`
+ );
+ const resourcesBySub = new Map();
+ for (const r of resourceRows as ResourceRef[]) {
+ const list = resourcesBySub.get(r.pid);
+ if (list) list.push(r);
+ else resourcesBySub.set(r.pid, [r]);
+ }
+
+ // H5P node -> content id, and which content ids still exist.
+ const [h5pRows] = await conn.query(
+ `SELECT entity_id, field_h5p_h5p_content_id AS content_id
+ FROM node__field_h5p
+ WHERE bundle = 'h5p' AND deleted = 0`
+ );
+ const contentOfNode = new Map(
+ (h5pRows as any[]).map((r) => [r.entity_id, r.content_id])
+ );
+ const [contentRows] = await conn.query(`SELECT id FROM h5p_content`);
+ const contentExists = new Set(
+ (contentRows as any[]).map((r) => r.id)
+ );
+
+ const description = await loadFieldMap(
+ conn,
+ "description",
+ `SELECT entity_id, field_description_value FROM node__field_description
+ WHERE bundle = 'collection' AND deleted = 0`,
+ (r) => [r.entity_id, r.field_description_value]
+ );
+ const oerReference = await loadFieldMap(
+ conn,
+ "oer_reference",
+ `SELECT entity_id, field_oer_reference_uri FROM node__field_oer_reference
+ WHERE bundle = 'collection' AND deleted = 0`,
+ (r) => [r.entity_id, r.field_oer_reference_uri]
+ );
+ const oerReferenceTitle = await loadFieldMap(
+ conn,
+ "oer_reference_title",
+ `SELECT entity_id, field_oer_reference_title FROM node__field_oer_reference
+ WHERE bundle = 'collection' AND deleted = 0 AND field_oer_reference_title IS NOT NULL
+ AND field_oer_reference_title <> ''`,
+ (r) => [r.entity_id, r.field_oer_reference_title]
+ );
+ const screenshots = await loadFieldMap(
+ conn,
+ "screenshots",
+ `SELECT entity_id, field_screenshots_target_id FROM node__field_screenshots
+ WHERE bundle = 'collection' AND deleted = 0`,
+ (r) => [r.entity_id, r.field_screenshots_target_id]
+ );
+ const subjects = await loadFieldMap(
+ conn,
+ "subject",
+ `SELECT f.entity_id, t.name FROM node__field_subject f
+ JOIN taxonomy_term_field_data t ON t.tid = f.field_subject_target_id AND t.default_langcode = 1
+ WHERE f.bundle = 'collection' AND f.deleted = 0
+ ORDER BY f.entity_id, f.delta`,
+ (r) => [r.entity_id, r.name]
+ );
+ const tags = await loadFieldMap(
+ conn,
+ "tags",
+ `SELECT f.entity_id, t.name FROM node__field_tags f
+ JOIN taxonomy_term_field_data t ON t.tid = f.field_tags_target_id AND t.default_langcode = 1
+ WHERE f.bundle = 'collection' AND f.deleted = 0
+ ORDER BY f.entity_id, f.delta`,
+ (r) => [r.entity_id, r.name]
+ );
+ const inLibrary = await loadFieldMap(
+ conn,
+ "include_in_library",
+ `SELECT entity_id, field_include_in_library_value FROM node__field_include_in_library
+ WHERE bundle = 'collection' AND deleted = 0`,
+ (r) => [r.entity_id, r.field_include_in_library_value]
+ );
+
+ // Which content ids have an Activity, to report dangling references, and
+ // which collections already exist, so reruns keep their collectionID.
+ const activityContentIDs = new Set();
+ const existing = new Map();
+ if (mongoose.connection.readyState === 1) {
+ const activities = await Activity.find(
+ { orgID },
+ { _id: 0, h5pContentID: 1 }
+ ).lean();
+ for (const a of activities) activityContentIDs.add(a.h5pContentID);
+
+ const rows = await ActivityCollection.find(
+ { orgID, legacyCollectionID: { $exists: true } },
+ { _id: 0, collectionID: 1, legacyCollectionID: 1 }
+ ).lean();
+ for (const r of rows) {
+ if (r.legacyCollectionID != null) {
+ existing.set(r.legacyCollectionID, r.collectionID);
+ }
+ }
+ log.info(
+ { activities: activityContentIDs.size, existing: existing.size },
+ "Loaded existing Conductor records"
+ );
+ } else {
+ log.warn("No Mongo connection; dangling-reference counts will be inflated");
+ }
+
+ const usedIDs = new Set(existing.values());
+ const syntheticUsers = new Map<
+ string,
+ {
+ uuid: string;
+ centralID: string;
+ email: string;
+ firstName: string;
+ lastName: string;
+ }
+ >();
+
+ const ops: any[] = [];
+ const createdAtOps: any[] = [];
+
+ for (const node of collections) {
+ const subRefs = subsByCollection.get(node.nid) ?? [];
+ if (!subRefs.length) report.collectionsWithNoSubCollections.push(node.nid);
+ if (screenshots.get(node.nid)?.length) {
+ report.collectionsWithScreenshots.push(node.nid);
+ }
+
+ const subCollections = subRefs.map((sub) => {
+ const h5pContentIDs: number[] = [];
+ for (const ref of resourcesBySub.get(sub.pid) ?? []) {
+ const contentID = contentOfNode.get(ref.target);
+ if (contentID == null) {
+ report.unresolvedReferences.push({
+ collectionNid: node.nid,
+ targetNid: ref.target,
+ });
+ continue;
+ }
+ if (!contentExists.has(contentID)) {
+ report.missingContentReferences.push({
+ collectionNid: node.nid,
+ targetNid: ref.target,
+ h5pContentID: contentID,
+ });
+ continue;
+ }
+ if (!activityContentIDs.has(contentID)) {
+ report.referencesWithoutActivity += 1;
+ }
+ h5pContentIDs.push(contentID);
+ }
+ report.activityReferences += h5pContentIDs.length;
+ return {
+ title: subTitles.get(sub.pid)?.[0] ?? "",
+ h5pContentIDs,
+ };
+ });
+ report.subCollections += subCollections.length;
+
+ const author: AuthorResolution =
+ authorMap.get(node.uid) ?? { tier: 0 as const };
+ report.authorTiers[String(author.tier)] += 1;
+
+ let authorCentralID = author.centralID;
+ let createdBy = author.conductorUUID;
+ if (anonymize && pseudo) {
+ // Same derivation as the activity import, so a pseudonymised author
+ // owns their collections and their activities under one identity.
+ authorCentralID = author.centralID
+ ? pseudo.centralID(author.centralID)
+ : undefined;
+ createdBy = undefined;
+ if (seedUsers && author.centralID && author.conductorUUID) {
+ const fakeUUID = pseudo.localUUID(author.conductorUUID);
+ const { firstName, lastName } = pseudo.name(author.centralID);
+ syntheticUsers.set(fakeUUID, {
+ uuid: fakeUUID,
+ centralID: pseudo.centralID(author.centralID),
+ email: pseudo.email(author.centralID),
+ firstName,
+ lastName,
+ });
+ createdBy = fakeUUID;
+ }
+ }
+
+ let collectionID = existing.get(node.nid);
+ if (!collectionID) {
+ do {
+ collectionID = b62(8);
+ } while (usedIDs.has(collectionID));
+ usedIDs.add(collectionID);
+ }
+
+ const rawDescription = description.get(node.nid)?.[0];
+ const rawOerReference = oerReference.get(node.nid)?.[0];
+ const rawOerReferenceTitle = oerReferenceTitle.get(node.nid)?.[0];
+
+ const doc = {
+ collectionID,
+ legacyCollectionID: node.nid,
+ orgID,
+ title: node.title || `Collection ${node.nid}`,
+ description:
+ anonymize && pseudo ? pseudo.scrubText(rawDescription) : rawDescription,
+ oerReference:
+ anonymize && pseudo ? pseudo.scrubText(rawOerReference) : rawOerReference,
+ oerReferenceTitle:
+ anonymize && pseudo ? pseudo.scrubText(rawOerReferenceTitle) : rawOerReferenceTitle,
+ authorCentralID,
+ createdBy,
+ subjects: subjects.get(node.nid) ?? [],
+ tags: tags.get(node.nid) ?? [],
+ subCollections,
+ visibility: node.status === 1 ? "public" : "private",
+ includeInLibrary: (inLibrary.get(node.nid)?.[0] ?? 1) === 1,
+ dataClass,
+ // Studio's own timestamps, because its collection list is ordered by
+ // `changed`. Written explicitly with timestamps off for this op, or
+ // Mongoose would stamp the import time over them.
+ ...(node.created ? { createdAt: new Date(node.created * 1000) } : {}),
+ ...(node.changed ? { updatedAt: new Date(node.changed * 1000) } : {}),
+ };
+
+ if (existing.has(node.nid)) report.updated += 1;
+ else report.created += 1;
+
+ ops.push({
+ updateOne: {
+ filter: { legacyCollectionID: node.nid, orgID },
+ update: { $set: doc },
+ upsert: true,
+ timestamps: false,
+ overwriteImmutable: true,
+ },
+ });
+ // On an upsert Mongoose moves immutable paths into `$setOnInsert`
+ // before it consults `overwriteImmutable`, so `createdAt` only lands on
+ // new documents. Correct it on existing ones with a plain update.
+ if (existing.has(node.nid) && doc.createdAt) {
+ createdAtOps.push({
+ updateOne: {
+ filter: { legacyCollectionID: node.nid, orgID },
+ update: { $set: { createdAt: doc.createdAt } },
+ timestamps: false,
+ overwriteImmutable: true,
+ },
+ });
+ }
+ }
+
+ report.syntheticUsersSeeded = syntheticUsers.size;
+
+ if (COMMIT) {
+ if (mongoose.connection.readyState !== 1) {
+ throw new Error("--commit requires an active Mongo connection.");
+ }
+ if (syntheticUsers.size) {
+ if (!anonymize) {
+ throw new Error(
+ "Refusing to seed synthetic users outside anonymised mode."
+ );
+ }
+ const userOps = [...syntheticUsers.values()].map((u) => ({
+ updateOne: {
+ filter: { uuid: u.uuid },
+ update: { $set: u },
+ upsert: true,
+ },
+ }));
+ for (let i = 0; i < userOps.length; i += 500) {
+ await User.bulkWrite(userOps.slice(i, i + 500), { ordered: false });
+ }
+ log.info({ users: userOps.length }, "Seeded synthetic authors");
+ }
+
+ const BATCH = 100;
+ for (let i = 0; i < ops.length; i += BATCH) {
+ await ActivityCollection.bulkWrite(ops.slice(i, i + BATCH), {
+ ordered: false,
+ });
+ log.info(
+ { written: Math.min(i + BATCH, ops.length), total: ops.length },
+ "Import progress"
+ );
+ }
+ // Separate pass, after the upserts: unordered bulk writes may run in
+ // any order, so mixing these in could apply before their upsert.
+ for (let i = 0; i < createdAtOps.length; i += BATCH) {
+ await ActivityCollection.bulkWrite(createdAtOps.slice(i, i + BATCH), {
+ ordered: false,
+ });
+ }
+ log.info({ written: ops.length }, "Import complete");
+ } else {
+ log.info({ wouldWrite: ops.length }, "Dry run complete; no records written");
+ }
+
+ const outDir = path.resolve("migration-reports");
+ fs.mkdirSync(outDir, { recursive: true });
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const reportPath = path.join(
+ outDir,
+ `studio-collections-import-${report.mode}-${stamp}.json`
+ );
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
+
+ process.stdout.write(
+ `\n${COMMIT ? "IMPORT" : "DRY RUN"} [${report.dataClass}${
+ report.anonymized ? ", pseudonymised" : ", REAL IDENTITIES"
+ }]\n` +
+ ` Collections read: ${report.collections}\n` +
+ ` new: ${report.created}\n` +
+ ` updated: ${report.updated}\n` +
+ ` with no sub-collections: ${report.collectionsWithNoSubCollections.length}\n` +
+ ` Sub-collections: ${report.subCollections}\n` +
+ ` Activity references: ${report.activityReferences}\n` +
+ ` without an Activity: ${report.referencesWithoutActivity}\n` +
+ ` Dropped, not an H5P node: ${report.unresolvedReferences.length}\n` +
+ ` Dropped, missing content: ${report.missingContentReferences.length}\n` +
+ ` Screenshots not migrated: ${report.collectionsWithScreenshots.length}\n` +
+ ` Author tiers 1/2/3/4/none: ${report.authorTiers["1"]}/${report.authorTiers["2"]}/${report.authorTiers["3"]}/${report.authorTiers["4"]}/${report.authorTiers["0"]}\n` +
+ (report.syntheticUsersSeeded
+ ? ` Synthetic authors: ${report.syntheticUsersSeeded}\n`
+ : "") +
+ `\nReport: ${reportPath}\n` +
+ (COMMIT ? "" : "\nRe-run with --commit to write these records.\n")
+ );
+ } finally {
+ await conn.end();
+ }
+}
+
+if (process.argv[1] && process.argv[1].includes("StudioImportCollections")) {
+ const mongoURI = process.env.MONGOOSEURI;
+ const connected = mongoURI
+ ? mongoose
+ .connect(mongoURI)
+ .then(() => true)
+ .catch((err) => {
+ logger.warn({ err }, "Mongo connect failed");
+ return false;
+ })
+ : Promise.resolve(false);
+
+ connected
+ .then(() => runMigration())
+ .catch((err) => {
+ logger.error({ err }, "Studio collection import failed");
+ process.exitCode = 1;
+ })
+ .finally(() => mongoose.disconnect().catch(() => undefined));
+}
diff --git a/server/migrations/StudioImportLegacyPaths.ts b/server/migrations/StudioImportLegacyPaths.ts
new file mode 100644
index 000000000..5f4ede564
--- /dev/null
+++ b/server/migrations/StudioImportLegacyPaths.ts
@@ -0,0 +1,217 @@
+/**
+ * Studio -> Conductor legacy URL import. DRY RUN BY DEFAULT.
+ *
+ * Writes one `StudioLegacyNode` per Studio H5P and collection node, with every
+ * URL alias that pointed at it, so the Commons legacy resolver can turn an old
+ * `studio.libretexts.org` page URL into its Conductor page. Reads Studio MySQL
+ * read-only and writes nothing unless `--commit` is passed.
+ *
+ * STUDIO_MYSQL_HOST=... STUDIO_MYSQL_USER=... STUDIO_MYSQL_PASSWORD=... \
+ * STUDIO_MYSQL_DATABASE=studio_main \
+ * npx tsx --env-file=.env migrations/StudioImportLegacyPaths.ts
+ * # ... --commit
+ *
+ * See `models/studioLegacyNode.ts` for why the Activity records are not
+ * enough on their own. Run it in any order relative to the other imports, and
+ * again after the final cutover delta: it reads aliases, not activities.
+ *
+ * No identities are read, so there is no pseudonymised mode. Aliases follow
+ * Studio's pathauto patterns (`h5p/[content id]`, `collection/[nid]`); the
+ * report lists any that do not, since the edge only sends `/h5p/*`, `/node/*`
+ * and `/collection/*` to the resolver.
+ *
+ * Idempotent: records are upserted on `nid`.
+ */
+import fs from "node:fs";
+import path from "node:path";
+import mongoose from "mongoose";
+import mysql from "mysql2/promise";
+import logger, { childLogger } from "../logger.js";
+import StudioLegacyNode, {
+ normalizeStudioAlias,
+ StudioLegacyNodeKind,
+} from "../models/studioLegacyNode.js";
+import dotenv from "dotenv";
+dotenv.config();
+
+const log = childLogger("studio-import-legacy-paths");
+
+const COMMIT = process.argv.includes("--commit");
+
+/** Alias shapes the edge sends to the resolver. Anything else never reaches it. */
+const ROUTED_ALIAS = /^\/(h5p|collection)\/[^/]+$/;
+
+export async function runMigration(): Promise {
+ const mysqlConfig = {
+ host: process.env.STUDIO_MYSQL_HOST,
+ port: parseInt(process.env.STUDIO_MYSQL_PORT || "3306", 10),
+ user: process.env.STUDIO_MYSQL_USER,
+ password: process.env.STUDIO_MYSQL_PASSWORD,
+ database: process.env.STUDIO_MYSQL_DATABASE,
+ };
+ if (!mysqlConfig.host || !mysqlConfig.user || !mysqlConfig.database) {
+ throw new Error(
+ "Missing STUDIO_MYSQL_HOST, STUDIO_MYSQL_USER or STUDIO_MYSQL_DATABASE."
+ );
+ }
+ const orgID = process.env.ORG_ID;
+ if (!orgID) throw new Error("Missing ORG_ID.");
+
+ log.info(
+ { mode: COMMIT ? "COMMIT" : "DRY RUN", orgID },
+ COMMIT
+ ? "Importing Studio legacy paths"
+ : "Dry run: no Conductor records will be written"
+ );
+
+ const conn = await mysql.createConnection(mysqlConfig);
+ const report = {
+ mode: COMMIT ? "commit" : "dry-run",
+ generatedAt: new Date().toISOString(),
+ nodes: { activity: 0, collection: 0 },
+ activityNodesWithoutContent: [] as number[],
+ aliases: 0,
+ /** Aliases claimed by more than one node. The lowest alias id wins, as in Drupal. */
+ aliasCollisions: [] as { alias: string; nids: number[] }[],
+ /** Aliases the edge will not send to the resolver, with the node they named. */
+ unroutedAliases: [] as { alias: string; nid: number }[],
+ };
+
+ try {
+ await conn.query("SET SESSION TRANSACTION READ ONLY");
+
+ const [nodeRows] = await conn.query(
+ `SELECT n.nid, n.type, f.field_h5p_h5p_content_id AS h5p_content_id
+ FROM node_field_data n
+ LEFT JOIN node__field_h5p f ON f.entity_id = n.nid AND f.deleted = 0
+ WHERE n.type IN ('h5p', 'collection') AND n.default_langcode = 1`
+ );
+ const nodes = new Map<
+ number,
+ { kind: StudioLegacyNodeKind; h5pContentID?: number; aliases: string[] }
+ >();
+ for (const r of nodeRows as any[]) {
+ const kind: StudioLegacyNodeKind = r.type === "h5p" ? "activity" : "collection";
+ nodes.set(r.nid, {
+ kind,
+ ...(r.h5p_content_id ? { h5pContentID: r.h5p_content_id } : {}),
+ aliases: [],
+ });
+ report.nodes[kind] += 1;
+ if (kind === "activity" && !r.h5p_content_id) {
+ report.activityNodesWithoutContent.push(r.nid);
+ }
+ }
+
+ // Ordered by id so the first claim on a colliding alias wins, matching
+ // Drupal's own lookup.
+ const [aliasRows] = await conn.query(
+ `SELECT id, path, alias FROM path_alias
+ WHERE status = 1 AND path LIKE '/node/%'
+ ORDER BY id`
+ );
+ const owner = new Map();
+ const collisions = new Map>();
+ for (const r of aliasRows as any[]) {
+ const nid = Number(String(r.path).slice("/node/".length));
+ const node = nodes.get(nid);
+ if (!node || !r.alias) continue;
+ const alias = normalizeStudioAlias(r.alias);
+ const claimed = owner.get(alias);
+ if (claimed !== undefined && claimed !== nid) {
+ const set = collisions.get(alias) ?? new Set([claimed]);
+ set.add(nid);
+ collisions.set(alias, set);
+ continue;
+ }
+ owner.set(alias, nid);
+ if (!node.aliases.includes(alias)) node.aliases.push(alias);
+ if (!ROUTED_ALIAS.test(alias)) report.unroutedAliases.push({ alias, nid });
+ }
+ report.aliases = owner.size;
+ report.aliasCollisions = [...collisions].map(([alias, nids]) => ({
+ alias,
+ nids: [...nids],
+ }));
+
+ const ops = [...nodes].map(([nid, n]) => ({
+ updateOne: {
+ filter: { nid },
+ update: {
+ $set: {
+ nid,
+ orgID,
+ kind: n.kind,
+ aliases: n.aliases,
+ ...(n.h5pContentID ? { h5pContentID: n.h5pContentID } : {}),
+ },
+ ...(n.h5pContentID ? {} : { $unset: { h5pContentID: 1 } }),
+ },
+ upsert: true,
+ },
+ }));
+
+ if (COMMIT) {
+ if (mongoose.connection.readyState !== 1) {
+ throw new Error("--commit requires an active Mongo connection.");
+ }
+ const BATCH = 1000;
+ for (let i = 0; i < ops.length; i += BATCH) {
+ await StudioLegacyNode.bulkWrite(ops.slice(i, i + BATCH), {
+ ordered: false,
+ });
+ log.info(
+ { written: Math.min(i + BATCH, ops.length), total: ops.length },
+ "Import progress"
+ );
+ }
+ log.info({ written: ops.length }, "Import complete");
+ } else {
+ log.info({ wouldWrite: ops.length }, "Dry run complete; no records written");
+ }
+
+ const outDir = path.resolve("migration-reports");
+ fs.mkdirSync(outDir, { recursive: true });
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const reportPath = path.join(
+ outDir,
+ `studio-legacy-paths-${report.mode}-${stamp}.json`
+ );
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
+
+ process.stdout.write(
+ `\n${COMMIT ? "IMPORT" : "DRY RUN"}\n` +
+ ` H5P nodes: ${report.nodes.activity}\n` +
+ ` without a content id: ${report.activityNodesWithoutContent.length}\n` +
+ ` Collection nodes: ${report.nodes.collection}\n` +
+ ` Aliases: ${report.aliases}\n` +
+ ` collisions: ${report.aliasCollisions.length}\n` +
+ ` not routed by the edge: ${report.unroutedAliases.length}\n` +
+ `\nReport: ${reportPath}\n` +
+ (COMMIT ? "" : "\nRe-run with --commit to write these records.\n")
+ );
+ } finally {
+ await conn.end();
+ }
+}
+
+if (process.argv[1] && process.argv[1].includes("StudioImportLegacyPaths")) {
+ const mongoURI = process.env.MONGOOSEURI;
+ const connected = mongoURI
+ ? mongoose
+ .connect(mongoURI)
+ .then(() => true)
+ .catch((err) => {
+ logger.warn({ err }, "Mongo connect failed");
+ return false;
+ })
+ : Promise.resolve(false);
+
+ connected
+ .then(() => runMigration())
+ .catch((err) => {
+ logger.error({ err }, "Studio legacy path import failed");
+ process.exitCode = 1;
+ })
+ .finally(() => mongoose.disconnect().catch(() => undefined));
+}
diff --git a/server/migrations/StudioPseudonymizer.ts b/server/migrations/StudioPseudonymizer.ts
new file mode 100644
index 000000000..1f88d5a5a
--- /dev/null
+++ b/server/migrations/StudioPseudonymizer.ts
@@ -0,0 +1,175 @@
+/**
+ * Deterministic pseudonymisation for non-production Studio imports.
+ *
+ * The goal is dev data that behaves like production without containing anyone's
+ * identity. Pseudonyms are derived with a keyed HMAC, so the same real person
+ * always maps to the same fake person: the author who owns 9,191 activities
+ * still owns 9,191 in dev, collaborator graphs stay intact, and anything that
+ * depends on the shape of the data behaves the way it will in production.
+ *
+ * A keyed HMAC rather than a plain hash, because LibreOne UUIDs are an
+ * enumerable input space. Anyone holding the production list could reverse a
+ * bare SHA-256 by brute force; without the key they cannot.
+ *
+ * ## The salt
+ *
+ * The salt is a secret and is read from `STUDIO_PSEUDONYM_SALT`. Supply it by
+ * running the importer under `asm-exec` with a resolve token, so the value is
+ * injected at runtime and never pasted into a shell, a file, or a log:
+ *
+ * STUDIO_PSEUDONYM_SALT='{{resolve:secretsmanager:studio/migration:SecretString:pseudonymSalt}}' \
+ * asm-exec npx tsx server/migrations/StudioImportActivities.ts
+ *
+ * Never commit it, never echo it, and never copy it into a dev environment:
+ * the salt sitting next to the pseudonymised data would defeat the point. If a
+ * dev environment is ever exposed, rotate the salt and re-import.
+ */
+import crypto from "node:crypto";
+
+/**
+ * Reserved by RFC 2606 and guaranteed never to resolve, so generated addresses
+ * are undeliverable by construction rather than by convention. A typo'd real
+ * domain could receive mail; `.invalid` cannot.
+ */
+const SYNTHETIC_EMAIL_DOMAIN = "dev.invalid";
+
+const FIRST_NAMES = [
+ "Avery", "Blair", "Casey", "Darcy", "Ellis", "Finley", "Harper", "Indigo",
+ "Jordan", "Kai", "Lennox", "Marlow", "Noor", "Oakley", "Paz", "Quinn",
+ "Reese", "Sage", "Tatum", "Umber", "Vale", "Wren", "Xan", "Yuri", "Zephyr",
+];
+
+const LAST_NAMES = [
+ "Alder", "Brooks", "Calder", "Dunne", "Ellery", "Fairbank", "Glass",
+ "Hollis", "Ingram", "Jessop", "Kestrel", "Lowry", "Mercer", "Northcote",
+ "Orford", "Pike", "Quill", "Rowan", "Sorrel", "Thorne", "Upton", "Vance",
+ "Whitlock", "Yardley", "Zinn",
+];
+
+/** Matches most real-world addresses well enough to redact them from prose. */
+const EMAIL_IN_TEXT =
+ /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
+
+export default class StudioPseudonymizer {
+ private readonly key: Buffer;
+
+ constructor(salt: string) {
+ if (!salt || salt.length < 16) {
+ throw new Error(
+ "STUDIO_PSEUDONYM_SALT must be set to at least 16 characters. " +
+ "Supply it via asm-exec with a secretsmanager resolve token."
+ );
+ }
+ this.key = Buffer.from(salt, "utf-8");
+ }
+
+ /** Keyed digest of one input, scoped so different fields never collide. */
+ private digest(scope: string, value: string): Buffer {
+ return crypto
+ .createHmac("sha256", this.key)
+ .update(`${scope}:${value}`)
+ .digest();
+ }
+
+ /**
+ * Derives a syntactically valid UUIDv4 to stand in for a LibreOne UUID.
+ *
+ * Version and variant bits are forced so the result passes the same UUID
+ * validation as a real one. A pseudonym that failed `z.string().uuid()`
+ * would make dev behave differently from production, which is the one thing
+ * this is supposed to prevent.
+ */
+ centralID(realCentralID: string): string {
+ return this.uuidFrom("centralID", realCentralID);
+ }
+
+ /**
+ * Pseudonym for a Conductor-local user uuid.
+ *
+ * Scoped separately from `centralID` so the two identifiers for one person
+ * stay distinct, exactly as they are in production.
+ */
+ localUUID(realConductorUUID: string): string {
+ return this.uuidFrom("localUUID", realConductorUUID);
+ }
+
+ /** Derives a syntactically valid UUIDv4 from any scoped input. */
+ uuidFrom(scope: string, value: string): string {
+ const h = this.digest(scope, value);
+ const b = Buffer.from(h.subarray(0, 16));
+ b[6] = (b[6] & 0x0f) | 0x40; // version 4
+ b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
+ const hex = b.toString("hex");
+ return [
+ hex.slice(0, 8),
+ hex.slice(8, 12),
+ hex.slice(12, 16),
+ hex.slice(16, 20),
+ hex.slice(20, 32),
+ ].join("-");
+ }
+
+ /** Stable fake name for a real identifier. */
+ name(realCentralID: string): { firstName: string; lastName: string } {
+ const h = this.digest("name", realCentralID);
+ return {
+ firstName: FIRST_NAMES[h.readUInt16BE(0) % FIRST_NAMES.length],
+ lastName: LAST_NAMES[h.readUInt16BE(2) % LAST_NAMES.length],
+ };
+ }
+
+ /**
+ * Stable undeliverable address. The short digest suffix keeps addresses
+ * unique when two pseudonyms draw the same name pair.
+ */
+ email(realCentralID: string): string {
+ const { firstName, lastName } = this.name(realCentralID);
+ const suffix = this.digest("email", realCentralID)
+ .toString("hex")
+ .slice(0, 8);
+ return `${firstName}.${lastName}.${suffix}@${SYNTHETIC_EMAIL_DOMAIN}`.toLowerCase();
+ }
+
+ /**
+ * Redacts addresses from free text.
+ *
+ * Identity is not the only carrier. `h5p_content.authors` is free text typed
+ * by content creators and routinely holds real names and addresses, and node
+ * bodies contain instructor contact details. Names in prose cannot be found
+ * reliably, so the honest position is that this reduces exposure rather than
+ * eliminating it, which is one more reason dev keeps production-grade access
+ * control.
+ */
+ scrubText(text: string | null | undefined): string | undefined {
+ if (!text) return undefined;
+ return text.replace(EMAIL_IN_TEXT, "redacted@dev.invalid");
+ }
+
+ /**
+ * Replaces the H5P metadata author list wholesale.
+ *
+ * Stored as a JSON array of `{name, role}`. The names are real and there is
+ * no safe partial redaction, so the structure is preserved and the content
+ * replaced.
+ */
+ h5pAuthors(
+ raw: string | null | undefined,
+ realCentralID: string
+ ): string | undefined {
+ if (!raw) return undefined;
+ const { firstName, lastName } = this.name(realCentralID);
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return undefined;
+ return JSON.stringify(
+ parsed.map((entry: any, i: number) => ({
+ name: i === 0 ? `${firstName} ${lastName}` : `Contributor ${i + 1}`,
+ ...(entry?.role ? { role: entry.role } : {}),
+ }))
+ );
+ } catch {
+ // Malformed JSON in production data is not worth guessing at.
+ return undefined;
+ }
+ }
+}
diff --git a/server/migrations/StudioSurvey.ts b/server/migrations/StudioSurvey.ts
new file mode 100644
index 000000000..afb455557
--- /dev/null
+++ b/server/migrations/StudioSurvey.ts
@@ -0,0 +1,474 @@
+/**
+ * Studio migration survey. READ-ONLY.
+ *
+ * Answers the open questions blocking the Studio-to-Conductor migration plan.
+ * It writes nothing to either database and creates no Conductor records. Run it
+ * against a production replica with read-only MySQL credentials.
+ *
+ * STUDIO_MYSQL_HOST=... STUDIO_MYSQL_USER=... STUDIO_MYSQL_PASSWORD=... \
+ * STUDIO_MYSQL_DATABASE=studio_main \
+ * npx tsx --env-file=.env server/migrations/StudioSurvey.ts
+ *
+ * What it answers:
+ * 1. Volume - how many activities, libraries, collections; sizing the
+ * freeze window and the S3 bill.
+ * 2. Author mapping - how many Studio authors resolve to a Conductor User.
+ * Studio stores the owner as their LibreOne UUID, which
+ * is `User.centralID` here, NOT `User.uuid`. Anything
+ * that fails to resolve is an activity that would land
+ * without an owner, so this gates the import.
+ * 3. Content types - the most-used H5P libraries, which is the list the
+ * compatibility spike must load.
+ * 4. ADAPT coverage - how many activities already carry an adapt_id.
+ * 5. Collections - whether sub-collection nesting is actually used, which
+ * decides fold-into-Collection vs a separate model.
+ * 6. User state - whether h5p_content_user_data and h5p_points hold
+ * enough to be worth migrating.
+ *
+ * Drupal keeps deleted field data in the field tables and stores one row per
+ * translation, so every probe that touches `node_field_data`, `users_field_data`
+ * or a `*__field_*` table filters `default_langcode = 1` and `deleted = 0`.
+ * Without those filters the counts silently over-report.
+ *
+ * Every probe runs independently. These queries target a Drupal 9 schema that
+ * was reconstructed from exported config rather than from a live database, so a
+ * probe that references a table or column that does not exist reports itself as
+ * failed and the rest of the survey still completes.
+ */
+import fs from "node:fs";
+import path from "node:path";
+import mongoose from "mongoose";
+import mysql from "mysql2/promise";
+import logger, { childLogger } from "../logger.js";
+import User from "../models/user.js";
+
+const log = childLogger("studio-survey");
+
+type ProbeResult = {
+ name: string;
+ question: string;
+ ok: boolean;
+ rows?: unknown;
+ error?: string;
+};
+
+const results: ProbeResult[] = [];
+
+/**
+ * Runs one read-only query. A probe that fails is recorded and the survey
+ * continues; a reconstructed schema will have some misses and a single bad
+ * table name should not cost a whole run against production.
+ */
+async function probe(
+ conn: mysql.Connection,
+ name: string,
+ question: string,
+ sql: string
+): Promise {
+ try {
+ const [rows] = await conn.query(sql);
+ results.push({ name, question, ok: true, rows });
+ log.info({ probe: name }, "Probe succeeded");
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ results.push({ name, question, ok: false, error: message });
+ log.warn({ probe: name, err }, "Probe failed; continuing");
+ }
+}
+
+async function surveyStudio(conn: mysql.Connection): Promise {
+ await probe(
+ conn,
+ "volume.content",
+ "How many H5P activities are we moving?",
+ `SELECT COUNT(*) AS h5p_content_rows FROM h5p_content`
+ );
+
+ await probe(
+ conn,
+ "volume.libraries",
+ "How many H5P libraries must be migrated into the new library storage?",
+ `SELECT COUNT(*) AS total,
+ SUM(runnable = 1) AS runnable,
+ SUM(restricted = 1) AS restricted
+ FROM h5p_libraries`
+ );
+
+ await probe(
+ conn,
+ "volume.nodes",
+ "How many H5P nodes exist, and how many are published?",
+ `SELECT status, COUNT(*) AS nodes
+ FROM node_field_data WHERE type = 'h5p' AND default_langcode = 1
+ GROUP BY status`
+ );
+
+ await probe(
+ conn,
+ "volume.orphans",
+ "Are there h5p_content rows with no node, or nodes pointing at missing content?",
+ `SELECT
+ (SELECT COUNT(*) FROM h5p_content c
+ LEFT JOIN node__field_h5p f
+ ON f.field_h5p_h5p_content_id = c.id AND f.deleted = 0
+ WHERE f.entity_id IS NULL) AS content_without_node,
+ (SELECT COUNT(*) FROM node__field_h5p f
+ JOIN node_field_data n
+ ON n.nid = f.entity_id AND n.default_langcode = 1
+ LEFT JOIN h5p_content c ON c.id = f.field_h5p_h5p_content_id
+ WHERE f.deleted = 0 AND c.id IS NULL) AS node_without_content,
+ (SELECT COUNT(DISTINCT field_h5p_h5p_content_id)
+ FROM node__field_h5p WHERE deleted = 0) AS distinct_content_referenced,
+ (SELECT COUNT(*) FROM (
+ SELECT field_h5p_h5p_content_id
+ FROM node__field_h5p WHERE deleted = 0
+ GROUP BY field_h5p_h5p_content_id HAVING COUNT(DISTINCT entity_id) > 1
+ ) AS shared) AS content_shared_by_multiple_nodes`
+ );
+
+ await probe(
+ conn,
+ "volume.contentIdRange",
+ "What is the id range? Confirms ids are plain integers we can carry over.",
+ `SELECT MIN(id) AS min_id, MAX(id) AS max_id, COUNT(DISTINCT id) AS distinct_ids
+ FROM h5p_content`
+ );
+
+ await probe(
+ conn,
+ "contentTypes.topLibraries",
+ "Which content types must the compatibility spike load first?",
+ `SELECT l.machine_name, l.major_version, l.minor_version, l.title,
+ COUNT(c.id) AS activities
+ FROM h5p_content c
+ JOIN h5p_libraries l ON l.library_id = c.library_id
+ GROUP BY l.library_id, l.machine_name, l.major_version, l.minor_version, l.title
+ ORDER BY activities DESC
+ LIMIT 30`
+ );
+
+ await probe(
+ conn,
+ "contentTypes.embedTypes",
+ "How many activities render as iframe vs div? Affects the embed renderer.",
+ `SELECT l.embed_types, COUNT(c.id) AS activities
+ FROM h5p_content c
+ JOIN h5p_libraries l ON l.library_id = c.library_id
+ GROUP BY l.embed_types`
+ );
+
+ await probe(
+ conn,
+ "authors.distinct",
+ "How many distinct authors own H5P activities?",
+ `SELECT COUNT(DISTINCT n.uid) AS distinct_authors
+ FROM node_field_data n
+ WHERE n.type = 'h5p' AND n.default_langcode = 1`
+ );
+
+ /**
+ * Drupal's `users_field_data.name` holds the LibreOne UUID, because Studio's
+ * CAS attribute mapping sets `name` from `[cas:attribute:uuid]`. That value is
+ * what must match `User.centralID` in Conductor.
+ */
+ await probe(
+ conn,
+ "authors.libreOneUUIDs",
+ "Which LibreOne UUIDs own activities, and how many each?",
+ `SELECT u.name AS libreone_uuid, u.mail AS email, COUNT(n.nid) AS activities
+ FROM node_field_data n
+ JOIN users_field_data u ON u.uid = n.uid AND u.default_langcode = 1
+ WHERE n.type = 'h5p' AND n.default_langcode = 1
+ GROUP BY u.uid, u.name, u.mail
+ ORDER BY activities DESC`
+ );
+
+ await probe(
+ conn,
+ "authors.collaborators",
+ "How many activities have collaborators, and how many distinct collaborators exist?",
+ `SELECT COUNT(DISTINCT entity_id) AS activities_with_collaborators,
+ COUNT(DISTINCT field_collaborators_target_id) AS distinct_collaborators,
+ COUNT(*) AS collaborator_rows
+ FROM node__field_collaborators
+ WHERE bundle = 'h5p' AND deleted = 0`
+ );
+
+ await probe(
+ conn,
+ "adapt.coverage",
+ "How many activities already carry an ADAPT question id?",
+ `SELECT COUNT(*) AS activities_with_adapt_id
+ FROM node__field_adapt_id
+ WHERE deleted = 0
+ AND field_adapt_id_value IS NOT NULL AND field_adapt_id_value <> ''`
+ );
+
+ await probe(
+ conn,
+ "collections.volume",
+ "How many collections, and how many are hidden or excluded from the library?",
+ `SELECT COUNT(*) AS collections FROM node_field_data
+ WHERE type = 'collection' AND default_langcode = 1`
+ );
+
+ /**
+ * The fold-vs-separate decision. If almost every collection has exactly one
+ * sub-collection, the paragraph layer is incidental and collections flatten
+ * into Conductor's existing model. If nesting is widely used, it is a real
+ * feature and needs its own model.
+ */
+ await probe(
+ conn,
+ "collections.nesting",
+ "Is sub-collection nesting actually used, or is it always a single group?",
+ `SELECT sub_collections, COUNT(*) AS collections FROM (
+ SELECT entity_id, COUNT(*) AS sub_collections
+ FROM node__field_collections
+ WHERE deleted = 0
+ GROUP BY entity_id
+ ) AS per_collection
+ GROUP BY sub_collections
+ ORDER BY sub_collections`
+ );
+
+ await probe(
+ conn,
+ "collections.size",
+ "How many activities does a collection hold?",
+ `SELECT MIN(items) AS min_items, MAX(items) AS max_items,
+ ROUND(AVG(items), 2) AS avg_items, COUNT(*) AS sub_collections
+ FROM (
+ SELECT entity_id, COUNT(*) AS items
+ FROM paragraph__field_collection_resources
+ WHERE deleted = 0
+ GROUP BY entity_id
+ ) AS per_paragraph`
+ );
+
+ await probe(
+ conn,
+ "visibility.private",
+ "How many H5P nodes are flagged private?",
+ `SELECT private, COUNT(*) AS nodes
+ FROM node_field_data WHERE type = 'h5p' AND default_langcode = 1
+ GROUP BY private`
+ );
+
+ await probe(
+ conn,
+ "visibility.includeInLibrary",
+ "How many activities are actually listed in the public library?",
+ `SELECT field_include_in_library_value AS include_in_library, COUNT(*) AS nodes
+ FROM node__field_include_in_library
+ WHERE bundle = 'h5p' AND deleted = 0
+ GROUP BY field_include_in_library_value`
+ );
+
+ await probe(
+ conn,
+ "userState.contentUserData",
+ "Is saved content state worth migrating? Studio had save_content_state off.",
+ `SELECT COUNT(*) AS rows_stored,
+ COUNT(DISTINCT content_main_id) AS distinct_content,
+ COUNT(DISTINCT user_id) AS distinct_users
+ FROM h5p_content_user_data`
+ );
+
+ await probe(
+ conn,
+ "userState.points",
+ "How many stored results exist?",
+ `SELECT COUNT(*) AS rows_stored,
+ COUNT(DISTINCT content_id) AS distinct_content,
+ COUNT(DISTINCT uid) AS distinct_users
+ FROM h5p_points`
+ );
+
+ await probe(
+ conn,
+ "accessibility.guides",
+ "How many accessibility guides exist?",
+ `SELECT COUNT(*) AS guides FROM node_field_data
+ WHERE type = 'accessibility' AND default_langcode = 1`
+ );
+
+ await probe(
+ conn,
+ "taxonomy.usage",
+ "How large are the vocabularies we would import?",
+ `SELECT vid, COUNT(*) AS terms FROM taxonomy_term_field_data
+ WHERE default_langcode = 1 GROUP BY vid`
+ );
+}
+
+/**
+ * Cross-checks Studio's author identifiers against Conductor.
+ *
+ * Studio's `users_field_data.name` is a LibreOne UUID. In Conductor that is
+ * `User.centralID`; `User.uuid` is local and will never match. An author that
+ * does not resolve here is an activity that would be imported with no owner.
+ */
+async function surveyAuthorMapping(): Promise {
+ const authorProbe = results.find((r) => r.name === "authors.libreOneUUIDs");
+ if (!authorProbe?.ok || !Array.isArray(authorProbe.rows)) {
+ results.push({
+ name: "authors.conductorMapping",
+ question: "Do Studio authors resolve to Conductor users?",
+ ok: false,
+ error: "Skipped: the authors.libreOneUUIDs probe did not return rows.",
+ });
+ return;
+ }
+
+ const rows = authorProbe.rows as {
+ libreone_uuid: string | null;
+ email: string | null;
+ activities: number;
+ }[];
+
+ const centralIDs = rows
+ .map((r) => r.libreone_uuid)
+ .filter((id): id is string => Boolean(id));
+
+ const matched = await User.find(
+ { centralID: { $in: centralIDs } },
+ { _id: 0, uuid: 1, centralID: 1, email: 1 }
+ ).lean();
+
+ const matchedByCentralID = new Map(matched.map((u) => [u.centralID, u]));
+
+ const unresolved = rows
+ .filter((r) => !r.libreone_uuid || !matchedByCentralID.has(r.libreone_uuid))
+ .map((r) => ({
+ libreone_uuid: r.libreone_uuid,
+ email: r.email,
+ activities: r.activities,
+ }));
+
+ const orphanedActivities = unresolved.reduce(
+ (sum, r) => sum + Number(r.activities || 0),
+ 0
+ );
+
+ results.push({
+ name: "authors.conductorMapping",
+ question:
+ "Do Studio authors resolve to Conductor users via centralID, and how much content is at risk if not?",
+ ok: true,
+ rows: {
+ studioAuthors: rows.length,
+ resolved: rows.length - unresolved.length,
+ unresolved: unresolved.length,
+ activitiesWithoutResolvableOwner: orphanedActivities,
+ // Capped: the point is whether the number is zero, not a full dump.
+ unresolvedSample: unresolved.slice(0, 500),
+ },
+ });
+}
+
+function renderReport(): string {
+ const lines: string[] = [];
+ lines.push("Studio migration survey");
+ lines.push(`Generated ${new Date().toISOString()}`);
+ lines.push("READ-ONLY: nothing was written to either database.");
+ lines.push("");
+
+ for (const r of results) {
+ lines.push(`## ${r.name}`);
+ lines.push(r.question);
+ if (!r.ok) {
+ lines.push(` FAILED: ${r.error}`);
+ } else {
+ lines.push(JSON.stringify(r.rows, null, 2));
+ }
+ lines.push("");
+ }
+
+ const failed = results.filter((r) => !r.ok);
+ lines.push(`${results.length - failed.length}/${results.length} probes succeeded.`);
+ if (failed.length) {
+ lines.push(`Failed: ${failed.map((f) => f.name).join(", ")}`);
+ }
+ lines.push("");
+ lines.push("Not answerable from the database, run on the Studio host:");
+ lines.push(" du -sh web/sites/default/files/h5p/libraries");
+ lines.push(" du -sh web/sites/default/files/h5p/content");
+ lines.push(" du -sh web/sites/default/files/h5p");
+
+ return lines.join("\n");
+}
+
+export async function runMigration(): Promise {
+ const mysqlConfig = {
+ host: process.env.STUDIO_MYSQL_HOST,
+ port: parseInt(process.env.STUDIO_MYSQL_PORT || "3306", 10),
+ user: process.env.STUDIO_MYSQL_USER,
+ password: process.env.STUDIO_MYSQL_PASSWORD,
+ database: process.env.STUDIO_MYSQL_DATABASE,
+ };
+
+ if (!mysqlConfig.host || !mysqlConfig.user || !mysqlConfig.database) {
+ throw new Error(
+ "Missing STUDIO_MYSQL_HOST, STUDIO_MYSQL_USER or STUDIO_MYSQL_DATABASE."
+ );
+ }
+
+ log.info(
+ { host: mysqlConfig.host, database: mysqlConfig.database },
+ "Starting read-only Studio survey"
+ );
+
+ const conn = await mysql.createConnection(mysqlConfig);
+ try {
+ // Belt and braces: even with read-only credentials, make the session itself
+ // refuse writes so a mistake here cannot touch production data.
+ await conn.query("SET SESSION TRANSACTION READ ONLY");
+ await surveyStudio(conn);
+ } finally {
+ await conn.end();
+ }
+
+ if (mongoose.connection.readyState === 1) {
+ await surveyAuthorMapping();
+ } else {
+ results.push({
+ name: "authors.conductorMapping",
+ question: "Do Studio authors resolve to Conductor users?",
+ ok: false,
+ error: "Skipped: no active Mongoose connection to Conductor.",
+ });
+ }
+
+ const outDir = path.resolve("migration-reports");
+ fs.mkdirSync(outDir, { recursive: true });
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
+
+ const jsonPath = path.join(outDir, `studio-survey-${stamp}.json`);
+ const textPath = path.join(outDir, `studio-survey-${stamp}.txt`);
+ fs.writeFileSync(jsonPath, JSON.stringify(results, null, 2));
+ fs.writeFileSync(textPath, renderReport());
+
+ log.info({ jsonPath, textPath }, "Survey complete");
+ // The report is the deliverable, so put it where whoever ran it will see it.
+ process.stdout.write(`\n${renderReport()}\n`);
+}
+
+// Allow `npx tsx server/migrations/StudioSurvey.ts` to run it directly.
+if (process.argv[1] && process.argv[1].includes("StudioSurvey")) {
+ const mongoURI = process.env.MONGOOSEURI;
+ const connected = mongoURI
+ ? mongoose.connect(mongoURI).then(() => true).catch((err) => {
+ logger.warn({ err }, "Mongo connect failed; author mapping will be skipped");
+ return false;
+ })
+ : Promise.resolve(false);
+
+ connected
+ .then(() => runMigration())
+ .catch((err) => {
+ logger.error({ err }, "Studio survey failed");
+ process.exitCode = 1;
+ })
+ .finally(() => mongoose.disconnect().catch(() => undefined));
+}
diff --git a/server/migrations/fixtures/studio-archive-sample.sql b/server/migrations/fixtures/studio-archive-sample.sql
new file mode 100644
index 000000000..19dd26254
--- /dev/null
+++ b/server/migrations/fixtures/studio-archive-sample.sql
@@ -0,0 +1,88 @@
+-- Test fixture for StudioArchiveExport.ts. NOT a migration.
+--
+-- Seeds production-shaped volumes (1,131,666 result rows, 673,977 users,
+-- 15,644 saved-state rows) so the streaming export is exercised at real scale.
+-- Streaming is the whole point of that script, and a small fixture would not
+-- catch a regression that buffers the result set.
+--
+-- docker run -d --name studio-archive-test -e MYSQL_ROOT_PASSWORD=test \n-- -e MYSQL_DATABASE=studio_main -p 33062:3306 mysql:8.0
+-- docker exec -i studio-archive-test mysql -uroot -ptest studio_main \n-- < server/migrations/fixtures/studio-archive-sample.sql
+-- STUDIO_MYSQL_HOST=127.0.0.1 STUDIO_MYSQL_PORT=33062 STUDIO_MYSQL_USER=root \n-- STUDIO_MYSQL_PASSWORD=test STUDIO_MYSQL_DATABASE=studio_main \n-- NODE_OPTIONS="--max-old-space-size=256" \n-- npx tsx migrations/StudioArchiveExport.ts
+--
+-- The heap cap is deliberate: it fails loudly if the export stops streaming.
+-- Seeding takes about a minute.
+
+-- Seeds a production-shaped volume so the streaming export is exercised for
+-- real: ~1.1M h5p_points rows and ~674k users, matching the survey.
+
+CREATE TABLE h5p_points (
+ content_id INT UNSIGNED NOT NULL, uid INT UNSIGNED NOT NULL,
+ started INT UNSIGNED NOT NULL, finished INT UNSIGNED NOT NULL DEFAULT 0,
+ points INT UNSIGNED, max_points INT UNSIGNED,
+ PRIMARY KEY (content_id, uid)
+);
+
+CREATE TABLE h5p_content_user_data (
+ user_id INT UNSIGNED NOT NULL, content_main_id INT UNSIGNED NOT NULL,
+ sub_content_id INT UNSIGNED NOT NULL, data_id VARCHAR(127) NOT NULL,
+ timestamp INT UNSIGNED NOT NULL, data LONGTEXT NOT NULL,
+ preloaded TINYINT UNSIGNED, delete_on_content_change TINYINT UNSIGNED,
+ PRIMARY KEY (user_id, content_main_id, sub_content_id, data_id)
+);
+
+CREATE TABLE users_field_data (
+ uid INT UNSIGNED NOT NULL, langcode VARCHAR(12) NOT NULL DEFAULT 'en',
+ name VARCHAR(60) NOT NULL DEFAULT '', mail VARCHAR(254),
+ status TINYINT DEFAULT 0, created INT, access INT, login INT,
+ default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (uid, langcode)
+);
+
+-- A numbers table to generate rows without a million INSERT statements.
+CREATE TABLE seq (n INT UNSIGNED NOT NULL PRIMARY KEY);
+INSERT INTO seq (n) SELECT 0;
+INSERT INTO seq (n) SELECT n + 1 FROM seq;
+INSERT INTO seq (n) SELECT n + 2 FROM seq;
+INSERT INTO seq (n) SELECT n + 4 FROM seq;
+INSERT INTO seq (n) SELECT n + 8 FROM seq;
+INSERT INTO seq (n) SELECT n + 16 FROM seq;
+INSERT INTO seq (n) SELECT n + 32 FROM seq;
+INSERT INTO seq (n) SELECT n + 64 FROM seq;
+INSERT INTO seq (n) SELECT n + 128 FROM seq;
+INSERT INTO seq (n) SELECT n + 256 FROM seq;
+INSERT INTO seq (n) SELECT n + 512 FROM seq;
+INSERT INTO seq (n) SELECT n + 1024 FROM seq;
+INSERT INTO seq (n) SELECT n + 2048 FROM seq;
+INSERT INTO seq (n) SELECT n + 4096 FROM seq;
+INSERT INTO seq (n) SELECT n + 8192 FROM seq;
+INSERT INTO seq (n) SELECT n + 16384 FROM seq;
+INSERT INTO seq (n) SELECT n + 32768 FROM seq;
+INSERT INTO seq (n) SELECT n + 65536 FROM seq;
+INSERT INTO seq (n) SELECT n + 131072 FROM seq;
+INSERT INTO seq (n) SELECT n + 262144 FROM seq;
+INSERT INTO seq (n) SELECT n + 524288 FROM seq;
+
+-- 673,977 users, matching the survey's distinct learner count.
+INSERT INTO users_field_data (uid, name, mail, status, created, access, login)
+SELECT n + 1,
+ CONCAT(LPAD(HEX(n), 8, '0'), '-1111-4111-8111-', LPAD(HEX(n), 12, '0')),
+ CONCAT('user', n, '@example.edu'),
+ 1, 1600000000, 1700000000, 1700000000
+FROM seq WHERE n < 673977;
+
+-- 1,131,666 result rows spread over 17,492 content ids.
+INSERT INTO h5p_points (content_id, uid, started, finished, points, max_points)
+SELECT (a.n % 17492) + 1, a.n + 1, 1600000000, 1600000100, a.n % 11, 10
+FROM seq a WHERE a.n < 565833;
+INSERT INTO h5p_points (content_id, uid, started, finished, points, max_points)
+SELECT ((a.n + 900000) % 17492) + 20000, a.n + 1, 1600000000, 1600000200, a.n % 7, 10
+FROM seq a WHERE a.n < 565833;
+
+-- 15,644 saved-state rows carrying a realistic JSON payload.
+INSERT INTO h5p_content_user_data
+ (user_id, content_main_id, sub_content_id, data_id, timestamp, data, preloaded, delete_on_content_change)
+SELECT n + 1, (n % 8738) + 1, 0, 'state', 1700000000,
+ CONCAT('{"progress":', n % 100, ',"answers":[1,2,3],"v":"1.0"}'), 1, 0
+FROM seq WHERE n < 15644;
+
+DROP TABLE seq;
diff --git a/server/migrations/fixtures/studio-collections-sample.sql b/server/migrations/fixtures/studio-collections-sample.sql
new file mode 100644
index 000000000..fb1545b56
--- /dev/null
+++ b/server/migrations/fixtures/studio-collections-sample.sql
@@ -0,0 +1,201 @@
+-- Test fixture for StudioImportCollections.ts. NOT a migration.
+--
+-- Exercises every branch the importer has to get right:
+-- * sub-collection order comes from `delta`, not row order (rows inserted
+-- out of order on purpose), and so does activity order inside each one
+-- * a deleted sub-collection row and a translation row, both ignored
+-- * a reference to a duplicate node, which must resolve to the shared
+-- content id (the canonical activity)
+-- * a reference to a node whose content is gone, and one to a node that is
+-- not an H5P node, both reported and dropped
+-- * a collection with no sub-collections, and one that is unpublished and
+-- excluded from the library
+-- * an email address in free text, scrubbed in pseudonymised mode
+--
+-- Expected result, 3 collections:
+-- 2001 "Biology Collection" public, in library
+-- "Week 1": [102, 101] (node 1003 is a duplicate of 1002 -> 102)
+-- "Week 2": [102]
+-- 2002 "Draft Collection" private, not in library
+-- "Only": [] (1004 missing content, 3001 not H5P)
+-- 2003 "Empty Collection" no sub-collections
+--
+-- docker run -d --name studio-collections-test -e MYSQL_ROOT_PASSWORD=test \
+-- -e MYSQL_DATABASE=studio_main -p 33065:3306 mysql:8.0
+-- docker exec -i studio-collections-test mysql -uroot -ptest studio_main \
+-- < server/migrations/fixtures/studio-collections-sample.sql
+--
+-- Pair it with a throwaway Mongo. Never point the importer at a real cluster
+-- while testing.
+
+CREATE TABLE users_field_data (
+ uid INT UNSIGNED NOT NULL, langcode VARCHAR(12) NOT NULL DEFAULT 'en',
+ name VARCHAR(60), mail VARCHAR(254), status TINYINT DEFAULT 1, created INT,
+ default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (uid, langcode)
+);
+
+CREATE TABLE node_field_data (
+ nid INT UNSIGNED NOT NULL, vid INT UNSIGNED NOT NULL,
+ type VARCHAR(32) NOT NULL, langcode VARCHAR(12) NOT NULL DEFAULT 'en',
+ status TINYINT NOT NULL DEFAULT 0, uid INT UNSIGNED NOT NULL,
+ title VARCHAR(255), created INT, changed INT,
+ default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (nid, langcode)
+);
+
+CREATE TABLE h5p_content (
+ id INT UNSIGNED NOT NULL PRIMARY KEY,
+ title VARCHAR(255)
+);
+
+CREATE TABLE node__field_h5p (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_h5p_h5p_content_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_collections (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_collections_target_id INT UNSIGNED NOT NULL,
+ field_collections_target_revision_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE paragraph__field_collection_title_name (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_collection_title_name_value VARCHAR(255),
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE paragraph__field_collection_resources (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_collection_resources_target_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_description (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_description_value LONGTEXT,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_oer_reference (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_oer_reference_uri VARCHAR(2048), field_oer_reference_title VARCHAR(255),
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_screenshots (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_screenshots_target_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_subject (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_subject_target_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_tags (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_tags_target_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_include_in_library (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_include_in_library_value TINYINT NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE taxonomy_term_field_data (
+ tid INT UNSIGNED NOT NULL, vid VARCHAR(32) NOT NULL,
+ langcode VARCHAR(12) NOT NULL DEFAULT 'en', name VARCHAR(255),
+ default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (tid, langcode)
+);
+
+INSERT INTO users_field_data (uid,name,mail,created) VALUES
+ (1,'11111111-1111-4111-8111-111111111111','alice@example.edu',1600000000),
+ (2,'bob','bob@example.edu',1600000000);
+
+INSERT INTO node_field_data (nid,vid,type,langcode,status,uid,title,created,changed,default_langcode) VALUES
+ (1001,1,'h5p','en',1,1,'Photosynthesis Quiz',1600000000,1700000000,1),
+ (1002,2,'h5p','en',1,1,'Mitosis Quiz',1600000000,1700000000,1),
+ (1003,3,'h5p','en',1,2,'Mitosis Quiz (copy)',1600000000,1700000000,1),
+ (1004,4,'h5p','en',1,1,'Quiz with deleted content',1600000000,1700000000,1),
+ (3001,5,'accessibility','en',1,1,'Accessibility: MultiChoice',1600000000,1700000000,1),
+ (2001,6,'collection','en',1,1,'Biology Collection',1610000000,1720000000,1),
+ (2001,6,'collection','es',1,1,'Coleccion de Biologia',1610000000,1720000000,0),
+ (2002,7,'collection','en',0,2,'Draft Collection',1620000000,1710000000,1),
+ (2003,8,'collection','en',1,1,'Empty Collection',1630000000,1730000000,1);
+
+INSERT INTO h5p_content (id,title) VALUES (101,'Photosynthesis Quiz'),(102,'Mitosis Quiz');
+
+INSERT INTO node__field_h5p (bundle,entity_id,revision_id,langcode,delta,field_h5p_h5p_content_id) VALUES
+ ('h5p',1001,1,'en',0,101),('h5p',1002,2,'en',0,102),
+ ('h5p',1003,3,'en',0,102),('h5p',1004,4,'en',0,199);
+
+-- Out of delta order on purpose, plus a deleted row that must not appear.
+INSERT INTO node__field_collections (bundle,deleted,entity_id,revision_id,langcode,delta,field_collections_target_id,field_collections_target_revision_id) VALUES
+ ('collection',0,2001,6,'en',1,9002,9002),
+ ('collection',0,2001,6,'en',0,9001,9001),
+ ('collection',1,2001,6,'en',2,9009,9009),
+ ('collection',0,2002,7,'en',0,9003,9003);
+
+INSERT INTO paragraph__field_collection_title_name (bundle,entity_id,revision_id,langcode,delta,field_collection_title_name_value) VALUES
+ ('collection',9001,9001,'en',0,'Week 1'),
+ ('collection',9002,9002,'en',0,'Week 2'),
+ ('collection',9003,9003,'en',0,'Only'),
+ ('collection',9009,9009,'en',0,'Deleted week');
+
+INSERT INTO paragraph__field_collection_resources (bundle,entity_id,revision_id,langcode,delta,field_collection_resources_target_id) VALUES
+ ('collection',9001,9001,'en',1,1001),
+ ('collection',9001,9001,'en',0,1003),
+ ('collection',9002,9002,'en',0,1002),
+ ('collection',9003,9003,'en',0,1004),
+ ('collection',9003,9003,'en',1,3001),
+ ('collection',9009,9009,'en',0,1001);
+
+INSERT INTO node__field_description (bundle,entity_id,revision_id,langcode,delta,field_description_value) VALUES
+ ('collection',2001,6,'en',0,'Intro biology activities. Questions to alice@example.edu.');
+
+INSERT INTO node__field_oer_reference (bundle,entity_id,revision_id,langcode,delta,field_oer_reference_uri,field_oer_reference_title) VALUES
+ ('collection',2001,6,'en',0,'https://bio.libretexts.org/Bookshelves/Introductory','Intro Bio');
+
+INSERT INTO node__field_screenshots (bundle,entity_id,revision_id,langcode,delta,field_screenshots_target_id) VALUES
+ ('collection',2001,6,'en',0,77);
+
+INSERT INTO node__field_subject (bundle,entity_id,revision_id,langcode,delta,field_subject_target_id) VALUES
+ ('collection',2001,6,'en',0,1);
+
+INSERT INTO node__field_tags (bundle,entity_id,revision_id,langcode,delta,field_tags_target_id) VALUES
+ ('collection',2001,6,'en',0,3);
+
+INSERT INTO node__field_include_in_library (bundle,entity_id,revision_id,langcode,delta,field_include_in_library_value) VALUES
+ ('collection',2001,6,'en',0,1),('collection',2002,7,'en',0,0);
+
+INSERT INTO taxonomy_term_field_data (tid,vid,name) VALUES
+ (1,'subjects','Biology'),(3,'tags','quiz');
diff --git a/server/migrations/fixtures/studio-import-sample.sql b/server/migrations/fixtures/studio-import-sample.sql
new file mode 100644
index 000000000..d8ea1a447
--- /dev/null
+++ b/server/migrations/fixtures/studio-import-sample.sql
@@ -0,0 +1,228 @@
+-- Test fixture for StudioImportActivities.ts. NOT a migration.
+--
+-- Exercises every branch the importer has to get right:
+-- * all five author resolution tiers (1, 2, 3, 4 and unresolvable)
+-- * a content id referenced by two nodes, where the canonical pick must
+-- prefer the published node that carries an ADAPT id over the lower nid
+-- * a node pointing at content that no longer exists (skip)
+-- * a content row with no node (must NOT produce an Activity)
+-- * collaborators, taxonomy fields, and the include_in_* flags
+-- * the feed fields Studio's API emits: node uuid, library title and id,
+-- language and license extras (101's carries an address to scrub)
+--
+-- docker run -d --name studio-import-test -e MYSQL_ROOT_PASSWORD=test \
+-- -e MYSQL_DATABASE=studio_main -p 33063:3306 mysql:8.0
+-- docker exec -i studio-import-test mysql -uroot -ptest studio_main \
+-- < server/migrations/fixtures/studio-import-sample.sql
+--
+-- Pair it with a throwaway Mongo. Never point the importer at a real cluster
+-- while testing.
+
+CREATE TABLE h5p_libraries (
+ library_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ machine_name VARCHAR(127) NOT NULL DEFAULT '',
+ title VARCHAR(255) NOT NULL DEFAULT '',
+ major_version INT UNSIGNED NOT NULL,
+ minor_version INT UNSIGNED NOT NULL,
+ patch_version INT UNSIGNED NOT NULL,
+ runnable TINYINT UNSIGNED NOT NULL DEFAULT 1,
+ embed_types VARCHAR(255) NOT NULL DEFAULT '',
+ semantics TEXT NOT NULL, restricted TINYINT UNSIGNED NOT NULL DEFAULT 0
+);
+
+CREATE TABLE h5p_content (
+ id INT UNSIGNED NOT NULL PRIMARY KEY,
+ library_id INT UNSIGNED NOT NULL,
+ title VARCHAR(255), authors LONGTEXT, source VARCHAR(2083),
+ license VARCHAR(32), license_version VARCHAR(10), license_extras LONGTEXT,
+ default_language VARCHAR(32)
+);
+
+CREATE TABLE node (
+ nid INT UNSIGNED NOT NULL PRIMARY KEY, vid INT UNSIGNED,
+ type VARCHAR(32) NOT NULL, uuid VARCHAR(128) NOT NULL, langcode VARCHAR(12) NOT NULL
+);
+
+CREATE TABLE users_field_data (
+ uid INT UNSIGNED NOT NULL, langcode VARCHAR(12) NOT NULL DEFAULT 'en',
+ name VARCHAR(60) NOT NULL DEFAULT '', mail VARCHAR(254),
+ default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (uid, langcode)
+);
+
+CREATE TABLE node_field_data (
+ nid INT UNSIGNED NOT NULL, vid INT UNSIGNED NOT NULL,
+ type VARCHAR(32) NOT NULL, langcode VARCHAR(12) NOT NULL DEFAULT 'en',
+ status TINYINT NOT NULL DEFAULT 0, uid INT UNSIGNED NOT NULL,
+ title VARCHAR(255), created INT, changed INT,
+ default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (nid, langcode)
+);
+
+CREATE TABLE node__field_h5p (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_h5p_h5p_content_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__body (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ body_value LONGTEXT, body_summary LONGTEXT, body_format VARCHAR(255),
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_adapt_id (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_adapt_id_value VARCHAR(255),
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_subject (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_subject_target_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_tags (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_tags_target_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_publisher (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_publisher_target_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_collaborators (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_collaborators_target_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_include_in_library (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_include_in_library_value TINYINT NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_include_in_profile (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_include_in_profile_value TINYINT NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE taxonomy_term_field_data (
+ tid INT UNSIGNED NOT NULL, vid VARCHAR(32) NOT NULL,
+ langcode VARCHAR(12) NOT NULL DEFAULT 'en', name VARCHAR(255),
+ default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (tid, langcode)
+);
+
+INSERT INTO h5p_libraries (library_id,machine_name,title,major_version,minor_version,patch_version,embed_types,semantics) VALUES
+ (1,'H5P.MultiChoice','Multiple Choice',1,16,0,'iframe','[]'),
+ (2,'H5P.Blanks','Fill in the Blanks',1,14,0,'iframe','[]');
+
+INSERT INTO h5p_content (id,library_id,title,authors,source,license,license_version,default_language) VALUES
+ (101,1,'Photosynthesis Quiz','[{"name":"A. Author"}]','https://example.edu/src','CC BY','4.0','en'),
+ (102,1,'Mitosis Quiz',NULL,NULL,'CC BY-SA','4.0','en'),
+ (103,2,'Cell Blanks',NULL,NULL,'PDM',NULL,'es'),
+ (104,2,'Orphan: no node points here',NULL,NULL,'CC BY','4.0','en'),
+ (106,1,'Unowned activity',NULL,NULL,NULL,NULL,'en'),
+ (107,2,'Legacy author activity',NULL,NULL,'CC BY','4.0','en');
+
+-- tier 1: name is a LibreOne UUID present in Conductor
+-- tier 2: legacy username resolved through cas_users.csv
+-- tier 3: LibreOne UUID with no Conductor account
+-- tier 4: no UUID, no CSV entry, but the email matches a Conductor user
+-- tier 0: nothing matches
+INSERT INTO users_field_data (uid,name,mail) VALUES
+ (1,'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa','alice@example.edu'),
+ (2,'legacyuser','legacy@example.edu'),
+ (3,'cccccccc-3333-4333-8333-cccccccccccc','ghostuuid@example.edu'),
+ (4,'emailonly','known@example.edu'),
+ (5,'nobody','nobody@example.edu');
+
+-- Content 101 is referenced twice. Node 1001 has the lower nid but is
+-- unpublished; node 1002 is published AND carries an ADAPT id, so it must win.
+-- Node 1003 has no `node` row on purpose: the uuid is optional.
+INSERT INTO node (nid,vid,type,uuid,langcode) VALUES
+ (1001,1,'h5p','aaaaaaaa-0000-4000-8000-000000001001','en'),
+ (1002,2,'h5p','aaaaaaaa-0000-4000-8000-000000001002','en'),
+ (1004,4,'h5p','aaaaaaaa-0000-4000-8000-000000001004','en'),
+ (1005,5,'h5p','aaaaaaaa-0000-4000-8000-000000001005','en'),
+ (1006,6,'h5p','aaaaaaaa-0000-4000-8000-000000001006','en'),
+ (1007,7,'h5p','aaaaaaaa-0000-4000-8000-000000001007','en');
+
+UPDATE h5p_content SET license_extras = 'Attribution requests to a.author@example.edu' WHERE id = 101;
+
+INSERT INTO node_field_data (nid,vid,type,status,uid,title,created,changed) VALUES
+ (1001,1,'h5p',0,2,'Photosynthesis Quiz (draft dupe)',1600000000,1700000000),
+ (1002,2,'h5p',1,1,'Photosynthesis Quiz',1600000000,1700000000),
+ (1003,3,'h5p',1,3,'Mitosis Quiz',1600000000,1700000000),
+ (1004,4,'h5p',1,4,'Cell Blanks',1600000000,1700000000),
+ (1005,5,'h5p',1,1,'Broken: content is gone',1600000000,1700000000),
+ (1006,6,'h5p',1,5,'Unowned activity',1600000000,1700000000),
+ (1007,7,'h5p',1,2,'Legacy author activity',1600000000,1700000000);
+
+INSERT INTO node__field_h5p (bundle,entity_id,revision_id,langcode,delta,field_h5p_h5p_content_id) VALUES
+ ('h5p',1001,1,'en',0,101),
+ ('h5p',1002,2,'en',0,101),
+ ('h5p',1003,3,'en',0,102),
+ ('h5p',1004,4,'en',0,103),
+ ('h5p',1005,5,'en',0,999),
+ ('h5p',1006,6,'en',0,106),
+ ('h5p',1007,7,'en',0,107);
+
+INSERT INTO node__body (bundle,entity_id,revision_id,langcode,delta,body_value,body_format) VALUES
+ ('h5p',1002,2,'en',0,'Instructor overview for photosynthesis.
','basic_html'),
+ ('h5p',1003,3,'en',0,'Mitosis overview.
','basic_html');
+
+INSERT INTO node__field_adapt_id (bundle,entity_id,revision_id,langcode,delta,field_adapt_id_value) VALUES
+ ('h5p',1002,2,'en',0,'55501'),
+ ('h5p',1003,3,'en',0,'');
+
+INSERT INTO taxonomy_term_field_data (tid,vid,name) VALUES
+ (1,'subjects','Biology'),(2,'subjects','Chemistry'),
+ (10,'tags','quiz'),(11,'tags','cells'),
+ (20,'publishers','LibreTexts');
+
+INSERT INTO node__field_subject (bundle,entity_id,revision_id,langcode,delta,field_subject_target_id) VALUES
+ ('h5p',1002,2,'en',0,1),('h5p',1003,3,'en',0,1),('h5p',1004,4,'en',0,2);
+
+INSERT INTO node__field_tags (bundle,entity_id,revision_id,langcode,delta,field_tags_target_id) VALUES
+ ('h5p',1002,2,'en',0,10),('h5p',1002,2,'en',1,11),('h5p',1003,3,'en',0,11);
+
+INSERT INTO node__field_publisher (bundle,entity_id,revision_id,langcode,delta,field_publisher_target_id) VALUES
+ ('h5p',1002,2,'en',0,20);
+
+-- uid 3 collaborates on 1002; uid 3 is tier 3 and has no Conductor uuid, so
+-- the collaborator list must come back empty rather than containing undefined.
+INSERT INTO node__field_collaborators (bundle,entity_id,revision_id,langcode,delta,field_collaborators_target_id) VALUES
+ ('h5p',1002,2,'en',0,4),
+ ('h5p',1002,2,'en',1,3);
+
+INSERT INTO node__field_include_in_library (bundle,entity_id,revision_id,langcode,delta,field_include_in_library_value) VALUES
+ ('h5p',1002,2,'en',0,1),('h5p',1003,3,'en',0,1),('h5p',1004,4,'en',0,0);
+
+INSERT INTO node__field_include_in_profile (bundle,entity_id,revision_id,langcode,delta,field_include_in_profile_value) VALUES
+ ('h5p',1002,2,'en',0,1),('h5p',1004,4,'en',0,0);
diff --git a/server/migrations/fixtures/studio-legacy-paths-sample.sql b/server/migrations/fixtures/studio-legacy-paths-sample.sql
new file mode 100644
index 000000000..8f5681110
--- /dev/null
+++ b/server/migrations/fixtures/studio-legacy-paths-sample.sql
@@ -0,0 +1,74 @@
+-- Test fixture for StudioImportLegacyPaths.ts and the legacy resolver. NOT a migration.
+--
+-- Exercises what makes Studio's URLs hard to resolve:
+-- * the current pattern, /h5p/{content id}, and the old disabled one,
+-- /h5p/{nid}, still attached to nodes
+-- * an old /h5p/{nid} alias whose number is ALSO a live content id: node
+-- 1003's alias /h5p/1003 must resolve through node 1003 to content 102,
+-- not to content 1003 (whose own alias got pathauto's -0 suffix)
+-- * a collapsed duplicate node (1002 shares content 101 with 1001)
+-- * an alias claimed by two nodes (the lower alias id wins), a disabled
+-- alias, an alias on a node type the resolver ignores, mixed case
+-- * an H5P node with no content id, and collection aliases inside and
+-- outside the /collection/ prefix
+--
+-- Expected import: 6 H5P nodes (1 without content), 2 collection nodes,
+-- 9 aliases, 1 collision (/h5p/101), 2 unrouted (/my-biology-set, /help/x).
+--
+-- docker run -d --name studio-legacy-test -e MYSQL_ROOT_PASSWORD=test \
+-- -e MYSQL_DATABASE=studio_main -p 33068:3306 mysql:8.0
+-- docker exec -i studio-legacy-test mysql -uroot -ptest studio_main \
+-- < server/migrations/fixtures/studio-legacy-paths-sample.sql
+
+CREATE TABLE node_field_data (
+ nid INT UNSIGNED NOT NULL, vid INT UNSIGNED NOT NULL,
+ type VARCHAR(32) NOT NULL, langcode VARCHAR(12) NOT NULL DEFAULT 'en',
+ status TINYINT NOT NULL DEFAULT 1, uid INT UNSIGNED NOT NULL DEFAULT 1,
+ title VARCHAR(255), default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (nid, langcode)
+);
+
+CREATE TABLE node__field_h5p (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_h5p_h5p_content_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE path_alias (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ revision_id INT UNSIGNED, uuid VARCHAR(128) NOT NULL DEFAULT '',
+ langcode VARCHAR(12) NOT NULL DEFAULT 'en',
+ path VARCHAR(255), alias VARCHAR(255), status TINYINT NOT NULL DEFAULT 1
+);
+
+INSERT INTO node_field_data (nid,vid,type,langcode,title,default_langcode) VALUES
+ (1001,1,'h5p','en','Photosynthesis Quiz',1),
+ (1002,2,'h5p','en','Photosynthesis Quiz (dupe)',1),
+ (1003,3,'h5p','en','Mitosis Quiz',1),
+ (1004,4,'h5p','en','Cell Blanks',1),
+ (1006,6,'h5p','en','Node with no content',1),
+ (1007,7,'h5p','en','Content 1003',1),
+ (1007,7,'h5p','es','Contenido 1003',0),
+ (2001,8,'collection','en','Biology Collection',1),
+ (2002,9,'collection','en','Hand-aliased Collection',1),
+ (3001,10,'page','en','About Studio',1);
+
+INSERT INTO node__field_h5p (bundle,entity_id,revision_id,langcode,delta,field_h5p_h5p_content_id) VALUES
+ ('h5p',1001,1,'en',0,101),('h5p',1002,2,'en',0,101),('h5p',1003,3,'en',0,102),
+ ('h5p',1004,4,'en',0,103),('h5p',1007,7,'en',0,1003);
+
+INSERT INTO path_alias (id,path,alias,status) VALUES
+ (1,'/node/1001','/h5p/101',1),
+ (2,'/node/1002','/h5p/1002',1),
+ (3,'/node/1003','/h5p/1003',1),
+ (4,'/node/1003','/h5p/102',1),
+ (5,'/node/1004','/H5P/Cell-Blanks/',1),
+ (6,'/node/1002','/h5p/101',1),
+ (7,'/node/1007','/h5p/1003-0',1),
+ (8,'/node/2001','/collection/2001',1),
+ (9,'/node/2002','/my-biology-set',1),
+ (10,'/node/1004','/h5p/retired-alias',0),
+ (11,'/node/3001','/about',1),
+ (12,'/node/1006','/help/x',1);
diff --git a/server/migrations/fixtures/studio-schema-sample.sql b/server/migrations/fixtures/studio-schema-sample.sql
new file mode 100644
index 000000000..907ad2b35
--- /dev/null
+++ b/server/migrations/fixtures/studio-schema-sample.sql
@@ -0,0 +1,214 @@
+-- Test fixture for StudioSurvey.ts. NOT a migration and NOT authoritative.
+--
+-- A reconstruction of the parts of the Studio schema the survey touches, built
+-- from h5p.install plus Drupal 9 field-table conventions, with a handful of
+-- sample rows. It exists so the survey's SQL can be executed and reviewed
+-- without access to production.
+--
+-- The sample data deliberately includes the awkward cases: an h5p_content row
+-- with no node, an empty adapt_id, a NULL adapt_id, an unpublished node, an
+-- activity excluded from the library, and a collection with two sub-collections.
+--
+-- docker run -d --name studio-survey-test -e MYSQL_ROOT_PASSWORD=test \
+-- -e MYSQL_DATABASE=studio_main -p 33061:3306 mysql:8.0
+-- docker exec -i studio-survey-test mysql -uroot -ptest studio_main \
+-- < server/migrations/fixtures/studio-schema-sample.sql
+-- STUDIO_MYSQL_HOST=127.0.0.1 STUDIO_MYSQL_PORT=33061 STUDIO_MYSQL_USER=root \
+-- STUDIO_MYSQL_PASSWORD=test STUDIO_MYSQL_DATABASE=studio_main \
+-- npx tsx --env-file=.env migrations/StudioSurvey.ts
+--
+-- Column definitions here are inferred. A probe that fails against the real
+-- database is telling you this fixture is wrong, not that the survey is broken.
+
+-- Minimal reconstruction of the Studio schema, from h5p.install plus the
+-- Drupal 9 field-table conventions. Enough to execute every survey probe.
+
+CREATE TABLE h5p_libraries (
+ library_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ machine_name VARCHAR(127) NOT NULL DEFAULT '',
+ title VARCHAR(255) NOT NULL DEFAULT '',
+ major_version INT UNSIGNED NOT NULL,
+ minor_version INT UNSIGNED NOT NULL,
+ patch_version INT UNSIGNED NOT NULL,
+ runnable TINYINT UNSIGNED NOT NULL DEFAULT 1,
+ fullscreen TINYINT UNSIGNED NOT NULL DEFAULT 0,
+ embed_types VARCHAR(255) NOT NULL DEFAULT '',
+ preloaded_js TEXT, preloaded_css TEXT, drop_library_css TEXT,
+ semantics TEXT NOT NULL, restricted TINYINT UNSIGNED NOT NULL DEFAULT 0,
+ tutorial_url VARCHAR(1000), has_icon TINYINT UNSIGNED NOT NULL DEFAULT 0,
+ add_to TEXT, metadata_settings TEXT
+);
+
+CREATE TABLE h5p_content (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(128), langcode VARCHAR(12),
+ library_id INT UNSIGNED NOT NULL,
+ parameters LONGTEXT, filtered_parameters LONGTEXT,
+ disabled_features SMALLINT UNSIGNED DEFAULT 0,
+ title VARCHAR(255), a11y_title VARCHAR(255), authors LONGTEXT,
+ source VARCHAR(2083), year_from INT UNSIGNED, year_to INT UNSIGNED,
+ license VARCHAR(32), license_version VARCHAR(10), changes LONGTEXT,
+ license_extras LONGTEXT, author_comments LONGTEXT, default_language VARCHAR(32)
+);
+
+CREATE TABLE h5p_content_user_data (
+ user_id INT UNSIGNED NOT NULL, content_main_id INT UNSIGNED NOT NULL,
+ sub_content_id INT UNSIGNED NOT NULL, data_id VARCHAR(127) NOT NULL,
+ timestamp INT UNSIGNED NOT NULL, data LONGTEXT NOT NULL,
+ preloaded TINYINT UNSIGNED, delete_on_content_change TINYINT UNSIGNED,
+ PRIMARY KEY (user_id, content_main_id, sub_content_id, data_id)
+);
+
+CREATE TABLE h5p_points (
+ content_id INT UNSIGNED NOT NULL, uid INT UNSIGNED NOT NULL,
+ started INT UNSIGNED NOT NULL, finished INT UNSIGNED NOT NULL DEFAULT 0,
+ points INT UNSIGNED, max_points INT UNSIGNED,
+ PRIMARY KEY (content_id, uid)
+);
+
+CREATE TABLE users_field_data (
+ uid INT UNSIGNED NOT NULL, langcode VARCHAR(12) NOT NULL DEFAULT 'en',
+ name VARCHAR(60) NOT NULL DEFAULT '', mail VARCHAR(254),
+ status TINYINT DEFAULT 0, created INT, access INT, login INT,
+ default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (uid, langcode)
+);
+
+CREATE TABLE node_field_data (
+ nid INT UNSIGNED NOT NULL, vid INT UNSIGNED NOT NULL,
+ type VARCHAR(32) NOT NULL, langcode VARCHAR(12) NOT NULL DEFAULT 'en',
+ status TINYINT NOT NULL DEFAULT 0, uid INT UNSIGNED NOT NULL,
+ title VARCHAR(255), created INT, changed INT,
+ private TINYINT DEFAULT 0,
+ default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (nid, langcode)
+);
+
+CREATE TABLE node__field_h5p (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_h5p_h5p_content_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_adapt_id (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_adapt_id_value VARCHAR(255),
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_collaborators (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_collaborators_target_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_include_in_library (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_include_in_library_value TINYINT NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE node__field_collections (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_collections_target_id INT UNSIGNED NOT NULL,
+ field_collections_target_revision_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE paragraph__field_collection_resources (
+ bundle VARCHAR(128) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0,
+ entity_id INT UNSIGNED NOT NULL, revision_id INT UNSIGNED NOT NULL,
+ langcode VARCHAR(32) NOT NULL, delta INT UNSIGNED NOT NULL,
+ field_collection_resources_target_id INT UNSIGNED NOT NULL,
+ PRIMARY KEY (entity_id, deleted, delta, langcode)
+);
+
+CREATE TABLE taxonomy_term_field_data (
+ tid INT UNSIGNED NOT NULL, vid VARCHAR(32) NOT NULL,
+ langcode VARCHAR(12) NOT NULL DEFAULT 'en', name VARCHAR(255),
+ default_langcode TINYINT NOT NULL DEFAULT 1,
+ PRIMARY KEY (tid, langcode)
+);
+
+INSERT INTO h5p_libraries (machine_name,title,major_version,minor_version,patch_version,runnable,embed_types,semantics,restricted) VALUES
+ ('H5P.MultiChoice','Multiple Choice',1,16,0,1,'iframe','[]',0),
+ ('H5P.InteractiveVideo','Interactive Video',1,22,0,1,'iframe','[]',0),
+ ('H5P.Column','Column',1,13,0,0,'div','[]',1);
+
+INSERT INTO h5p_content (id,library_id,parameters,title,license,default_language) VALUES
+ (101,1,'{}','Photosynthesis Quiz','CC BY','en'),
+ (102,1,'{}','Mitosis Quiz','CC BY-SA','en'),
+ (103,2,'{}','Cell Division Video','PDM','en'),
+ (104,2,'{}','Orphan content with no node','CC BY','en');
+
+INSERT INTO users_field_data (uid,name,mail,status,created) VALUES
+ (1,'11111111-1111-4111-8111-111111111111','alice@example.edu',1,1600000000),
+ (2,'22222222-2222-4222-8222-222222222222','bob@example.edu',1,1600000000);
+
+INSERT INTO node_field_data (nid,vid,type,status,uid,title,created,changed,private) VALUES
+ (1001,1,'h5p',1,1,'Photosynthesis Quiz',1600000000,1700000000,1),
+ (1002,2,'h5p',1,1,'Mitosis Quiz',1600000000,1700000000,1),
+ (1003,3,'h5p',0,2,'Cell Division Video',1600000000,1700000000,0),
+ (2001,4,'collection',1,1,'Biology Collection',1600000000,1700000000,1),
+ (3001,5,'accessibility',1,1,'Accessibility: MultiChoice',1600000000,1700000000,0);
+
+INSERT INTO node__field_h5p (bundle,entity_id,revision_id,langcode,delta,field_h5p_h5p_content_id) VALUES
+ ('h5p',1001,1,'en',0,101),('h5p',1002,2,'en',0,102),('h5p',1003,3,'en',0,103);
+
+INSERT INTO node__field_adapt_id (bundle,entity_id,revision_id,langcode,delta,field_adapt_id_value) VALUES
+ ('h5p',1001,1,'en',0,'55501'),('h5p',1002,2,'en',0,''),('h5p',1003,3,'en',0,NULL);
+
+INSERT INTO node__field_collaborators (bundle,entity_id,revision_id,langcode,delta,field_collaborators_target_id) VALUES
+ ('h5p',1001,1,'en',0,2);
+
+INSERT INTO node__field_include_in_library (bundle,entity_id,revision_id,langcode,delta,field_include_in_library_value) VALUES
+ ('h5p',1001,1,'en',0,1),('h5p',1002,2,'en',0,1),('h5p',1003,3,'en',0,0);
+
+INSERT INTO node__field_collections (bundle,entity_id,revision_id,langcode,delta,field_collections_target_id,field_collections_target_revision_id) VALUES
+ ('collection',2001,4,'en',0,9001,9001),('collection',2001,4,'en',1,9002,9002);
+
+INSERT INTO paragraph__field_collection_resources (bundle,entity_id,revision_id,langcode,delta,field_collection_resources_target_id) VALUES
+ ('collection',9001,9001,'en',0,1001),('collection',9001,9001,'en',1,1002),
+ ('collection',9002,9002,'en',0,1003);
+
+INSERT INTO taxonomy_term_field_data (tid,vid,name) VALUES
+ (1,'subjects','Biology'),(2,'subjects','Chemistry'),(3,'tags','quiz'),(4,'publishers','LibreTexts');
+
+INSERT INTO h5p_points (content_id,uid,started,finished,points,max_points) VALUES (101,2,1600000000,1600000100,8,10);
+
+-- Rows that exist purely to prove the deleted/translation filters work. Without
+-- `deleted = 0` and `default_langcode = 1` every count below is inflated.
+
+-- A Spanish translation of node 1001. Must NOT be counted as a second activity.
+INSERT INTO node_field_data (nid,vid,type,langcode,status,uid,title,created,changed,private,default_langcode) VALUES
+ (1001,1,'h5p','es',1,1,'Cuestionario de fotosintesis',1600000000,1700000000,1,0);
+
+-- A soft-deleted field row. Must NOT be counted.
+INSERT INTO node__field_adapt_id (bundle,deleted,entity_id,revision_id,langcode,delta,field_adapt_id_value) VALUES
+ ('h5p',1,1003,3,'en',0,'99999');
+
+-- A soft-deleted H5P field row pointing at content that has no live node.
+-- Content 104 must still report as an orphan.
+INSERT INTO node__field_h5p (bundle,deleted,entity_id,revision_id,langcode,delta,field_h5p_h5p_content_id) VALUES
+ ('h5p',1,1099,99,'en',0,104);
+
+-- Two live nodes sharing one content id, the case that makes node and content
+-- counts disagree.
+INSERT INTO node_field_data (nid,vid,type,status,uid,title,created,changed,private) VALUES
+ (1004,6,'h5p',1,2,'Photosynthesis Quiz (copy)',1600000000,1700000000,0);
+INSERT INTO node__field_h5p (bundle,entity_id,revision_id,langcode,delta,field_h5p_h5p_content_id) VALUES
+ ('h5p',1004,6,'en',0,101);
+
+-- A translated user row. Must NOT inflate the author list.
+INSERT INTO users_field_data (uid,langcode,name,mail,status,created,default_langcode) VALUES
+ (1,'es','11111111-1111-4111-8111-111111111111','alice@example.edu',1,1600000000,0);
diff --git a/server/models/activity.ts b/server/models/activity.ts
new file mode 100644
index 000000000..a8003c782
--- /dev/null
+++ b/server/models/activity.ts
@@ -0,0 +1,301 @@
+import { model, Schema, Document } from "mongoose";
+import { License } from "../types";
+
+export const ACTIVITY_VISIBILITY_SETTINGS = ["public", "private"];
+
+export const ACTIVITY_DATA_CLASSES = ["production", "synthetic"];
+
+export type ActivityDataClass = "production" | "synthetic";
+
+export type ActivityVisibility = "public" | "private";
+
+export interface RawActivityInterface {
+ activityID: string;
+ h5pContentID: number;
+ orgID: string;
+ authorCentralID?: string;
+ title: string;
+ description?: string;
+ library?: string;
+ h5pAuthors?: string;
+ source?: string;
+ adaptID?: string;
+ adaptIDCheckedAt?: Date;
+ createdBy?: string;
+ collaborators?: string[];
+ publisher?: string;
+ subjects?: string[];
+ tags?: string[];
+ screenshots?: string[];
+ visibility: ActivityVisibility;
+ dataClass: ActivityDataClass;
+ includeInLibrary?: boolean;
+ includeInProfile?: boolean;
+ license?: License;
+ language?: string;
+ libraryTitle?: string;
+ legacyLibraryID?: number;
+ legacyNodeID?: number;
+ legacyNodeUUID?: string;
+ pendingFirstSave?: boolean;
+}
+
+export interface ActivityInterface extends RawActivityInterface, Document {}
+
+const ActivitySchema = new Schema(
+ {
+ /**
+ * Unique Conductor-side identifier of the activity: `h5pContentID` as a
+ * string. Studio users know activities by that number, so Conductor shows
+ * the same one in its URLs instead of a random id.
+ */
+ activityID: {
+ type: String,
+ required: true,
+ unique: true,
+ },
+ /**
+ * Identifier of the H5P content this activity wraps, as assigned by the H5P
+ * service.
+ *
+ * This is the integer Studio used as `h5p_content.id`, carried over verbatim
+ * during migration. Published textbook embeds (`/h5p/{id}/embed`) and the
+ * ADAPT question-bank lookup both address activities by this number, so it is
+ * a public contract and must never be regenerated.
+ */
+ h5pContentID: {
+ type: Number,
+ required: true,
+ unique: true,
+ },
+ /**
+ * Identifier of the organization the activity belongs to.
+ */
+ orgID: {
+ type: String,
+ required: true,
+ index: true,
+ },
+ /**
+ * UI-name of the activity.
+ */
+ title: {
+ type: String,
+ required: true,
+ },
+ /**
+ * Instructor-facing overview. Migrated from the Studio node body.
+ */
+ description: String,
+ /**
+ * H5P library string the activity runs on, e.g. "H5P.MultiChoice 1.16".
+ * Denormalized from the H5P service so the catalog can filter and badge by
+ * content type without a per-row call out to it.
+ */
+ library: {
+ type: String,
+ index: true,
+ },
+ /**
+ * Display title of the H5P library, e.g. "Interactive Video". Studio's API
+ * served it as `type`, and ADAPT matches on these exact names: it treats
+ * "Interactive Video" and "Question Set" as activity sets. Kept verbatim
+ * rather than derived from `library`, which carries only the machine name.
+ */
+ libraryTitle: String,
+ /**
+ * Language code of the H5P content, from Studio's `default_language`.
+ */
+ language: String,
+ /**
+ * Author credit carried inside the H5P package metadata. Distinct from
+ * `createdBy`, which is the Conductor account that owns the record.
+ */
+ h5pAuthors: String,
+ /**
+ * Original source URL declared in the H5P package metadata.
+ */
+ source: String,
+ /**
+ * ADAPT question identifier corresponding to this activity, cached from
+ * ADAPT's lookup endpoint. Exposed in the ADAPT reconciliation feed.
+ */
+ adaptID: {
+ type: String,
+ index: true,
+ },
+ /**
+ * When the ADAPT id sync last asked ADAPT about this activity. The sync
+ * takes the least recently checked first. See `adapt-id-sync-service.ts`.
+ */
+ adaptIDCheckedAt: Date,
+ /**
+ * LibreOne UUID of the author. Authoritative.
+ *
+ * LibreOne is the identity source of record across LibreTexts, and this is
+ * the one author identifier that means the same thing in Studio, Conductor
+ * and ADAPT. It is always set, even when the person has no Conductor
+ * account, so attribution never depends on a local record existing.
+ */
+ authorCentralID: {
+ type: String,
+ index: true,
+ },
+ /**
+ * Conductor-local UUID of the user that owns the activity, when one exists.
+ *
+ * Nullable on purpose. The Studio survey found 87 authors holding 459
+ * activities who have a valid LibreOne identity but have never logged into
+ * Conductor, so no local `User` row exists for them. Provisioning accounts
+ * as a side effect of a content import would be the wrong trade, so
+ * `authorCentralID` carries the attribution and this fills in when they
+ * first sign in, via the LibreOne user-created lifecycle webhook.
+ *
+ * Consequence worth knowing: ownership checks match on this field, so an
+ * author in that state cannot see their own private activity until the
+ * backfill runs. Studio has exactly one private node in total, so the
+ * exposure is theoretical rather than practical.
+ */
+ createdBy: {
+ type: String,
+ index: true,
+ },
+ /**
+ * Conductor-local UUIDs of users granted edit access by the owner, resolved
+ * from LibreOne UUIDs the same way as `createdBy`. Studio capped this at 10
+ * and had no invitation flow; the cap is enforced in the validator.
+ */
+ collaborators: {
+ type: [String],
+ default: [],
+ // My H5P matches on owner or collaborator.
+ index: true,
+ },
+ /**
+ * Publisher attribution, migrated from the Studio publishers vocabulary.
+ */
+ publisher: String,
+ /**
+ * Subject classifications.
+ */
+ subjects: {
+ type: [String],
+ default: [],
+ index: true,
+ },
+ /**
+ * Free-form tags.
+ */
+ tags: {
+ type: [String],
+ default: [],
+ },
+ /**
+ * Keys of screenshot images in object storage.
+ */
+ screenshots: {
+ type: [String],
+ default: [],
+ },
+ /**
+ * Indicates which users can view the activity on Commons.
+ */
+ visibility: {
+ type: String,
+ enum: ACTIVITY_VISIBILITY_SETTINGS,
+ default: "public",
+ index: true,
+ },
+ /**
+ * Whether the activity is listed in the public catalog. Studio defaulted this
+ * on, and the migration preserves whatever each node carried.
+ */
+ includeInLibrary: {
+ type: Boolean,
+ default: true,
+ },
+ /**
+ * Whether the activity is listed on its owner's public profile.
+ */
+ includeInProfile: {
+ type: Boolean,
+ default: true,
+ },
+ /**
+ * License information for the activity.
+ */
+ license: {
+ name: String,
+ url: String,
+ version: String,
+ sourceURL: String,
+ modifiedFromSource: Boolean,
+ additionalTerms: String,
+ },
+ /**
+ * Whether this record describes real production data or a pseudonymised
+ * copy made for a non-production environment.
+ *
+ * Stamped at write time rather than inferred later, so "is this dev data?"
+ * is a query and not a judgement call. The default keys off `APP_ENV`:
+ * anything created outside production is synthetic by definition. The
+ * Studio importer sets it explicitly and refuses to write synthetic
+ * records to production or real identities anywhere else.
+ */
+ dataClass: {
+ type: String,
+ enum: ACTIVITY_DATA_CLASSES,
+ required: true,
+ index: true,
+ default: () =>
+ (process.env.APP_ENV ?? "production") === "production"
+ ? "production"
+ : "synthetic",
+ },
+ /**
+ * Drupal node id this activity was migrated from.
+ *
+ * Retained so the legacy `/h5p/{nid}` and inbound Studio links can be
+ * resolved to a redirect after cutover. Not set for activities created in
+ * Conductor.
+ */
+ legacyNodeID: {
+ type: Number,
+ index: true,
+ },
+ /**
+ * Drupal uuid of the migrated node, which Studio's `/api/h5p/{id}` emitted
+ * as `uuid`. Kept so the replacement endpoint can return the same value.
+ */
+ legacyNodeUUID: String,
+ /**
+ * Studio's integer `h5p_libraries.library_id`, which its API emitted as
+ * `type_id` and ADAPT reads. Studio-internal and meaningless elsewhere, so
+ * it is not set for activities created in Conductor.
+ */
+ legacyLibraryID: Number,
+ /**
+ * Set when an activity is created in Conductor, cleared by its first H5P
+ * save. Until then the activity has a content id but no content. A daily
+ * job deletes activities still pending after a day: someone opened New
+ * H5P and never saved. Never set on migrated activities.
+ */
+ pendingFirstSave: {
+ type: Boolean,
+ index: { sparse: true },
+ },
+ },
+ {
+ timestamps: true,
+ }
+);
+
+/**
+ * Backs the default catalog listing: public, listable activities for this
+ * instance's organization, newest first.
+ */
+ActivitySchema.index({ orgID: 1, visibility: 1, includeInLibrary: 1, updatedAt: -1 });
+
+const Activity = model("Activity", ActivitySchema);
+
+export default Activity;
diff --git a/server/models/activityCollection.ts b/server/models/activityCollection.ts
new file mode 100644
index 000000000..1bff7db3b
--- /dev/null
+++ b/server/models/activityCollection.ts
@@ -0,0 +1,213 @@
+import { model, Schema, Document } from "mongoose";
+import {
+ ACTIVITY_DATA_CLASSES,
+ ACTIVITY_VISIBILITY_SETTINGS,
+ ActivityDataClass,
+ ActivityVisibility,
+} from "./activity.js";
+
+/**
+ * A titled, ordered group of activities inside an `ActivityCollection`.
+ *
+ * Studio modelled these as paragraphs on the collection node. ADAPT's bulk
+ * importer turns each one into an assignment named after `title`, so both the
+ * grouping and the title are load-bearing, not presentational.
+ */
+export interface ActivitySubCollectionInterface {
+ title: string;
+ h5pContentIDs: number[];
+}
+
+export interface RawActivityCollectionInterface {
+ collectionID: string;
+ legacyCollectionID?: number;
+ orgID: string;
+ title: string;
+ description?: string;
+ oerReference?: string;
+ oerReferenceTitle?: string;
+ thumbnail?: string;
+ authorCentralID?: string;
+ createdBy?: string;
+ subjects?: string[];
+ tags?: string[];
+ subCollections: ActivitySubCollectionInterface[];
+ visibility: ActivityVisibility;
+ includeInLibrary?: boolean;
+ dataClass: ActivityDataClass;
+}
+
+export interface ActivityCollectionInterface
+ extends RawActivityCollectionInterface,
+ Document {}
+
+const ActivitySubCollectionSchema = new Schema(
+ {
+ title: {
+ type: String,
+ required: true,
+ },
+ /**
+ * Activities in display order, by H5P content id.
+ *
+ * Studio referenced H5P nodes, not content. The import resolves each node to
+ * its content id, which is the identity Conductor and h5p-service share, so a
+ * reference to one of the duplicate nodes collapsed by the activity import
+ * lands on the canonical activity.
+ */
+ h5pContentIDs: {
+ type: [Number],
+ default: [],
+ },
+ },
+ { _id: false }
+);
+
+/**
+ * An author-curated collection of H5P activities, migrated from Studio.
+ *
+ * Deliberately separate from `Collection`, which drives Commons programs and
+ * shelves and whose resources reference books. These group activities into
+ * titled sub-collections and are addressed by ADAPT through Studio's integer
+ * collection id, so folding them into `Collection` would put an external
+ * contract inside the Commons catalog model.
+ *
+ * Studio ordered its collections API by node `changed`. The import writes
+ * Studio's `created` and `changed` into `createdAt` and `updatedAt` rather than
+ * letting Mongoose stamp the import time, so that ordering survives.
+ */
+const ActivityCollectionSchema = new Schema(
+ {
+ /**
+ * Unique Conductor-side identifier of the collection.
+ */
+ collectionID: {
+ type: String,
+ required: true,
+ unique: true,
+ },
+ /**
+ * Drupal node id of the Studio collection.
+ *
+ * ADAPT stores this as `cid` and requests `/api/collections/{cid}` with it,
+ * so it is a public contract and must never be regenerated. Not set for
+ * collections created in Conductor.
+ */
+ legacyCollectionID: {
+ type: Number,
+ unique: true,
+ sparse: true,
+ },
+ /**
+ * Identifier of the organization the collection belongs to.
+ */
+ orgID: {
+ type: String,
+ required: true,
+ index: true,
+ },
+ /**
+ * UI-name of the collection.
+ */
+ title: {
+ type: String,
+ required: true,
+ },
+ description: String,
+ /**
+ * Free-text reference to the OER the collection supports, migrated from
+ * Studio's `field_oer_reference`.
+ */
+ oerReference: String,
+ /**
+ * Link text for `oerReference`, from Studio's `field_oer_reference_title`.
+ * Studio's collection API rendered the pair as a link.
+ */
+ oerReferenceTitle: String,
+ /**
+ * Key or URL of the collection's thumbnail image.
+ */
+ thumbnail: String,
+ /**
+ * LibreOne UUID of the author. Authoritative, as on `Activity`.
+ */
+ authorCentralID: {
+ type: String,
+ index: true,
+ },
+ /**
+ * Conductor-local UUID of the owner, when a local account exists. Nullable
+ * for the same reason as `Activity.createdBy`.
+ */
+ createdBy: {
+ type: String,
+ index: true,
+ },
+ subjects: {
+ type: [String],
+ default: [],
+ },
+ tags: {
+ type: [String],
+ default: [],
+ },
+ /**
+ * Ordered sub-collections. Embedded rather than referenced: the largest
+ * Studio sub-collection holds 656 activities, well within a document, and a
+ * collection is always read whole.
+ */
+ subCollections: {
+ type: [ActivitySubCollectionSchema],
+ default: [],
+ },
+ /**
+ * Studio's publication status. Studio's collection list served only
+ * published collections that were also included in the library.
+ */
+ visibility: {
+ type: String,
+ enum: ACTIVITY_VISIBILITY_SETTINGS,
+ default: "public",
+ index: true,
+ },
+ includeInLibrary: {
+ type: Boolean,
+ default: true,
+ },
+ /**
+ * Real production data or a pseudonymised copy. Same semantics and default
+ * as `Activity.dataClass`.
+ */
+ dataClass: {
+ type: String,
+ enum: ACTIVITY_DATA_CLASSES,
+ required: true,
+ index: true,
+ default: () =>
+ (process.env.APP_ENV ?? "production") === "production"
+ ? "production"
+ : "synthetic",
+ },
+ },
+ {
+ timestamps: true,
+ }
+);
+
+/**
+ * Backs the collection list: listable collections for this instance's
+ * organization, most recently changed first.
+ */
+ActivityCollectionSchema.index({ orgID: 1, visibility: 1, includeInLibrary: 1, updatedAt: -1 });
+
+/**
+ * Finds the collections containing a given activity.
+ */
+ActivityCollectionSchema.index({ "subCollections.h5pContentIDs": 1 });
+
+const ActivityCollection = model(
+ "ActivityCollection",
+ ActivityCollectionSchema
+);
+
+export default ActivityCollection;
diff --git a/server/models/studioLegacyNode.ts b/server/models/studioLegacyNode.ts
new file mode 100644
index 000000000..130c10a99
--- /dev/null
+++ b/server/models/studioLegacyNode.ts
@@ -0,0 +1,90 @@
+import { model, Schema, Document } from "mongoose";
+
+export const STUDIO_LEGACY_NODE_KINDS = ["activity", "collection"] as const;
+
+export type StudioLegacyNodeKind = (typeof STUDIO_LEGACY_NODE_KINDS)[number];
+
+/**
+ * Normalises a Studio URL alias for storage and lookup: one leading slash, no
+ * trailing slash, lowercase. The import and the resolver must agree on this.
+ */
+export function normalizeStudioAlias(alias: string): string {
+ const trimmed = alias.trim().replace(/\/+$/, "");
+ return (trimmed.startsWith("/") ? trimmed : `/${trimmed}`).toLowerCase();
+}
+
+export interface RawStudioLegacyNodeInterface {
+ nid: number;
+ orgID: string;
+ kind: StudioLegacyNodeKind;
+ h5pContentID?: number;
+ aliases: string[];
+}
+
+export interface StudioLegacyNodeInterface
+ extends RawStudioLegacyNodeInterface,
+ Document {}
+
+/**
+ * One Studio H5P or collection node, and the URL aliases that pointed at it.
+ *
+ * Backs the legacy resolver that old `studio.libretexts.org` page URLs are
+ * redirected to after cutover. Studio's URLs cannot be resolved from the
+ * Activity records alone:
+ *
+ * - Pathauto aliased H5P nodes as `/h5p/{content id}`, but an earlier,
+ * disabled pattern aliased them as `/h5p/{nid}`, aliases from it persist,
+ * and colliding aliases got a numeric suffix. The number in a `/h5p/` URL
+ * is not reliably a content id.
+ * - 674 content ids were shared by several nodes. The activity import keeps
+ * one canonical node per content id, so the other nids are on no Activity.
+ *
+ * Migration data only, written by `StudioImportLegacyPaths.ts`. Retire it when
+ * old Studio links stop mattering.
+ */
+const StudioLegacyNodeSchema = new Schema(
+ {
+ /**
+ * Drupal node id.
+ */
+ nid: {
+ type: Number,
+ required: true,
+ unique: true,
+ },
+ orgID: {
+ type: String,
+ required: true,
+ },
+ kind: {
+ type: String,
+ enum: STUDIO_LEGACY_NODE_KINDS,
+ required: true,
+ },
+ /**
+ * Content id the node referenced, for `activity` nodes. Resolves to the
+ * canonical Activity even when this nid was a collapsed duplicate.
+ */
+ h5pContentID: Number,
+ /**
+ * Every alias that pointed at the node, lowercased with a leading slash,
+ * e.g. `/h5p/31483`. Lowercased because Drupal matched aliases
+ * case-insensitively.
+ */
+ aliases: {
+ type: [String],
+ default: [],
+ index: true,
+ },
+ },
+ {
+ timestamps: true,
+ }
+);
+
+const StudioLegacyNode = model(
+ "StudioLegacyNode",
+ StudioLegacyNodeSchema
+);
+
+export default StudioLegacyNode;
diff --git a/server/package-lock.json b/server/package-lock.json
index 106b5b191..0f8c0ff4a 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -94,6 +94,7 @@
"eslint": "8.57.1",
"eslint-config-airbnb-base": "15.0.0",
"eslint-plugin-import": "2.32.0",
+ "mysql2": "3.24.6",
"pino-pretty": "13.1.3",
"tsc-alias": "1.8.16",
"tsup": "8.5.1",
@@ -4979,6 +4980,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/aws-ssl-profiles": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
+ "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/axios": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
@@ -7465,6 +7476,16 @@
"url": "https://github.com/sponsors/krisk"
}
},
+ "node_modules/generate-function": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
+ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-property": "^1.0.2"
+ }
+ },
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -8415,6 +8436,13 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -9205,6 +9233,22 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
+ "node_modules/lru.min": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz",
+ "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "bun": ">=1.0.0",
+ "deno": ">=1.30.0",
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wellwelwel"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -9628,6 +9672,45 @@
"url": "https://github.com/sponsors/raouldeheer"
}
},
+ "node_modules/mysql2": {
+ "version": "3.24.6",
+ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz",
+ "integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "aws-ssl-profiles": "^1.1.2",
+ "generate-function": "^2.3.1",
+ "iconv-lite": "^0.7.3",
+ "long": "^5.3.2",
+ "lru.min": "^1.1.4",
+ "named-placeholders": "^1.1.6",
+ "sql-escaper": "^1.5.1"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">= 8"
+ }
+ },
+ "node_modules/mysql2/node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -9640,6 +9723,19 @@
"thenify-all": "^1.0.0"
}
},
+ "node_modules/named-placeholders": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
+ "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lru.min": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
@@ -11842,6 +11938,22 @@
"node": ">= 10.x"
}
},
+ "node_modules/sql-escaper": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
+ "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "bun": ">=1.0.0",
+ "deno": ">=2.0.0",
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
diff --git a/server/package.json b/server/package.json
index 434079485..3079b8ad9 100644
--- a/server/package.json
+++ b/server/package.json
@@ -100,6 +100,7 @@
"eslint": "8.57.1",
"eslint-config-airbnb-base": "15.0.0",
"eslint-plugin-import": "2.32.0",
+ "mysql2": "3.24.6",
"pino-pretty": "13.1.3",
"tsc-alias": "1.8.16",
"tsup": "8.5.1",
diff --git a/server/scripts/diff-studio-compat.mjs b/server/scripts/diff-studio-compat.mjs
new file mode 100644
index 000000000..ff6d2d230
--- /dev/null
+++ b/server/scripts/diff-studio-compat.mjs
@@ -0,0 +1,224 @@
+/**
+ * Diffs Conductor's Studio API replacements against responses captured from
+ * production Studio (the Phase 6 step 8 check in the migration runbook).
+ *
+ * node scripts/diff-studio-compat.mjs \
+ * --base http://localhost:5000/api/v1/studio \
+ * --fixtures ../../studio-live-fixtures \
+ * [--identities pseudonymised|real] [--cid 1234] [--changed 2026-10-12] [--show-values]
+ *
+ * For each captured endpoint it requests the same thing from Conductor and
+ * compares the rows present on both sides, key order, and every field. Rows
+ * only one side has are counted, not failed: data changes after the capture,
+ * and a sampled staging import leaves activities out.
+ *
+ * `--identities pseudonymised` (the default) compares names, emails and author
+ * lists by shape only, because staging replaced them. Use `real` against an
+ * environment imported with real identities.
+ *
+ * Output masks values (letters become "a", digits "9") except ids, codes and
+ * dates, because the fixtures hold real names and emails. `--show-values`
+ * prints them; use it only in your own terminal.
+ *
+ * Exits non-zero when a field differs in a way that is not a known, accepted
+ * difference (see KNOWN below).
+ */
+import fs from "node:fs";
+import path from "node:path";
+
+const args = {};
+for (let i = 2; i < process.argv.length; i++) {
+ const a = process.argv[i];
+ if (a === "--show-values") args.showValues = true;
+ else if (a.startsWith("--")) args[a.slice(2)] = process.argv[++i];
+}
+if (!args.base || !args.fixtures) {
+ console.error("usage: --base /api/v1/studio --fixtures [--identities pseudonymised|real] [--cid N] [--changed YYYY-MM-DD]");
+ process.exit(2);
+}
+const base = args.base.replace(/\/+$/, "");
+const identities = args.identities ?? "pseudonymised";
+
+/** Values safe to print: ids, codes, dates, URLs built from ids. */
+const SAFE = new Set([
+ "id", "h5p_id", "cid", "type_id", "node_id", "adapt_id", "created", "changed", "date_created",
+ "license", "license_version", "default_language", "view_node", "url", "count", "uuid", "type",
+]);
+/** Fields that carry people. Pseudonymised environments replaced these. */
+const IDENTITY = new Set(["uid", "authors", "mail", "node_author", "author_name", "h5p_authors"]);
+/** Free text a pseudonymised import scrubbed of email addresses. */
+const SCRUBBED = new Set(["license_extras", "body", "description", "oer_reference"]);
+/**
+ * Accepted differences, by endpoint and field, documented in
+ * server/api/studio-compat.ts. ADAPT reads none of these fields.
+ */
+const KNOWN = {
+ "*": {
+ view_user: "Studio's Drupal user page; Conductor has no Drupal user id",
+ thumbnail: "Studio-hosted collection screenshots, not migrated",
+ },
+ "api/collections/list": {
+ tags: "Studio split a collection's tags across repeated rows; ADAPT keeps the first row",
+ subject: "Studio split a collection's subjects across repeated rows; ADAPT keeps the first row",
+ count: "Studio counted references to activities whose content was deleted; the import drops them",
+ },
+};
+
+const mask = (v) =>
+ typeof v !== "string"
+ ? JSON.stringify(v)
+ : JSON.stringify(v.replace(/[A-Za-zÀ-ɏ]/g, "a").replace(/[0-9]/g, "9").replace(/a+/g, "a").replace(/9+/g, "9").slice(0, 60));
+const show = (key, v) => (args.showValues || SAFE.has(key) ? JSON.stringify(v) : mask(v));
+const shapeOf = (v) => (typeof v === "string" ? v.replace(/[^\s&;#<>/":,=\[\]{}@.-]/g, "x").replace(/x+/g, "x") : typeof v);
+
+function readFixture(name) {
+ const file = path.join(args.fixtures, name);
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null;
+}
+
+async function get(url) {
+ const res = await fetch(url, { headers: { accept: "application/json" } });
+ const body = await res.json().catch(() => null);
+ return { status: res.status, body };
+}
+
+let unexpected = 0;
+
+function compareRows(label, keyOf, fixtureRows, conductorRows, endpoint = label) {
+ console.log(`\n== ${label}`);
+ const theirs = new Map(fixtureRows.map((r) => [keyOf(r), r]));
+ const ours = new Map(conductorRows.map((r) => [keyOf(r), r]));
+ const both = [...theirs.keys()].filter((k) => ours.has(k));
+ console.log(` rows: Studio ${fixtureRows.length}, Conductor ${conductorRows.length}, compared ${both.length}`);
+ console.log(` only in Studio's capture: ${[...theirs.keys()].filter((k) => !ours.has(k)).length} only in Conductor: ${[...ours.keys()].filter((k) => !theirs.has(k)).length}`);
+ if (!both.length) {
+ console.log(" nothing to compare");
+ return;
+ }
+
+ const studioKeys = Object.keys(fixtureRows[0]);
+ const ourKeys = Object.keys(conductorRows[0] ?? {});
+ const orderOK = JSON.stringify(studioKeys) === JSON.stringify(ourKeys);
+ console.log(` key order: ${orderOK ? "identical" : "DIFFERENT"}`);
+ if (!orderOK) {
+ unexpected++;
+ console.log(` Studio: ${studioKeys.join(",")}`);
+ console.log(` Conductor: ${ourKeys.join(",")}`);
+ }
+
+ for (const key of studioKeys) {
+ let diff = 0;
+ const examples = [];
+ for (const k of both) {
+ const a = theirs.get(k)[key];
+ const b = ours.get(k)[key];
+ const same =
+ identities === "pseudonymised" && IDENTITY.has(key)
+ ? shapeOf(a).length > 0 === shapeOf(b).length > 0 && typeof a === typeof b
+ : identities === "pseudonymised" && SCRUBBED.has(key)
+ ? a.replace(/[^\s@]+@[^\s@]+/g, "") .length > 0 === b.length > 0
+ : a === b;
+ if (!same) {
+ diff++;
+ if (examples.length < 3) examples.push(`${k}: Studio ${show(key, a)} | Conductor ${show(key, b)}`);
+ }
+ }
+ if (!diff) continue;
+ const pseudonymOnly =
+ identities === "pseudonymised" && IDENTITY.has(key)
+ ? "an owner the pseudonymised data cannot resolve; check against real identities"
+ : undefined;
+ const known = KNOWN[endpoint]?.[key] ?? KNOWN["*"][key] ?? pseudonymOnly;
+ if (!known) unexpected++;
+ console.log(` ${known ? "known" : "DIFF "} ${key}: ${diff} of ${both.length}${known ? ` (${known})` : ""}`);
+ if (!known) examples.forEach((e) => console.log(` ${e}`));
+ }
+}
+
+// ---------------------------------------------------------------- h5p/{id}
+const byId = readFixture("h5p-by-id.json");
+if (byId) {
+ const rows = [];
+ for (const r of byId) {
+ const { body } = await get(`${base}/h5p/${r.id}`);
+ if (Array.isArray(body)) rows.push(...body);
+ }
+ compareRows("api/h5p/{id}", (r) => r.id, byId, rows);
+}
+
+// ---------------------------------------------------------------- h5p/all
+const all = readFixture("h5p-all.json");
+if (all) {
+ // The capture's date is not recorded. Default to the oldest change in it,
+ // as a US Eastern date, so Conductor's window covers every captured row.
+ let changed = args.changed;
+ if (!changed) {
+ const oldest = all
+ .map((r) => r.changed.match(/^(\d+)\/(\d+)\/(\d+) - (\d+):(\d+)$/))
+ .filter(Boolean)
+ .map((m) => Date.UTC(+m[3], m[1] - 1, +m[2], +m[4], +m[5]))
+ .sort((a, b) => a - b)[0];
+ changed = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" }).format(new Date(oldest));
+ }
+ const { status, body } = await get(`${base}/h5p/all?changed=${changed}`);
+ console.log(`\n(api/h5p/all requested with changed=${changed}, status ${status}, ${Array.isArray(body) ? body.length : "no"} rows; the cap is 100)`);
+ compareRows("api/h5p/all", (r) => r.id, all, Array.isArray(body) ? body : []);
+}
+
+// ---------------------------------------------------------------- collections/list
+const list = readFixture("collection-list.json");
+let listBody = [];
+if (list) {
+ const { body } = await get(`${base}/collections/list`);
+ listBody = Array.isArray(body) ? body : [];
+ // Studio repeated collections; compare its first row per cid, which is
+ // also what ADAPT keeps.
+ const firstPerCid = [];
+ const seen = new Set();
+ for (const r of list) if (!seen.has(r.cid)) { seen.add(r.cid); firstPerCid.push(r); }
+ compareRows("api/collections/list (first row per cid)", (r) => r.cid, firstPerCid, listBody, "api/collections/list");
+ const ourCids = listBody.map((r) => r.cid);
+ console.log(` repeated cids in Conductor's list: ${ourCids.length - new Set(ourCids).size}`);
+}
+
+// ---------------------------------------------------------------- collections/{cid}
+const byCid = readFixture("collection-by-cid.json");
+if (byCid) {
+ let cid = args.cid;
+ if (!cid && list) {
+ // Studio's list counted each collection's activity references.
+ const candidates = [...new Set(list.filter((r) => Number(r.count) === byCid.length).map((r) => r.cid))];
+ if (candidates.length === 1) cid = candidates[0];
+ }
+ if (!cid) {
+ console.log("\n== api/collections/{cid}\n skipped: pass --cid with the collection id the capture was taken for");
+ } else {
+ const { body } = await get(`${base}/collections/${cid}`);
+ const rows = Array.isArray(body) ? body : [];
+ // Rows repeat an activity when it appears twice; key by position within
+ // its sub-collection so repeats line up.
+ const keyed = (rs) => {
+ const pos = new Map();
+ return rs.map((r) => {
+ const k = `${r.subcollection_title}#${r.h5p_id}`;
+ const n = (pos.get(k) ?? 0) + 1;
+ pos.set(k, n);
+ return { ...r, __key: `${k}#${n}` };
+ });
+ };
+ const theirs = keyed(byCid);
+ const ours = keyed(rows);
+ console.log(`\n(api/collections/${cid})`);
+ compareRows("api/collections/{cid}", (r) => r.__key, theirs.map(({ __key, ...r }) => Object.assign(r, { __key })), ours);
+ const order = (rs) => rs.map((r) => `${r.subcollection_title}#${r.h5p_id}`).join("|");
+ const shared = new Set(ours.map((r) => r.__key));
+ const studioOrder = order(byCid.filter((r, i) => shared.has(theirs[i].__key)));
+ const ourOrder = order(rows.filter((r, i) => theirs.some((t) => t.__key === ours[i].__key)));
+ const sameOrder = studioOrder === ourOrder;
+ if (!sameOrder) unexpected++;
+ console.log(` row order of shared rows: ${sameOrder ? "identical" : "DIFFERENT"}`);
+ }
+}
+
+console.log(unexpected ? `\n${unexpected} unexpected difference(s).` : "\nNo unexpected differences.");
+process.exitCode = unexpected ? 1 : 0;
diff --git a/server/util/adapt-integration.ts b/server/util/adapt-integration.ts
new file mode 100644
index 000000000..618c99338
--- /dev/null
+++ b/server/util/adapt-integration.ts
@@ -0,0 +1,81 @@
+import { NextFunction, Request, Response } from "express";
+import { childLogger } from "../logger.js";
+
+const log = childLogger("adapt-integration");
+
+/**
+ * Keeps non-production Conductor away from ADAPT, in both directions.
+ *
+ * Staging holds a pseudonymised copy of production: real activities, fake
+ * people. If ADAPT ever read it (through a misrouted Worker or a copied
+ * hostname), synthetic authors and ADAPT ids would flow into real courses. If
+ * staging ever wrote to ADAPT, it would act on real course data. Both are the
+ * intermingling the migration's staging phase exists to prevent (runbook,
+ * Phase 2 step 2).
+ *
+ * Applies to everything H5P-related Conductor exchanges with ADAPT:
+ * - `/commons/activities-adapt-feed`, the reconciliation feed;
+ * - `/studio/*`, the replacements for the Studio API ADAPT calls;
+ * - the ADAPT id sync (`services/adapt-id-sync-service.ts`), which calls
+ * `assertAdaptIntegrationAllowed()` before contacting ADAPT.
+ *
+ * Two independent guards, because each alone has a hole:
+ * - Environment: outside `APP_ENV=production` these endpoints answer 404,
+ * unless `ADAPT_FEEDS_OUTSIDE_PRODUCTION=true` reopens them deliberately,
+ * for diffing the Studio replacements on staging.
+ * - Data: in production they serve only records stamped
+ * `dataClass: "production"`. Conductor treats an unset `APP_ENV` as
+ * production, so a staging instance missing that variable would pass the
+ * first guard; it would still find no production records to hand over.
+ *
+ * Conductor's homework and analytics syncs with ADAPT predate the migration,
+ * carry no H5P data, and are not affected.
+ */
+
+/** Same rule as `Activity.dataClass`'s default: unset means production. */
+export function isProductionEnvironment(): boolean {
+ return (process.env.APP_ENV ?? "production") === "production";
+}
+
+/** Whether this instance may exchange H5P data with ADAPT at all. */
+export function adaptIntegrationAllowed(): boolean {
+ return isProductionEnvironment() || process.env.ADAPT_FEEDS_OUTSIDE_PRODUCTION === "true";
+}
+
+/**
+ * Query filter for records ADAPT may see. Production serves production data
+ * only; a non-production instance that was deliberately reopened serves its
+ * synthetic data, which is the point of reopening it.
+ */
+export function adaptDataFilter(): { dataClass?: "production" } {
+ return isProductionEnvironment() ? { dataClass: "production" } : {};
+}
+
+let warned = false;
+
+/**
+ * Route guard for ADAPT-facing endpoints. A 404, not a 403, so a misrouted
+ * caller learns nothing about what would be here.
+ */
+export function requireAdaptIntegration(_req: Request, res: Response, next: NextFunction) {
+ if (!adaptIntegrationAllowed()) {
+ return res.status(404).json({ err: true, errMsg: "Not found" });
+ }
+ if (!isProductionEnvironment() && !warned) {
+ warned = true;
+ log.warn(
+ { appEnv: process.env.APP_ENV },
+ "ADAPT-facing endpoints are open outside production (ADAPT_FEEDS_OUTSIDE_PRODUCTION=true). They serve synthetic data; never point ADAPT at this instance."
+ );
+ }
+ return next();
+}
+
+/** For outbound jobs: throws unless this instance may contact ADAPT. */
+export function assertAdaptIntegrationAllowed(what: string): void {
+ if (!isProductionEnvironment()) {
+ // Outbound is stricter than inbound: no override. Reading synthetic data
+ // is a test; writing to ADAPT from staging would touch real courses.
+ throw new Error(`${what}: ADAPT is only contacted from production (APP_ENV is "${process.env.APP_ENV}")`);
+ }
+}
diff --git a/server/util/studio-format.ts b/server/util/studio-format.ts
new file mode 100644
index 000000000..b8f6ef903
--- /dev/null
+++ b/server/util/studio-format.ts
@@ -0,0 +1,80 @@
+import { formatInTimeZone, zonedTimeToUtc } from "date-fns-tz";
+
+/**
+ * Output formatting for the Studio API replacements (`api/studio-compat.ts`).
+ *
+ * ADAPT parses Studio's responses as Drupal rendered them, down to entity
+ * escaping and date formats, so these reproduce Drupal's behaviour rather
+ * than anything more natural. Sources: the `api_h5p`, `api_collections` and
+ * `api_author_h5p` view configs in Studio, and responses captured from
+ * production (`studio-live-fixtures/` in the migration workspace).
+ */
+
+/** Studio's public origin. Its URLs in these responses are a contract. */
+export const STUDIO_ORIGIN = "https://studio.libretexts.org";
+
+/**
+ * The timezone Studio rendered site-time dates in. The config copy in
+ * `studio_main` says America/Los_Angeles, but production's responses carry
+ * `-0400` offsets in October (captured 2026-10-26), so production ran on US
+ * Eastern. The responses are what ADAPT received, so they win.
+ */
+export const STUDIO_TIMEZONE = "America/New_York";
+
+/**
+ * Drupal's `Html::escape`: `htmlspecialchars` with `ENT_QUOTES`. Every plain
+ * text field Studio rendered went through it, so `"` arrives as `"` and
+ * `'` as `'`. ADAPT un-escapes `"` itself when it reads `authors`.
+ */
+export function drupalEscape(value: string | undefined | null): string {
+ if (!value) return "";
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+/** Drupal's `basic_string` formatter: escaped, then PHP's `nl2br`. */
+export function drupalBasicString(value: string | undefined | null): string {
+ return drupalEscape(value).replace(/(\r\n|\n\r|\r|\n)/g, " $1");
+}
+
+/** Multi-value labels, as Studio's views joined them. */
+export function drupalLabels(values: string[] | undefined): string {
+ return (values ?? []).map(drupalEscape).join(", ");
+}
+
+/** Drupal's "short" format (`m/d/Y - H:i`), in UTC: the `api/h5p` view. */
+export function drupalShortUTC(date: Date | string | undefined): string {
+ if (!date) return "";
+ return formatInTimeZone(new Date(date), "UTC", "MM/dd/yyyy - HH:mm");
+}
+
+/**
+ * Drupal's "medium" format (`D, m/d/Y - H:i`), in Studio's site timezone:
+ * the `api/collections/{cid}` view.
+ */
+export function drupalMediumSite(date: Date | string | undefined): string {
+ if (!date) return "";
+ return formatInTimeZone(new Date(date), STUDIO_TIMEZONE, "EEE, MM/dd/yyyy - HH:mm");
+}
+
+/**
+ * Drupal's `html_datetime` format (`Y-m-d\TH:i:sO`, offset without a colon),
+ * in Studio's site timezone: the collection list's `date_created`.
+ */
+export function drupalHtmlDatetimeSite(date: Date | string | undefined): string {
+ if (!date) return "";
+ return formatInTimeZone(new Date(date), STUDIO_TIMEZONE, "yyyy-MM-dd'T'HH:mm:ssxx");
+}
+
+/**
+ * The instant Studio's `changed >= YYYY-MM-DD` filter compared against:
+ * Drupal parsed the date as midnight in the site timezone (US Eastern), not
+ * UTC.
+ */
+export function studioDateFilterStart(day: string): Date {
+ return zonedTimeToUtc(`${day}T00:00:00`, STUDIO_TIMEZONE);
+}