diff --git a/.gitignore b/.gitignore index 6e93d9a92..a93ebb1c3 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,9 @@ agents/ .mcp.json .kiro/ CLAUDE.md + +# Vendored copy of the legacy Studio Drupal install +studio_main/ + +# Output of server/migrations/StudioSurvey.ts +migration-reports/ diff --git a/client/src/Commons.tsx b/client/src/Commons.tsx index 01b97b840..8746d2b1b 100644 --- a/client/src/Commons.tsx +++ b/client/src/Commons.tsx @@ -1,11 +1,13 @@ import { Suspense, useEffect, useState } from "react"; -import { useLocation, Switch, Route, useHistory } from "react-router-dom"; +import { useLocation, Switch, Route, Redirect, useHistory } from "react-router-dom"; +import CommonsActivity from "./screens/commons/Activity"; import CommonsAuthor from "./screens/commons/Author"; import CommonsBook from "./screens/commons/Book"; import CommonsCatalog from "./components/commons/CommonsCatalog"; import CommonsCollection from "./screens/commons/Collection"; import Footer from "./components/navigation/Footer"; import CommonsHomework from "./components/commons/CommonsHomework"; +import CommonsInteractives from "./screens/commons/Interactives"; import CommonsJumbotron from "./components/commons/CommonsJumbotron"; import CommonsMenu from "./components/commons/CommonsMenu"; import Navbar from "./components/navigation/Navbar"; @@ -65,7 +67,10 @@ const Commons = () => { } const currentPath = location.pathname; - if (currentPath.includes("/collection")) { + // Checked before "/collection", which "/interactives-collections" also contains. + if (currentPath.startsWith("/interactive")) { + setActiveItem("interactives"); + } else if (currentPath.includes("/collection")) { setActiveItem("collections"); } else if (currentPath.includes("/homework")) { setActiveItem("homework"); @@ -102,7 +107,20 @@ const Commons = () => { key="homework" component={CommonsHomework} />, + , ]} + + {/* The page's first path, kept so links shared from staging still open. */} + } + /> diff --git a/client/src/Conductor.tsx b/client/src/Conductor.tsx index cd81669ba..93b37df10 100644 --- a/client/src/Conductor.tsx +++ b/client/src/Conductor.tsx @@ -55,6 +55,8 @@ const ProjectsCompleted = lazyWithRetry(() => import('./screens/conductor/Projec const ProjectsFlagged = lazyWithRetry(() => import('./screens/conductor/Projects/ProjectsFlagged')); import ProjectTimeline from './components/projects/ProjectTimeline'; const MyTasks = lazyWithRetry(() => import('./screens/conductor/Tasks')); +const MyH5P = lazyWithRetry(() => import('./screens/conductor/H5P')); +const EditH5P = lazyWithRetry(() => import('./screens/conductor/H5P/Edit')); import ProjectView from './components/projects/ProjectView'; const Search = lazyWithRetry(() => import('./screens/conductor/Search')); const ShapeshiftConsole = lazyWithRetry(() => import('./screens/conductor/controlpanel/ShapeshiftConsole')); @@ -132,6 +134,9 @@ const Conductor = () => { + + {/* One route for both, so a new activity's editor is not reloaded when its URL changes after the first save. */} + diff --git a/client/src/Platform.tsx b/client/src/Platform.tsx index e6e16ed77..fbe7ff245 100644 --- a/client/src/Platform.tsx +++ b/client/src/Platform.tsx @@ -47,6 +47,8 @@ const queryClient = new QueryClient({ }); export const COMMONS_PATHS = [ + "/activity", + "/interactive", "/catalog", "/collections", "/commons-project", @@ -91,6 +93,9 @@ const Platform = () => { const commonsRouterPaths = [ "/", + "/activity/:activityID", + "/interactive/:activityID", + "/interactives", "/catalog", "/catalog/:entryType", "/collections", diff --git a/client/src/api.ts b/client/src/api.ts index d089491a9..0b7be89fc 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -1,6 +1,16 @@ import axios, { AxiosRequestConfig } from "axios"; import { Announcement, + ActivityMetadataInput, + ActivityPerson, + CommonsActivity, + CommonsActivitiesParams, + CommonsActivitySummary, + EditableActivity, + EditorSavedContent, + EditorToken, + MyActivitiesParams, + MyActivitiesResult, AssetSearchParams, AssetTagFramework, AssetTagFrameworkWithCampusDefault, @@ -436,6 +446,77 @@ class API { return res; } + /** + * Activities listed in the public catalog, for the Interactives page. + */ + async getCommonsActivities(params: CommonsActivitiesParams) { + const res = await axios.get< + { activities: CommonsActivitySummary[]; total_items: number } & ConductorBaseResponse + >("/commons/activities", { params }); + return res; + } + + /** + * The public page data for one H5P activity. A 404 means missing, removed + * or not visible to the caller, which the page deliberately treats alike. + */ + async getCommonsActivity(activityID: string) { + const res = await axios.get< + { activity: CommonsActivity } & ConductorBaseResponse + >(`/commons/activities/${activityID}`); + return res; + } + + /** + * The signed-in user's activities for My H5P: owned and collaborating. + */ + async getMyActivities(params: MyActivitiesParams) { + const res = await axios.get( + "/activities/mine", + { params } + ); + return res; + } + + /** An activity with everything its edit screen needs. Owners and collaborators only. */ + async getActivityForEditing(activityID: string) { + const res = await axios.get< + { activity: EditableActivity; role: "owner" | "collaborator"; people: ActivityPerson[] } & ConductorBaseResponse + >(`/activities/${activityID}/authoring`); + return res; + } + + async getActivitySubjects() { + const res = await axios.get<{ subjects: string[] } & ConductorBaseResponse>("/activities/subjects"); + return res; + } + + /** A token for the H5P editor: `create` without an id, `edit` with one. */ + async getActivityEditorToken(activityID?: string) { + const res = await axios.post( + activityID ? `/activities/${activityID}/editor-token` : "/activities/editor-token" + ); + return res; + } + + /** Allocates the H5P content id and creates the activity; returns an edit token for it. */ + async createActivity(metadata: ActivityMetadataInput) { + const res = await axios.post< + { activity: { activityID: string; h5pContentID: number } } & EditorToken & ConductorBaseResponse + >("/activities", metadata); + return res; + } + + async updateActivity(activityID: string, update: ActivityMetadataInput & { contentSaved?: EditorSavedContent }) { + const res = await axios.patch(`/activities/${activityID}`, update); + return res; + } + + async deleteActivity(activityID: string) { + const res = await axios.delete(`/activities/${activityID}`); + return res; + } + async getAuthor(id: string) { const res = await axios.get< { diff --git a/client/src/components/commons/ActivityCardDetails.tsx b/client/src/components/commons/ActivityCardDetails.tsx new file mode 100644 index 000000000..17e6099b3 --- /dev/null +++ b/client/src/components/commons/ActivityCardDetails.tsx @@ -0,0 +1,66 @@ +import { Badge } from "@libretexts/davis-react"; +import { IconCategory, IconLanguage, IconLicense, IconUser } from "@tabler/icons-react"; +import type { Icon } from "@tabler/icons-react"; +import { ActivityCardFields } from "../../types"; +import { getActivityLicense } from "../../utils/activityHelpers"; +import { getLanguageName } from "../../utils/languageCodes"; + +/** Tags beyond this many collapse into a "+N" count. */ +const MAX_TAGS = 3; + +/** + * Author, subjects, license, language and tags for an activity card, with the + * icons and labels of the public activity page. A field with no value is left + * out, and with none at all nothing renders. The description is not included: + * each card places it under its own title. + */ +const ActivityCardDetails = ({ activity, className }: { activity: ActivityCardFields; className?: string }) => { + const authors = (activity.authors ?? []).filter((a) => a?.trim()); + const subjects = (activity.subjects ?? []).filter((s) => s?.trim()); + const tags = (activity.tags ?? []).filter((t) => t?.trim()); + const license = getActivityLicense(activity.license); + const language = activity.language?.trim() ? getLanguageName(activity.language.trim()) : undefined; + + const rows: { key: string; label: string; icon: Icon; value: string }[] = []; + if (authors.length) rows.push({ key: "author", label: authors.length > 1 ? "Authors" : "Author", icon: IconUser, value: authors.join(", ") }); + if (subjects.length) rows.push({ key: "subject", label: subjects.length > 1 ? "Subjects" : "Subject", icon: IconCategory, value: subjects.join(", ") }); + if (license) rows.push({ key: "license", label: "License", icon: IconLicense, value: license.text }); + if (language) rows.push({ key: "language", label: "Language", icon: IconLanguage, value: language }); + + if (!rows.length && !tags.length) return null; + const hiddenTags = tags.length - MAX_TAGS; + return ( +
+ {rows.length > 0 && ( +
+ {rows.map(({ key, label, icon: RowIcon, value }) => ( +
+
+
+
{value}
+
+ ))} +
+ )} + {tags.length > 0 && ( +
    + {tags.slice(0, MAX_TAGS).map((tag) => ( +
  • + +
  • + ))} + {hiddenTags > 0 && ( +
  • + + {` more tag${hiddenTags === 1 ? "" : "s"}`} +
  • + )} +
+ )} +
+ ); +}; + +export default ActivityCardDetails; diff --git a/client/src/components/commons/CommonsMenu/index.tsx b/client/src/components/commons/CommonsMenu/index.tsx index 5141bdf4e..4d8e79777 100644 --- a/client/src/components/commons/CommonsMenu/index.tsx +++ b/client/src/components/commons/CommonsMenu/index.tsx @@ -22,6 +22,7 @@ const CommonsMenu = ({ activeItem = "catalog" }: { activeItem?: string }) => { if (org.orgID === "libretexts") { opts.push({ key: "homework", text: "Homework" }); + opts.push({ key: "interactives", text: "Interactives" }); } return opts; diff --git a/client/src/components/navigation/Navbar/index.tsx b/client/src/components/navigation/Navbar/index.tsx index 3ffba05fc..38d9de860 100644 --- a/client/src/components/navigation/Navbar/index.tsx +++ b/client/src/components/navigation/Navbar/index.tsx @@ -5,6 +5,7 @@ import { useTypedSelector } from "../../../state/hooks.js"; import NavbarShell from "./NavbarShell.js"; import EnvironmentBanner from "../EnvironmentBanner.js"; import useClientConfig from "../../../hooks/useClientConfig.js"; +import useH5PAuthoringEnabled from "../../../hooks/useH5PAuthoringEnabled.js"; import { Button } from "@libretexts/davis-react"; import { NavbarContext } from "../../../types/index.js"; import { @@ -51,50 +52,67 @@ const MobileActionRow: React.FC<{ children: React.ReactNode }> = ({ children }) const ConductorNavItems: React.FC = ({ activeItem, setActiveItem, -}) => ( - <> -
  • - setActiveItem("home")} - > - Home - -
  • -
  • - setActiveItem("projects")} - > - My Projects - -
  • -
  • - setActiveItem("tasks")} - > - My Tasks - -
  • -
  • - setActiveItem("alerts")} - > - My Alerts - -
  • - -); +}) => { + // My H5P stays hidden until authoring opens on this deployment, so + // production shows no empty list before the Studio import. + const h5pAuthoringEnabled = useH5PAuthoringEnabled(); + return ( + <> +
  • + setActiveItem("home")} + > + Home + +
  • +
  • + setActiveItem("projects")} + > + My Projects + +
  • + {h5pAuthoringEnabled && ( +
  • + setActiveItem("h5p")} + > + My H5P + +
  • + )} +
  • + setActiveItem("tasks")} + > + My Tasks + +
  • +
  • + setActiveItem("alerts")} + > + My Alerts + +
  • + + ); +}; /** * Single navbar orchestrator for all app contexts (conductor, commons, store, support). @@ -135,6 +153,7 @@ const Navbar: React.FC<{}> = () => { const p = location.pathname; if (p.includes("/home")) setActiveItem("home"); else if (p.includes("/projects")) setActiveItem("projects"); + else if (p.startsWith("/h5p")) setActiveItem("h5p"); else if (p.includes("/tasks")) setActiveItem("tasks"); else if (p.includes("/alerts")) setActiveItem("alerts"); else setActiveItem(""); diff --git a/client/src/hooks/useCanCreateActivities.ts b/client/src/hooks/useCanCreateActivities.ts new file mode 100644 index 000000000..b24f5d3c1 --- /dev/null +++ b/client/src/hooks/useCanCreateActivities.ts @@ -0,0 +1,14 @@ +import { useTypedSelector } from "../state/hooks"; +import useH5PAuthoringEnabled from "./useH5PAuthoringEnabled"; + +/** + * Whether the signed-in user may create H5P activities: authoring is open on + * this deployment and they are a verified instructor. The server enforces the + * same rule. Editing an activity someone shares with you needs only + * `useH5PAuthoringEnabled`. + */ +export default function useCanCreateActivities(): boolean { + const authoringEnabled = useH5PAuthoringEnabled(); + const verified = useTypedSelector((state) => state.user.verifiedInstructor); + return authoringEnabled && verified === true; +} diff --git a/client/src/hooks/useH5PAuthoringEnabled.ts b/client/src/hooks/useH5PAuthoringEnabled.ts new file mode 100644 index 000000000..413d52dc9 --- /dev/null +++ b/client/src/hooks/useH5PAuthoringEnabled.ts @@ -0,0 +1,13 @@ +import useClientConfig from "./useClientConfig"; + +/** + * Whether H5P authoring is open on this deployment: the My H5P nav link, New + * H5P, Upload and Edit in My H5P, and the create/edit screen. Set per + * environment with `CLIENT__H5P_AUTHORING_ENABLED`, because authoring opens in + * staging well before production, which waits until Studio is frozen + * (migration runbook, Stage 8). Off until the configuration says otherwise. + */ +export default function useH5PAuthoringEnabled(): boolean { + const { clientConfig } = useClientConfig(); + return clientConfig?.h5p_authoring_enabled === true; +} diff --git a/client/src/screens/commons/Activity/index.test.tsx b/client/src/screens/commons/Activity/index.test.tsx new file mode 100644 index 000000000..caaae4153 --- /dev/null +++ b/client/src/screens/commons/Activity/index.test.tsx @@ -0,0 +1,150 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route } from "react-router-dom"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import CommonsActivityPage from "./index"; +import { CommonsActivity } from "../../../types"; + +const mockGetActivity = vi.hoisted(() => vi.fn()); +const mockMediaQuery = vi.hoisted(() => vi.fn(() => true)); + +vi.mock("../../../api", () => ({ + default: { getCommonsActivity: mockGetActivity }, +})); + +vi.mock("../../../hooks/useClientConfig", () => ({ + default: () => ({ + clientConfig: { h5p_embed_origin: "https://studio.libretexts.org" }, + loading: false, + isProduction: true, + }), +})); + +vi.mock("usehooks-ts", async (importOriginal) => ({ + ...(await importOriginal()), + useMediaQuery: mockMediaQuery, +})); + +const full: CommonsActivity = { + activityID: "31483", + h5pContentID: 31483, + title: "Regulations on Fintech", + description: "

    An interactive walkthrough.

    ", + library: "H5P.InteractiveVideo 1.27", + libraryTitle: "Interactive Video", + language: "en", + authors: ["Sophie K"], + publisher: "Western Washington University", + subjects: ["Business & Management"], + tags: ["fintech"], + visibility: "public", + license: { name: "CC BY", version: "4.0" }, + updatedAt: "2026-10-24T15:12:00.000Z", +}; + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + ); +} + +describe("CommonsActivityPage", () => { + beforeEach(() => { + mockGetActivity.mockReset(); + mockMediaQuery.mockReturnValue(true); + }); + + it("renders the details, player, embed URL and attribution", async () => { + mockGetActivity.mockResolvedValue({ data: { activity: full } }); + renderPage(); + + expect(await screen.findByRole("heading", { level: 1, name: full.title })).toBeInTheDocument(); + expect(screen.getByText("Interactive Video")).toBeInTheDocument(); + expect(screen.getByText("Western Washington University")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /CC BY 4.0/ })).toHaveAttribute( + "href", + "https://creativecommons.org/licenses/by/4.0/" + ); + expect(screen.getByTitle(/Regulations on Fintech \(interactive activity\)/)).toHaveAttribute( + "src", + "https://studio.libretexts.org/h5p/31483/embed" + ); + expect(screen.getByText(/is shared under a CC BY 4.0 license/)).toBeInTheDocument(); + }); + + it("sanitizes a migrated HTML description", async () => { + mockGetActivity.mockResolvedValue({ data: { activity: full } }); + const { container } = renderPage(); + expect(await screen.findByText("An interactive walkthrough.")).toBeInTheDocument(); + expect(container.querySelector("script")).toBeNull(); + }); + + it("still reads as a page with only a title, type and player", async () => { + mockGetActivity.mockResolvedValue({ + data: { + activity: { + activityID: "7", + h5pContentID: 7, + title: "Bare", + library: "H5P.MultiChoice 1.16", + authors: [], + visibility: "public", + }, + }, + }); + renderPage(); + expect(await screen.findByRole("heading", { level: 1, name: "Bare" })).toBeInTheDocument(); + expect(screen.getByText("Multi Choice")).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Summary" })).toBeNull(); + expect(screen.getByText("Bare.")).toBeInTheDocument(); + }); + + it("shows one unavailable message for a 404", async () => { + mockGetActivity.mockRejectedValue({ response: { status: 404 } }); + renderPage(); + expect(await screen.findByText("This activity isn't available")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Browse interactives" })).toHaveAttribute("href", "/interactives"); + }); + + it("offers a retry for server errors", async () => { + mockGetActivity.mockRejectedValue({ response: { status: 500 } }); + renderPage(); + // The page retries a 500 twice with backoff before giving up. + expect(await screen.findByText("This activity couldn't be loaded", {}, { timeout: 8000 })).toBeInTheDocument(); + mockGetActivity.mockResolvedValue({ data: { activity: full } }); + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByRole("heading", { level: 1, name: full.title })).toBeInTheDocument(); + }); + + it("opens the embed dialog with the embed code", async () => { + mockGetActivity.mockResolvedValue({ data: { activity: full } }); + renderPage(); + // Wait for the loaded page: the loading view has its own, soft-disabled Embed button. + await screen.findByRole("heading", { level: 1, name: full.title }); + await userEvent.click(screen.getByRole("button", { name: "Embed" })); + expect(await screen.findByText("Embed this activity")).toBeInTheDocument(); + expect(screen.getByLabelText("Embed code").textContent).toContain( + "https://studio.libretexts.org/h5p/core/js/h5p-resizer.js" + ); + }); + + it("orders the mobile layout title, player, actions, details", async () => { + mockMediaQuery.mockReturnValue(false); + mockGetActivity.mockResolvedValue({ data: { activity: full } }); + renderPage(); + const title = await screen.findByRole("heading", { level: 1 }); + const player = screen.getByTitle(/interactive activity/); + const copyLink = screen.getByRole("button", { name: "Copy link" }); + const details = screen.getByRole("heading", { name: "Details" }); + const order = [title, player, copyLink, details].map((el) => + Array.from(document.querySelectorAll("*")).indexOf(el) + ); + expect([...order].sort((a, b) => a - b)).toEqual(order); + }); +}); diff --git a/client/src/screens/commons/Activity/index.tsx b/client/src/screens/commons/Activity/index.tsx new file mode 100644 index 000000000..3bcac0bdc --- /dev/null +++ b/client/src/screens/commons/Activity/index.tsx @@ -0,0 +1,563 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { useDocumentTitle, useMediaQuery } from "usehooks-ts"; +import { format, parseISO } from "date-fns"; +import DOMPurify from "dompurify"; +import { + Badge, + Breadcrumb, + Button, + Card, + Dialog, + EmptyState, + Heading, + Link as DavisLink, + Skeleton, + Spinner, + Stack, + Text, + VisuallyHidden, +} from "@libretexts/davis-react"; +import { + Icon, + IconAlertTriangle, + IconBuildingBank, + IconCategory, + IconCheck, + IconClock, + IconCode, + IconCopy, + IconLanguage, + IconLicense, + IconLink, + IconLock, + IconUser, +} from "@tabler/icons-react"; +import api from "../../../api"; +import CopyButton from "../../../components/util/CopyButton"; +import useClientConfig from "../../../hooks/useClientConfig"; +import { CommonsActivity } from "../../../types"; +import { getLanguageName } from "../../../utils/languageCodes"; +import { + buildActivityAttribution, + buildActivityEmbedCode, + getActivityEmbedURL, + getActivityLicense, + getContentTypeIcon, + getContentTypeName, +} from "../../../utils/activityHelpers"; + +/** Matches Tailwind's `lg`, where the page switches to two columns. */ +const DESKTOP_QUERY = "(min-width: 1024px)"; + +/** + * Tags allowed in a description. Migrated Studio descriptions are Drupal rich + * text; this keeps their structure and drops everything else. + */ +const DESCRIPTION_TAGS = ["p", "br", "ul", "ol", "li", "strong", "em", "b", "i", "a", "h3", "h4", "blockquote"]; + +function isHTML(value: string): boolean { + return /<[a-z][\s\S]*>/i.test(value); +} + +/** + * Loads h5p-service's resizer once per page. It grows the player iframe to fit + * the activity, through the same postMessage handshake published embeds use. + */ +function useH5PResizer(origin?: string) { + useEffect(() => { + if (!origin || document.querySelector("script[data-h5p-resizer]")) return; + const script = document.createElement("script"); + script.src = `${origin.replace(/\/+$/, "")}/h5p/core/js/h5p-resizer.js`; + script.async = true; + script.dataset.h5pResizer = "true"; + document.body.appendChild(script); + }, [origin]); +} + +/** Polite announcement for copy actions, whose button label also changes. */ +const CopiedAnnouncement = ({ copied, what }: { copied: boolean; what: string }) => ( + {copied ? `${what} copied to clipboard` : ""} +); + +const ContentTypeTile = ({ icon: TypeIcon, size }: { icon: Icon; size: "lg" | "sm" }) => ( + +); + +type DetailRow = { key: string; label: string; icon: Icon; value: React.ReactNode }; + +function buildDetailRows(activity: CommonsActivity): DetailRow[] { + const rows: DetailRow[] = []; + const typeName = getContentTypeName(activity); + const license = getActivityLicense(activity.license); + if (typeName) { + rows.push({ key: "type", label: "Content type", icon: getContentTypeIcon(activity.library), value: typeName }); + } + if (activity.subjects?.length) { + rows.push({ + key: "subject", + label: activity.subjects.length > 1 ? "Subjects" : "Subject", + icon: IconCategory, + value: activity.subjects.join(", "), + }); + } + if (activity.authors.length) { + rows.push({ + key: "author", + label: activity.authors.length > 1 ? "Authors" : "Author", + icon: IconUser, + value: activity.authors.join(", "), + }); + } + if (activity.publisher) { + rows.push({ key: "institution", label: "Institution", icon: IconBuildingBank, value: activity.publisher }); + } + if (license) { + rows.push({ + key: "license", + label: "License", + icon: IconLicense, + value: license.url ? ( + + {license.text} + + ) : ( + license.text + ), + }); + } + if (activity.language) { + rows.push({ key: "language", label: "Language", icon: IconLanguage, value: getLanguageName(activity.language) }); + } + if (activity.updatedAt) { + rows.push({ + key: "updated", + label: "Updated", + icon: IconClock, + value: format(parseISO(activity.updatedAt), "MMM d, yyyy"), + }); + } + return rows; +} + +const DetailsList = ({ rows }: { rows: DetailRow[] }) => ( +
    + {rows.map(({ key, label, icon: RowIcon, value }) => ( +
    +
    + ))} +
    +); + +const Tags = ({ tags }: { tags?: string[] }) => + tags?.length ? ( +
      + {tags.map((tag) => ( +
    • + +
    • + ))} +
    + ) : null; + +const PublicActions = ({ + pageURL, + onEmbed, + embedAvailable, + size, +}: { + pageURL: string; + onEmbed: () => void; + embedAvailable: boolean; + size: "md" | "lg"; +}) => ( +
    + + {({ copied, copy }) => ( + <> + + + + )} + + +
    +); + +const Player = ({ activity, origin }: { activity: CommonsActivity; origin?: string }) => { + const [loaded, setLoaded] = useState(false); + if (!origin) { + return ( +
    + The activity player is not available right now. +
    + ); + } + return ( +
    + {!loaded && ( +
    + +
    + )} + ` + + `` + ); +} 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); +}