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
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
+6 -2
View File
@@ -1,4 +1,4 @@
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import {
getCourseProgress as getCourseProgressQuery,
type TCourseCertificationRow
@@ -67,7 +67,11 @@ export function scheduleCertificationCompletionWork(params: {
return;
}
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
const base = getDashboardBaseUrl({
siteName: courseRow.orgSiteName,
customDomain: courseRow.orgCustomDomain,
isCustomDomainVerified: courseRow.orgIsCustomDomainVerified
});
const certificateUrl = `${base}/courses/${courseId}/certificates`;
await enqueueTransactionalEmail('studentCourseCompletion', {
+8 -19
View File
@@ -1,24 +1,13 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { Context } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { ErrorCodes, type ErrorCode } from '@cio/utils/constants';
import { AppError, ErrorCodes, type ErrorCode } from '@cio/utils/errors';
export { ErrorCodes, type ErrorCode };
/**
* Standard application error with code for easy identification
*/
export class AppError extends Error {
constructor(
error: string | Error,
public code: string,
public statusCode: ContentfulStatusCode = 500,
public field?: string
) {
super(error instanceof Error ? error.message : error);
this.name = 'AppError';
}
}
// AppError moved to `@cio/utils/errors` so it can be thrown from
// non-api workspace packages (notably `@cio/core`). Re-exported
// here to keep the long-standing `@api/utils/errors` import path working
// for the rest of the api codebase.
export { AppError, ErrorCodes, type ErrorCode };
/**
* Standard error response format
@@ -68,7 +57,7 @@ export const handleError = (
code: responseCode,
field: isServerError ? undefined : error.field
},
error.statusCode
error.statusCode as ContentfulStatusCode
);
}
-148
View File
@@ -1,148 +0,0 @@
import { enrichObjectsWithUrls, extractKeysFromObjects } from '@api/utils/presigned-urls';
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@api/utils/s3';
import { getAssetsByIds } from '@cio/db/queries/assets';
import type { LessonById } from '@cio/db/queries/lesson';
import type { TLesson } from '@cio/db/types';
// Extract types from schema
type LessonVideo = NonNullable<TLesson['videos']>[number];
type LessonDocument = NonNullable<TLesson['documents']>[number];
function mapProviderToVideoType(provider: string): LessonVideo['type'] {
if (provider === 'youtube') return 'youtube';
if (provider === 'google_drive') return 'google_drive';
if (provider === 'generic' || provider === 'external_url') return 'generic';
return 'upload';
}
function mapAssetMimeTypeToDocumentType(mimeType: string | null, fallbackType: LessonDocument['type']) {
if (!mimeType) {
return fallbackType;
}
if (mimeType === 'application/pdf') {
return 'pdf';
}
if (mimeType === 'application/msword') {
return 'doc';
}
if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
return 'docx';
}
return fallbackType;
}
function applyCanonicalVideoMetadata(
videos: LessonVideo[],
assets: Awaited<ReturnType<typeof getAssetsByIds>>
): LessonVideo[] {
if (assets.length === 0) {
return videos;
}
const assetsById = new Map(assets.map((asset) => [asset.id, asset]));
return videos.map((video) => {
if (!video.assetId) {
return video;
}
const asset = assetsById.get(video.assetId);
if (!asset) {
return video;
}
const mergedMetadata = {
...(video.metadata ?? {}),
title: asset.title ?? video.metadata?.title,
description: asset.description ?? video.metadata?.description,
thumbnailUrl: asset.thumbnailUrl ?? video.metadata?.thumbnailUrl,
duration: asset.durationSeconds ?? video.metadata?.duration,
aspectRatio: asset.aspectRatio ?? video.metadata?.aspectRatio
};
return {
...video,
type: mapProviderToVideoType(asset.provider),
key: asset.storageKey ?? video.key,
link: asset.provider === 'upload' ? video.link : (asset.sourceUrl ?? video.link),
fileName: asset.title ?? video.fileName,
metadata: mergedMetadata
};
});
}
function applyCanonicalDocumentMetadata(
documents: LessonDocument[],
assets: Awaited<ReturnType<typeof getAssetsByIds>>
): LessonDocument[] {
if (assets.length === 0) {
return documents;
}
const assetsById = new Map(assets.map((asset) => [asset.id, asset]));
return documents.map((document) => {
if (!document.assetId) {
return document;
}
const asset = assetsById.get(document.assetId);
if (!asset) {
return document;
}
return {
...document,
type: mapAssetMimeTypeToDocumentType(asset.mimeType, document.type),
key: asset.storageKey ?? document.key,
link: asset.provider === 'upload' ? document.link : (asset.sourceUrl ?? document.link),
name: asset.title ?? document.name,
size: asset.byteSize ?? document.size
};
});
}
/**
* Enriches lesson with presigned URLs for videos and documents
* @param lesson Lesson to enrich
* @returns Lesson with presigned URLs added
*/
export async function enrichLessonWithPresignedUrls(lesson: LessonById): Promise<LessonById> {
// videos and documents are jsonb fields from the database, now properly typed
// Normalize them to arrays safely (handles null/undefined cases)
const videos = lesson.videos || [];
const documents = lesson.documents || [];
const videoAssetIds = videos.map((video) => video.assetId).filter((assetId): assetId is string => Boolean(assetId));
const documentAssetIds = documents
.map((document) => document.assetId)
.filter((assetId): assetId is string => Boolean(assetId));
const assetIds = Array.from(new Set([...videoAssetIds, ...documentAssetIds]));
const canonicalAssets = assetIds.length ? await getAssetsByIds(assetIds) : [];
const canonicalVideos = applyCanonicalVideoMetadata(videos, canonicalAssets);
const canonicalDocuments = applyCanonicalDocumentMetadata(documents, canonicalAssets);
const videoKeys = extractKeysFromObjects(canonicalVideos.filter((video) => video.type === 'upload'));
const docKeys = extractKeysFromObjects(canonicalDocuments);
// Early return if no keys to process
if (videoKeys.length === 0 && docKeys.length === 0) {
return lesson;
}
// Generate presigned URLs in parallel
const [videoUrls, docUrls] = await Promise.all([
generateVideoDownloadPresignedUrls(videoKeys),
generateDocumentDownloadPresignedUrls(docKeys)
]);
return {
...lesson,
videos: enrichObjectsWithUrls<LessonVideo>(canonicalVideos, videoUrls),
documents: enrichObjectsWithUrls<LessonDocument>(canonicalDocuments, docUrls)
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { Scalar } from '@scalar/hono-api-reference';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import { AuthSession } from '@api/types/auth';
export function configureOpenAPI(app: Hono<AuthSession>) {
-77
View File
@@ -1,77 +0,0 @@
/**
* Generic utilities for working with presigned URLs
* These functions are reusable across multiple services that need to enrich
* objects with presigned URLs based on keys
*/
/**
* Extracts keys from an array of objects that have a `key` property
* @param items Array of objects with a key property
* @returns Array of string keys
*/
export function extractKeysFromObjects<T extends { key?: string }>(items: T[]): string[] {
return items.reduce<string[]>((keys, item) => {
if (item.key) {
keys.push(item.key);
}
return keys;
}, []);
}
/**
* Enriches an array of objects with URLs from a key-to-URL mapping
* Adds a `link` property to objects that have a matching key in the urls record
* @param items Array of objects to enrich
* @param urls Record mapping keys to presigned URLs
* @returns New array with enriched objects (immutable)
*/
export function enrichObjectsWithUrls<T extends { key?: string }>(
items: T[],
urls: Record<string, string>
): Array<T & { link?: string }> {
return items.map((item) => {
if (item.key && urls[item.key]) {
return { ...item, link: urls[item.key] };
}
return item;
});
}
/**
* Generic function to extract keys from objects with a custom key extractor
* @param items Array of items
* @param keyExtractor Function to extract the key from an item
* @returns Array of string keys
*/
export function extractKeysFromArray<T>(items: T[], keyExtractor: (item: T) => string | undefined): string[] {
return items.reduce<string[]>((keys, item) => {
const key = keyExtractor(item);
if (key) {
keys.push(key);
}
return keys;
}, []);
}
/**
* Generic function to enrich array items with URLs
* @param items Array of items to enrich
* @param urls Record mapping keys to presigned URLs
* @param keyGetter Function to get the key from an item
* @param urlSetter Function to set the URL on an item
* @returns New array with enriched items (immutable)
*/
export function enrichArrayWithUrls<T>(
items: T[],
urls: Record<string, string>,
keyGetter: (item: T) => string | undefined,
urlSetter: (item: T, url: string) => T
): T[] {
return items.map((item) => {
const key = keyGetter(item);
if (key && urls[key]) {
return urlSetter(item, urls[key]);
}
return item;
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ import {
RATE_LIMIT_KEY_PREFIX
} from '@api/constants/rate-limiter';
import { redis, type RedisClient } from './redis';
import { redis, type RedisClient } from '@cio/core/utils/redis/redis';
export class RedisRateLimiter {
private redis: RedisClient;
+1 -1
View File
@@ -1,4 +1,4 @@
import { logRedisUnavailableOnce, redis } from './redis';
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
const KEY = (token: string) => `oauth-handoff:${token}`;
const TTL_SECONDS = 120; // OAuth callbacks usually finish in < 2s; 2 minutes gives plenty of slack.
-57
View File
@@ -1,57 +0,0 @@
import { createClient, type RedisClientType } from 'redis';
import { env } from '@api/config/env';
// Create Redis client
const client: RedisClientType = createClient({
url: env.REDIS_URL
});
let hasLoggedRedisUnavailable = false;
export function logRedisUnavailableOnce(message: string, error?: unknown): void {
if (hasLoggedRedisUnavailable) {
return;
}
hasLoggedRedisUnavailable = true;
if (error) {
console.warn(message, error);
return;
}
console.warn(message);
}
// Handle connection events
client.on('error', (err) => {
logRedisUnavailableOnce('Redis unavailable, Redis-backed features disabled', err);
});
client.on('connect', () => {
console.log('Redis Client Connected');
});
// Track connection state
let isConnected = false;
export async function connectRedis(): Promise<void> {
if (!env.REDIS_URL) {
logRedisUnavailableOnce('REDIS_URL not set, Redis features disabled');
return;
}
if (isConnected) return;
try {
await client.connect();
isConnected = true;
} catch (error) {
logRedisUnavailableOnce('Redis connection failed, API will run without Redis', error);
// Don't throw - allow API to start; rate limiting and caching will degrade gracefully
}
}
// Export the client (connection will be established before server starts)
export const redis: RedisClientType = client;
// Export type for use in other files
export type RedisClient = RedisClientType;
-215
View File
@@ -1,215 +0,0 @@
import {
DeleteObjectCommand,
DeleteObjectCommandInput,
GetObjectCommand,
GetObjectCommandInput,
PutObjectCommand,
PutObjectCommandInput
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getPresignS3Client, getS3Client, getStorageConfig } from '@api/config/storage';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
const PRESIGN_CACHE_TTL_SECONDS = 3000; // 50 min; presigned URLs typically valid 1 hour
const PRESIGN_CACHE_KEY_PREFIX = 'presign:download:';
export async function uploadToS3(params: PutObjectCommandInput): Promise<{ success: boolean; error?: string }> {
try {
await getS3Client().send(new PutObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function getFromS3(
params: GetObjectCommandInput
): Promise<{ success: boolean; data?: any; error?: string }> {
try {
const data = await getS3Client().send(new GetObjectCommand(params));
return { success: true, data };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function deleteFromS3(params: DeleteObjectCommandInput): Promise<{ success: boolean; error?: string }> {
try {
await getS3Client().send(new DeleteObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
/**
* Generates presigned URLs for download for a list of keys in a specific bucket.
* Uses Redis cache to avoid repeated S3 getSignedUrl calls for the same file.
* @param keys Array of S3 keys
* @param bucketName Bucket name (e.g., 'videos', 'documents')
* @returns Record mapping keys to presigned URLs
*/
export async function generateDownloadPresignedUrls(
keys: string[],
bucketName: string
): Promise<Record<string, string>> {
const signedUrls: Record<string, string> = {};
const config = getStorageConfig();
const client = getPresignS3Client();
if (keys.length === 0) {
return signedUrls;
}
const cacheKeys = keys.map((key) => `${PRESIGN_CACHE_KEY_PREFIX}${bucketName}:${key}`);
let cachedValues: (string | null)[] | null = null;
try {
cachedValues = await redis.mGet(cacheKeys);
} catch (error) {
logRedisUnavailableOnce('Redis cache unavailable for presigned URLs, falling back to S3', error);
}
const keysToGenerate: string[] = [];
if (cachedValues) {
cachedValues.forEach((val, i) => {
if (val) {
signedUrls[keys[i]] = val;
} else {
keysToGenerate.push(keys[i]);
}
});
} else {
keysToGenerate.push(...keys);
}
if (keysToGenerate.length === 0) {
return signedUrls;
}
const urlPromises = keysToGenerate.map(async (key) => {
try {
const command = new GetObjectCommand({
Bucket: bucketName,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(client as GetSignedUrlParameters[0], command, {
expiresIn: config.presignDownloadExpiresSeconds
});
return { key, presignedUrl };
} catch (error) {
console.error(`Failed to generate presigned URL for key "${key}" in bucket "${bucketName}":`, error);
return null;
}
});
const results = await Promise.all(urlPromises);
try {
const pipeline = redis.multi();
results.forEach((result) => {
if (result) {
signedUrls[result.key] = result.presignedUrl;
const cacheKey = `${PRESIGN_CACHE_KEY_PREFIX}${bucketName}:${result.key}`;
pipeline.setEx(cacheKey, PRESIGN_CACHE_TTL_SECONDS, result.presignedUrl);
}
});
await pipeline.exec();
} catch (error) {
logRedisUnavailableOnce('Redis cache write unavailable for presigned URLs, continuing without cache', error);
// URLs are already in signedUrls; cache write failure is non-fatal
}
return signedUrls;
}
/**
* Generates a presigned URL for upload
* @param fileKey S3 key for the file
* @param bucketName Bucket name (e.g., 'videos', 'documents')
* @param contentType MIME type of the file
* @returns Presigned URL for upload
*/
export async function generateUploadPresignedUrl(
fileKey: string,
bucketName: string,
contentType: string
): Promise<string> {
const config = getStorageConfig();
const client = getPresignS3Client();
const command = new PutObjectCommand({
Bucket: bucketName,
Key: fileKey,
ContentType: contentType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(client as GetSignedUrlParameters[0], command, {
expiresIn: config.presignUploadExpiresSeconds
});
return presignedUrl;
}
/** 6h presigned GET for private transcript VTT objects (not Redis-cached). */
export const TRANSCRIPT_VTT_PRESIGN_SECONDS = 21_600;
export async function generateTranscriptVttPresignedUrl(key: string, bucketName: string): Promise<string> {
const client = getPresignS3Client();
const command = new GetObjectCommand({
Bucket: bucketName,
Key: key
}) as GetSignedUrlParameters[1];
return getSignedUrl(client as GetSignedUrlParameters[0], command, {
expiresIn: TRANSCRIPT_VTT_PRESIGN_SECONDS
});
}
/**
* Generates presigned URLs for video download
* @param keys Array of video keys
* @returns Record mapping keys to presigned URLs
*/
export async function generateVideoDownloadPresignedUrls(keys: string[]): Promise<Record<string, string>> {
const config = getStorageConfig();
return generateDownloadPresignedUrls(keys, config.bucketVideos);
}
/**
* Generates presigned URLs for document download
* @param keys Array of document keys
* @returns Record mapping keys to presigned URLs
*/
export async function generateDocumentDownloadPresignedUrls(keys: string[]): Promise<Record<string, string>> {
const config = getStorageConfig();
return generateDownloadPresignedUrls(keys, config.bucketDocuments);
}
/**
* Generates a presigned URL for video upload
* @param fileKey S3 key for the video file
* @param contentType MIME type of the video file
* @returns Presigned URL for upload
*/
export async function generateVideoUploadPresignedUrl(fileKey: string, contentType: string): Promise<string> {
const config = getStorageConfig();
return generateUploadPresignedUrl(fileKey, config.bucketVideos, contentType);
}
/**
* Generates a presigned URL for document upload
* @param fileKey S3 key for the document file
* @param contentType MIME type of the document file
* @returns Presigned URL for upload
*/
export async function generateDocumentUploadPresignedUrl(fileKey: string, contentType: string): Promise<string> {
const config = getStorageConfig();
return generateUploadPresignedUrl(fileKey, config.bucketDocuments, contentType);
}
-39
View File
@@ -1,39 +0,0 @@
import { createSanitizeHtmlConfig, stripSvgDataUrls } from '@cio/utils/functions';
import DOMPurify from 'isomorphic-dompurify';
export function sanitizeHtml(html: string): string {
let sanitized = DOMPurify.sanitize(html, createSanitizeHtmlConfig());
sanitized = stripSvgDataUrls(sanitized);
return sanitized;
}
export function sanitizeOptionalHtml(html: string | undefined): string | undefined;
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined;
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined {
if (html == null) return html;
return sanitizeHtml(html);
}
export function sanitizeUnknownStrings<T>(value: T): T {
if (typeof value === 'string') {
return sanitizeHtml(value) as T;
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeUnknownStrings(item)) as T;
}
if (value && typeof value === 'object') {
const sanitizedEntries = Object.entries(value).map(([key, entryValue]) => [
key,
sanitizeUnknownStrings(entryValue)
]);
return Object.fromEntries(sanitizedEntries) as T;
}
return value;
}
-40
View File
@@ -1,40 +0,0 @@
/**
* Tinybird event tracking for AI assistant observability.
* Fire-and-forget: never blocks the agent, silently skipped when not configured.
*/
const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN;
const TINYBIRD_BASE_URL = process.env.TINYBIRD_BASE_URL || 'https://api.tinybird.co';
/**
* Send an event to Tinybird for analytics and debugging.
* Safe to call without awaiting — errors are swallowed.
*/
export function trackAgentEvent(eventName: string, data: Record<string, unknown>): void {
if (!TINYBIRD_TOKEN) return;
fetch(`${TINYBIRD_BASE_URL}/v0/events?name=${eventName}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TINYBIRD_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ ...data, timestamp: new Date().toISOString() })
}).catch(() => {
// Intentionally swallowed — observability must never break the agent
});
}
// ─── Event Names ─────────────────────────────────────────────────────────────
export const AgentEvent = {
CHAT_STARTED: 'agent.chat.started',
CHAT_COMPLETED: 'agent.chat.completed',
CHAT_ERROR: 'agent.chat.error',
TOOL_CALLED: 'agent.tool.called',
TOOL_COMPLETED: 'agent.tool.completed',
TOOL_FAILED: 'agent.tool.failed',
DOCUMENT_UPLOADED: 'agent.document.uploaded',
PLAN_GENERATED: 'agent.plan.generated',
PLAN_EXECUTED: 'agent.plan.executed'
} as const;
-34
View File
@@ -1,34 +0,0 @@
import { nanoid } from 'nanoid';
import path from 'path';
/**
* Safely extracts the file extension from a filename.
* Returns an empty string if no extension is found.
*/
export function getExtension(filename: string): string {
if (!filename || typeof filename !== 'string') return '';
const ext = path.extname(filename).split('.');
return ext.length > 1 ? ext[ext.length - 1] : '';
}
export function removeSpacesAndSpecialCharacters(fileName: string): string {
return fileName
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9.-]/g, '')
.toLowerCase();
}
/**
* Generates a unique file key for storage, preserving the file extension.
* Throws an error if the filename is invalid or has no extension.
*/
export function generateFileKey(fileName: string): string {
const ext = getExtension(fileName);
if (!ext) {
throw new Error('Invalid file name or missing file extension');
}
const cleanedFileName = removeSpacesAndSpecialCharacters(fileName);
return `${nanoid()}-${cleanedFileName}`;
}