Files
classroomio/apps/api/src/services/course/public-course.ts
T
Rotimi Best d5b07d356e feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
2026-06-07 18:43:05 +01:00

272 lines
9.5 KiB
TypeScript

import { AppError, ErrorCodes } from '@api/utils/errors';
import {
generateTranscriptVttPresignedUrl,
generateVideoDownloadPresignedUrls,
TRANSCRIPT_VTT_PRESIGN_SECONDS
} from '@cio/core/utils/s3';
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { getAssetsByIds } from '@cio/db/queries/assets';
import { mintHlsToken } from '@cio/core/services/assets/assets';
import {
getCourseRowBySlug,
getPublicCourseItem as getPublicCourseItemQuery,
getPublicCourseTreeBySlug,
type PublicCourseItemContent,
type PublicCourseTree
} from '@cio/db/queries/course';
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section';
/**
* Anonymous-safe: fetch the full tree for a published public course by slug.
* Callers should return 404 when this resolves to `null`.
*/
export async function getPublicCourseTreeService(courseSlug: string): Promise<PublicCourseTree | null> {
try {
return await getPublicCourseTreeBySlug(courseSlug);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError('Failed to load public course', ErrorCodes.COURSE_FETCH_FAILED, 500);
}
}
export type PublicItemResponse =
| (Omit<Extract<PublicCourseItemContent, { kind: 'lesson' }>, 'courseOrganizationId'> & { kind: 'lesson' })
| (Extract<PublicCourseItemContent, { kind: 'exercise' }> & {
kind: 'exercise';
questions: PublicExercisePayload;
});
export interface PublicExercisePayload {
unsectioned: PublicExerciseQuestion[];
sections: Array<{
id: string;
title: string;
description: string | null;
order: number;
colorTheme: string;
afterBehavior: unknown;
questions: PublicExerciseQuestion[];
}>;
}
export interface PublicExerciseQuestion {
id: number | string;
title: string;
questionTypeId: number;
questionTypeKey: string;
points: number;
order: number | null;
settings: Record<string, unknown>;
options: Array<{
id: number | string;
label: string;
/** Answer keys are intentionally shipped to the client for PUBLIC courses. */
isCorrect: boolean;
settings: Record<string, unknown>;
}>;
}
/**
* Anonymous-safe: fetch a single lesson or exercise by its per-course slug.
* For exercises, questions + options are returned with answer keys included
* because PUBLIC courses grade entirely on the client.
*/
/**
* Mint a `cio_hls` cookie for an anonymous learner viewing a public
* lesson. The asset id is **derived from the public course tree**, not
* accepted from the caller — so a learner who only knows a random asset
* UUID cannot mint a cookie. They have to know a public course slug +
* lesson slug whose video actually exposes the asset.
*/
export async function issuePublicHlsCookieService(courseSlug: string, itemSlug: string) {
try {
const item = await getPublicCourseItemQuery(courseSlug, itemSlug);
if (!item || item.kind !== 'lesson') {
throw new AppError('Public lesson not found', ErrorCodes.NOT_FOUND, 404);
}
const video = item.video;
if (video?.type !== 'upload' || !video.assetId) {
throw new AppError('Lesson has no playable HLS asset', ErrorCodes.VALIDATION_ERROR, 400);
}
const assetId = video.assetId;
const assets = await getAssetsByIds([assetId], item.courseOrganizationId ?? undefined);
const asset = assets[0];
if (!asset?.hlsManifestKey) {
throw new AppError('Lesson has no playable HLS asset', ErrorCodes.VALIDATION_ERROR, 400);
}
return await mintHlsToken(assetId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to issue HLS cookie',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function getPublicCourseItemService(
courseSlug: string,
itemSlug: string
): Promise<PublicItemResponse | null> {
try {
const baseItem = await getPublicCourseItemQuery(courseSlug, itemSlug);
if (!baseItem) return null;
if (baseItem.kind === 'lesson') {
const { courseOrganizationId, ...lesson } = baseItem;
let video = lesson.video;
if (video?.type === 'upload') {
let storageKey: string | undefined;
let hlsManifestKey: string | null = null;
if (video.assetId) {
const assets = await getAssetsByIds([video.assetId], courseOrganizationId ?? undefined);
storageKey = assets[0]?.storageKey ?? undefined;
hlsManifestKey = assets[0]?.hlsManifestKey ?? null;
}
if (!storageKey && video.key) {
storageKey = video.key;
}
// HLS assets: serve the manifest URL via the api route (auth'd by
// the cio_hls cookie that the public-cookie endpoint mints when the
// learner loads the lesson). Don't presign storageKey — for HLS
// it's null anyway.
if (hlsManifestKey) {
const { key: _omitKey, ...rest } = video;
video = {
...rest,
link: `/hls/${hlsManifestKey}`.replace(/\/{2,}/g, '/'),
hls: true
};
} else if (storageKey) {
const urls = await generateVideoDownloadPresignedUrls([storageKey]);
const signedLink = urls[storageKey];
if (signedLink) {
const { key: _omitKey, ...rest } = video;
video = { ...rest, link: signedLink };
}
}
}
if (video?.assetId && courseOrganizationId) {
const transcriptRow = await getMediaTranscriptByAsset(video.assetId, courseOrganizationId);
let transcript: {
vttUrl: string;
vttUrlExpiresAt: string;
language: string;
} | null = null;
if (transcriptRow) {
const vttUrl = await generateTranscriptVttPresignedUrl(transcriptRow.vttStorageKey, transcriptRow.vttBucket);
transcript = {
vttUrl,
vttUrlExpiresAt: new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString(),
language: transcriptRow.language
};
}
video = { ...video, transcript };
}
if (video?.key !== undefined) {
const { key: _omitS3Key, ...withoutKey } = video;
video = withoutKey;
}
return { ...lesson, video };
}
if (!baseItem.isUnlocked) {
return {
...baseItem,
questions: {
unsectioned: [],
sections: []
}
};
}
const { exercise, questions } = await getExerciseWithRelationsOptimized(baseItem.id);
const questionTypeMap = new Map(questions.map((question) => [question.questionType.id, question.questionType]));
const allOptions = questions.flatMap((question) => question.options ?? []);
const strippedQuestions = questions.map(({ questionType: _type, options: _options, ...rest }) => rest);
const transformed = transformQuestions(strippedQuestions, allOptions, questionTypeMap);
const sectionRows = await getExerciseSectionsByExerciseId(exercise.id);
const sectionMap = new Map(sectionRows.map((section) => [section.id, section]));
const unsectioned: PublicExerciseQuestion[] = [];
const sectionBuckets = new Map<string, PublicExerciseQuestion[]>();
for (const question of transformed) {
// `transformQuestions` only keeps `{ id, label }` on `question.questionType`, so the DB
// `typename` (e.g. RADIO) must come from the map built before stripping relations.
const questionTypeRow = questionTypeMap.get(question.questionTypeId);
const payloadQuestion: PublicExerciseQuestion = {
id: question.id,
title: question.title ?? '',
questionTypeId: question.questionTypeId,
questionTypeKey: questionTypeRow?.typename ?? '',
points: question.points ?? 0,
order: typeof question.order === 'number' ? question.order : null,
settings: (question.settings ?? {}) as Record<string, unknown>,
options: (question.options ?? []).map((option) => ({
id: option.id,
label: option.label ?? '',
isCorrect: option.isCorrect ?? false,
settings: (option.settings ?? {}) as Record<string, unknown>
}))
};
const sectionId = question.exerciseSectionId ?? null;
if (sectionId && sectionMap.has(sectionId)) {
const bucket = sectionBuckets.get(sectionId) ?? [];
bucket.push(payloadQuestion);
sectionBuckets.set(sectionId, bucket);
} else {
unsectioned.push(payloadQuestion);
}
}
const sections = sectionRows.map((section) => ({
id: section.id,
title: section.title,
description: section.description,
order: Number(section.order),
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior,
questions: sectionBuckets.get(section.id) ?? []
}));
return {
...baseItem,
questions: {
unsectioned,
sections
}
};
} catch (error) {
if (error instanceof AppError) throw error;
console.error('getPublicCourseItemService error:', error);
throw new AppError('Failed to load public course item', ErrorCodes.COURSE_FETCH_FAILED, 500);
}
}
/**
* Resolve a course slug to the backing course row with minimal fields. Used by
* rate limiting / logging that need the course id without the full tree.
*/
export async function resolvePublicCourseBySlug(courseSlug: string) {
return getCourseRowBySlug(courseSlug);
}