From 61c397d1baeb6ae4f40efdcbda0ffcbc69e6db55 Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Mon, 26 Oct 2026 21:56:16 -0700 Subject: [PATCH 01/11] feat(activities): add H5P activity records and Commons activity pages Conductor owns everything around an H5P activity: title, authorship, collaborators, visibility, subjects, tags and licensing. h5p-service owns the content and addresses it by the integer content id Studio assigned. - Activity and ActivityCollection models. - Commons read API: /commons/activities, /commons/activities/:activityID, /commons/activities/h5p/:h5pContentID, /commons/activities/author/:uuid, and /commons/activities-adapt-feed, the CSV that replaces Studio's /api/v2/nodes for ADAPT. - Commons activity page at /activity/:activityID, embedding from CLIENT__H5P_EMBED_ORIGIN. - Ignores studio_main/ (a copy of the legacy Studio install, which holds credentials) and migration-reports/. --- .gitignore | 6 + client/src/Commons.tsx | 2 + client/src/Platform.tsx | 2 + client/src/api.ts | 12 + .../screens/commons/Activity/index.test.tsx | 150 +++++ client/src/screens/commons/Activity/index.tsx | 563 ++++++++++++++++++ client/src/types/Activity.ts | 113 ++++ client/src/types/ClientConfig.ts | 1 + client/src/types/index.ts | 1 + client/src/utils/activityHelpers.test.ts | 77 +++ client/src/utils/activityHelpers.ts | 195 ++++++ server/api.js | 45 ++ server/api/activities.ts | 373 ++++++++++++ server/api/services/client-config-service.ts | 2 + server/api/validators/activities.ts | 86 +++ server/models/activity.ts | 293 +++++++++ server/models/activityCollection.ts | 213 +++++++ 17 files changed, 2134 insertions(+) create mode 100644 client/src/screens/commons/Activity/index.test.tsx create mode 100644 client/src/screens/commons/Activity/index.tsx create mode 100644 client/src/types/Activity.ts create mode 100644 client/src/utils/activityHelpers.test.ts create mode 100644 client/src/utils/activityHelpers.ts create mode 100644 server/api/activities.ts create mode 100644 server/api/validators/activities.ts create mode 100644 server/models/activity.ts create mode 100644 server/models/activityCollection.ts diff --git a/.gitignore b/.gitignore index 6e93d9a92..067daaaf4 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,9 @@ agents/ .mcp.json .kiro/ CLAUDE.md + +# Vendored copy of the legacy Studio Drupal install (contains live credentials) +studio_main/ + +# Output of server/migrations/StudioSurvey.ts +migration-reports/ diff --git a/client/src/Commons.tsx b/client/src/Commons.tsx index 01b97b840..861a9ee89 100644 --- a/client/src/Commons.tsx +++ b/client/src/Commons.tsx @@ -1,5 +1,6 @@ import { Suspense, useEffect, useState } from "react"; import { useLocation, Switch, Route, 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"; @@ -103,6 +104,7 @@ const Commons = () => { component={CommonsHomework} />, ]} + diff --git a/client/src/Platform.tsx b/client/src/Platform.tsx index e6e16ed77..45f00d7c2 100644 --- a/client/src/Platform.tsx +++ b/client/src/Platform.tsx @@ -47,6 +47,7 @@ const queryClient = new QueryClient({ }); export const COMMONS_PATHS = [ + "/activity", "/catalog", "/collections", "/commons-project", @@ -91,6 +92,7 @@ const Platform = () => { const commonsRouterPaths = [ "/", + "/activity/:activityID", "/catalog", "/catalog/:entryType", "/collections", diff --git a/client/src/api.ts b/client/src/api.ts index d089491a9..37d5567b9 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -1,6 +1,7 @@ import axios, { AxiosRequestConfig } from "axios"; import { Announcement, + CommonsActivity, AssetSearchParams, AssetTagFramework, AssetTagFrameworkWithCampusDefault, @@ -436,6 +437,17 @@ class API { 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; + } + async getAuthor(id: string) { const res = await axios.get< { 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..8cb09a90a --- /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: "AbCd1234", + 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: "AbCd1234", + 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 the catalog" })).toHaveAttribute("href", "/catalog"); + }); + + 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..085ac255d --- /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..69350e676 100644 --- a/server/api.js +++ b/server/api.js @@ -22,6 +22,7 @@ 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 booksAPI from "./api/books.js"; import homeworkAPI from "./api/homework.js"; import librariesAPI from "./api/libraries.js"; @@ -50,6 +51,7 @@ 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 kbValidators from "./api/validators/kb.js"; import * as LibraryValidators from "./api/validators/libraries.js"; import * as supportValidators from "./api/validators/support.js"; @@ -1106,6 +1108,49 @@ 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 + +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, + middleware.validateZod(activityValidators.getAdaptFeedSchema), + activitiesAPI.getAdaptFeed + ); + // 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..ff7a209df --- /dev/null +++ b/server/api/activities.ts @@ -0,0 +1,373 @@ +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, +} from "./validators/activities.js"; +import { ZodReqWithOptionalUser } from "../types"; + +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, + 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 []; + } +} + +/** + * 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); + + let authors = _parseH5PAuthorNames(activity.h5pAuthors); + if (!authors.length && activity.createdBy) { + const owner = await User.findOne( + { uuid: activity.createdBy }, + { _id: 0, firstName: 1, lastName: 1 } + ).lean(); + const name = [owner?.firstName, owner?.lastName].filter(Boolean).join(" "); + if (name) authors = [name]; + } + + const { h5pAuthors: _h5pAuthors, createdBy: _createdBy, ...rest } = activity; + return res.send({ err: false, activity: { ...rest, authors } }); + } 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( + { + h5pContentID: 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); + } +} + +/** + * 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 }, + { _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, + getAdaptFeed, +}; diff --git a/server/api/services/client-config-service.ts b/server/api/services/client-config-service.ts index 3d5191eaa..bce461414 100644 --- a/server/api/services/client-config-service.ts +++ b/server/api/services/client-config-service.ts @@ -8,6 +8,8 @@ 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 || "", }; } } diff --git a/server/api/validators/activities.ts b/server/api/validators/activities.ts new file mode 100644 index 000000000..4e6c94dc7 --- /dev/null +++ b/server/api/validators/activities.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import conductorErrors from "../../conductor-errors.js"; +import { PaginationSchema, SortDirection } from "./misc.js"; + +/** + * Conductor-side activity identifier, as produced by `b62(8)`. + */ +const activityIDSchema = z.string().length(8, { 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 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"), + }), +}); diff --git a/server/models/activity.ts b/server/models/activity.ts new file mode 100644 index 000000000..8975fb336 --- /dev/null +++ b/server/models/activity.ts @@ -0,0 +1,293 @@ +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; + 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. + */ + 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, + }, + /** + * 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; From a93b5620f20226e71b145b9f1476b025e28c0419 Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Mon, 26 Oct 2026 21:57:01 -0700 Subject: [PATCH 02/11] feat(migrations): add Studio activity, collection and legacy path importers Reads a Studio MySQL database read-only. Each importer is a dry run unless given --commit, upserts by natural key so a re-run resumes, and pseudonymises identities unless given --with-identities. Pseudonymised records are stamped dataClass "synthetic". - StudioImportActivities, StudioImportCollections and StudioImportLegacyPaths (with the StudioLegacyNode model). - StudioSurvey and StudioArchiveExport. - SQL fixtures with synthetic data for running against ephemeral databases. - mysql2 3.24.6 as a devDependency. All three importers ran on staging with the full corpus on 2026-10-26. --- server/migrations/StudioArchiveExport.ts | 299 ++++++ server/migrations/StudioImportActivities.ts | 897 ++++++++++++++++++ server/migrations/StudioImportCollections.ts | 545 +++++++++++ server/migrations/StudioImportLegacyPaths.ts | 217 +++++ server/migrations/StudioPseudonymizer.ts | 175 ++++ server/migrations/StudioSurvey.ts | 474 +++++++++ .../fixtures/studio-archive-sample.sql | 88 ++ .../fixtures/studio-collections-sample.sql | 201 ++++ .../fixtures/studio-import-sample.sql | 228 +++++ .../fixtures/studio-legacy-paths-sample.sql | 74 ++ .../fixtures/studio-schema-sample.sql | 214 +++++ server/models/studioLegacyNode.ts | 90 ++ server/package-lock.json | 112 +++ server/package.json | 1 + 14 files changed, 3615 insertions(+) create mode 100644 server/migrations/StudioArchiveExport.ts create mode 100644 server/migrations/StudioImportActivities.ts create mode 100644 server/migrations/StudioImportCollections.ts create mode 100644 server/migrations/StudioImportLegacyPaths.ts create mode 100644 server/migrations/StudioPseudonymizer.ts create mode 100644 server/migrations/StudioSurvey.ts create mode 100644 server/migrations/fixtures/studio-archive-sample.sql create mode 100644 server/migrations/fixtures/studio-collections-sample.sql create mode 100644 server/migrations/fixtures/studio-import-sample.sql create mode 100644 server/migrations/fixtures/studio-legacy-paths-sample.sql create mode 100644 server/migrations/fixtures/studio-schema-sample.sql create mode 100644 server/models/studioLegacyNode.ts 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..b30d62edb --- /dev/null +++ b/server/migrations/StudioImportActivities.ts @@ -0,0 +1,897 @@ +/** + * 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`, and an existing + * `activityID` 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 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" + ); + } + + // Existing records keep their activityID so a rerun does not reissue + // public identifiers. + 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 usedIDs = new Set(existing.values()); + + 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); + } + + let activityID = existing.get(contentID); + if (!activityID) { + do { + activityID = b62(8); + } while (usedIDs.has(activityID)); + usedIDs.add(activityID); + } + + 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/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", From 3efec518c3152cee814cacc439837a92509db874 Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Mon, 26 Oct 2026 21:57:43 -0700 Subject: [PATCH 03/11] feat(commons): redirect old studio.libretexts.org page URLs GET /commons/studio-legacy/:kind(h5p|node|collection)/:value answers with a redirect to the activity's Commons page, using the paths and aliases the legacy path importer recorded. Cloudflare sends old Studio page URLs here after cutover. LibreTexts instance only. --- server/api.js | 13 ++++ server/api/studio-legacy.ts | 104 +++++++++++++++++++++++++ server/api/validators/studio-legacy.ts | 17 ++++ 3 files changed, 134 insertions(+) create mode 100644 server/api/studio-legacy.ts create mode 100644 server/api/validators/studio-legacy.ts diff --git a/server/api.js b/server/api.js index 69350e676..f842dc48e 100644 --- a/server/api.js +++ b/server/api.js @@ -23,6 +23,7 @@ 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 booksAPI from "./api/books.js"; import homeworkAPI from "./api/homework.js"; import librariesAPI from "./api/libraries.js"; @@ -52,6 +53,7 @@ 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"; @@ -1151,6 +1153,17 @@ router 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/studio-legacy.ts b/server/api/studio-legacy.ts new file mode 100644 index 000000000..5d5586d39 --- /dev/null +++ b/server/api/studio-legacy.ts @@ -0,0 +1,104 @@ +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 + * catalog for now. When `/activity-collection/:collectionID` exists, set + * `COLLECTION_PAGE_LIVE` to true. + */ +const COLLECTION_PAGE_LIVE = false; + +export const STUDIO_LEGACY_TARGETS = { + activity: (activityID: string) => `/activity/${activityID}`, + collection: (collectionID: string) => + COLLECTION_PAGE_LIVE ? `/activity-collection/${collectionID}` : "/catalog", + fallback: "/catalog", +}; + +/** + * 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 the + * catalog. 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/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 }), + }), +}); From 818c85a5b8173e34c33e20318a608ba90b44dab1 Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Mon, 26 Oct 2026 21:58:11 -0700 Subject: [PATCH 04/11] feat(h5p): add My H5P and H5P activity authoring - My H5P at /h5p: the signed-in user's owned and collaborating activities, with search and filters (GET /activities/mine). - Create and edit screen at /h5p/new and /h5p/:activityID/edit. It frames h5p-service's editor page and saves metadata alongside content. - Authoring API: POST /activities, PATCH and DELETE /activities/:activityID, editor tokens, subjects, and a cleanup job for activities whose first save never happened (PUT /activities/automation/cleanup, LibreAPI key). - Conductor checks ownership and signs Ed25519 capability tokens for h5p-service (H5P_CAPABILITY_PRIVATE_KEY, H5P_SERVICE_URL). scripts/generate-h5p-capability-keys.mjs creates the key pair. Hidden unless CLIENT__H5P_AUTHORING_ENABLED is "true". The API answers 503 (err93) until H5P_SERVICE_URL and the private key are set. --- client/src/Conductor.tsx | 5 + client/src/api.ts | 57 ++ .../components/navigation/Navbar/index.tsx | 11 + client/src/hooks/useH5PAuthoringEnabled.ts | 13 + .../src/screens/conductor/H5P/Edit.test.tsx | 215 +++++ client/src/screens/conductor/H5P/Edit.tsx | 796 ++++++++++++++++++ .../src/screens/conductor/H5P/index.test.tsx | 148 ++++ client/src/screens/conductor/H5P/index.tsx | 476 +++++++++++ .../screens/conductor/H5P/useEditorBridge.ts | 151 ++++ client/src/types/ClientConfig.ts | 1 + server/api.js | 63 ++ server/api/activities.ts | 122 ++- server/api/activity-authoring.ts | 368 ++++++++ server/api/services/client-config-service.ts | 2 + server/api/services/h5p-authoring-service.ts | 159 ++++ server/api/validators/activities.ts | 77 ++ server/conductor-errors.ts | 1 + .../scripts/generate-h5p-capability-keys.mjs | 164 ++++ 18 files changed, 2828 insertions(+), 1 deletion(-) create mode 100644 client/src/hooks/useH5PAuthoringEnabled.ts create mode 100644 client/src/screens/conductor/H5P/Edit.test.tsx create mode 100644 client/src/screens/conductor/H5P/Edit.tsx create mode 100644 client/src/screens/conductor/H5P/index.test.tsx create mode 100644 client/src/screens/conductor/H5P/index.tsx create mode 100644 client/src/screens/conductor/H5P/useEditorBridge.ts create mode 100644 server/api/activity-authoring.ts create mode 100644 server/api/services/h5p-authoring-service.ts create mode 100644 server/scripts/generate-h5p-capability-keys.mjs 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/api.ts b/client/src/api.ts index 37d5567b9..a0e3816bb 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -1,7 +1,14 @@ import axios, { AxiosRequestConfig } from "axios"; import { Announcement, + ActivityMetadataInput, + ActivityPerson, CommonsActivity, + EditableActivity, + EditorSavedContent, + EditorToken, + MyActivitiesParams, + MyActivitiesResult, AssetSearchParams, AssetTagFramework, AssetTagFrameworkWithCampusDefault, @@ -448,6 +455,56 @@ class API { 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/navigation/Navbar/index.tsx b/client/src/components/navigation/Navbar/index.tsx index 3ffba05fc..a385fb179 100644 --- a/client/src/components/navigation/Navbar/index.tsx +++ b/client/src/components/navigation/Navbar/index.tsx @@ -73,6 +73,16 @@ const ConductorNavItems: React.FC = ({ My Projects +
  • + setActiveItem("h5p")} + > + My H5P + +
  • = () => { 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/useH5PAuthoringEnabled.ts b/client/src/hooks/useH5PAuthoringEnabled.ts new file mode 100644 index 000000000..be9400787 --- /dev/null +++ b/client/src/hooks/useH5PAuthoringEnabled.ts @@ -0,0 +1,13 @@ +import useClientConfig from "./useClientConfig"; + +/** + * Whether H5P authoring is open on this deployment: 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, + * Phase 7, "Opening authoring"). 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/conductor/H5P/Edit.test.tsx b/client/src/screens/conductor/H5P/Edit.test.tsx new file mode 100644 index 000000000..885a416bf --- /dev/null +++ b/client/src/screens/conductor/H5P/Edit.test.tsx @@ -0,0 +1,215 @@ +import { act, render, screen, waitFor } 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 EditH5P from "./Edit"; + +const EDITOR = "https://studio.example.test"; + +const api = vi.hoisted(() => ({ + getActivityForEditing: vi.fn(), + getActivitySubjects: vi.fn(), + getActivityEditorToken: vi.fn(), + createActivity: vi.fn(), + updateActivity: vi.fn(), + deleteActivity: vi.fn(), +})); +const config = vi.hoisted(() => ({ authoring: true, origin: "https://studio.example.test" })); + +vi.mock("../../../api", () => ({ default: api })); +vi.mock("../../../hooks/useClientConfig", () => ({ + default: () => ({ + clientConfig: { h5p_embed_origin: config.origin, h5p_authoring_enabled: config.authoring }, + loading: false, + isProduction: true, + }), +})); +vi.mock("usehooks-ts", async (importOriginal) => ({ + ...(await importOriginal()), + useMediaQuery: () => true, +})); + +let location: { pathname: string } = { pathname: "" }; +function renderAt(path: string) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + { location = l; return null; }} /> + + + ); +} + +/** The editor iframe, with a spy on what Conductor posts into it. */ +function editorFrame() { + const iframe = screen.getByTitle("H5P editor") as HTMLIFrameElement; + const posted = vi.spyOn(iframe.contentWindow!, "postMessage"); + return { iframe, posted }; +} + +/** Delivers a message as if the H5P editor page sent it. */ +function fromEditor(iframe: HTMLIFrameElement, data: Record, origin = EDITOR) { + act(() => { + window.dispatchEvent(new MessageEvent("message", { data, origin, source: iframe.contentWindow })); + }); +} + +const sentOfType = (posted: { mock: { calls: unknown[][] } }, type: string): any[] => + posted.mock.calls.map((call) => call[0] as any).filter((m: any) => m?.type === type); + +beforeEach(() => { + Object.values(api).forEach((f) => f.mockReset()); + config.authoring = true; + config.origin = EDITOR; + api.getActivitySubjects.mockResolvedValue({ data: { subjects: ["Biology", "Chemistry"] } }); + api.getActivityEditorToken.mockResolvedValue({ data: { token: "create-token", expiresAt: "", ttlSeconds: 900 } }); + api.updateActivity.mockResolvedValue({ data: { err: false } }); +}); + +describe("Create H5P", () => { + it("frames the new-activity editor and answers its ready with a token, only from the editor's origin", async () => { + renderAt("/h5p/new"); + const { iframe, posted } = editorFrame(); + expect(iframe.getAttribute("src")).toBe(`${EDITOR}/h5p/new/edit`); + + fromEditor(iframe, { type: "ready", mode: "new" }, "https://evil.example.test"); + await new Promise((r) => setTimeout(r, 20)); + expect(api.getActivityEditorToken).not.toHaveBeenCalled(); + + fromEditor(iframe, { type: "ready", mode: "new" }); + await waitFor(() => expect(posted).toHaveBeenCalledWith({ type: "token", token: "create-token" }, EDITOR)); + expect(api.getActivityEditorToken).toHaveBeenCalledWith(undefined); + }); + + it("requires a subject before anything is created", async () => { + renderAt("/h5p/new"); + const { iframe } = editorFrame(); + fromEditor(iframe, { type: "loaded", title: "", displayOptions: { frame: true, download: true, embed: true, copyright: true } }); + await userEvent.click(screen.getByRole("button", { name: "Create" })); + expect(await screen.findByText("Choose a subject before saving.")).toBeInTheDocument(); + expect(api.createActivity).not.toHaveBeenCalled(); + }); + + it("creates the activity, hands the editor its edit token, saves, then records what was saved", async () => { + api.createActivity.mockResolvedValue({ data: { activity: { activityID: "NewAct01", h5pContentID: 31484 }, token: "edit-token", expiresAt: "", ttlSeconds: 900 } }); + renderAt("/h5p/new"); + const { iframe, posted } = editorFrame(); + fromEditor(iframe, { type: "loaded", title: "", displayOptions: { frame: true, download: true, embed: true, copyright: true } }); + fromEditor(iframe, { type: "title", title: "Cell basics" }); + await screen.findByRole("option", { name: "Biology" }); + await userEvent.selectOptions(screen.getByRole("combobox", { name: /Subject/ }), "Biology"); + await userEvent.click(screen.getByRole("checkbox", { name: /Allow download/ })); + await userEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => expect(sentOfType(posted, "save")).toHaveLength(1)); + expect(api.createActivity).toHaveBeenCalledWith(expect.objectContaining({ subjects: ["Biology"], visibility: "private" })); + const tokenIndex = posted.mock.calls.findIndex(([m]) => (m as any).token === "edit-token"); + const saveIndex = posted.mock.calls.findIndex(([m]) => (m as any).type === "save"); + expect(tokenIndex).toBeGreaterThan(-1); + expect(tokenIndex).toBeLessThan(saveIndex); + const save = sentOfType(posted, "save")[0]; + expect(save.displayOptions).toEqual({ frame: true, download: false, embed: true, copyright: true }); + + fromEditor(iframe, { type: "saved", requestId: save.requestId, contentId: "31484", title: "Cell basics", library: "H5P.MultiChoice 1.16", license: "CC BY", licenseVersion: "4.0", authors: [{ name: "Ada" }] }); + await waitFor(() => expect(api.updateActivity).toHaveBeenCalled()); + expect(api.updateActivity).toHaveBeenCalledWith("NewAct01", expect.objectContaining({ + subjects: ["Biology"], + contentSaved: expect.objectContaining({ title: "Cell basics", library: "H5P.MultiChoice 1.16", license: "CC BY" }), + })); + await waitFor(() => expect(location.pathname).toBe("/h5p/NewAct01/edit")); + expect(screen.getByTitle("H5P editor")).toBe(iframe); + }); + + it("is unavailable when authoring is switched off for this deployment", async () => { + config.authoring = false; + renderAt("/h5p/new"); + expect(await screen.findByText("The H5P editor isn't available")).toBeInTheDocument(); + expect(screen.queryByTitle("H5P editor")).toBeNull(); + }); +}); + +describe("Edit H5P", () => { + const editable = (role: "owner" | "collaborator") => ({ + data: { + activity: { + activityID: "AbCd1234", h5pContentID: 101, title: "Regulations on Fintech", subjects: ["Business"], tags: ["fintech"], + language: "en", visibilityLevel: "public", license: { name: "CC BY", version: "4.0" }, h5pAuthors: JSON.stringify([{ name: "Sophie K" }]), + updatedAt: "2026-10-24T15:12:00.000Z", + }, + role, + people: [{ uuid: "o", name: "Sophie K", role: "owner" }, { uuid: "c", name: "Jake Turner", role: "collaborator" }], + }, + }); + + it("frames the activity's editor, requests an edit token, and shows owner-only delete", async () => { + api.getActivityForEditing.mockResolvedValue(editable("owner")); + api.getActivityEditorToken.mockResolvedValue({ data: { token: "edit-token", expiresAt: "", ttlSeconds: 900 } }); + renderAt("/h5p/AbCd1234/edit"); + const iframe = (await screen.findByTitle("H5P editor")) as HTMLIFrameElement; + expect(iframe.getAttribute("src")).toBe(`${EDITOR}/h5p/101/edit`); + fromEditor(iframe, { type: "ready", mode: "edit" }); + await waitFor(() => expect(api.getActivityEditorToken).toHaveBeenCalledWith("AbCd1234")); + expect(screen.getByText("Published")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete H5P" })).toBeInTheDocument(); + // The License section starts collapsed, as in the design. + await userEvent.click(screen.getByRole("button", { name: /License and attribution/ })); + expect(await screen.findByText(/Regulations on Fintech by Sophie K, is shared under a CC BY 4.0 license/)).toBeInTheDocument(); + }); + + it("does not offer delete to a collaborator", async () => { + api.getActivityForEditing.mockResolvedValue(editable("collaborator")); + renderAt("/h5p/AbCd1234/edit"); + await screen.findByTitle("H5P editor"); + expect(screen.queryByRole("button", { name: "Delete H5P" })).toBeNull(); + }); + + it("refreshes the token when the editor says it is expiring", async () => { + api.getActivityForEditing.mockResolvedValue(editable("owner")); + api.getActivityEditorToken.mockResolvedValue({ data: { token: "fresh-token", expiresAt: "", ttlSeconds: 900 } }); + renderAt("/h5p/AbCd1234/edit"); + const iframe = (await screen.findByTitle("H5P editor")) as HTMLIFrameElement; + const posted = vi.spyOn(iframe.contentWindow!, "postMessage"); + fromEditor(iframe, { type: "token-expiring" }); + await waitFor(() => expect(posted).toHaveBeenCalledWith({ type: "token", token: "fresh-token" }, EDITOR)); + }); + + it("retries only the details when the content saved but the details did not", async () => { + api.getActivityForEditing.mockResolvedValue(editable("owner")); + api.updateActivity.mockRejectedValueOnce(new Error("boom")); + renderAt("/h5p/AbCd1234/edit"); + const iframe = (await screen.findByTitle("H5P editor")) as HTMLIFrameElement; + const posted = vi.spyOn(iframe.contentWindow!, "postMessage"); + fromEditor(iframe, { type: "loaded", title: "Regulations on Fintech", displayOptions: { frame: true, download: true, embed: true, copyright: true } }); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(sentOfType(posted, "save")).toHaveLength(1)); + fromEditor(iframe, { type: "saved", requestId: sentOfType(posted, "save")[0].requestId, contentId: "101", title: "Regulations on Fintech", library: "H5P.InteractiveVideo 1.27" }); + expect(await screen.findByText("Your activity was saved, but its details were not. Try saving again.")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(api.updateActivity).toHaveBeenCalledTimes(2)); + expect(sentOfType(posted, "save")).toHaveLength(1); + expect(api.updateActivity.mock.calls[1][1]).toEqual(expect.objectContaining({ contentSaved: expect.objectContaining({ library: "H5P.InteractiveVideo 1.27" }) })); + }); + + it("explains a validation failure from the editor", async () => { + api.getActivityForEditing.mockResolvedValue(editable("owner")); + renderAt("/h5p/AbCd1234/edit"); + const iframe = (await screen.findByTitle("H5P editor")) as HTMLIFrameElement; + const posted = vi.spyOn(iframe.contentWindow!, "postMessage"); + fromEditor(iframe, { type: "loaded", title: "x" }); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(sentOfType(posted, "save")).toHaveLength(1)); + fromEditor(iframe, { type: "save-failed", requestId: sentOfType(posted, "save")[0].requestId, reason: "validation" }); + expect(await screen.findByText(/The editor found problems with this activity/)).toBeInTheDocument(); + expect(api.updateActivity).not.toHaveBeenCalled(); + }); + + it("shows one message for an activity the user cannot edit", async () => { + api.getActivityForEditing.mockRejectedValue({ response: { status: 403 } }); + renderAt("/h5p/AbCd1234/edit"); + expect(await screen.findByText("This activity isn't available to edit")).toBeInTheDocument(); + }); +}); diff --git a/client/src/screens/conductor/H5P/Edit.tsx b/client/src/screens/conductor/H5P/Edit.tsx new file mode 100644 index 000000000..e43c14c2c --- /dev/null +++ b/client/src/screens/conductor/H5P/Edit.tsx @@ -0,0 +1,796 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Link, useHistory, useParams } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useDocumentTitle, useMediaQuery } from "usehooks-ts"; +import { format, parseISO } from "date-fns"; +import { + Accordion, + Alert, + Badge, + Breadcrumb, + Button, + Card, + Checkbox, + Dialog, + EmptyState, + Heading, + Input, + Select, + Skeleton, + Spinner, + Text, + Textarea, + VisuallyHidden, +} from "@libretexts/davis-react"; +import { IconAlertTriangle, IconLink, IconLock, IconTrash, IconWorld } from "@tabler/icons-react"; +import api from "../../../api"; +import useClientConfig from "../../../hooks/useClientConfig"; +import useH5PAuthoringEnabled from "../../../hooks/useH5PAuthoringEnabled"; +import { + ActivityDisplayOptions, + ActivityMetadataInput, + ActivityPerson, + ActivityVisibilityLevel, + EditableActivity, +} from "../../../types"; +import languageCodes from "../../../utils/languageCodes"; +import { buildActivityAttribution, getActivityLicense } from "../../../utils/activityHelpers"; +import { EditorSaveError, useEditorBridge } from "./useEditorBridge"; + +/** Below Tailwind's `lg`, the screen becomes Content / Details / Sharing tabs. */ +const DESKTOP_QUERY = "(min-width: 1024px)"; + +const DEFAULT_DISPLAY_OPTIONS: ActivityDisplayOptions = { frame: true, download: true, embed: true, copyright: true }; + +type FormState = { + description: string; + subject: string; + tags: string[]; + language: string; + publisher: string; + visibility: ActivityVisibilityLevel; +}; + +const EMPTY_FORM: FormState = { + description: "", + subject: "", + tags: [], + language: "en", + publisher: "", + visibility: "private", +}; + +function formFrom(activity: EditableActivity): FormState { + return { + description: activity.description ?? "", + subject: activity.subjects?.[0] ?? "", + tags: activity.tags ?? [], + language: activity.language ?? "en", + publisher: activity.publisher ?? "", + visibility: activity.visibilityLevel, + }; +} + +function metadataFrom(form: FormState): ActivityMetadataInput { + return { + description: form.description, + subjects: form.subject ? [form.subject] : [], + tags: form.tags, + language: form.language, + publisher: form.publisher, + visibility: form.visibility, + }; +} + +function parseAuthors(raw?: string): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map((a) => a?.name).filter((n) => typeof n === "string" && n.trim()) : []; + } catch { + return []; + } +} + +const VISIBILITY_CHOICES: { value: ActivityVisibilityLevel; label: string; description: string; icon: JSX.Element }[] = [ + { value: "public", label: "Public", description: "Listed in Commons and search results", icon: