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:
@@ -0,0 +1,48 @@
|
||||
import { TENANT_ROOT_DOMAIN, BRAND_ROOT_DOMAIN } from '@cio/utils/constants/domains';
|
||||
|
||||
import { env } from './env';
|
||||
|
||||
/**
|
||||
* Minimal org shape consumed by `getDashboardBaseUrl`. Accepts the three
|
||||
* fields that decide which host the org's URLs live on. All optional so
|
||||
* callers without an org context can still ask for a sensible default.
|
||||
*/
|
||||
export type DashboardOrg = {
|
||||
siteName?: string | null;
|
||||
customDomain?: string | null;
|
||||
isCustomDomainVerified?: boolean | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the base URL for the dashboard app, used for invite links, login
|
||||
* URLs, course URLs, and email-embedded links.
|
||||
*
|
||||
* Resolution order (highest precedence first):
|
||||
* 1. Org's verified `customDomain` → `https://<customDomain>`
|
||||
* 2. `DASHBOARD_ORIGIN` env var → that value (self-hosted override)
|
||||
* 3. `NODE_ENV === 'development'` → `http://localhost:5173`
|
||||
* 4. Org's `siteName` → `https://<siteName>.myclassroomio.com`
|
||||
* 5. Final fallback → `https://app.classroomio.com`
|
||||
*
|
||||
* `customDomain` wins over `DASHBOARD_ORIGIN` because per-org BYOD must
|
||||
* override the platform-wide self-hosted dashboard host for that org's URLs.
|
||||
*/
|
||||
export function getDashboardBaseUrl(org?: DashboardOrg): string {
|
||||
if (org?.customDomain && org.isCustomDomainVerified) {
|
||||
return `https://${org.customDomain}`;
|
||||
}
|
||||
|
||||
if (env.DASHBOARD_ORIGIN) {
|
||||
return env.DASHBOARD_ORIGIN.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
if (env.NODE_ENV === 'development') {
|
||||
return 'http://localhost:5173';
|
||||
}
|
||||
|
||||
if (org?.siteName) {
|
||||
return `https://${org.siteName}.${TENANT_ROOT_DOMAIN}`;
|
||||
}
|
||||
|
||||
return `https://app.${BRAND_ROOT_DOMAIN}`;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
// Legacy Cloudflare R2 config (used when OBJECT_STORAGE_* vars are absent)
|
||||
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
|
||||
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
|
||||
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
|
||||
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(),
|
||||
APPROXIMATED_API_KEY: z.string().optional(),
|
||||
APPROXIMATED_TARGET_ADDRESS: z.string().optional(),
|
||||
/** Public IP customers point their A record to (Approximated edge IP). */
|
||||
APPROXIMATED_DNS_TARGET_IP: z.string().optional(),
|
||||
/** Optional CNAME target customers can point to instead of A record. */
|
||||
APPROXIMATED_DNS_TARGET_CNAME: z.string().optional(),
|
||||
// S3-compatible storage (MinIO, AWS S3, or other S3-compatible backends)
|
||||
OBJECT_STORAGE_ENDPOINT: z.string().optional(),
|
||||
OBJECT_STORAGE_REGION: z.string().optional(),
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: z.string().optional(),
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: z.string().optional(),
|
||||
OBJECT_STORAGE_BUCKET_VIDEOS: z.string().optional(),
|
||||
OBJECT_STORAGE_BUCKET_DOCUMENTS: z.string().optional(),
|
||||
OBJECT_STORAGE_BUCKET_MEDIA: z.string().optional(),
|
||||
/** Public URL base for media bucket (images, thumbnails). Required for image uploads. */
|
||||
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: z.string().optional(),
|
||||
/** Optional: endpoint for presigned URLs when different from internal (e.g. browser-reachable) */
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT: z.string().optional(),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
OPENAPI_URL: z.string().optional(),
|
||||
PORT: z.string().optional(),
|
||||
REDIS_URL: z.string().optional(),
|
||||
PUBLIC_EMBED_BASE_URL: z.string().optional(),
|
||||
PUBLIC_SERVER_URL: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_SENDER: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
ZOHO_TOKEN: z.string().optional(),
|
||||
TRUSTED_ORIGINS: z.string().optional(),
|
||||
PRIVATE_SERVER_KEY: z.string().optional(),
|
||||
PROJECT_ID_VERCEL: z.string().optional(),
|
||||
TEAM_ID_VERCEL: z.string().optional(),
|
||||
AUTH_BEARER_TOKEN: z.string().optional(),
|
||||
UNSPLASH_API_KEY: z.string().optional(),
|
||||
/** When 'true', instance runs in self-hosted mode: single org, auto-enroll as student, no org switching */
|
||||
PUBLIC_IS_SELFHOSTED: z.string().optional(),
|
||||
/** License key for enterprise features (SSO, token-auth, no-tracking, etc.) */
|
||||
LICENSE_KEY: z.string().optional(),
|
||||
/** Dashboard origin for invite/email links (e.g. https://app.yourdomain.com). When set, all email links point here instead of app.classroomio.com. Required for self-hosted. */
|
||||
DASHBOARD_ORIGIN: z.string().optional(),
|
||||
/**
|
||||
* Comma-separated list of Better Auth user emails allowed to access the
|
||||
* BullMQ dashboard at /admin/queues in production. Leave unset to keep the
|
||||
* dashboard hidden everywhere except dev.
|
||||
*/
|
||||
QUEUE_DASHBOARD_ADMIN_EMAILS: z.string().optional(),
|
||||
/**
|
||||
* Bearer-token escape hatch for /admin/queues — useful for curl scripts or
|
||||
* when SSO is unavailable. When set, requests with `Authorization: Bearer
|
||||
* <token>` (or `?token=<token>`) bypass the email allowlist.
|
||||
*/
|
||||
QUEUE_DASHBOARD_TOKEN: z.string().optional(),
|
||||
/** Optional Bearer token for Jina Reader (higher rate limits). */
|
||||
JINA_API_KEY: z.string().optional(),
|
||||
/** Max completed fetch_documentation_url calls per agent conversation (default 15). */
|
||||
AGENT_MAX_FETCHES_PER_CONVERSATION: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => {
|
||||
if (v === undefined || v === '') {
|
||||
return 15;
|
||||
}
|
||||
|
||||
const parsed = Number(v);
|
||||
|
||||
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 15;
|
||||
}),
|
||||
/** Sentry error tracking (server-side). Leave SENTRY_DSN unset to disable. */
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
SENTRY_ENVIRONMENT: z.string().optional(),
|
||||
SENTRY_TRACES_SAMPLE_RATE: z.string().optional(),
|
||||
/** Max fetch_documentation_url calls per org per UTC day (default 500). */
|
||||
AGENT_MAX_FETCHES_PER_ORG_PER_DAY: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => {
|
||||
if (v === undefined || v === '') {
|
||||
return 500;
|
||||
}
|
||||
|
||||
const parsed = Number(v);
|
||||
|
||||
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 500;
|
||||
})
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Object storage configuration for S3-compatible backends (Cloudflare R2, MinIO, AWS S3).
|
||||
* Supports both generic OBJECT_STORAGE_* vars and backward-compatible CLOUDFLARE_* fallback.
|
||||
*/
|
||||
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
import { env } from './env';
|
||||
|
||||
const BUCKET_DEFAULTS = {
|
||||
VIDEOS: 'videos',
|
||||
DOCUMENTS: 'documents',
|
||||
MEDIA: 'media'
|
||||
} as const;
|
||||
|
||||
/** Whether we're using the generic S3-compatible config (MinIO, etc.) vs Cloudflare R2 fallback */
|
||||
export function useS3CompatibleConfig(): boolean {
|
||||
return Boolean(
|
||||
env.OBJECT_STORAGE_ENDPOINT && env.OBJECT_STORAGE_ACCESS_KEY_ID && env.OBJECT_STORAGE_SECRET_ACCESS_KEY
|
||||
);
|
||||
}
|
||||
|
||||
function isCloudflareConfigured(): boolean {
|
||||
return Boolean(env.CLOUDFLARE_ACCOUNT_ID && env.CLOUDFLARE_ACCESS_KEY && env.CLOUDFLARE_SECRET_ACCESS_KEY);
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
forcePathStyle: boolean;
|
||||
bucketVideos: string;
|
||||
bucketDocuments: string;
|
||||
bucketMedia: string;
|
||||
mediaPublicBaseUrl: string | null;
|
||||
presignUploadExpiresSeconds: number;
|
||||
presignDownloadExpiresSeconds: number;
|
||||
}
|
||||
|
||||
function resolveStorageConfig(): StorageConfig {
|
||||
if (useS3CompatibleConfig()) {
|
||||
const forcePathStyle =
|
||||
env.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true' || env.OBJECT_STORAGE_FORCE_PATH_STYLE === '1';
|
||||
return {
|
||||
endpoint: env.OBJECT_STORAGE_ENDPOINT!,
|
||||
region: env.OBJECT_STORAGE_REGION || 'us-east-1',
|
||||
accessKeyId: env.OBJECT_STORAGE_ACCESS_KEY_ID!,
|
||||
secretAccessKey: env.OBJECT_STORAGE_SECRET_ACCESS_KEY!,
|
||||
forcePathStyle,
|
||||
bucketVideos: env.OBJECT_STORAGE_BUCKET_VIDEOS || BUCKET_DEFAULTS.VIDEOS,
|
||||
bucketDocuments: env.OBJECT_STORAGE_BUCKET_DOCUMENTS || BUCKET_DEFAULTS.DOCUMENTS,
|
||||
bucketMedia: env.OBJECT_STORAGE_BUCKET_MEDIA || BUCKET_DEFAULTS.MEDIA,
|
||||
mediaPublicBaseUrl: env.OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL || null,
|
||||
presignUploadExpiresSeconds: 60 * 60, // 1 hour
|
||||
presignDownloadExpiresSeconds: 60 * 60 // 1 hour
|
||||
};
|
||||
}
|
||||
|
||||
if (isCloudflareConfigured()) {
|
||||
return {
|
||||
endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
region: 'auto',
|
||||
accessKeyId: env.CLOUDFLARE_ACCESS_KEY!,
|
||||
secretAccessKey: env.CLOUDFLARE_SECRET_ACCESS_KEY!,
|
||||
forcePathStyle: false,
|
||||
bucketVideos: BUCKET_DEFAULTS.VIDEOS,
|
||||
bucketDocuments: BUCKET_DEFAULTS.DOCUMENTS,
|
||||
bucketMedia: BUCKET_DEFAULTS.MEDIA,
|
||||
mediaPublicBaseUrl: env.CLOUDFLARE_IMAGE_BUCKET_DOMAIN || null,
|
||||
presignUploadExpiresSeconds: 60 * 60,
|
||||
presignDownloadExpiresSeconds: 60 * 60
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Object storage not configured. Set either OBJECT_STORAGE_ENDPOINT, OBJECT_STORAGE_ACCESS_KEY_ID, OBJECT_STORAGE_SECRET_ACCESS_KEY (for MinIO/S3) or CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ACCESS_KEY, CLOUDFLARE_SECRET_ACCESS_KEY (for Cloudflare R2).'
|
||||
);
|
||||
}
|
||||
|
||||
let cachedConfig: StorageConfig | null = null;
|
||||
|
||||
export function getStorageConfig(): StorageConfig {
|
||||
if (!cachedConfig) {
|
||||
cachedConfig = resolveStorageConfig();
|
||||
if (useS3CompatibleConfig()) {
|
||||
console.log('[storage] Using S3-compatible object storage');
|
||||
} else {
|
||||
console.log('[storage] Using Cloudflare R2 (fallback)');
|
||||
}
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
/** S3 client for API operations (upload, get, delete). Uses internal endpoint. */
|
||||
let s3ClientInstance: S3Client | null = null;
|
||||
|
||||
export function getS3Client(): S3Client {
|
||||
if (!s3ClientInstance) {
|
||||
const config = getStorageConfig();
|
||||
s3ClientInstance = new S3Client({
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey
|
||||
},
|
||||
forcePathStyle: config.forcePathStyle
|
||||
});
|
||||
}
|
||||
return s3ClientInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* S3 client for generating presigned URLs. Uses public endpoint when configured
|
||||
* (e.g. when internal endpoint like minio:9000 is not reachable by browser).
|
||||
*/
|
||||
let presignClientInstance: S3Client | null = null;
|
||||
|
||||
export function getPresignS3Client(): S3Client {
|
||||
if (!presignClientInstance) {
|
||||
const config = getStorageConfig();
|
||||
const endpoint = env.OBJECT_STORAGE_PUBLIC_ENDPOINT || config.endpoint;
|
||||
presignClientInstance = new S3Client({
|
||||
region: config.region,
|
||||
endpoint,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey
|
||||
},
|
||||
forcePathStyle: config.forcePathStyle
|
||||
});
|
||||
}
|
||||
return presignClientInstance;
|
||||
}
|
||||
|
||||
export { BUCKET_DEFAULTS };
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Public surface of `@cio/core`.
|
||||
*
|
||||
* Most consumers reach into the package via subpath imports (`./services/*`,
|
||||
* `./utils/*`, `./config/*` — see `package.json` exports). The top-level
|
||||
* surface is intentionally narrow: it only re-exports the durable agent
|
||||
* runner used by the BullMQ worker in `apps/jobs`.
|
||||
*/
|
||||
|
||||
export {
|
||||
runAgentCourseGenerationJob,
|
||||
type RunAgentCourseGenerationJobInput
|
||||
} from './services/agent/course-generation-runner';
|
||||
@@ -0,0 +1,327 @@
|
||||
import { z } from 'zod';
|
||||
import { CoursePlanSchema } from '@cio/ai-assistant';
|
||||
import {
|
||||
LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT,
|
||||
LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT,
|
||||
LANDING_PAGE_SECTION_HTML_AGENT_HINT
|
||||
} from '@cio/ai-assistant/tools';
|
||||
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
|
||||
import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
|
||||
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
// courseId is NOT a parameter — it's injected from the authenticated request context.
|
||||
// This prevents prompt injection from tricking the LLM into targeting another course.
|
||||
|
||||
export const emptyParam = z.object({});
|
||||
export const lessonReadParam = z.object({ lessonId: z.string(), locale: z.string().default('en') });
|
||||
export const exerciseReadParam = z.object({ exerciseId: z.string() });
|
||||
export const createSectionParam = z.object({ title: z.string().min(1), order: z.number().int().min(0) });
|
||||
export const updateSectionParam = z
|
||||
.object({
|
||||
sectionId: z.string(),
|
||||
title: z.string().min(1).optional(),
|
||||
order: z.number().int().min(0).optional()
|
||||
})
|
||||
.refine((data) => data.title !== undefined || data.order !== undefined, {
|
||||
message: 'Provide at least one field to update'
|
||||
});
|
||||
export const createLessonParam = z.object({
|
||||
sectionId: z.string(),
|
||||
title: z.string().min(1),
|
||||
order: z.number().int().min(0)
|
||||
});
|
||||
export const updateLessonParam = z
|
||||
.object({
|
||||
lessonId: z.string(),
|
||||
title: z.string().min(1).optional(),
|
||||
sectionId: z.string().optional(),
|
||||
order: z.number().int().min(0).optional(),
|
||||
lessonAt: z.string().optional(),
|
||||
callUrl: z.string().optional(),
|
||||
isUnlocked: z.boolean().optional(),
|
||||
public: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.title !== undefined ||
|
||||
data.sectionId !== undefined ||
|
||||
data.order !== undefined ||
|
||||
data.lessonAt !== undefined ||
|
||||
data.callUrl !== undefined ||
|
||||
data.isUnlocked !== undefined ||
|
||||
data.public !== undefined,
|
||||
{
|
||||
message: 'Provide at least one field to update'
|
||||
}
|
||||
);
|
||||
export const updateContentParam = z.object({
|
||||
lessonId: z.string(),
|
||||
locale: z.string().default('en'),
|
||||
content: z.string().min(1)
|
||||
});
|
||||
|
||||
export const questionSchema = z.object({
|
||||
question: z.string().min(1),
|
||||
questionTypeId: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(QUESTION_TYPE_REGISTRY.length)
|
||||
.describe(
|
||||
'Required. Use the numeric question type IDs from the teacher system prompt (Question Types). Omitting this field is invalid — set an explicit type on every question and vary types within each exercise.'
|
||||
),
|
||||
points: z.number().min(0).default(1),
|
||||
order: z.number().int().min(0),
|
||||
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
|
||||
});
|
||||
|
||||
export const createExerciseParam = z.object({
|
||||
lessonId: z.string().optional(),
|
||||
sectionId: z.string().optional(),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional().describe('Optional short description shown to students above the questions.'),
|
||||
order: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe(
|
||||
'Display order within the section (0-based). Required when sectionId is provided. Lessons and exercises share the same order space within a section.'
|
||||
),
|
||||
questions: z.array(questionSchema)
|
||||
});
|
||||
|
||||
export const updateExerciseParam = z
|
||||
.object({
|
||||
exerciseId: z.string().describe('The exercise to update. Must be a real UUID returned by a prior tool call.'),
|
||||
title: z.string().min(1).optional().describe('New exercise title. Omit to keep unchanged.'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New short description shown to students. Pass an empty string to clear it.'),
|
||||
lessonId: z.string().optional().describe('Link the exercise to a lesson in this course.'),
|
||||
sectionId: z.string().optional().describe('Move the exercise to a section in this course.'),
|
||||
order: z.number().int().min(0).optional().describe('New order within the section (0-based).'),
|
||||
dueBy: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'),
|
||||
isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'),
|
||||
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.')
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.title !== undefined ||
|
||||
data.description !== undefined ||
|
||||
data.lessonId !== undefined ||
|
||||
data.sectionId !== undefined ||
|
||||
data.order !== undefined ||
|
||||
data.dueBy !== undefined ||
|
||||
data.isUnlocked !== undefined ||
|
||||
data.allowMultipleAttempts !== undefined,
|
||||
{
|
||||
message: 'Provide at least one field to update'
|
||||
}
|
||||
);
|
||||
|
||||
export const updateExerciseSectionParam = z
|
||||
.object({
|
||||
exerciseId: z
|
||||
.string()
|
||||
.describe('The exercise that contains this section. Must match the exercise you read with get_exercise_details.'),
|
||||
exerciseSectionId: z
|
||||
.string()
|
||||
.describe('The exercise section id from get_exercise_details `sections[].id` — not a course section id.'),
|
||||
title: z.string().min(1).optional().describe('New section heading shown above questions in this block.'),
|
||||
description: z.string().optional().describe('Optional intro HTML for this block. Pass an empty string to clear it.')
|
||||
})
|
||||
.refine((data) => data.title !== undefined || data.description !== undefined, {
|
||||
message: 'Provide at least title or description to update'
|
||||
});
|
||||
|
||||
export const createExerciseSectionParam = z.object({
|
||||
exerciseId: z.string().describe('The exercise that will contain this section.'),
|
||||
title: z.string().min(1).describe('Section heading shown above the questions in this block.'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional intro HTML for this block. Pass an empty string to leave it blank.'),
|
||||
order: z.number().int().min(0).describe('Display order within the exercise (0-based).'),
|
||||
colorTheme: z.enum(['blue', 'green', 'amber', 'rose', 'violet', 'slate']).optional(),
|
||||
afterBehavior: ZExerciseSectionAfterBehavior.optional()
|
||||
});
|
||||
|
||||
export const addQuestionsParam = z.object({
|
||||
exerciseId: z.string(),
|
||||
exerciseSectionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe(
|
||||
'In-exercise section id from get_exercise_details `sections[].id` where the new questions should appear (not a course outline section id).'
|
||||
),
|
||||
questions: z.array(questionSchema)
|
||||
});
|
||||
|
||||
export const updateQuestionSettingsSchema = z
|
||||
.object({
|
||||
correctValue: z.union([z.number(), z.string(), z.boolean()]).optional(),
|
||||
tolerance: z.number().min(0).optional(),
|
||||
correctAnswers: z.array(z.string().min(1)).optional(),
|
||||
template: z.string().min(1).optional(),
|
||||
maxStars: z.number().int().min(1).optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
distractors: z.array(z.string().min(1)).optional()
|
||||
})
|
||||
.passthrough()
|
||||
.refine((settings) => Object.keys(settings).length > 0, {
|
||||
message: 'Provide at least one settings field'
|
||||
})
|
||||
.describe(
|
||||
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
|
||||
);
|
||||
|
||||
export const updateOptionSchema = z.object({
|
||||
id: z.number().int().optional(),
|
||||
label: z.string().min(1).optional(),
|
||||
isCorrect: z.boolean().optional(),
|
||||
settings: updateQuestionSettingsSchema.optional()
|
||||
});
|
||||
|
||||
export const updateQuestionPatchSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
question: z.string().min(1).optional(),
|
||||
questionTypeId: z.number().int().min(1).max(QUESTION_TYPE_REGISTRY.length).optional(),
|
||||
points: z.number().min(0).optional(),
|
||||
order: z.number().int().min(0).optional(),
|
||||
exerciseSectionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.'
|
||||
),
|
||||
settings: updateQuestionSettingsSchema.optional(),
|
||||
options: z.array(updateOptionSchema).optional()
|
||||
})
|
||||
.refine(
|
||||
(patch) =>
|
||||
patch.question !== undefined ||
|
||||
patch.questionTypeId !== undefined ||
|
||||
patch.points !== undefined ||
|
||||
patch.order !== undefined ||
|
||||
patch.exerciseSectionId !== undefined ||
|
||||
patch.settings !== undefined ||
|
||||
patch.options !== undefined,
|
||||
{
|
||||
message: 'Provide at least one field to update besides id'
|
||||
}
|
||||
);
|
||||
|
||||
export const updateQuestionsParam = z.object({
|
||||
exerciseId: z.string(),
|
||||
questions: z.array(updateQuestionPatchSchema).min(1)
|
||||
});
|
||||
export const coursePlanParam = z.object({ plan: CoursePlanSchema });
|
||||
|
||||
const courseTemplateIdParam = z.enum(['product_101', 'product_onboarding', 'expert_on_x']);
|
||||
|
||||
/**
|
||||
* The model only needs to identify which template to render. The server pulls
|
||||
* the form title and field set from the shared registry — keeping the contract
|
||||
* server-authoritative means the form can never drift from the pedagogy and
|
||||
* the model can't omit `id`s or invent unsupported field types.
|
||||
*/
|
||||
export const askTemplateQuestionsParam = z.object({
|
||||
templateId: courseTemplateIdParam
|
||||
});
|
||||
|
||||
export const fetchDocumentationUrlParam = z.object({
|
||||
url: z.string().url()
|
||||
});
|
||||
|
||||
// Gemini's tool-schema validator only accepts string enums, so numeric/boolean
|
||||
// `z.literal` values must be relaxed to plain types in the schemas the model sees.
|
||||
export const agentLessonTabsOrder = z.array(
|
||||
z.object({
|
||||
id: z.number().int().min(1).max(4),
|
||||
name: z.string()
|
||||
})
|
||||
);
|
||||
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({
|
||||
lessonTabsOrder: agentLessonTabsOrder.optional(),
|
||||
requirements: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||
description: z.string().optional().describe(LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT),
|
||||
goals: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||
instructor: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
coursesNo: z.number().optional(),
|
||||
description: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||
imgUrl: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
export const updateCourseLandingPageParam = ZCourseLandingPageUpdate.extend({
|
||||
title: z.string().min(1).optional().describe('Plain-text public course title (no HTML).'),
|
||||
description: z.string().min(1).optional().describe(LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT),
|
||||
overview: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||
generateImage: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set true to auto-resolve a banner image from Unsplash. The server searches Unsplash using imageQuery if provided, otherwise the course title. Use this to fix a missing-banner blocker — do NOT ask the teacher to describe an image.'
|
||||
),
|
||||
imageQuery: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.optional()
|
||||
.describe('Optional Unsplash search query (1–120 chars). Omit to let the server use the course title.'),
|
||||
metadata: agentLandingPageMetadataUpdate.optional()
|
||||
}).refine(
|
||||
(data) =>
|
||||
data.title !== undefined ||
|
||||
data.description !== undefined ||
|
||||
data.overview !== undefined ||
|
||||
data.cost !== undefined ||
|
||||
data.currency !== undefined ||
|
||||
data.imageUrl !== undefined ||
|
||||
data.generateImage !== undefined ||
|
||||
data.imageQuery !== undefined ||
|
||||
data.metadata !== undefined,
|
||||
{
|
||||
message: 'Provide at least one landing-page field to update'
|
||||
}
|
||||
);
|
||||
export const goLiveParam = z.object({
|
||||
confirmPublish: z
|
||||
.boolean()
|
||||
.describe('Must be true. Only use this after the teacher explicitly asks to publish or go live.')
|
||||
});
|
||||
|
||||
export const reorderContentParam = z.object({
|
||||
sections: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
order: z.number().int().min(0)
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.describe('Reorder sections by setting new order values'),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(['LESSON', 'EXERCISE']),
|
||||
order: z.number().int().min(0).optional().describe('New order within the section'),
|
||||
sectionId: z.string().nullable().optional().describe('Move item to a different section')
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.describe('Reorder or move lessons/exercises')
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { QUESTION_TYPE_IDS } from '@cio/question-types';
|
||||
|
||||
/**
|
||||
* Balances which option position holds the correct answer across a batch of
|
||||
* AI-generated questions, so an exercise never ends up with "B is always the
|
||||
* answer". LLMs cannot produce a uniform distribution on instruction alone —
|
||||
* this enforces it deterministically at persist time.
|
||||
*
|
||||
* - RADIO (single correct): each question's correct option is relocated to a
|
||||
* target position drawn from a balanced-then-shuffled assignment, so the
|
||||
* correct position is spread as evenly as possible across the batch (and not
|
||||
* in a predictable A,B,C,D cycle).
|
||||
* - CHECKBOX (multiple correct): options are shuffled so the correct set does
|
||||
* not cluster at the top. Round-robin balancing does not apply because the
|
||||
* number of correct answers varies per question.
|
||||
*
|
||||
* Questions are left untouched when reordering would be unsafe or meaningless:
|
||||
* non-RADIO/CHECKBOX types, fewer than two options, a malformed correct-count,
|
||||
* position-referential options ("all of the above"), or naturally-ordered
|
||||
* option sets (numeric values/ranges).
|
||||
*
|
||||
* The shuffle is seeded from the batch content, so the result is deterministic
|
||||
* and unit-testable — no `Math.random`.
|
||||
*/
|
||||
|
||||
const RADIO_ID = QUESTION_TYPE_IDS.RADIO;
|
||||
const CHECKBOX_ID = QUESTION_TYPE_IDS.CHECKBOX;
|
||||
|
||||
export interface BalanceableOption {
|
||||
label: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
type WithOptions = { questionTypeId: number; options: BalanceableOption[] };
|
||||
|
||||
// Options whose text refers to other positions ("all of the above", "both A
|
||||
// and B", …) break if shuffled into the middle, so the question is skipped.
|
||||
const POSITION_REFERENTIAL = [
|
||||
/\ball of the (above|below)\b/,
|
||||
/\bnone of the (above|below)\b/,
|
||||
/\b(all|none|both|any) of these\b/,
|
||||
/\bboth\b.*\band\b/,
|
||||
/^(answers?\s+)?[a-d]\s+and\s+[a-d]\b/,
|
||||
/^[a-d],\s*[a-d]/
|
||||
];
|
||||
|
||||
function isPositionReferential(label: string): boolean {
|
||||
const normalized = label.trim().toLowerCase();
|
||||
|
||||
return POSITION_REFERENTIAL.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
// "0-10 / 11-20 / 21-30" or "1 / 2 / 3" read better in order — don't shuffle.
|
||||
function leadingNumber(label: string): number | null {
|
||||
const match = label
|
||||
.trim()
|
||||
.replace(/^[^\d-]+/, '')
|
||||
.match(/^-?\d+(?:\.\d+)?/);
|
||||
|
||||
return match ? Number(match[0]) : null;
|
||||
}
|
||||
|
||||
function isNaturallyOrdered(options: BalanceableOption[]): boolean {
|
||||
const numbers = options.map((option) => leadingNumber(option.label));
|
||||
if (numbers.some((value) => value === null)) return false;
|
||||
|
||||
const values = numbers as number[];
|
||||
const ascending = values.every((value, i) => i === 0 || value >= values[i - 1]);
|
||||
const descending = values.every((value, i) => i === 0 || value <= values[i - 1]);
|
||||
|
||||
return ascending || descending;
|
||||
}
|
||||
|
||||
function correctCount(options: BalanceableOption[]): number {
|
||||
return options.reduce((total, option) => total + (option.isCorrect ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
function isShuffleSafe(question: WithOptions): boolean {
|
||||
const { options } = question;
|
||||
if (options.length < 2) return false;
|
||||
if (options.some((option) => isPositionReferential(option.label))) return false;
|
||||
if (isNaturallyOrdered(options)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// FNV-1a hash → mulberry32 PRNG. Seeded from batch content so identical input
|
||||
// yields an identical (but well-distributed) layout.
|
||||
function hashSeed(input: string): number {
|
||||
let hash = 2166136261 >>> 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function shuffleInPlace<T>(items: T[], random: () => number): void {
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[items[i], items[j]] = [items[j], items[i]];
|
||||
}
|
||||
}
|
||||
|
||||
// Move the single correct option to `targetIndex`, preserving distractor order.
|
||||
function relocateCorrect(options: BalanceableOption[], targetIndex: number): BalanceableOption[] {
|
||||
const correctIndex = options.findIndex((option) => option.isCorrect);
|
||||
const correct = options[correctIndex];
|
||||
const rest = options.filter((_, i) => i !== correctIndex);
|
||||
rest.splice(targetIndex, 0, correct);
|
||||
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function balanceCorrectAnswerPositions<Q extends WithOptions>(questions: Q[]): Q[] {
|
||||
if (questions.length === 0) return questions;
|
||||
|
||||
const seedSource = questions.map((question) => question.options.map((option) => option.label).join('|')).join('||');
|
||||
const random = mulberry32(hashSeed(seedSource));
|
||||
|
||||
// RADIO: assign balanced target positions per option-count group so the
|
||||
// correct index is spread evenly even when questions have different option
|
||||
// counts. Built before any mutation so consumption of `random` is stable.
|
||||
const radioTargets = new Map<number, number>();
|
||||
const radioGroups = new Map<number, number[]>();
|
||||
questions.forEach((question, index) => {
|
||||
if (question.questionTypeId !== RADIO_ID) return;
|
||||
if (!isShuffleSafe(question) || correctCount(question.options) !== 1) return;
|
||||
|
||||
const group = radioGroups.get(question.options.length) ?? [];
|
||||
group.push(index);
|
||||
radioGroups.set(question.options.length, group);
|
||||
});
|
||||
|
||||
for (const [optionCount, indexes] of radioGroups) {
|
||||
const targets = indexes.map((_, position) => position % optionCount);
|
||||
shuffleInPlace(targets, random);
|
||||
indexes.forEach((questionIndex, position) => radioTargets.set(questionIndex, targets[position]));
|
||||
}
|
||||
|
||||
return questions.map((question, index) => {
|
||||
if (question.questionTypeId === RADIO_ID && radioTargets.has(index)) {
|
||||
return { ...question, options: relocateCorrect(question.options, radioTargets.get(index)!) };
|
||||
}
|
||||
|
||||
if (question.questionTypeId === CHECKBOX_ID && isShuffleSafe(question)) {
|
||||
const correct = correctCount(question.options);
|
||||
if (correct > 0 && correct < question.options.length) {
|
||||
const shuffled = [...question.options];
|
||||
shuffleInPlace(shuffled, random);
|
||||
|
||||
return { ...question, options: shuffled };
|
||||
}
|
||||
}
|
||||
|
||||
return question;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { AppError } from '@cio/utils/errors';
|
||||
import { getDocumentText } from './document';
|
||||
import { redis } from '../../utils/redis/redis';
|
||||
import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent';
|
||||
import { z } from 'zod';
|
||||
import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant';
|
||||
|
||||
type ResourceOwnershipRow = {
|
||||
courseId: string | null;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
function buildResourceOwnershipError(params: {
|
||||
resourceType: 'Lesson' | 'Exercise' | 'Section';
|
||||
resourceId: string;
|
||||
courseId: string;
|
||||
resource?: ResourceOwnershipRow;
|
||||
}) {
|
||||
const { resourceType, resourceId, resource } = params;
|
||||
|
||||
if (!resource) {
|
||||
return new AppError(
|
||||
`${resourceType} ${resourceId} does not exist in this course. The ID may have been hallucinated — call get_course_structure to fetch real IDs and retry.`,
|
||||
'RESOURCE_NOT_IN_COURSE',
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const titleSuffix = resource.title ? ` (${resource.title})` : '';
|
||||
|
||||
return new AppError(
|
||||
`${resourceType} ${resourceId}${titleSuffix} belongs to a different course. Call get_course_structure to fetch IDs for the current course and retry.`,
|
||||
'RESOURCE_NOT_IN_COURSE',
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function assertValidUuid(
|
||||
resourceType: 'Lesson' | 'Exercise' | 'Section' | 'ExerciseSection',
|
||||
value: string
|
||||
): void {
|
||||
if (!UUID_REGEX.test(value)) {
|
||||
throw new AppError(
|
||||
`${resourceType} id "${value}" is not a valid UUID. Call get_course_structure to fetch real IDs and try again — never invent or guess UUIDs.`,
|
||||
'INVALID_RESOURCE_ID',
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyLessonBelongsToCourse(lessonId: string, courseId: string): Promise<void> {
|
||||
assertValidUuid('Lesson', lessonId);
|
||||
|
||||
const lesson = await getLessonCourseBinding(lessonId);
|
||||
|
||||
if (!lesson || lesson.courseId !== courseId) {
|
||||
throw buildResourceOwnershipError({
|
||||
resourceType: 'Lesson',
|
||||
resourceId: lessonId,
|
||||
courseId,
|
||||
resource: lesson
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyExerciseBelongsToCourse(exerciseId: string, courseId: string): Promise<void> {
|
||||
assertValidUuid('Exercise', exerciseId);
|
||||
|
||||
const exercise = await getExerciseCourseBinding(exerciseId);
|
||||
|
||||
if (!exercise || exercise.courseId !== courseId) {
|
||||
throw buildResourceOwnershipError({
|
||||
resourceType: 'Exercise',
|
||||
resourceId: exerciseId,
|
||||
courseId,
|
||||
resource: exercise
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySectionBelongsToCourse(sectionId: string, courseId: string): Promise<void> {
|
||||
assertValidUuid('Section', sectionId);
|
||||
|
||||
const section = await getCourseSectionBinding(sectionId);
|
||||
|
||||
if (!section || section.courseId !== courseId) {
|
||||
throw buildResourceOwnershipError({
|
||||
resourceType: 'Section',
|
||||
resourceId: sectionId,
|
||||
courseId,
|
||||
resource: section
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type AttachedMessage = { metadata?: { attachment?: { documentId?: string } } };
|
||||
|
||||
export function collectDocumentIds(messages: unknown[], currentDocumentId?: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const msg of messages as AttachedMessage[]) {
|
||||
const id = msg?.metadata?.attachment?.documentId;
|
||||
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
|
||||
if (currentDocumentId) ids.add(currentDocumentId);
|
||||
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
export async function loadDocumentsText(documentIds: string[], userId: string): Promise<string | undefined> {
|
||||
const loaded = await Promise.all(
|
||||
documentIds.map(async (id) => {
|
||||
const text = await getDocumentText(id, userId, redis);
|
||||
|
||||
return text ? { id, text } : null;
|
||||
})
|
||||
);
|
||||
|
||||
const sections = loaded
|
||||
.filter((d): d is { id: string; text: string } => d !== null)
|
||||
.map((d, i) => `--- Document ${i + 1} (id: ${d.id}) ---\n${d.text}`);
|
||||
|
||||
return sections.length > 0 ? sections.join('\n\n') : undefined;
|
||||
}
|
||||
|
||||
type PlanMetadataMessage = {
|
||||
role?: string;
|
||||
metadata?: {
|
||||
plan?: {
|
||||
action?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanFieldsSchema> | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index] as PlanMetadataMessage;
|
||||
|
||||
if (message?.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message?.metadata?.plan?.action !== 'implement_course_plan') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedPlan = CoursePlanFieldsSchema.safeParse(message.metadata.plan.payload);
|
||||
|
||||
if (parsedPlan.success) {
|
||||
return parsedPlan.data;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const COURSE_TEMPLATE_ID_SET = new Set<CourseTemplateId>(['product_101', 'product_onboarding', 'expert_on_x']);
|
||||
|
||||
export function getActiveCourseTemplateId(messages: unknown[]): CourseTemplateId | undefined {
|
||||
for (const message of messages) {
|
||||
const candidate = message as {
|
||||
role?: string;
|
||||
metadata?: { template?: { id?: string } };
|
||||
};
|
||||
|
||||
if (candidate.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = candidate.metadata?.template?.id;
|
||||
|
||||
if (id && COURSE_TEMPLATE_ID_SET.has(id as CourseTemplateId)) {
|
||||
return id as CourseTemplateId;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
const MAX_MESSAGES = 20;
|
||||
const KEEP_RECENT = 16;
|
||||
const MAX_TOOL_OUTPUT_CHARS = 2000;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function trimToolOutputs(messages: any[]): any[] {
|
||||
return messages.map((message) => {
|
||||
if (!Array.isArray(message.parts)) return message;
|
||||
|
||||
const trimmedParts = message.parts.map((part: any) => {
|
||||
if (part.state !== 'output-available' || part.output == null) return part;
|
||||
|
||||
const serialized = typeof part.output === 'string' ? part.output : JSON.stringify(part.output);
|
||||
if (serialized.length <= MAX_TOOL_OUTPUT_CHARS) return part;
|
||||
|
||||
const truncated = serialized.slice(0, MAX_TOOL_OUTPUT_CHARS) + '…[truncated]';
|
||||
return { ...part, output: truncated };
|
||||
});
|
||||
|
||||
if (trimmedParts === message.parts) return message;
|
||||
|
||||
return { ...message, parts: trimmedParts };
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function trimMessageHistory(messages: any[]): any[] {
|
||||
const countTrimmed = messages.length <= MAX_MESSAGES ? messages : [messages[0], ...messages.slice(-KEEP_RECENT)];
|
||||
|
||||
return trimToolOutputs(countTrimmed);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { hostname } from 'node:os';
|
||||
|
||||
import { convertToModelMessages, generateText, stepCountIs } from 'ai';
|
||||
import {
|
||||
createAgentRunEvent,
|
||||
getAgentRunById,
|
||||
getChatConversation,
|
||||
listAgentRunSteps,
|
||||
updateAgentRun
|
||||
} from '@cio/db/queries/agent';
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { AgentRole, AIProvider, MAX_STEPS_PER_ROUND, getCourseTemplate, type AgentContext } from '@cio/ai-assistant';
|
||||
import { createModel, getProviderConfigForProvider } from '@cio/ai-assistant/providers';
|
||||
import { buildContextMessage, buildSystemPrompt } from '@cio/ai-assistant/prompt';
|
||||
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
|
||||
import { AppError } from '@cio/utils/errors';
|
||||
import { isOrgOnPaidPlan, recordTokenUsage } from './usage';
|
||||
import {
|
||||
collectDocumentIds,
|
||||
getActiveCourseTemplateId,
|
||||
getLatestImplementationPlan,
|
||||
loadDocumentsText
|
||||
} from './chat-context';
|
||||
import { buildAgentTools } from './chat-tools';
|
||||
import { buildModelContextMessages } from './model-context';
|
||||
import { sanitizeDanglingToolCalls } from './sanitize-tool-calls';
|
||||
import { listCourseSections } from '../course/section';
|
||||
|
||||
const MAX_WORKER_ROUNDS = 24;
|
||||
const CONTINUE_IMPLEMENTATION_PROMPT =
|
||||
'Continue implementing the approved course plan from the durable run checkpoint. Re-read the current course structure before creating anything that might already exist.';
|
||||
|
||||
export interface RunAgentCourseGenerationJobInput {
|
||||
runId: string;
|
||||
bullmqJobId?: string | null;
|
||||
workerId?: string;
|
||||
}
|
||||
|
||||
function getWorkerId(inputWorkerId?: string): string {
|
||||
return inputWorkerId ?? `agent-course-generation:${hostname()}:${process.pid}`;
|
||||
}
|
||||
|
||||
function getErrorPayload(error: unknown): { code: string; message: string; details?: unknown } {
|
||||
if (error instanceof AppError) {
|
||||
return { code: error.code, message: error.message };
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return { code: 'AGENT_RUN_FAILED', message: error.message, details: { stack: error.stack } };
|
||||
}
|
||||
|
||||
return { code: 'AGENT_RUN_FAILED', message: String(error) };
|
||||
}
|
||||
|
||||
function isAgentModelId(value: unknown): value is AgentModelId {
|
||||
return typeof value === 'string' && value in AGENT_MODELS;
|
||||
}
|
||||
|
||||
function getRunModelId(executionCursor: Record<string, unknown>): AgentModelId {
|
||||
const modelId = executionCursor.modelId;
|
||||
|
||||
return isAgentModelId(modelId) ? modelId : DEFAULT_PICKER_MODEL_ID;
|
||||
}
|
||||
|
||||
function buildRunStateMessage(input: {
|
||||
phase: string;
|
||||
executionCursor: Record<string, unknown>;
|
||||
completedStepCount: number;
|
||||
failedStepCount: number;
|
||||
}): string {
|
||||
return [
|
||||
'Durable course-generation run state:',
|
||||
`- phase: ${input.phase}`,
|
||||
`- execution cursor: ${JSON.stringify(input.executionCursor)}`,
|
||||
`- completed checkpointed tool calls: ${input.completedStepCount}`,
|
||||
`- failed checkpointed tool calls: ${input.failedStepCount}`,
|
||||
'- On retry, never recreate content that is already present in the current course structure or already checkpointed as completed.',
|
||||
'- Before creating sections, lessons, exercises, or questions, call get_course_structure or the relevant read tool and continue from the last missing item.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function assertRunCanContinue(runId: string): Promise<NonNullable<Awaited<ReturnType<typeof getAgentRunById>>>> {
|
||||
const run = await getAgentRunById(runId);
|
||||
|
||||
if (!run) {
|
||||
throw new AppError('Agent run not found', 'AGENT_RUN_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
if (run.status === 'completed') {
|
||||
throw new AppError('Agent run is already completed', 'AGENT_RUN_ALREADY_COMPLETED', 409);
|
||||
}
|
||||
|
||||
if (run.status === 'canceled' || run.cancelRequestedAt) {
|
||||
throw new AppError('Agent run was canceled', 'AGENT_RUN_CANCELED', 409);
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function runAgentCourseGenerationJob(input: RunAgentCourseGenerationJobInput): Promise<{
|
||||
runId: string;
|
||||
status: 'completed' | 'paused' | 'canceled';
|
||||
rounds: number;
|
||||
}> {
|
||||
const workerId = getWorkerId(input.workerId);
|
||||
const initialRun = await assertRunCanContinue(input.runId);
|
||||
|
||||
const courseOrgId = await getCourseOrganizationId(initialRun.courseId);
|
||||
|
||||
if (courseOrgId !== initialRun.orgId) {
|
||||
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
|
||||
}
|
||||
|
||||
const [course] = await getCourseById(initialRun.courseId);
|
||||
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
const startedAt = initialRun.startedAt ?? new Date().toISOString();
|
||||
|
||||
await updateAgentRun({
|
||||
runId: initialRun.id,
|
||||
userId: initialRun.userId,
|
||||
status: 'running',
|
||||
phase: 'implementing',
|
||||
startedAt,
|
||||
finishedAt: null,
|
||||
workerId,
|
||||
lockedAt: new Date().toISOString(),
|
||||
lastError: null
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: initialRun.id,
|
||||
eventType: 'run.started',
|
||||
message: 'Agent course-generation worker started',
|
||||
payload: { bullmqJobId: input.bullmqJobId ?? null, workerId }
|
||||
});
|
||||
|
||||
try {
|
||||
const conversation = initialRun.conversationId
|
||||
? await getChatConversation(initialRun.conversationId, initialRun.userId)
|
||||
: null;
|
||||
const visibleMessages = (conversation?.messages ?? []) as Array<Record<string, unknown>>;
|
||||
const documentIds = collectDocumentIds(visibleMessages);
|
||||
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, initialRun.userId) : undefined;
|
||||
const existingSections = await listCourseSections(initialRun.courseId);
|
||||
const executionCursor = initialRun.executionCursor ?? {};
|
||||
const modelId = getRunModelId(executionCursor);
|
||||
const modelDescriptor = AGENT_MODELS[modelId];
|
||||
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
|
||||
|
||||
if (!baseProviderConfig) {
|
||||
throw new AppError(`AI assistant is not configured for model "${modelId}"`, 'AI_NOT_CONFIGURED', 503);
|
||||
}
|
||||
|
||||
const providerConfig = { ...baseProviderConfig, model: modelDescriptor.backendModelId };
|
||||
const agentContext: AgentContext = {
|
||||
orgId: initialRun.orgId,
|
||||
courseId: initialRun.courseId,
|
||||
courseTitle: course.title,
|
||||
courseDescription: course.description || undefined,
|
||||
userId: initialRun.userId,
|
||||
role: AgentRole.TEACHER,
|
||||
locale: 'en',
|
||||
documentText,
|
||||
existingSectionCount: existingSections.length
|
||||
};
|
||||
const approvedPlan = initialRun.approvedPlan ?? getLatestImplementationPlan(visibleMessages);
|
||||
const activeTemplateId = getActiveCourseTemplateId(visibleMessages);
|
||||
const activeTemplate = activeTemplateId ? getCourseTemplate(activeTemplateId) : undefined;
|
||||
const systemPrompt = buildSystemPrompt(agentContext);
|
||||
const contextMessageText = buildContextMessage(agentContext, {
|
||||
template: activeTemplate,
|
||||
approvedPlan
|
||||
});
|
||||
const steps = await listAgentRunSteps(initialRun.id);
|
||||
const runStateText = buildRunStateMessage({
|
||||
phase: initialRun.phase,
|
||||
executionCursor,
|
||||
completedStepCount: steps.filter((step) => step.status === 'completed').length,
|
||||
failedStepCount: steps.filter((step) => step.status === 'failed').length
|
||||
});
|
||||
|
||||
const contextManaged = await buildModelContextMessages({
|
||||
conversationId: initialRun.conversationId ?? undefined,
|
||||
courseId: initialRun.courseId,
|
||||
userId: initialRun.userId,
|
||||
messages: visibleMessages as any
|
||||
});
|
||||
const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(contextManaged.messages as any));
|
||||
const modelMessages: any[] = [
|
||||
{ role: 'user' as const, content: [{ type: 'text' as const, text: runStateText }] },
|
||||
...(contextMessageText.length > 0
|
||||
? [{ role: 'user' as const, content: [{ type: 'text' as const, text: contextMessageText }] }]
|
||||
: []),
|
||||
...convertedMessages
|
||||
];
|
||||
|
||||
modelMessages.push({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: CONTINUE_IMPLEMENTATION_PROMPT }]
|
||||
});
|
||||
|
||||
const model = createModel(providerConfig);
|
||||
const isOrgPaid = await isOrgOnPaidPlan(initialRun.orgId);
|
||||
const agentTools = buildAgentTools(initialRun.orgId, initialRun.userId, initialRun.courseId, visibleMessages, {
|
||||
runId: initialRun.id,
|
||||
isOrgOnPaidPlan: isOrgPaid
|
||||
});
|
||||
|
||||
for (let roundIndex = 0; roundIndex < MAX_WORKER_ROUNDS; roundIndex += 1) {
|
||||
const latestRun = await assertRunCanContinue(initialRun.id);
|
||||
|
||||
if (latestRun.queuedInstructions.length > 0) {
|
||||
const instructionText = latestRun.queuedInstructions.map((instruction) => `- ${instruction.text}`).join('\n');
|
||||
|
||||
modelMessages.push({
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: `Teacher instructions queued while this run was active:\n${instructionText}` }
|
||||
]
|
||||
});
|
||||
|
||||
await updateAgentRun({
|
||||
runId: latestRun.id,
|
||||
userId: latestRun.userId,
|
||||
queuedInstructions: []
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: latestRun.id,
|
||||
eventType: 'instruction.applied',
|
||||
message: 'Queued teacher instructions applied',
|
||||
payload: { count: latestRun.queuedInstructions.length }
|
||||
});
|
||||
}
|
||||
|
||||
await updateAgentRun({
|
||||
runId: initialRun.id,
|
||||
userId: initialRun.userId,
|
||||
status: 'running',
|
||||
phase: 'implementing',
|
||||
progressPercent: Math.min(95, Math.round((roundIndex / MAX_WORKER_ROUNDS) * 100)),
|
||||
lockedAt: new Date().toISOString(),
|
||||
executionCursor: {
|
||||
...latestRun.executionCursor,
|
||||
modelId,
|
||||
workerRound: roundIndex,
|
||||
bullmqJobId: input.bullmqJobId ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
maxRetries: 2,
|
||||
system: systemPrompt,
|
||||
messages: modelMessages,
|
||||
tools: agentTools,
|
||||
stopWhen: stepCountIs(MAX_STEPS_PER_ROUND)
|
||||
});
|
||||
|
||||
if (result.usage) {
|
||||
await recordTokenUsage(
|
||||
initialRun.orgId,
|
||||
initialRun.userId,
|
||||
initialRun.courseId,
|
||||
{
|
||||
promptTokens: result.usage.inputTokens ?? 0,
|
||||
completionTokens: result.usage.outputTokens ?? 0,
|
||||
totalTokens: (result.usage.inputTokens ?? 0) + (result.usage.outputTokens ?? 0)
|
||||
},
|
||||
providerConfig.model || providerConfig.provider
|
||||
);
|
||||
}
|
||||
|
||||
modelMessages.push(...result.response.messages);
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: initialRun.id,
|
||||
eventType: 'round.completed',
|
||||
message: 'Agent generation round completed',
|
||||
payload: {
|
||||
round: roundIndex + 1,
|
||||
finishReason: result.finishReason,
|
||||
stepCount: result.steps.length,
|
||||
textPreview: result.text.slice(0, 500)
|
||||
}
|
||||
});
|
||||
|
||||
if (result.steps.length < MAX_STEPS_PER_ROUND) {
|
||||
await updateAgentRun({
|
||||
runId: initialRun.id,
|
||||
userId: initialRun.userId,
|
||||
status: 'completed',
|
||||
phase: 'completed',
|
||||
progressPercent: 100,
|
||||
currentStepKey: null,
|
||||
finishedAt: new Date().toISOString(),
|
||||
workerId: null,
|
||||
lockedAt: null,
|
||||
executionCursor: {
|
||||
...latestRun.executionCursor,
|
||||
modelId,
|
||||
workerRound: roundIndex + 1,
|
||||
finishReason: result.finishReason,
|
||||
finalText: result.text.slice(0, 4_000)
|
||||
}
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: initialRun.id,
|
||||
eventType: 'run.completed',
|
||||
message: 'Agent course-generation run completed',
|
||||
payload: { rounds: roundIndex + 1 }
|
||||
});
|
||||
|
||||
return { runId: initialRun.id, status: 'completed', rounds: roundIndex + 1 };
|
||||
}
|
||||
|
||||
modelMessages.push({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: CONTINUE_IMPLEMENTATION_PROMPT }]
|
||||
});
|
||||
}
|
||||
|
||||
await updateAgentRun({
|
||||
runId: initialRun.id,
|
||||
userId: initialRun.userId,
|
||||
status: 'paused',
|
||||
phase: 'paused',
|
||||
currentStepKey: null,
|
||||
workerId: null,
|
||||
lockedAt: null,
|
||||
executionCursor: {
|
||||
...initialRun.executionCursor,
|
||||
modelId,
|
||||
pauseReason: 'max_worker_rounds_reached'
|
||||
}
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: initialRun.id,
|
||||
eventType: 'run.paused',
|
||||
message: 'Run paused after reaching the worker round limit',
|
||||
payload: { maxWorkerRounds: MAX_WORKER_ROUNDS }
|
||||
});
|
||||
|
||||
return { runId: initialRun.id, status: 'paused', rounds: MAX_WORKER_ROUNDS };
|
||||
} catch (error) {
|
||||
const errorPayload = getErrorPayload(error);
|
||||
|
||||
if (errorPayload.code === 'AGENT_RUN_CANCELED') {
|
||||
await updateAgentRun({
|
||||
runId: initialRun.id,
|
||||
userId: initialRun.userId,
|
||||
status: 'canceled',
|
||||
phase: 'canceled',
|
||||
currentStepKey: null,
|
||||
workerId: null,
|
||||
lockedAt: null,
|
||||
finishedAt: new Date().toISOString(),
|
||||
lastError: null
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: initialRun.id,
|
||||
eventType: 'run.canceled',
|
||||
message: 'Run canceled before the next checkpoint',
|
||||
payload: {}
|
||||
});
|
||||
|
||||
return { runId: initialRun.id, status: 'canceled', rounds: 0 };
|
||||
}
|
||||
|
||||
await updateAgentRun({
|
||||
runId: initialRun.id,
|
||||
userId: initialRun.userId,
|
||||
status: 'failed',
|
||||
phase: 'failed',
|
||||
currentStepKey: null,
|
||||
workerId: null,
|
||||
lockedAt: null,
|
||||
finishedAt: new Date().toISOString(),
|
||||
lastError: errorPayload
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: initialRun.id,
|
||||
eventType: 'run.failed',
|
||||
message: 'Agent course-generation run failed',
|
||||
payload: { error: errorPayload }
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { AppError } from '@cio/utils/errors';
|
||||
import {
|
||||
MAX_DOCUMENT_TEXT_LENGTH,
|
||||
MAX_AGENT_DOCUMENT_SIZE,
|
||||
DOCUMENT_REDIS_TTL,
|
||||
SUPPORTED_DOCUMENT_TYPES
|
||||
} from '@cio/ai-assistant';
|
||||
import type { DocumentUploadResult } from '@cio/ai-assistant';
|
||||
import { agentDocumentKey } from '../../utils/redis-keys';
|
||||
import { trackAgentEvent, AgentEvent } from '../../utils/tinybird';
|
||||
import type { RedisClient } from '../../utils/redis/redis';
|
||||
import { createChatDocument, getChatDocument } from '@cio/db/queries/agent';
|
||||
import { generateFileKey } from '../../utils/upload';
|
||||
import { uploadToS3 } from '../../utils/s3';
|
||||
import { getStorageConfig } from '../../config/storage';
|
||||
import { createAssetFromUploadService } from '../assets/assets';
|
||||
|
||||
/**
|
||||
* Parse an uploaded document, store extracted text in Redis (hot cache) and Postgres
|
||||
* (durable, scoped to a conversation). Supports PDF, DOCX, and PPTX files.
|
||||
*/
|
||||
export async function parseAndStoreDocument(
|
||||
file: File,
|
||||
orgId: string,
|
||||
userId: string,
|
||||
courseId: string,
|
||||
conversationId: string,
|
||||
redis: RedisClient
|
||||
): Promise<DocumentUploadResult> {
|
||||
const mimeType = file.type;
|
||||
|
||||
if (!SUPPORTED_DOCUMENT_TYPES.includes(mimeType as (typeof SUPPORTED_DOCUMENT_TYPES)[number])) {
|
||||
throw new AppError('Unsupported file type. Allowed: PDF, DOCX, PPTX', 'UNSUPPORTED_FILE_TYPE', 415);
|
||||
}
|
||||
|
||||
if (file.size > MAX_AGENT_DOCUMENT_SIZE) {
|
||||
throw new AppError(
|
||||
`File too large. Maximum size is ${MAX_AGENT_DOCUMENT_SIZE / (1024 * 1024)}MB`,
|
||||
'FILE_TOO_LARGE',
|
||||
413
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
let extractedText: string;
|
||||
let pageCount: number | null = null;
|
||||
|
||||
switch (mimeType) {
|
||||
case 'application/pdf':
|
||||
({ text: extractedText, pageCount } = await extractPdfText(buffer));
|
||||
break;
|
||||
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
||||
extractedText = await extractDocxText(buffer);
|
||||
break;
|
||||
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
||||
({ text: extractedText, pageCount } = await extractPptxText(buffer));
|
||||
break;
|
||||
default:
|
||||
throw new AppError('Unsupported file type', 'UNSUPPORTED_FILE_TYPE', 415);
|
||||
}
|
||||
|
||||
const truncated = extractedText.length > MAX_DOCUMENT_TEXT_LENGTH;
|
||||
|
||||
if (truncated) {
|
||||
extractedText = extractedText.slice(0, MAX_DOCUMENT_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
const wordCount = extractedText.split(/\s+/).filter(Boolean).length;
|
||||
const textPreview = extractedText.slice(0, 200);
|
||||
const documentId = nanoid();
|
||||
|
||||
// Persist the original file to S3 and register it as an asset so it shows
|
||||
// up in the org's asset manager — same pipeline lessons use.
|
||||
const storageKey = generateFileKey(file.name);
|
||||
const storageConfig = getStorageConfig();
|
||||
const uploadResult = await uploadToS3({
|
||||
Bucket: storageConfig.bucketDocuments,
|
||||
Key: storageKey,
|
||||
Body: buffer,
|
||||
ContentType: mimeType
|
||||
});
|
||||
|
||||
if (!uploadResult.success) {
|
||||
throw new AppError(
|
||||
`Failed to upload document to storage: ${uploadResult.error ?? 'unknown error'}`,
|
||||
'DOCUMENT_STORAGE_FAILED',
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
const asset = await createAssetFromUploadService(orgId, userId, {
|
||||
kind: 'document',
|
||||
provider: 'upload',
|
||||
storageProvider: 's3',
|
||||
storageKey,
|
||||
byteSize: file.size,
|
||||
mimeType,
|
||||
title: file.name,
|
||||
isExternal: false,
|
||||
metadata: { source: 'ai_chat', conversationId }
|
||||
});
|
||||
|
||||
await redis.set(
|
||||
agentDocumentKey(documentId),
|
||||
JSON.stringify({
|
||||
text: extractedText,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
userId,
|
||||
uploadedAt: new Date().toISOString()
|
||||
}),
|
||||
{ EX: DOCUMENT_REDIS_TTL }
|
||||
);
|
||||
|
||||
await createChatDocument({
|
||||
id: documentId,
|
||||
conversationId,
|
||||
courseId,
|
||||
userId,
|
||||
assetId: asset.id,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
text: extractedText,
|
||||
wordCount,
|
||||
pageCount
|
||||
});
|
||||
|
||||
trackAgentEvent(AgentEvent.DOCUMENT_UPLOADED, {
|
||||
orgId,
|
||||
userId,
|
||||
courseId,
|
||||
mimeType,
|
||||
fileSize: file.size,
|
||||
wordCount,
|
||||
truncated
|
||||
});
|
||||
|
||||
return {
|
||||
documentId,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
pageCount,
|
||||
wordCount,
|
||||
textPreview,
|
||||
truncated
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve stored document text. Tries the Redis hot cache first; on miss
|
||||
* falls back to Postgres and rehydrates Redis for next time.
|
||||
*/
|
||||
export async function getDocumentText(documentId: string, userId: string, redis: RedisClient): Promise<string | null> {
|
||||
const raw = await redis.get(agentDocumentKey(documentId));
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { text: string; userId?: string };
|
||||
|
||||
if (parsed.userId && parsed.userId !== userId) return null;
|
||||
|
||||
return parsed.text;
|
||||
}
|
||||
|
||||
const record = await getChatDocument(documentId, userId);
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
await redis.set(
|
||||
agentDocumentKey(documentId),
|
||||
JSON.stringify({
|
||||
text: record.text,
|
||||
fileName: record.fileName,
|
||||
mimeType: record.mimeType,
|
||||
userId: record.userId,
|
||||
uploadedAt: record.createdAt
|
||||
}),
|
||||
{ EX: DOCUMENT_REDIS_TTL }
|
||||
);
|
||||
|
||||
return record.text;
|
||||
}
|
||||
|
||||
// ─── Extraction Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function extractPdfText(buffer: Buffer): Promise<{ text: string; pageCount: number }> {
|
||||
const pdfParse = (await import('pdf-parse')).default;
|
||||
const result = await pdfParse(buffer);
|
||||
|
||||
return { text: result.text, pageCount: result.numpages };
|
||||
}
|
||||
|
||||
async function extractDocxText(buffer: Buffer): Promise<string> {
|
||||
const mammoth = await import('mammoth');
|
||||
const result = await mammoth.extractRawText({ buffer });
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async function extractPptxText(buffer: Buffer): Promise<{ text: string; pageCount: number }> {
|
||||
// pptx-parser may not be available yet — use a simpler approach
|
||||
// For now, try to use officegen or a basic XML extraction
|
||||
try {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const slideTexts: string[] = [];
|
||||
const slideFiles = Object.keys(zip.files)
|
||||
.filter((name) => name.match(/^ppt\/slides\/slide\d+\.xml$/))
|
||||
.sort();
|
||||
|
||||
for (const slideFile of slideFiles) {
|
||||
const content = await zip.files[slideFile].async('text');
|
||||
// Extract text from XML by stripping tags
|
||||
const textContent = content
|
||||
.replace(/<a:t[^>]*>/g, '')
|
||||
.replace(/<\/a:t>/g, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
if (textContent) {
|
||||
slideTexts.push(textContent);
|
||||
}
|
||||
}
|
||||
|
||||
return { text: slideTexts.join('\n\n---\n\n'), pageCount: slideFiles.length };
|
||||
} catch {
|
||||
throw new AppError('Failed to parse PPTX file', 'PPTX_PARSE_ERROR', 422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isIPv4, isIPv6 } from 'node:net';
|
||||
import { AppError } from '@cio/utils/errors';
|
||||
import { env } from '../../config/env';
|
||||
import { redis, logRedisUnavailableOnce } from '../../utils/redis/redis';
|
||||
import { isOrgOnPaidPlan } from './usage';
|
||||
import { ErrorCodes } from '@cio/utils/constants/error-codes';
|
||||
|
||||
const JINA_READER_PREFIX = 'https://r.jina.ai/';
|
||||
const MAX_MARKDOWN_BYTES = 150 * 1024;
|
||||
const CACHE_TTL_SEC = 7 * 24 * 3600;
|
||||
const QUOTA_TTL_SEC = 25 * 3600;
|
||||
|
||||
export type FetchDocumentationUrlResult = {
|
||||
url: string;
|
||||
pageTitle: string;
|
||||
content: string;
|
||||
links: string[];
|
||||
contentTokens: number;
|
||||
fetchedAt: string;
|
||||
cacheHit: boolean;
|
||||
};
|
||||
|
||||
type CachedFetchPayload = {
|
||||
url: string;
|
||||
pageTitle: string;
|
||||
rawMarkdown: string;
|
||||
links: string[];
|
||||
fetchedAt: string;
|
||||
};
|
||||
|
||||
function isForbiddenHostname(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase();
|
||||
|
||||
if (host === 'localhost' || host.endsWith('.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isIPv4(host) || isIPv6(host)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function assertFetchableDocumentationUrl(rawUrl: string): URL {
|
||||
let parsed: URL;
|
||||
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new AppError('Invalid documentation URL', 'INVALID_DOCUMENTATION_URL', 400);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new AppError('Documentation URL must use http or https', 'INVALID_DOCUMENTATION_URL', 400);
|
||||
}
|
||||
|
||||
if (isForbiddenHostname(parsed.hostname)) {
|
||||
throw new AppError('Documentation URL hostname is not allowed', 'INVALID_DOCUMENTATION_URL', 400);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function extractSameOriginLinks(markdown: string, origin: URL): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
const re = /\[[^\]]*\]\((https?:[^)\s]+)\)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = re.exec(markdown)) !== null) {
|
||||
try {
|
||||
const linkUrl = new URL(match[1]);
|
||||
if (linkUrl.protocol !== 'http:' && linkUrl.protocol !== 'https:') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (linkUrl.hostname !== origin.hostname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = linkUrl.href;
|
||||
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
out.push(normalized);
|
||||
}
|
||||
} catch {
|
||||
// skip invalid URL in markdown
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function guessPageTitle(markdown: string, fallbackUrl: string): string {
|
||||
const trimmed = markdown.trim();
|
||||
const firstLine = trimmed.split('\n')[0] ?? '';
|
||||
|
||||
if (firstLine.startsWith('# ')) {
|
||||
return firstLine.slice(2).trim();
|
||||
}
|
||||
|
||||
if (firstLine.startsWith('#')) {
|
||||
return firstLine.replace(/^#+\s*/, '').trim();
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(fallbackUrl).hostname;
|
||||
} catch {
|
||||
return 'Documentation';
|
||||
}
|
||||
}
|
||||
|
||||
function truncateMarkdown(body: string): { text: string; truncated: boolean } {
|
||||
if (body.length <= MAX_MARKDOWN_BYTES) {
|
||||
return { text: body, truncated: false };
|
||||
}
|
||||
|
||||
const suffix = '\n\n[Truncated: content exceeded 150 KB]';
|
||||
|
||||
return {
|
||||
text: body.slice(0, MAX_MARKDOWN_BYTES - suffix.length) + suffix,
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
function wrapUntrustedMarkdown(url: string, markdown: string): string {
|
||||
const safeSrc = url.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
|
||||
return `<external_untrusted_document src="${safeSrc}">\n${markdown}\n</external_untrusted_document>`;
|
||||
}
|
||||
|
||||
export function countCompletedFetchDocumentationCalls(messages: unknown[]): number {
|
||||
let count = 0;
|
||||
|
||||
for (const msg of messages as { parts?: unknown[] }[]) {
|
||||
const parts = msg.parts;
|
||||
|
||||
if (!parts) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = part as Record<string, unknown>;
|
||||
const type = typeof record.type === 'string' ? record.type : '';
|
||||
let toolName: string | null = null;
|
||||
|
||||
if (type === 'tool-invocation') {
|
||||
toolName = (record.toolInvocation as { toolName?: string } | undefined)?.toolName ?? null;
|
||||
} else if (type.startsWith('tool-')) {
|
||||
toolName = type.slice(5);
|
||||
}
|
||||
|
||||
if (toolName !== 'fetch_documentation_url') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = record.state;
|
||||
|
||||
if (state === 'result' || state === 'output-available' || state === 'output-error') {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async function redisSafeGet(key: string): Promise<string | null> {
|
||||
try {
|
||||
if (!redis.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await redis.get(key);
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('fetch-url cache get failed', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function redisSafeSet(key: string, value: string, ttlSec: number): Promise<void> {
|
||||
try {
|
||||
if (!redis.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
await redis.set(key, value, { EX: ttlSec });
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('fetch-url cache set failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function incrementOrgDailyFetchQuota(orgId: string, maxPerDay: number): Promise<void> {
|
||||
const dayKey = new Date().toISOString().slice(0, 10);
|
||||
const key = `agent:fetch_url:quota:${orgId}:${dayKey}`;
|
||||
|
||||
try {
|
||||
if (!redis.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await redis.incr(key);
|
||||
|
||||
if (count === 1) {
|
||||
await redis.expire(key, QUOTA_TTL_SEC);
|
||||
}
|
||||
|
||||
if (count > maxPerDay) {
|
||||
throw new AppError(
|
||||
'Daily documentation fetch quota exceeded for this organization',
|
||||
'AGENT_FETCH_QUOTA_EXCEEDED',
|
||||
429
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logRedisUnavailableOnce('fetch-url quota redis failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMarkdownFromJina(url: string): Promise<{ markdown: string; status: number }> {
|
||||
const readerHref = `${JINA_READER_PREFIX}${url}`;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (env.JINA_API_KEY) {
|
||||
headers.Authorization = `Bearer ${env.JINA_API_KEY}`;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(readerHref, {
|
||||
headers,
|
||||
redirect: 'follow'
|
||||
});
|
||||
} catch {
|
||||
throw new AppError('Failed to reach documentation reader', 'DOCUMENTATION_FETCH_FAILED', 502);
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
const bytesHint = response.headers.get('content-length');
|
||||
|
||||
if (!response.ok) {
|
||||
console.info('[fetch_documentation_url]', { url, status, bytes: bytesHint });
|
||||
|
||||
throw new AppError(`Documentation fetch failed with status ${status}`, 'DOCUMENTATION_FETCH_FAILED', 502);
|
||||
}
|
||||
|
||||
const markdown = await response.text();
|
||||
|
||||
console.info('[fetch_documentation_url]', { url, status, bytes: String(markdown.length) });
|
||||
|
||||
return { markdown, status };
|
||||
}
|
||||
|
||||
export async function fetchDocumentationUrl(params: {
|
||||
url: string;
|
||||
orgId: string;
|
||||
courseId: string;
|
||||
priorMessages: unknown[];
|
||||
}): Promise<FetchDocumentationUrlResult> {
|
||||
const { url, orgId, courseId, priorMessages } = params;
|
||||
|
||||
const normalizedUrl = assertFetchableDocumentationUrl(url);
|
||||
const normalizedHref = normalizedUrl.href;
|
||||
|
||||
const priorFetches = countCompletedFetchDocumentationCalls(priorMessages);
|
||||
|
||||
if (priorFetches >= env.AGENT_MAX_FETCHES_PER_CONVERSATION) {
|
||||
throw new AppError('Maximum documentation fetches per conversation reached', 'AGENT_FETCH_CONVERSATION_LIMIT', 429);
|
||||
}
|
||||
|
||||
const paid = await isOrgOnPaidPlan(orgId);
|
||||
|
||||
if (!paid) {
|
||||
throw new AppError('Documentation fetching requires a paid plan', ErrorCodes.UPGRADE_REQUIRED, 403);
|
||||
}
|
||||
|
||||
const cacheKeyRaw = `${orgId}:${normalizedHref}`;
|
||||
const cacheKey = `agent:fetch_url:${createHash('sha256').update(cacheKeyRaw).digest('hex')}`;
|
||||
|
||||
const cachedRaw = await redisSafeGet(cacheKey);
|
||||
|
||||
if (cachedRaw) {
|
||||
try {
|
||||
const cached = JSON.parse(cachedRaw) as CachedFetchPayload;
|
||||
const truncated = truncateMarkdown(cached.rawMarkdown);
|
||||
|
||||
return {
|
||||
url: cached.url,
|
||||
pageTitle: cached.pageTitle,
|
||||
content: wrapUntrustedMarkdown(cached.url, truncated.text),
|
||||
links: cached.links,
|
||||
contentTokens: Math.ceil(truncated.text.length / 4),
|
||||
fetchedAt: cached.fetchedAt,
|
||||
cacheHit: true
|
||||
};
|
||||
} catch {
|
||||
// fall through to refetch
|
||||
}
|
||||
}
|
||||
|
||||
await incrementOrgDailyFetchQuota(orgId, env.AGENT_MAX_FETCHES_PER_ORG_PER_DAY);
|
||||
|
||||
const { markdown } = await fetchMarkdownFromJina(normalizedHref);
|
||||
const truncated = truncateMarkdown(markdown);
|
||||
const pageTitle = guessPageTitle(truncated.text, normalizedHref);
|
||||
const links = extractSameOriginLinks(truncated.text, normalizedUrl);
|
||||
const fetchedAt = new Date().toISOString();
|
||||
|
||||
const cachePayload: CachedFetchPayload = {
|
||||
url: normalizedHref,
|
||||
pageTitle,
|
||||
rawMarkdown: truncated.text,
|
||||
links,
|
||||
fetchedAt
|
||||
};
|
||||
|
||||
await redisSafeSet(cacheKey, JSON.stringify(cachePayload), CACHE_TTL_SEC);
|
||||
|
||||
void courseId;
|
||||
|
||||
return {
|
||||
url: normalizedHref,
|
||||
pageTitle,
|
||||
content: wrapUntrustedMarkdown(normalizedHref, truncated.text),
|
||||
links,
|
||||
contentTokens: Math.ceil(truncated.text.length / 4),
|
||||
fetchedAt,
|
||||
cacheHit: false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function stripLeadingLessonTitle(content: string, lessonTitle: string): string {
|
||||
const normalizedTitle = lessonTitle.trim();
|
||||
|
||||
if (!normalizedTitle) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const escapedTitle = escapeRegExp(normalizedTitle);
|
||||
const patterns = [
|
||||
new RegExp(`^\\s*<h[1-6][^>]*>\\s*${escapedTitle}\\s*</h[1-6]>\\s*`, 'i'),
|
||||
new RegExp(
|
||||
`^\\s*<p[^>]*>\\s*(?:<strong[^>]*>|<b[^>]*>)?\\s*${escapedTitle}\\s*(?:</strong>|</b>)?\\s*</p>\\s*`,
|
||||
'i'
|
||||
)
|
||||
];
|
||||
|
||||
let nextContent = content;
|
||||
|
||||
for (const pattern of patterns) {
|
||||
nextContent = nextContent.replace(pattern, '');
|
||||
}
|
||||
|
||||
return nextContent;
|
||||
}
|
||||
|
||||
function normalizeHeadingLevels(content: string): string {
|
||||
return content
|
||||
.replace(/<h1(\s|>)/gi, '<h3$1')
|
||||
.replace(/<\/h1>/gi, '</h3>')
|
||||
.replace(/<h2(\s|>)/gi, '<h3$1')
|
||||
.replace(/<\/h2>/gi, '</h3>');
|
||||
}
|
||||
|
||||
export function normalizeAgentLessonContent(content: string, lessonTitle: string): string {
|
||||
let normalizedContent = content.trim();
|
||||
|
||||
normalizedContent = stripLeadingLessonTitle(normalizedContent, lessonTitle);
|
||||
normalizedContent = normalizeHeadingLevels(normalizedContent);
|
||||
|
||||
return normalizedContent.trim();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { getChatModelContext, upsertChatModelContext } from '@cio/db/queries/agent';
|
||||
import { trimMessageHistory } from './context-window';
|
||||
import { summarizeConversationDelta, type SummarizeOptions } from './summarize';
|
||||
|
||||
const KEEP_RECENT_MESSAGES = 16;
|
||||
const MIN_MESSAGES_BEFORE_SUMMARY = KEEP_RECENT_MESSAGES + 4;
|
||||
|
||||
type AgentMessage = SummarizeOptions['messages'][number] & {
|
||||
id?: string;
|
||||
metadata?: Record<string, unknown> | null | undefined;
|
||||
};
|
||||
|
||||
export interface BuildModelContextMessagesOptions {
|
||||
conversationId?: string;
|
||||
courseId: string;
|
||||
userId: string;
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
export interface BuildModelContextMessagesResult {
|
||||
messages: AgentMessage[];
|
||||
summarized: boolean;
|
||||
compactedThroughMessageId?: string | null;
|
||||
}
|
||||
|
||||
function getMessageId(message: AgentMessage | undefined): string | null {
|
||||
return typeof message?.id === 'string' && message.id.length > 0 ? message.id : null;
|
||||
}
|
||||
|
||||
function buildSummarySeed(summary: string, compactedThroughMessageId?: string | null): AgentMessage {
|
||||
return {
|
||||
id: `model-context-summary-${randomUUID()}`,
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Conversation summary for continuity. Use this as prior context, then continue from the recent messages that follow.\n\n${summary}`
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
modelContextSummary: {
|
||||
compactedThroughMessageId: compactedThroughMessageId ?? null
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildModelContextMessages(
|
||||
options: BuildModelContextMessagesOptions
|
||||
): Promise<BuildModelContextMessagesResult> {
|
||||
const { conversationId, courseId, userId, messages } = options;
|
||||
|
||||
if (!conversationId || messages.length <= MIN_MESSAGES_BEFORE_SUMMARY) {
|
||||
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized: false };
|
||||
}
|
||||
|
||||
const existingContext = await getChatModelContext(conversationId, userId);
|
||||
const splitIndex = Math.max(0, messages.length - KEEP_RECENT_MESSAGES);
|
||||
const messagesToRepresent = messages.slice(0, splitIndex);
|
||||
const recentMessages = messages.slice(splitIndex);
|
||||
const compactedThroughMessageId = getMessageId(messagesToRepresent[messagesToRepresent.length - 1]);
|
||||
|
||||
if (messagesToRepresent.length === 0) {
|
||||
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized: false };
|
||||
}
|
||||
|
||||
let modelSummary = existingContext?.modelSummary ?? '';
|
||||
let summarized = false;
|
||||
|
||||
if (compactedThroughMessageId && existingContext?.compactedThroughMessageId !== compactedThroughMessageId) {
|
||||
const previousCompactedIndex = existingContext?.compactedThroughMessageId
|
||||
? messagesToRepresent.findIndex((message) => getMessageId(message) === existingContext.compactedThroughMessageId)
|
||||
: -1;
|
||||
const deltaStartIndex = previousCompactedIndex >= 0 ? previousCompactedIndex + 1 : 0;
|
||||
const deltaMessages = messagesToRepresent.slice(deltaStartIndex);
|
||||
|
||||
if (deltaMessages.length > 0) {
|
||||
modelSummary = await summarizeConversationDelta({
|
||||
existingSummary: modelSummary,
|
||||
messages: deltaMessages
|
||||
});
|
||||
summarized = true;
|
||||
}
|
||||
|
||||
await upsertChatModelContext({
|
||||
conversationId,
|
||||
courseId,
|
||||
userId,
|
||||
modelSummary,
|
||||
compactedThroughMessageId,
|
||||
sourceIds: existingContext?.sourceIds ?? []
|
||||
});
|
||||
}
|
||||
|
||||
if (!modelSummary.trim()) {
|
||||
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized };
|
||||
}
|
||||
|
||||
const contextManagedMessages = [buildSummarySeed(modelSummary, compactedThroughMessageId), ...recentMessages];
|
||||
|
||||
return {
|
||||
messages: trimMessageHistory(contextManagedMessages) as AgentMessage[],
|
||||
summarized,
|
||||
compactedThroughMessageId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { AppError } from '@cio/utils/errors';
|
||||
|
||||
export type CurrentQuestionForPatch = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
questionTypeId: number;
|
||||
points: number;
|
||||
order: number;
|
||||
exerciseSectionId?: string | null;
|
||||
settings?: Record<string, unknown>;
|
||||
options: Array<{
|
||||
id: number | string;
|
||||
label: string | null;
|
||||
isCorrect: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type QuestionPatch = {
|
||||
id: number;
|
||||
question?: string;
|
||||
questionTypeId?: number;
|
||||
points?: number;
|
||||
order?: number;
|
||||
exerciseSectionId?: string | null;
|
||||
settings?: Record<string, unknown>;
|
||||
options?: Array<{
|
||||
id?: number;
|
||||
label?: string;
|
||||
isCorrect?: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type MergedOption = {
|
||||
id?: number;
|
||||
label: string;
|
||||
isCorrect: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MergedQuestion = {
|
||||
id: number;
|
||||
question: string;
|
||||
questionTypeId: number;
|
||||
points: number;
|
||||
order: number;
|
||||
exerciseSectionId?: string | null;
|
||||
settings?: Record<string, unknown>;
|
||||
options: MergedOption[];
|
||||
};
|
||||
|
||||
export function buildUpdatedQuestions(
|
||||
currentQuestions: CurrentQuestionForPatch[],
|
||||
patches: QuestionPatch[],
|
||||
exerciseId: string
|
||||
): MergedQuestion[] {
|
||||
const byId = new Map(currentQuestions.map((q) => [Number(q.id), q] as const));
|
||||
|
||||
return patches.map((patch) => {
|
||||
const current = byId.get(patch.id);
|
||||
if (!current) {
|
||||
throw new AppError(
|
||||
`Question ${patch.id} does not belong to exercise ${exerciseId}`,
|
||||
'RESOURCE_NOT_IN_COURSE',
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const currentOptions: MergedOption[] = (current.options || []).map((o) => ({
|
||||
id: Number(o.id),
|
||||
label: o.label || '',
|
||||
isCorrect: o.isCorrect,
|
||||
settings: o.settings ?? undefined
|
||||
}));
|
||||
|
||||
const mergedSettings =
|
||||
patch.settings === undefined ? current.settings : { ...(current.settings ?? {}), ...patch.settings };
|
||||
|
||||
let mergedOptions: MergedOption[];
|
||||
if (patch.options === undefined) {
|
||||
mergedOptions = currentOptions;
|
||||
} else {
|
||||
mergedOptions = patch.options.map((opt) => {
|
||||
if (opt.id != null) {
|
||||
const existingOpt = currentOptions.find((co) => co.id === opt.id);
|
||||
return {
|
||||
id: opt.id,
|
||||
label: opt.label ?? existingOpt?.label ?? '',
|
||||
isCorrect: opt.isCorrect ?? existingOpt?.isCorrect ?? false,
|
||||
settings: opt.settings ?? existingOpt?.settings
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: opt.label ?? '',
|
||||
isCorrect: opt.isCorrect ?? false,
|
||||
settings: opt.settings
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const merged: MergedQuestion = {
|
||||
id: patch.id,
|
||||
question: patch.question ?? current.title ?? '',
|
||||
questionTypeId: patch.questionTypeId ?? current.questionTypeId,
|
||||
points: patch.points ?? current.points,
|
||||
order: patch.order ?? current.order,
|
||||
settings: mergedSettings,
|
||||
options: mergedOptions
|
||||
};
|
||||
|
||||
if (patch.exerciseSectionId !== undefined) {
|
||||
merged.exerciseSectionId = patch.exerciseSectionId;
|
||||
}
|
||||
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Anthropic strictly requires every `tool_use` block to be followed in the very
|
||||
* next message by a `tool_result` block with a matching id. OpenAI tolerates
|
||||
* mismatches; Anthropic 400s.
|
||||
*
|
||||
* The break-shapes this catches:
|
||||
* (a) `convertToModelMessages` splits an assistant UIMessage with parts
|
||||
* `[tool-call, text, text]` into TWO assistant ModelMessages plus a tool
|
||||
* message — the wedged second assistant message separates tool_use from
|
||||
* tool_result.
|
||||
* (b) `trimMessageHistory` slices the array between an assistant tool-call
|
||||
* turn and its tool-result message.
|
||||
* (c) A tool call was aborted mid-stream and persisted without a result.
|
||||
*
|
||||
* Strategy: walk the ModelMessage[] once. For every assistant message that has
|
||||
* tool-call parts, **immediately** emit a tool message containing matching
|
||||
* tool-result parts — pulled from anywhere later in the array, or synthesized
|
||||
* as an error result if missing entirely. Tool messages whose results have
|
||||
* already been claimed by an earlier assistant turn are dropped (or trimmed)
|
||||
* to avoid duplicates.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
type ToolCallPart = { type: 'tool-call' | 'dynamic-tool-call'; toolCallId: string; toolName: string };
|
||||
type ToolResultPart = {
|
||||
type: 'tool-result' | 'dynamic-tool-result';
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
output?: unknown;
|
||||
};
|
||||
|
||||
function isToolCallPart(part: unknown): part is ToolCallPart {
|
||||
const type = (part as { type?: unknown })?.type;
|
||||
return type === 'tool-call' || type === 'dynamic-tool-call';
|
||||
}
|
||||
|
||||
function isToolResultPart(part: unknown): part is ToolResultPart {
|
||||
const type = (part as { type?: unknown })?.type;
|
||||
return type === 'tool-result' || type === 'dynamic-tool-result';
|
||||
}
|
||||
|
||||
function syntheticErrorResult(tc: ToolCallPart): ToolResultPart {
|
||||
return {
|
||||
type: 'tool-result',
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
output: { type: 'error-text', value: 'Tool call was interrupted; no result captured.' }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic accepts assistant content in the canonical shape `[text*, tool_use*]`
|
||||
* (text/reasoning first, then tool_uses at the end). When the assistant content
|
||||
* has text AFTER tool_use blocks (`[tool_use, text, text]`), Anthropic's strict
|
||||
* validator can reject it with the same "tool_result missing immediately after"
|
||||
* error even though the next message IS a tool message — because it doesn't
|
||||
* recognise the assistant turn as ending on tool_use. Reorder so tool-calls
|
||||
* always come last within each assistant message.
|
||||
*/
|
||||
function reorderAssistantContent(content: any[]): any[] {
|
||||
const toolCalls: any[] = [];
|
||||
const nonToolCalls: any[] = [];
|
||||
|
||||
for (const part of content) {
|
||||
if (isToolCallPart(part)) {
|
||||
toolCalls.push(part);
|
||||
} else {
|
||||
nonToolCalls.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
// Only return a new array if we actually moved something.
|
||||
if (toolCalls.length === 0 || nonToolCalls.length === 0) return content;
|
||||
|
||||
return [...nonToolCalls, ...toolCalls];
|
||||
}
|
||||
|
||||
export function sanitizeDanglingToolCalls(messages: any[]): any[] {
|
||||
// Pass 1 — index every tool-result we can find anywhere in the conversation,
|
||||
// keyed by toolCallId. If duplicates exist, last one wins (most recent).
|
||||
const toolResultsByCallId = new Map<string, ToolResultPart>();
|
||||
for (const m of messages) {
|
||||
if (m?.role !== 'tool' || !Array.isArray(m.content)) continue;
|
||||
|
||||
for (const part of m.content) {
|
||||
if (isToolResultPart(part)) {
|
||||
toolResultsByCallId.set(part.toolCallId, part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — rebuild. After each assistant message with tool-calls, inject a
|
||||
// tool message whose results match those calls in the same order. Skip the
|
||||
// original tool messages whose parts we've already claimed. Also reorder
|
||||
// assistant content so tool-calls always sit at the END.
|
||||
const claimedResultIds = new Set<string>();
|
||||
const result: any[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i += 1) {
|
||||
const message = messages[i];
|
||||
|
||||
// Filter or drop tool messages whose results were already pulled forward.
|
||||
if (message?.role === 'tool' && Array.isArray(message.content)) {
|
||||
const remaining = message.content.filter(
|
||||
(part: unknown) => !isToolResultPart(part) || !claimedResultIds.has(part.toolCallId)
|
||||
);
|
||||
if (remaining.length > 0) {
|
||||
result.push({ ...message, content: remaining });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message?.role !== 'assistant' || !Array.isArray(message.content)) {
|
||||
result.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reorderedContent = reorderAssistantContent(message.content);
|
||||
const reorderedMessage = reorderedContent === message.content ? message : { ...message, content: reorderedContent };
|
||||
|
||||
result.push(reorderedMessage);
|
||||
|
||||
const toolCalls = reorderedContent.filter(isToolCallPart);
|
||||
if (toolCalls.length === 0) continue;
|
||||
|
||||
const resultParts: ToolResultPart[] = toolCalls.map((tc: ToolCallPart) => {
|
||||
claimedResultIds.add(tc.toolCallId);
|
||||
const existing = toolResultsByCallId.get(tc.toolCallId);
|
||||
|
||||
return existing ?? syntheticErrorResult(tc);
|
||||
});
|
||||
|
||||
result.push({ role: 'tool', content: resultParts });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { generateText } from 'ai';
|
||||
import { createModel, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
||||
import { AppError } from '@cio/utils/errors';
|
||||
|
||||
const SUMMARIZE_SYSTEM_PROMPT = `You are summarizing a multi-turn ClassroomIO course-creation chat so the assistant can pick up exactly where it left off in a new conversation.
|
||||
|
||||
Write the summary as a first-person user message to the assistant. It must be DETAILED — assume the receiving assistant has zero prior context.
|
||||
|
||||
Required structure (use these exact section headings, written in plain text without markdown):
|
||||
|
||||
1. Course context: course title, intended audience, primary outcomes, and the active template flow (Product 101, Product Onboarding, Expert on X, or other) if any.
|
||||
2. Template form answers: every answered field by key and value, including the chosen depth tier (light / balanced / deep_doc) and any documentation URL provided.
|
||||
3. Documentation fetched: list every URL that was fetched via fetch_documentation_url and a one-line note on what was learned from each (key product surface area, vocabulary, workflows). Do NOT skip this section if any docs were fetched.
|
||||
4. Plan status: whether a course plan has been generated, the plan title and section count, and whether it was approved, edited, or still pending changes.
|
||||
5. Implementation progress: which sections / lessons / exercises were already created (by title), and any tool errors hit.
|
||||
6. Open decisions: anything the teacher said they still wanted to do, change, or get an answer on.
|
||||
7. Continue by: an explicit next action for the assistant (e.g. "Fetch the remaining linked docs and then call generate_course_plan", or "Resume implementing from section 4").
|
||||
|
||||
Length target: at least 250 words; expand as needed to capture detail. Do not write a high-level paragraph; write the structured handoff above. Never abbreviate or omit sections — write "(none)" if a section is empty.`;
|
||||
|
||||
interface InboundMessagePart {
|
||||
type: string;
|
||||
text?: string;
|
||||
toolName?: string;
|
||||
state?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
interface InboundMessage {
|
||||
role: string;
|
||||
content?: string;
|
||||
parts?: InboundMessagePart[];
|
||||
metadata?: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
|
||||
export interface SummarizeOptions {
|
||||
messages: InboundMessage[];
|
||||
}
|
||||
|
||||
export interface SummarizeDeltaOptions extends SummarizeOptions {
|
||||
existingSummary?: string | null;
|
||||
}
|
||||
|
||||
function truncate(input: string, max: number): string {
|
||||
if (input.length <= max) return input;
|
||||
|
||||
return `${input.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
function stringifySafely(value: unknown): string {
|
||||
try {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
} catch {
|
||||
return '[unserializable]';
|
||||
}
|
||||
}
|
||||
|
||||
function partsToText(message: InboundMessage): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (message.content && message.content.trim().length > 0) {
|
||||
lines.push(message.content.trim());
|
||||
}
|
||||
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
lines.push(part.text.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type === 'tool-invocation' || part.type?.startsWith('tool-')) {
|
||||
const toolName = part.toolName ?? (part.type.startsWith('tool-') ? part.type.slice(5) : 'unknown_tool');
|
||||
|
||||
const inputText = part.input !== undefined ? truncate(stringifySafely(part.input), 600) : '';
|
||||
const outputText = part.output !== undefined ? truncate(stringifySafely(part.output), 1500) : '';
|
||||
|
||||
const segments: string[] = [`[tool:${toolName}]`];
|
||||
|
||||
if (inputText) {
|
||||
segments.push(`input=${inputText}`);
|
||||
}
|
||||
|
||||
if (outputText) {
|
||||
segments.push(`output=${outputText}`);
|
||||
}
|
||||
|
||||
lines.push(segments.join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
const metadataTemplate = message.metadata?.template;
|
||||
|
||||
if (metadataTemplate && typeof metadataTemplate === 'object') {
|
||||
lines.push(`[template-action] ${truncate(stringifySafely(metadataTemplate), 600)}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const MAX_CONVERSATION_CHARS = 60_000;
|
||||
const MAX_OUTPUT_TOKENS = 2_000;
|
||||
|
||||
export async function summarizeConversation(options: SummarizeOptions): Promise<string> {
|
||||
const providerConfig = pickAnyConfiguredProvider();
|
||||
|
||||
if (!providerConfig) {
|
||||
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
|
||||
}
|
||||
|
||||
const model = createModel(providerConfig);
|
||||
|
||||
const conversationLines: string[] = [];
|
||||
|
||||
for (const message of options.messages ?? []) {
|
||||
const role = message.role === 'user' ? 'User' : 'Assistant';
|
||||
const body = partsToText(message);
|
||||
|
||||
if (!body.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
conversationLines.push(`${role}: ${body}`);
|
||||
}
|
||||
|
||||
const conversationText = conversationLines.join('\n\n');
|
||||
|
||||
// Prefer keeping the END of the transcript when truncating — recent context is most
|
||||
// valuable for "continue from here" continuations.
|
||||
const truncatedText =
|
||||
conversationText.length > MAX_CONVERSATION_CHARS
|
||||
? conversationText.slice(conversationText.length - MAX_CONVERSATION_CHARS)
|
||||
: conversationText;
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
prompt: `Here is the conversation to summarize. Tool calls appear as [tool:name] input=… output=… lines.\n\n${truncatedText}\n\nReturn the structured handoff message described in the system prompt.`,
|
||||
maxOutputTokens: MAX_OUTPUT_TOKENS,
|
||||
maxRetries: 0
|
||||
});
|
||||
|
||||
return result.text.trim();
|
||||
}
|
||||
|
||||
export async function summarizeConversationDelta(options: SummarizeDeltaOptions): Promise<string> {
|
||||
if (!options.existingSummary?.trim()) {
|
||||
return summarizeConversation({ messages: options.messages });
|
||||
}
|
||||
|
||||
const providerConfig = pickAnyConfiguredProvider();
|
||||
|
||||
if (!providerConfig) {
|
||||
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
|
||||
}
|
||||
|
||||
const model = createModel(providerConfig);
|
||||
const conversationLines: string[] = [];
|
||||
|
||||
for (const message of options.messages ?? []) {
|
||||
const role = message.role === 'user' ? 'User' : 'Assistant';
|
||||
const body = partsToText(message);
|
||||
|
||||
if (!body.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
conversationLines.push(`${role}: ${body}`);
|
||||
}
|
||||
|
||||
const conversationText = conversationLines.join('\n\n');
|
||||
const truncatedText =
|
||||
conversationText.length > MAX_CONVERSATION_CHARS
|
||||
? conversationText.slice(conversationText.length - MAX_CONVERSATION_CHARS)
|
||||
: conversationText;
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
prompt: `Existing structured handoff summary:\n\n${options.existingSummary.trim()}\n\nNew conversation messages to merge into that summary:\n\n${truncatedText}\n\nReturn one updated structured handoff message using the required section headings. Preserve concrete plan status, template answers, fetched documentation URLs/source identifiers, created course items, open decisions, and the next action.`,
|
||||
maxOutputTokens: MAX_OUTPUT_TOKENS,
|
||||
maxRetries: 0
|
||||
});
|
||||
|
||||
return result.text.trim();
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { TokenBalance, TokenUsage } from '@cio/ai-assistant';
|
||||
import {
|
||||
aggregateTokenUsageByUser,
|
||||
countRequests,
|
||||
getDailyTokenUsageHistory,
|
||||
getMonthlyTokenUsage,
|
||||
getOrgCreditBalance,
|
||||
insertTokenUsageAndDrainCredits,
|
||||
summarizePurchases,
|
||||
upsertCreditBalance
|
||||
} from '@cio/db/queries/agent';
|
||||
import type { UsageLeaderboardRow } from '@cio/db/queries/agent';
|
||||
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
|
||||
|
||||
import { AppError } from '@cio/utils/errors';
|
||||
|
||||
function startOfCurrentMonth(): Date {
|
||||
const date = new Date();
|
||||
date.setDate(1);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
// ─── Plan-Based Token Allowances ─────────────────────────────────────────────
|
||||
|
||||
const PLAN_TOKEN_ALLOWANCES: Record<string, number> = {
|
||||
BASIC: 500_000,
|
||||
EARLY_ADOPTER: 3_000_000,
|
||||
ENTERPRISE: 15_000_000
|
||||
};
|
||||
|
||||
// ─── Model Cost Multipliers ───────────────────────────────────────────────────
|
||||
|
||||
// Blended multiplier vs Gemini 3.1 Flash Lite baseline ($0.50/1M blended at 80/20 input/output mix); unknown → 1×.
|
||||
// gemini-2.5-flash kept for backward compat with historical usage records.
|
||||
const MODEL_COST_MULTIPLIER: Record<string, number> = {
|
||||
'gemini-3.1-flash-lite': 1,
|
||||
'gemini-2.5-flash': 1.5,
|
||||
'gpt-5.4-mini': 4,
|
||||
'claude-sonnet-4-6': 11,
|
||||
'claude-haiku-4-5-20251001': 1.5,
|
||||
'kimi-k2.6': 4
|
||||
};
|
||||
|
||||
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number {
|
||||
const multiplier = MODEL_COST_MULTIPLIER[model] ?? 1;
|
||||
return Math.round((promptTokens + completionTokens) * multiplier);
|
||||
}
|
||||
|
||||
async function getPlanAllowance(orgId: string): Promise<{ planName: string; allowance: number }> {
|
||||
const activePlan = await getActiveOrganizationPlan(orgId);
|
||||
|
||||
if (!activePlan || !activePlan.planName) {
|
||||
return { planName: 'BASIC', allowance: PLAN_TOKEN_ALLOWANCES.BASIC };
|
||||
}
|
||||
|
||||
const planName = activePlan.planName;
|
||||
const payload = activePlan.payload as Record<string, unknown> | null;
|
||||
const customAllowance = payload?.aiTokenAllowance as number | undefined;
|
||||
const allowance = customAllowance ?? PLAN_TOKEN_ALLOWANCES[planName] ?? 0;
|
||||
|
||||
return { planName, allowance };
|
||||
}
|
||||
|
||||
export async function getMonthlyUsage(orgId: string): Promise<number> {
|
||||
return getMonthlyTokenUsage(orgId, startOfCurrentMonth());
|
||||
}
|
||||
|
||||
export async function getCreditBalance(orgId: string): Promise<number> {
|
||||
return getOrgCreditBalance(orgId);
|
||||
}
|
||||
|
||||
export async function getTokenBalance(orgId: string): Promise<TokenBalance> {
|
||||
const [{ allowance }, monthlyUsage, creditBalance] = await Promise.all([
|
||||
getPlanAllowance(orgId),
|
||||
getMonthlyUsage(orgId),
|
||||
getCreditBalance(orgId)
|
||||
]);
|
||||
|
||||
const remainingAllowance = Math.max(0, allowance - monthlyUsage);
|
||||
const remaining = remainingAllowance + creditBalance;
|
||||
|
||||
return {
|
||||
used: monthlyUsage,
|
||||
allowance,
|
||||
creditBalance,
|
||||
remaining
|
||||
};
|
||||
}
|
||||
|
||||
export async function enforceTokenBalance(orgId: string): Promise<TokenBalance> {
|
||||
const balance = await getTokenBalance(orgId);
|
||||
|
||||
if (balance.remaining <= 0) {
|
||||
throw new AppError('Token limit reached', 'TOKEN_LIMIT_REACHED', 402);
|
||||
}
|
||||
|
||||
return balance;
|
||||
}
|
||||
|
||||
export function computePurchasedTokenOverflow(params: {
|
||||
allowance: number;
|
||||
monthlyUsageBefore: number;
|
||||
requestTokens: number;
|
||||
}): number {
|
||||
const allowanceRemainingBefore = Math.max(0, params.allowance - params.monthlyUsageBefore);
|
||||
|
||||
return Math.max(0, params.requestTokens - allowanceRemainingBefore);
|
||||
}
|
||||
|
||||
/** Record token usage after an LLM call; drains purchased credits when usage exceeds plan allowance. */
|
||||
export async function recordTokenUsage(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
courseId: string,
|
||||
usage: TokenUsage,
|
||||
model: string
|
||||
): Promise<void> {
|
||||
const costUnits = computeCostUnits(usage.promptTokens, usage.completionTokens, model);
|
||||
const { allowance } = await getPlanAllowance(orgId);
|
||||
|
||||
await insertTokenUsageAndDrainCredits({
|
||||
orgId,
|
||||
userId,
|
||||
courseId,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
costUnits,
|
||||
model,
|
||||
planAllowance: allowance,
|
||||
since: startOfCurrentMonth()
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrgPlanName(orgId: string): Promise<string> {
|
||||
const { planName } = await getPlanAllowance(orgId);
|
||||
|
||||
return planName;
|
||||
}
|
||||
|
||||
export async function isOrgOnPaidPlan(orgId: string): Promise<boolean> {
|
||||
const planName = await getOrgPlanName(orgId);
|
||||
|
||||
return planName !== 'BASIC';
|
||||
}
|
||||
|
||||
// ─── Usage History ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DailyUsage {
|
||||
date: string;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
export async function getUsageHistory(orgId: string): Promise<DailyUsage[]> {
|
||||
return getDailyTokenUsageHistory(orgId, startOfCurrentMonth());
|
||||
}
|
||||
|
||||
export interface PurchasedSummary {
|
||||
totalPurchasedTokens: number;
|
||||
totalSpentCents: number;
|
||||
currency: 'USD';
|
||||
currentBalance: number;
|
||||
lastPurchaseAt: string | null;
|
||||
}
|
||||
|
||||
export async function getPurchasedSummary(orgId: string): Promise<PurchasedSummary> {
|
||||
const [summary, currentBalance] = await Promise.all([summarizePurchases(orgId), getCreditBalance(orgId)]);
|
||||
|
||||
return {
|
||||
totalPurchasedTokens: summary.totalPurchasedTokens,
|
||||
totalSpentCents: summary.totalSpentCents,
|
||||
currency: 'USD',
|
||||
currentBalance,
|
||||
lastPurchaseAt: summary.lastPurchaseAt
|
||||
};
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
userId: string;
|
||||
fullname: string | null;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
tokens: number;
|
||||
requests: number;
|
||||
favoriteModel: string | null;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export async function getTeamLeaderboard(orgId: string): Promise<{ entries: LeaderboardEntry[]; totalTokens: number }> {
|
||||
const rows: UsageLeaderboardRow[] = await aggregateTokenUsageByUser(orgId, startOfCurrentMonth());
|
||||
const totalTokens = rows.reduce((sum: number, row: UsageLeaderboardRow) => sum + row.tokens, 0);
|
||||
|
||||
const entries: LeaderboardEntry[] = rows.map((row: UsageLeaderboardRow) => ({
|
||||
userId: row.userId,
|
||||
fullname: row.fullname,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl,
|
||||
tokens: row.tokens,
|
||||
requests: row.requests,
|
||||
favoriteModel: row.favoriteModel,
|
||||
percentage: totalTokens > 0 ? row.tokens / totalTokens : 0
|
||||
}));
|
||||
|
||||
return { entries, totalTokens };
|
||||
}
|
||||
|
||||
export async function getDetailedUsage(orgId: string) {
|
||||
const start = startOfCurrentMonth();
|
||||
const [balance, history, requestsThisMonth] = await Promise.all([
|
||||
getTokenBalance(orgId),
|
||||
getDailyTokenUsageHistory(orgId, start),
|
||||
countRequests(orgId, start)
|
||||
]);
|
||||
|
||||
return { ...balance, history, requestsThisMonth };
|
||||
}
|
||||
|
||||
// ─── Credit Purchases ────────────────────────────────────────────────────────
|
||||
|
||||
export async function addCredits(orgId: string, amount: number): Promise<number> {
|
||||
if (amount <= 0) {
|
||||
throw new AppError('Credit amount must be positive', 'INVALID_CREDIT_AMOUNT', 400);
|
||||
}
|
||||
|
||||
return upsertCreditBalance(orgId, amount);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import type { DbOrTxClient } from '@cio/db/drizzle';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { applyCourseContentBulkUpdates, applyCourseSectionOrderUpdates } from '@cio/db/queries/course/content-batch';
|
||||
import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
|
||||
import { OperationalQueryError } from '@cio/db/queries/query-errors';
|
||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import { deleteLesson } from '@cio/db/queries/lesson';
|
||||
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
|
||||
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
type CourseContentReorderResult = {
|
||||
courseId: string;
|
||||
updatedSections: number;
|
||||
updatedItems: number;
|
||||
updatedLessons: number;
|
||||
updatedExercises: number;
|
||||
};
|
||||
|
||||
function buildContentKey(item: { id: string; type: string }) {
|
||||
return `${item.type}-${item.id}`;
|
||||
}
|
||||
|
||||
function normalizeDeleteItems<TItem extends { id: string; type: string }>(items: TItem[]): TItem[] {
|
||||
const seenKeys = new Set<string>();
|
||||
const uniqueItems: TItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const itemKey = buildContentKey(item);
|
||||
if (seenKeys.has(itemKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenKeys.add(itemKey);
|
||||
uniqueItems.push(item);
|
||||
}
|
||||
|
||||
const exercises = uniqueItems.filter((item) => item.type === ContentType.Exercise);
|
||||
const lessons = uniqueItems.filter((item) => item.type === ContentType.Lesson);
|
||||
|
||||
return [...exercises, ...lessons];
|
||||
}
|
||||
|
||||
async function getCourseContentMap(courseId: string) {
|
||||
const contentItems = await getCourseContentItems(courseId);
|
||||
const contentMap = new Map<string, CourseContentItemRow>();
|
||||
|
||||
contentItems.forEach((item) => {
|
||||
if (item.type === ContentType.Lesson || item.type === ContentType.Exercise) {
|
||||
contentMap.set(buildContentKey(item), item);
|
||||
}
|
||||
});
|
||||
|
||||
return contentMap;
|
||||
}
|
||||
|
||||
async function assertCourseContentItems(courseId: string, items: TCourseContentUpdate['items']) {
|
||||
const contentMap = await getCourseContentMap(courseId);
|
||||
|
||||
items.forEach((item) => {
|
||||
const key = buildContentKey({ id: item.id, type: item.type });
|
||||
|
||||
if (!contentMap.has(key)) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
throw new AppError('Lesson does not belong to this course', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (item.type === ContentType.Exercise) {
|
||||
throw new AppError('Exercise does not belong to this course', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
throw new AppError('Invalid content type', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCourseSections(courseId: string, sections: NonNullable<TCourseContentReorder['sections']>) {
|
||||
const existingSections = await getCourseSectionsByCourseId(courseId);
|
||||
const sectionIds = new Set(existingSections.map((section) => section.id));
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (!sectionIds.has(section.id)) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function mapContentBatchError(error: unknown): AppError | null {
|
||||
if (error instanceof OperationalQueryError) {
|
||||
return new AppError(error.message, error.errorCode, error.statusCode, error.field);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function transactionWithContentBatch<TResult>(runner: (tx: DbOrTxClient) => Promise<TResult>): Promise<TResult> {
|
||||
try {
|
||||
return await db.transaction(async (tx) => runner(tx));
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const mapped = mapContentBatchError(error);
|
||||
|
||||
if (mapped) {
|
||||
throw mapped;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) {
|
||||
try {
|
||||
await assertCourseContentItems(courseId, items);
|
||||
|
||||
await transactionWithContentBatch(async (tx) => applyCourseContentBulkUpdates(tx, items));
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update course content',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderCourseContent(
|
||||
courseId: string,
|
||||
payload: TCourseContentReorder
|
||||
): Promise<CourseContentReorderResult> {
|
||||
try {
|
||||
if (payload.sections?.length) {
|
||||
await assertCourseSections(courseId, payload.sections);
|
||||
}
|
||||
|
||||
if (payload.items?.length) {
|
||||
await assertCourseContentItems(courseId, payload.items);
|
||||
}
|
||||
|
||||
return await transactionWithContentBatch(async (tx) => {
|
||||
const updatedSections = payload.sections?.length ? await applyCourseSectionOrderUpdates(tx, payload.sections) : 0;
|
||||
|
||||
const itemResult = payload.items?.length
|
||||
? await applyCourseContentBulkUpdates(tx, payload.items)
|
||||
: {
|
||||
updatedItems: 0,
|
||||
updatedLessons: 0,
|
||||
updatedExercises: 0
|
||||
};
|
||||
|
||||
return {
|
||||
courseId,
|
||||
updatedSections,
|
||||
...itemResult
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to reorder course content',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCourseContent(courseId: string, payload: TCourseContentDelete) {
|
||||
try {
|
||||
if (payload.sectionId) {
|
||||
const sectionId = payload.sectionId;
|
||||
const section = await getCourseSectionById(sectionId);
|
||||
if (!section || section.courseId !== courseId) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const contentItems = await getCourseContentItems(courseId);
|
||||
const itemsToDelete = normalizeDeleteItems(
|
||||
contentItems.filter(
|
||||
(item) =>
|
||||
(item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === sectionId
|
||||
)
|
||||
);
|
||||
|
||||
console.log('Items to delete:', itemsToDelete);
|
||||
await db.transaction(async (tx) => {
|
||||
for (const item of itemsToDelete) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
const deleted = await deleteLesson(item.id, tx);
|
||||
if (!deleted) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const deleted = await deleteExercise(item.id, tx);
|
||||
if (!deleted) {
|
||||
console.log('delete exercise failed for id:', item.id);
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
|
||||
const deletedSection = await deleteCourseSection(sectionId, tx);
|
||||
if (!deletedSection) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.items) {
|
||||
throw new AppError('Content items are required', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const items = payload.items;
|
||||
await assertCourseContentItems(courseId, items);
|
||||
const normalizedItems = normalizeDeleteItems(items);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const item of normalizedItems) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
const deleted = await deleteLesson(item.id, tx);
|
||||
if (!deleted) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const deleted = await deleteExercise(item.id, tx);
|
||||
if (!deleted) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete course content',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '../../utils/sanitize-html';
|
||||
import {
|
||||
createCourseNewsfeed,
|
||||
createCourse as createCourseQuery,
|
||||
createCourseSections,
|
||||
deleteCourse as deleteCourseQuery,
|
||||
getCourseProgress as getCourseProgressQuery,
|
||||
getCourseTypeById,
|
||||
getCourseWithRelations,
|
||||
updateCourse as updateCourseQuery,
|
||||
updateLessonsSectionId
|
||||
} from '@cio/db/queries/course';
|
||||
import {
|
||||
addGroupMember,
|
||||
createGroup,
|
||||
getCourseProgramAccess,
|
||||
insertGroupMembersOnConflictDoNothing
|
||||
} from '@cio/db/queries/group';
|
||||
import {
|
||||
getLastLogin,
|
||||
getLessonsWithCompletion,
|
||||
getProfileCourseProgress,
|
||||
getUserExercisesStats
|
||||
} from '@cio/db/queries/analytics/analytics';
|
||||
|
||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||
import type { TCourse } from '@cio/db/types';
|
||||
import type { TCourseCreate } from '@cio/utils/validation/course';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
||||
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
|
||||
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
|
||||
import { guardCourseTypeTransition } from './public-course-guard';
|
||||
|
||||
const DEFAULT_CONTENT_GROUPING = true;
|
||||
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
|
||||
|
||||
export async function ensureProgramCourseAccess(courseId: string, profileId: string) {
|
||||
const access = await getCourseProgramAccess(courseId, profileId);
|
||||
if (!access) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const organizationId = access.organizationId;
|
||||
if (!organizationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedEmail = access.profileEmail?.trim() || undefined;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await insertOrganizationMembersOnConflictDoNothing(
|
||||
[
|
||||
{
|
||||
organizationId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
|
||||
await insertGroupMembersOnConflictDoNothing(
|
||||
[
|
||||
{
|
||||
groupId: access.courseGroupId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail ?? undefined
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function sanitizeCourseMetadata(metadata: TCourse['metadata'] | undefined) {
|
||||
if (!metadata) return metadata;
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
description: sanitizeOptionalHtml(metadata.description),
|
||||
goals: sanitizeOptionalHtml(metadata.goals),
|
||||
requirements: sanitizeOptionalHtml(metadata.requirements),
|
||||
reward: metadata.reward
|
||||
? {
|
||||
...metadata.reward,
|
||||
description: sanitizeHtml(metadata.reward.description)
|
||||
}
|
||||
: metadata.reward,
|
||||
instructor: metadata.instructor
|
||||
? {
|
||||
...metadata.instructor,
|
||||
description: sanitizeHtml(metadata.instructor.description)
|
||||
}
|
||||
: metadata.instructor,
|
||||
reviews: metadata.reviews?.map((review) => ({
|
||||
...review,
|
||||
description: sanitizeHtml(review.description)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeCourseCertificate(certificate: TCourse['certificate'] | undefined) {
|
||||
if (!certificate) return certificate;
|
||||
|
||||
return {
|
||||
...certificate,
|
||||
emailMessage: sanitizeOptionalHtml(certificate.emailMessage)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a course by ID or slug with all related data
|
||||
* @param courseId Course ID (optional if slug provided)
|
||||
* @param slug Course slug (optional if courseId provided)
|
||||
* @param profileId Profile ID of the current user (optional, for computing lesson completion)
|
||||
* @returns Course with all related data
|
||||
*/
|
||||
export async function getCourse(courseId?: string, slug?: string, profileId?: string) {
|
||||
try {
|
||||
if (!courseId && !slug) {
|
||||
throw new AppError('Either courseId or slug must be provided', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const course = await getCourseWithRelations(courseId, slug, profileId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const isContentGroupingEnabled = course.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING;
|
||||
const content = buildCourseContent(course.contentItems, isContentGroupingEnabled);
|
||||
|
||||
const { contentItems, org: courseOrg, ...rest } = course;
|
||||
|
||||
const base = {
|
||||
...rest,
|
||||
content
|
||||
};
|
||||
|
||||
if (courseOrg) {
|
||||
return {
|
||||
...base,
|
||||
org: {
|
||||
id: courseOrg.id,
|
||||
name: courseOrg.name,
|
||||
siteName: courseOrg.siteName ?? '',
|
||||
theme: courseOrg.theme ?? undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
} catch (error) {
|
||||
console.error('Error getting course:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch course',
|
||||
ErrorCodes.COURSE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new course with group, group member, and default newsfeed
|
||||
* @param profileId Profile ID of the creator
|
||||
* @param data Course creation data
|
||||
* @returns Created course with group and member information
|
||||
*/
|
||||
export async function createCourse(
|
||||
profileId: string,
|
||||
data: TCourseCreate
|
||||
): Promise<{ course: TCourse; groupId: string; memberId: string }> {
|
||||
try {
|
||||
const description = sanitizeHtml(data.description);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [newGroup] = await createGroup(
|
||||
{
|
||||
name: data.title,
|
||||
description,
|
||||
organizationId: data.organizationId
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newGroup) {
|
||||
throw new AppError('Failed to create group', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
const [newCourse] = await createCourseQuery(
|
||||
{
|
||||
title: data.title,
|
||||
description,
|
||||
type: data.type,
|
||||
groupId: newGroup.id,
|
||||
compliance: data.compliance ? sanitizeUnknownStrings(data.compliance) : undefined
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newCourse) {
|
||||
throw new AppError('Failed to create course', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
const [newMember] = await addGroupMember(
|
||||
{
|
||||
profileId,
|
||||
groupId: newGroup.id,
|
||||
roleId: ROLE.TUTOR
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newMember) {
|
||||
throw new AppError('Failed to add group member', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
await createCourseNewsfeed(
|
||||
{
|
||||
content: sanitizeHtml(`<h2>Welcome to this course 🎉 </h2>
|
||||
<p>Thank you for joining this course and I hope you get the best out of it.</p>`),
|
||||
courseId: newCourse.id,
|
||||
isPinned: true,
|
||||
authorId: newMember.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return {
|
||||
course: newCourse,
|
||||
groupId: newGroup.id,
|
||||
memberId: newMember.id
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create course',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a course
|
||||
* @param courseId Course ID
|
||||
* @param data Course update data
|
||||
* @returns Updated course
|
||||
*/
|
||||
export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
||||
try {
|
||||
const sanitizedData: Partial<TCourse> = {
|
||||
...data,
|
||||
description: sanitizeOptionalHtml(data.description),
|
||||
overview: sanitizeOptionalHtml(data.overview),
|
||||
metadata: sanitizeCourseMetadata(data.metadata),
|
||||
certificate: sanitizeCourseCertificate(data.certificate),
|
||||
logo: data.logo,
|
||||
slug: data.slug
|
||||
};
|
||||
|
||||
if (data.type !== undefined) {
|
||||
const currentType = await getCourseTypeById(courseId);
|
||||
|
||||
await guardCourseTypeTransition({
|
||||
courseId,
|
||||
currentType: currentType ?? null,
|
||||
nextType: data.type
|
||||
});
|
||||
}
|
||||
|
||||
if (data.certificate?.requiredExerciseId) {
|
||||
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
|
||||
if (!ok) {
|
||||
throw new AppError('Certification exercise must belong to this course', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateCourseQuery(courseId, sanitizeUnknownStrings(sanitizedData));
|
||||
if (!updated) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const isContentGroupingEnabled = (updated.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING) === true;
|
||||
|
||||
if (isContentGroupingEnabled) {
|
||||
const courseWithRelations = await getCourseWithRelations(courseId);
|
||||
if (courseWithRelations) {
|
||||
const contentItems = courseWithRelations.contentItems;
|
||||
const hasSections = contentItems.some((item) => item.type === ContentType.Section);
|
||||
const hasUngroupedItems = contentItems.some(
|
||||
(item) => (item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === null
|
||||
);
|
||||
|
||||
if (!hasSections && hasUngroupedItems) {
|
||||
await db.transaction(async (tx) => {
|
||||
const [section] = await createCourseSections(
|
||||
[
|
||||
{
|
||||
title: DEFAULT_SECTION_TITLE,
|
||||
order: 1,
|
||||
courseId
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
|
||||
if (!section) {
|
||||
throw new AppError('Failed to create course section', ErrorCodes.COURSE_SECTION_NOT_FOUND, 500);
|
||||
}
|
||||
|
||||
await updateLessonsSectionId(courseId, section.id, tx);
|
||||
await updateExercisesSectionId(courseId, section.id, tx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update course',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft deletes a course by setting status to 'DELETED'
|
||||
* @param courseId Course ID
|
||||
* @returns Deleted course
|
||||
*/
|
||||
export async function deleteCourse(courseId: string) {
|
||||
try {
|
||||
const deleted = await deleteCourseQuery(courseId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete course',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course progress for a profile
|
||||
* @param courseId Course ID
|
||||
* @param profileId Profile ID
|
||||
* @returns Course progress with counts
|
||||
*/
|
||||
export async function getCourseProgress(courseId: string, profileId: string) {
|
||||
try {
|
||||
const progress = await getCourseProgressQuery(courseId, profileId);
|
||||
return progress;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get course progress',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course analytics including student progress, completion rates, and grades
|
||||
* @param courseId Course ID
|
||||
* @returns Course analytics data
|
||||
*/
|
||||
export async function getCourseAnalytics(courseId: string) {
|
||||
try {
|
||||
// Get course with members (no profileId needed for analytics)
|
||||
const course = await getCourseWithRelations(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
// Get all members
|
||||
const members = course.group?.members || [];
|
||||
|
||||
// Filter students and tutors
|
||||
const students = members.filter((member) => member.roleId === ROLE.STUDENT);
|
||||
const tutors = members.filter((member) => member.roleId === ROLE.ADMIN || member.roleId === ROLE.TUTOR);
|
||||
|
||||
// Get lessons and exercises
|
||||
const lessons = course.contentItems.filter((item) => item.type === ContentType.Lesson);
|
||||
const exercises = course.contentItems.filter((item) => item.type === ContentType.Exercise);
|
||||
|
||||
// Get student analytics
|
||||
const studentAnalytics = await Promise.all(
|
||||
students
|
||||
.filter((student) => student.profileId !== null)
|
||||
.map(async (student) => {
|
||||
try {
|
||||
const [courseProgress, userExercisesStats, lastLoginDate] = await Promise.all([
|
||||
getCourseProgressQuery(courseId, student.profileId!),
|
||||
getUserExercisesStats(courseId, student.profileId!),
|
||||
getLastLogin(student.profileId!)
|
||||
]);
|
||||
|
||||
if (!courseProgress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedExercises = userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0;
|
||||
const totalExercises = userExercisesStats?.length || 0;
|
||||
|
||||
const totalEarnedPoints = userExercisesStats?.reduce((sum, exercise) => sum + exercise.score, 0) || 0;
|
||||
const totalPoints = userExercisesStats?.reduce((sum, exercise) => sum + exercise.totalPoints, 0) || 0;
|
||||
const averageGrade = calcPercentageWithRounding(totalEarnedPoints, totalPoints);
|
||||
|
||||
const lessonsCompleted = courseProgress.lessonsCompleted || 0;
|
||||
const totalLessons = courseProgress.lessonsCount || 0;
|
||||
const progressPercentage = calcPercentageWithRounding(lessonsCompleted, totalLessons);
|
||||
|
||||
return {
|
||||
id: student.profileId,
|
||||
profile: {
|
||||
fullname: student.profile?.fullname || 'Unknown',
|
||||
email: student.profile?.email || '',
|
||||
avatar_url: student.profile?.avatarUrl || ''
|
||||
},
|
||||
lessonsCompleted,
|
||||
totalLessons,
|
||||
exercisesSubmitted: completedExercises,
|
||||
totalExercises,
|
||||
averageGrade,
|
||||
lastSeen: formatLastSeen(lastLoginDate),
|
||||
progressPercentage
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting student overview:', error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const validStudentAnalytics = studentAnalytics.filter(
|
||||
(student): student is NonNullable<typeof student> => student !== null
|
||||
);
|
||||
|
||||
// Calculate aggregated metrics
|
||||
let lessonCompletionRate = 0;
|
||||
let exerciseCompletionRate = 0;
|
||||
let averageGrade = 0;
|
||||
|
||||
if (validStudentAnalytics.length > 0) {
|
||||
lessonCompletionRate = Math.round(
|
||||
validStudentAnalytics.reduce((sum, student) => sum + student.progressPercentage, 0) /
|
||||
validStudentAnalytics.length
|
||||
);
|
||||
|
||||
exerciseCompletionRate = Math.round(
|
||||
validStudentAnalytics.reduce((sum, student) => {
|
||||
const completionRate =
|
||||
student.totalExercises > 0 ? (student.exercisesSubmitted / student.totalExercises) * 100 : 0;
|
||||
return sum + completionRate;
|
||||
}, 0) / validStudentAnalytics.length
|
||||
);
|
||||
|
||||
averageGrade = Math.round(
|
||||
validStudentAnalytics.reduce((sum, student) => sum + student.averageGrade, 0) / validStudentAnalytics.length
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
totalTutors: tutors.length,
|
||||
totalStudents: students.length,
|
||||
totalLessons: lessons.length,
|
||||
totalExercises: exercises.length,
|
||||
lessonCompletionRate,
|
||||
exerciseCompletionRate,
|
||||
averageGrade,
|
||||
students: validStudentAnalytics
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get course analytics',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user course analytics for a specific course
|
||||
* @param courseId Course ID
|
||||
* @param userId User ID (profile ID)
|
||||
* @returns User course analytics data
|
||||
*/
|
||||
export async function getUserCourseAnalytics(courseId: string, userId: string) {
|
||||
try {
|
||||
// Get user profile
|
||||
const profile = await getProfileById(userId);
|
||||
if (!profile) {
|
||||
throw new AppError('User profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
// Get last login
|
||||
const lastLoginDate = await getLastLogin(userId);
|
||||
|
||||
// Fetch user exercises stats, lessons with completion, and course progress
|
||||
const [userExercisesStats, lessons, courseProgress] = await Promise.all([
|
||||
getUserExercisesStats(courseId, userId),
|
||||
getLessonsWithCompletion(courseId, userId),
|
||||
getProfileCourseProgress(courseId, userId)
|
||||
]);
|
||||
|
||||
if (!userExercisesStats || !lessons || !courseProgress) {
|
||||
throw new AppError('Failed to fetch course analytics data', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
const totalEarnedPoints = userExercisesStats.reduce((sum, exercise) => sum + exercise.score, 0);
|
||||
const totalPoints = userExercisesStats.reduce((sum, exercise) => sum + exercise.totalPoints, 0);
|
||||
const averageGrade = calcPercentageWithRounding(totalEarnedPoints, totalPoints);
|
||||
|
||||
const completedLessons = lessons.filter((lesson) => lesson.completed);
|
||||
const progressPercentage = calcPercentageWithRounding(completedLessons.length, lessons.length);
|
||||
|
||||
const completedExercises = userExercisesStats.filter((exercise) => exercise.isCompleted).length;
|
||||
const totalExercises = courseProgress.exercises_count || 0;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
fullName: profile.fullname || '',
|
||||
email: profile.email || '',
|
||||
avatarUrl: profile.avatarUrl || '',
|
||||
lastSeen: formatLastSeen(lastLoginDate)
|
||||
},
|
||||
averageGrade,
|
||||
userExercisesStats,
|
||||
totalExercises,
|
||||
completedExercises,
|
||||
progressPercentage
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError('Failed to get user exercises stats', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export type { CourseContent, CourseContentItem } from './utils';
|
||||
@@ -0,0 +1,264 @@
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import type { TCourse, TOrganization } from '@cio/db/types';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import { getDashboardBaseUrl } from '../../config/dashboard-url';
|
||||
import { updateCourse } from './course';
|
||||
import { ensureCourseSlug, generateUniqueCourseSlug } from './landing-page';
|
||||
|
||||
export type CourseGoLiveIssue = {
|
||||
code: string;
|
||||
message: string;
|
||||
target?: string;
|
||||
};
|
||||
|
||||
export type CourseGoLiveReadiness = {
|
||||
ready: boolean;
|
||||
blockers: CourseGoLiveIssue[];
|
||||
warnings: CourseGoLiveIssue[];
|
||||
suggestedFixes: {
|
||||
slug?: string;
|
||||
landingPage?: TCourseLandingPageUpdate;
|
||||
};
|
||||
courseUrl?: string;
|
||||
};
|
||||
|
||||
type CourseReadinessInput = {
|
||||
course: Pick<
|
||||
TCourse,
|
||||
'id' | 'title' | 'description' | 'overview' | 'slug' | 'logo' | 'bannerImage' | 'metadata' | 'type' | 'cost'
|
||||
>;
|
||||
contentItems: CourseContentItemRow[];
|
||||
suggestedSlug?: string;
|
||||
organization?: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'> | null;
|
||||
};
|
||||
|
||||
const PLACEHOLDER_PATTERNS = [
|
||||
/\[edit me\]/i,
|
||||
/^untitled\b/i,
|
||||
/^new course$/i,
|
||||
/^course title$/i,
|
||||
/^course description$/i,
|
||||
/welcome to this amazing course/i
|
||||
];
|
||||
|
||||
function normalizeReadableText(value: string | null | undefined): string {
|
||||
return (value ?? '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ | /gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasMeaningfulText(value: string | null | undefined): boolean {
|
||||
const normalized = normalizeReadableText(value);
|
||||
|
||||
if (normalized.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function buildIssue(code: string, message: string, target?: string): CourseGoLiveIssue {
|
||||
return target ? { code, message, target } : { code, message };
|
||||
}
|
||||
|
||||
function hasLessonContent(item: CourseContentItemRow): boolean {
|
||||
return Boolean(
|
||||
item.hasNoteContent || item.hasSlideContent || (item.videosCount ?? 0) > 0 || (item.documentsCount ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function buildCourseUrl(
|
||||
organization: CourseReadinessInput['organization'],
|
||||
slug: string | null | undefined
|
||||
): string | undefined {
|
||||
if (!organization || !slug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${getDashboardBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
export function evaluateCourseGoLiveReadiness(input: CourseReadinessInput): CourseGoLiveReadiness {
|
||||
const { course, contentItems, organization, suggestedSlug } = input;
|
||||
const blockers: CourseGoLiveIssue[] = [];
|
||||
const warnings: CourseGoLiveIssue[] = [];
|
||||
const landingPageFixes: TCourseLandingPageUpdate = {};
|
||||
const metadataFixes: NonNullable<TCourseLandingPageUpdate['metadata']> = {};
|
||||
|
||||
if (!hasMeaningfulText(course.title)) {
|
||||
blockers.push(buildIssue('COURSE_TITLE_MISSING', 'Add a clear course title.', 'course.title'));
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.description)) {
|
||||
blockers.push(buildIssue('COURSE_DESCRIPTION_MISSING', 'Add a clear course description.', 'course.description'));
|
||||
}
|
||||
|
||||
if (!course.slug && !suggestedSlug) {
|
||||
blockers.push(buildIssue('COURSE_SLUG_MISSING', 'Generate a public course slug.', 'course.slug'));
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.overview)) {
|
||||
blockers.push(buildIssue('LANDING_OVERVIEW_MISSING', 'Add a landing-page overview.', 'course.overview'));
|
||||
landingPageFixes.overview = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.description)) {
|
||||
blockers.push(
|
||||
buildIssue('LANDING_DESCRIPTION_MISSING', 'Add a landing-page description.', 'course.metadata.description')
|
||||
);
|
||||
metadataFixes.description = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.goals)) {
|
||||
blockers.push(
|
||||
buildIssue('LANDING_GOALS_MISSING', 'Add course goals for the landing page.', 'course.metadata.goals')
|
||||
);
|
||||
metadataFixes.goals = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.requirements)) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'LANDING_REQUIREMENTS_MISSING',
|
||||
'Add course requirements for the landing page.',
|
||||
'course.metadata.requirements'
|
||||
)
|
||||
);
|
||||
metadataFixes.requirements = '';
|
||||
}
|
||||
|
||||
if (!course.logo && !course.bannerImage) {
|
||||
blockers.push(buildIssue('LANDING_IMAGE_MISSING', 'Add a landing-page banner or course image.', 'course.logo'));
|
||||
landingPageFixes.generateImage = true;
|
||||
}
|
||||
|
||||
const learningItems = contentItems.filter(
|
||||
(item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise
|
||||
);
|
||||
|
||||
if (learningItems.length === 0) {
|
||||
blockers.push(buildIssue('COURSE_CONTENT_MISSING', 'Add at least one lesson or exercise.', 'course.content'));
|
||||
}
|
||||
|
||||
for (const item of contentItems) {
|
||||
if (item.type === ContentType.Lesson && !hasLessonContent(item)) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'LESSON_CONTENT_EMPTY',
|
||||
`Add content to lesson "${item.title ?? 'Untitled lesson'}".`,
|
||||
`lesson:${item.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === ContentType.Exercise && (item.questionCount ?? 0) === 0) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'EXERCISE_QUESTIONS_MISSING',
|
||||
`Add at least one question to exercise "${item.title ?? 'Untitled exercise'}".`,
|
||||
`exercise:${item.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.instructor?.name)) {
|
||||
warnings.push(
|
||||
buildIssue('INSTRUCTOR_MISSING', 'Add instructor information for more trust.', 'course.metadata.instructor')
|
||||
);
|
||||
}
|
||||
|
||||
if (!course.metadata?.reviews?.some((review) => !review.hide && hasMeaningfulText(review.description))) {
|
||||
warnings.push(
|
||||
buildIssue('REVIEWS_MISSING', 'Add testimonials or reviews when available.', 'course.metadata.reviews')
|
||||
);
|
||||
}
|
||||
|
||||
if ((course.cost ?? 0) > 0 && !hasMeaningfulText(course.metadata?.paymentLink)) {
|
||||
warnings.push(
|
||||
buildIssue('PAYMENT_LINK_MISSING', 'Add a payment link for this paid course.', 'course.metadata.paymentLink')
|
||||
);
|
||||
}
|
||||
|
||||
if (course.type === 'COMPLIANCE') {
|
||||
warnings.push(
|
||||
buildIssue('COMPLIANCE_REVIEW_RECOMMENDED', 'Review compliance settings before publishing.', 'course.compliance')
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(metadataFixes).length > 0) {
|
||||
landingPageFixes.metadata = metadataFixes;
|
||||
}
|
||||
|
||||
const suggestedFixes: CourseGoLiveReadiness['suggestedFixes'] = {};
|
||||
if (!course.slug && suggestedSlug) {
|
||||
suggestedFixes.slug = suggestedSlug;
|
||||
}
|
||||
|
||||
if (Object.keys(landingPageFixes).length > 0) {
|
||||
suggestedFixes.landingPage = landingPageFixes;
|
||||
}
|
||||
|
||||
return {
|
||||
ready: blockers.length === 0,
|
||||
blockers,
|
||||
warnings,
|
||||
suggestedFixes,
|
||||
courseUrl: buildCourseUrl(organization, course.slug ?? suggestedSlug)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseGoLiveReadiness(courseId: string): Promise<CourseGoLiveReadiness> {
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const organizationId = await getCourseOrganizationId(courseId);
|
||||
const [contentItems, organization] = await Promise.all([
|
||||
getCourseContentItems(courseId),
|
||||
organizationId ? getOrganizationById(organizationId) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
const suggestedSlug = course.slug
|
||||
? undefined
|
||||
: await generateUniqueCourseSlug(generateSlug(course.title, { fallback: 'course' }));
|
||||
|
||||
return evaluateCourseGoLiveReadiness({
|
||||
course,
|
||||
contentItems,
|
||||
suggestedSlug,
|
||||
organization
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishCourseWhenReady(courseId: string) {
|
||||
const readiness = await getCourseGoLiveReadiness(courseId);
|
||||
if (!readiness.ready) {
|
||||
const blockerList = readiness.blockers.map((blocker) => blocker.message).join(' ');
|
||||
throw new AppError(`Course is not ready to go live. ${blockerList}`, 'COURSE_GO_LIVE_BLOCKED', 400);
|
||||
}
|
||||
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const slug = await ensureCourseSlug(courseId, course.title);
|
||||
const publishedCourse = await updateCourse(courseId, { slug, isPublished: true });
|
||||
const publishedReadiness = await getCourseGoLiveReadiness(courseId);
|
||||
|
||||
return {
|
||||
course: publishedCourse,
|
||||
readiness: publishedReadiness
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import type { TCourse, TOrganization } from '@cio/db/types';
|
||||
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { env } from '../../config/env';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { getCourseById, isCourseSlugTaken, updateCourseSlug } from '@cio/db/queries/course';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getDashboardBaseUrl } from '../../config/dashboard-url';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import { updateCourse } from './course';
|
||||
|
||||
type CourseLandingPageUpdateResult = {
|
||||
course: TCourse;
|
||||
courseUrl: string;
|
||||
bannerImageUrl: string | null;
|
||||
};
|
||||
|
||||
export async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let suffix = 0;
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const taken = await isCourseSlugTaken(candidate);
|
||||
if (!taken) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
candidate = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (course?.slug) {
|
||||
return course.slug;
|
||||
}
|
||||
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
const updated = await updateCourseSlug(courseId, uniqueSlug);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return updated.slug ?? uniqueSlug;
|
||||
}
|
||||
|
||||
export async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
|
||||
const organization = await getOrganizationById(organizationId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const slug = await ensureCourseSlug(courseId, title);
|
||||
return `${getDashboardBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
|
||||
if (!env.UNSPLASH_API_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
|
||||
const response = await fetch(
|
||||
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
results?: Array<{
|
||||
urls?: {
|
||||
regular?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const photos = data.results ?? [];
|
||||
if (photos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const photo = photos[Math.floor(Math.random() * photos.length)];
|
||||
return photo?.urls?.regular ?? null;
|
||||
}
|
||||
|
||||
async function resolveLandingPageImage(
|
||||
courseTitle: string,
|
||||
payload: Pick<TCourseLandingPageUpdate, 'generateImage' | 'imageQuery' | 'imageUrl'>
|
||||
) {
|
||||
if (payload.imageUrl) {
|
||||
return payload.imageUrl;
|
||||
}
|
||||
|
||||
if (!payload.generateImage && !payload.imageQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await resolveUnsplashBannerImage(courseTitle, payload.imageQuery);
|
||||
} catch (error) {
|
||||
console.error('resolveLandingPageImage error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLandingPageMetadata(
|
||||
existing: TCourse['metadata'] | null | undefined,
|
||||
update: TCourseLandingPageMetadataUpdate | undefined
|
||||
): TCourse['metadata'] | undefined {
|
||||
if (!update) {
|
||||
return existing ?? undefined;
|
||||
}
|
||||
|
||||
const current: NonNullable<TCourse['metadata']> = {
|
||||
goals: '',
|
||||
description: '',
|
||||
requirements: '',
|
||||
allowNewStudent: true,
|
||||
...(existing ?? {})
|
||||
};
|
||||
|
||||
return {
|
||||
...current,
|
||||
...update,
|
||||
reward: update.reward
|
||||
? {
|
||||
show: update.reward.show ?? current.reward?.show ?? false,
|
||||
description: update.reward.description ?? current.reward?.description ?? ''
|
||||
}
|
||||
: current.reward,
|
||||
instructor: update.instructor
|
||||
? {
|
||||
name: update.instructor.name ?? current.instructor?.name ?? '',
|
||||
role: update.instructor.role ?? current.instructor?.role ?? '',
|
||||
coursesNo: update.instructor.coursesNo ?? current.instructor?.coursesNo ?? 0,
|
||||
description: update.instructor.description ?? current.instructor?.description ?? '',
|
||||
imgUrl: update.instructor.imgUrl ?? current.instructor?.imgUrl ?? ''
|
||||
}
|
||||
: current.instructor,
|
||||
certificate: update.certificate
|
||||
? {
|
||||
templateUrl: update.certificate.templateUrl ?? current.certificate?.templateUrl ?? ''
|
||||
}
|
||||
: current.certificate,
|
||||
sectionDisplay: update.sectionDisplay
|
||||
? { ...(current.sectionDisplay ?? {}), ...update.sectionDisplay }
|
||||
: current.sectionDisplay,
|
||||
reviews: update.reviews ?? current.reviews,
|
||||
lessonTabsOrder: update.lessonTabsOrder ?? current.lessonTabsOrder
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReviews(reviews: NonNullable<TCourse['metadata']>['reviews'] | undefined) {
|
||||
return reviews?.map((review, index) => ({
|
||||
id: review.id ?? Date.now() + index,
|
||||
hide: review.hide ?? false,
|
||||
name: review.name,
|
||||
avatar_url: review.avatar_url,
|
||||
rating: review.rating,
|
||||
created_at: review.created_at ?? Date.now(),
|
||||
description: review.description
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateCourseLandingPageService(
|
||||
courseId: string,
|
||||
payload: TCourseLandingPageUpdate
|
||||
): Promise<CourseLandingPageUpdateResult> {
|
||||
const [existingCourse] = await getCourseById(courseId);
|
||||
if (!existingCourse) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const nextTitle = payload.title ?? existingCourse.title;
|
||||
const metadata = mergeLandingPageMetadata(existingCourse.metadata ?? undefined, payload.metadata);
|
||||
const imageUrl = await resolveLandingPageImage(nextTitle, payload);
|
||||
|
||||
const updatedCourse = await updateCourse(courseId, {
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
overview: payload.overview,
|
||||
cost: payload.cost,
|
||||
currency: payload.currency,
|
||||
metadata: metadata
|
||||
? {
|
||||
...metadata,
|
||||
reviews: normalizeReviews(metadata.reviews)
|
||||
}
|
||||
: undefined,
|
||||
...(imageUrl ? { logo: imageUrl, bannerImage: imageUrl } : {})
|
||||
});
|
||||
|
||||
const organizationId = await getCourseOrganizationId(courseId);
|
||||
if (!organizationId) {
|
||||
throw new AppError('Course organization not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const courseUrl = await resolveCourseUrl(organizationId, courseId, updatedCourse.title);
|
||||
|
||||
return {
|
||||
course: updatedCourse,
|
||||
courseUrl,
|
||||
bannerImageUrl: updatedCourse.logo || imageUrl || null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import { findNonAutoGradableQuestionsInCourse, getCourseTypeById } from '@cio/db/queries/course';
|
||||
import { AUTO_GRADABLE_QUESTION_TYPE_IDS, isAutoGradableQuestionTypeId } from '@cio/question-types';
|
||||
|
||||
/**
|
||||
* Throws `QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE` when a non-auto-gradable question
|
||||
* type is being attached to an exercise that belongs to a `PUBLIC` course.
|
||||
*
|
||||
* Accepts the full list of question type ids being created / updated (ignore-null safe).
|
||||
*/
|
||||
export async function guardNonAutoGradableQuestionsForCourseType(params: {
|
||||
courseId: string;
|
||||
questionTypeIds: Array<number | null | undefined>;
|
||||
}): Promise<void> {
|
||||
const typeIds = params.questionTypeIds.filter((id): id is number => typeof id === 'number');
|
||||
if (typeIds.length === 0) return;
|
||||
|
||||
const hasDisallowed = typeIds.some((id) => !isAutoGradableQuestionTypeId(id));
|
||||
if (!hasDisallowed) return;
|
||||
|
||||
const rowType = await getCourseTypeById(params.courseId);
|
||||
|
||||
if (!rowType) return;
|
||||
|
||||
if (rowType === 'PUBLIC') {
|
||||
throw new AppError(
|
||||
'Public courses only support auto-gradable question types',
|
||||
ErrorCodes.QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE,
|
||||
400,
|
||||
'questions'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When converting a course to `PUBLIC`, enumerate any questions whose type is not
|
||||
* auto-gradable. Throws `PUBLIC_COURSE_CONVERSION_BLOCKED` with the offending list
|
||||
* in `message`; callers should surface the list to the creator so they can fix them.
|
||||
*/
|
||||
export async function guardCourseTypeTransition(params: {
|
||||
courseId: string;
|
||||
currentType: string | null;
|
||||
nextType: string | null | undefined;
|
||||
}): Promise<void> {
|
||||
if (!params.nextType) return;
|
||||
if (params.nextType !== 'PUBLIC') return;
|
||||
if (params.currentType === 'PUBLIC') return;
|
||||
|
||||
const offenders = await findNonAutoGradableQuestionsInCourse(params.courseId, AUTO_GRADABLE_QUESTION_TYPE_IDS);
|
||||
if (offenders.length === 0) return;
|
||||
|
||||
const preview = offenders.slice(0, 5).map((item) => `"${item.questionTitle}" in "${item.exerciseTitle}"`);
|
||||
const more = offenders.length > 5 ? ` (+${offenders.length - 5} more)` : '';
|
||||
|
||||
throw new AppError(
|
||||
`Remove or replace these non-auto-gradable questions before converting to Public: ${preview.join('; ')}${more}`,
|
||||
ErrorCodes.PUBLIC_COURSE_CONVERSION_BLOCKED,
|
||||
400,
|
||||
'type'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { TCourseSection } from '@cio/db/types';
|
||||
import {
|
||||
createCourseSections,
|
||||
deleteCourseSection,
|
||||
getCourseSectionById,
|
||||
getCourseSectionsByCourseId,
|
||||
updateCourseSection,
|
||||
updateUngroupedLessonsSectionId
|
||||
} from '@cio/db/queries/course';
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import type {
|
||||
TCourseSectionCreate,
|
||||
TCourseSectionPromoteUngrouped,
|
||||
TCourseSectionReorder,
|
||||
TCourseSectionUpdate
|
||||
} from '@cio/utils/validation/course/section';
|
||||
import { updateUngroupedExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
|
||||
/**
|
||||
* Creates a new course section
|
||||
* @param courseId Course ID
|
||||
* @param data Section creation data
|
||||
* @returns Created section
|
||||
*/
|
||||
export async function createCourseSection(courseId: string, data: TCourseSectionCreate): Promise<TCourseSection> {
|
||||
try {
|
||||
const sectionData = {
|
||||
...data,
|
||||
courseId
|
||||
};
|
||||
|
||||
const [section] = await createCourseSections([sectionData]);
|
||||
if (!section) {
|
||||
throw new AppError('Failed to create course section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
return section;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create course section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes synthetic "Ungrouped" content into a real persisted section.
|
||||
* Creates a section and reassigns all ungrouped lessons/exercises to it transactionally.
|
||||
*/
|
||||
export async function promoteUngroupedSection(
|
||||
courseId: string,
|
||||
data: TCourseSectionPromoteUngrouped
|
||||
): Promise<{ section: TCourseSection; movedLessons: number; movedExercises: number }> {
|
||||
try {
|
||||
const existingSections = await getCourseSectionsByCourseId(courseId);
|
||||
const nextOrder = Math.max(...existingSections.map((section) => section.order ?? 0), 0) + 1;
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [section] = await createCourseSections(
|
||||
[
|
||||
{
|
||||
title: data.title,
|
||||
courseId,
|
||||
order: nextOrder
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
|
||||
if (!section) {
|
||||
throw new AppError('Failed to create course section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
const [movedLessons, movedExercises] = await Promise.all([
|
||||
updateUngroupedLessonsSectionId(courseId, section.id, tx),
|
||||
updateUngroupedExercisesSectionId(courseId, section.id, tx)
|
||||
]);
|
||||
|
||||
if (movedLessons + movedExercises === 0) {
|
||||
throw new AppError('No ungrouped content found', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
return {
|
||||
section,
|
||||
movedLessons,
|
||||
movedExercises
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to promote ungrouped content',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a course section by ID
|
||||
* @param sectionId Section ID
|
||||
* @returns Section or throws error if not found
|
||||
*/
|
||||
export async function getCourseSection(sectionId: string): Promise<TCourseSection> {
|
||||
try {
|
||||
const section = await getCourseSectionById(sectionId);
|
||||
if (!section) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return section;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch course section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a course section
|
||||
* @param sectionId Section ID
|
||||
* @param data Partial section update data
|
||||
* @returns Updated section
|
||||
*/
|
||||
export async function updateCourseSectionService(
|
||||
sectionId: string,
|
||||
data: TCourseSectionUpdate
|
||||
): Promise<TCourseSection> {
|
||||
try {
|
||||
const section = await getCourseSectionById(sectionId);
|
||||
if (!section) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const updated = await updateCourseSection(sectionId, data);
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update course section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update course section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a course section
|
||||
* @param sectionId Section ID
|
||||
* @returns Deleted section
|
||||
*/
|
||||
export async function deleteCourseSectionService(sectionId: string): Promise<TCourseSection> {
|
||||
try {
|
||||
const section = await getCourseSectionById(sectionId);
|
||||
if (!section) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const deleted = await deleteCourseSection(sectionId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Failed to delete course section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete course section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists course sections for a course
|
||||
* @param courseId Course ID
|
||||
* @returns Array of sections
|
||||
*/
|
||||
export async function listCourseSections(courseId: string): Promise<TCourseSection[]> {
|
||||
try {
|
||||
return await getCourseSectionsByCourseId(courseId);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list course sections',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders course sections
|
||||
* @param sections Array of section IDs and orders
|
||||
* @returns Updated sections
|
||||
*/
|
||||
export async function reorderCourseSections(sections: TCourseSectionReorder['sections']): Promise<TCourseSection[]> {
|
||||
try {
|
||||
const updatePromises = sections.map(({ id, order }) => updateCourseSection(id, { order }));
|
||||
|
||||
const updated = await Promise.all(updatePromises);
|
||||
return updated.filter((section): section is TCourseSection => section !== null);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to reorder course sections',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import { getTakenItemSlugs } from '@cio/db/queries/course';
|
||||
import { SLUG_REGEX, resolveSlugCollision, slugifyTitle } from '@cio/utils/validation';
|
||||
|
||||
/**
|
||||
* Resolve an item (lesson or exercise) slug for a given course:
|
||||
* - If `requestedSlug` is provided, validate it and reject if already taken (excluding `ignoreItemId`).
|
||||
* - Otherwise derive a slug from `title` and append `-2`, `-3`, ... until free.
|
||||
*
|
||||
* Uniqueness is enforced across the union of `lesson.slug` and `exercise.slug` per course.
|
||||
*/
|
||||
export async function resolveItemSlug(params: {
|
||||
courseId: string;
|
||||
title: string;
|
||||
requestedSlug?: string;
|
||||
ignoreItemSlug?: string;
|
||||
}): Promise<string> {
|
||||
const { courseId, title, requestedSlug, ignoreItemSlug } = params;
|
||||
const takenSlugs = await getTakenItemSlugs(courseId);
|
||||
|
||||
if (ignoreItemSlug) {
|
||||
takenSlugs.delete(ignoreItemSlug);
|
||||
}
|
||||
|
||||
if (requestedSlug) {
|
||||
if (!SLUG_REGEX.test(requestedSlug)) {
|
||||
throw new AppError(
|
||||
'Slug must be lowercase letters, numbers, and single dashes',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'slug'
|
||||
);
|
||||
}
|
||||
if (takenSlugs.has(requestedSlug)) {
|
||||
throw new AppError('Slug is already in use in this course', ErrorCodes.SLUG_IN_USE, 409, 'slug');
|
||||
}
|
||||
return requestedSlug;
|
||||
}
|
||||
|
||||
const base = slugifyTitle(title);
|
||||
return resolveSlugCollision(base, takenSlugs);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import type { CourseContentItemRow } from '@cio/db/queries/course';
|
||||
|
||||
export type CourseContentItem = {
|
||||
id: string;
|
||||
type: ContentType;
|
||||
title: string;
|
||||
order: number | null;
|
||||
createdAt: string | null;
|
||||
sectionId: string | null;
|
||||
isUnlocked: boolean | null;
|
||||
isComplete: boolean | null;
|
||||
lessonAt: string | null;
|
||||
callUrl: string | null;
|
||||
hasNoteContent: boolean | null;
|
||||
hasSlideContent: boolean | null;
|
||||
videosCount: number | null;
|
||||
documentsCount: number | null;
|
||||
questionCount: number | null;
|
||||
dueBy: string | null;
|
||||
};
|
||||
|
||||
export type CourseContentSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
order: number | null;
|
||||
items: CourseContentItem[];
|
||||
};
|
||||
|
||||
export type CourseContent = {
|
||||
grouped: boolean;
|
||||
sections: CourseContentSection[];
|
||||
items: CourseContentItem[];
|
||||
};
|
||||
|
||||
const CONTENT_TYPE_PRIORITY: Record<ContentType, number> = {
|
||||
[ContentType.Section]: -1,
|
||||
[ContentType.Lesson]: 0,
|
||||
[ContentType.Exercise]: 1
|
||||
};
|
||||
|
||||
function sortContentItems(items: CourseContentItem[]): CourseContentItem[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const orderDiff = (a.order ?? 0) - (b.order ?? 0);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
|
||||
const typeDiff = CONTENT_TYPE_PRIORITY[a.type] - CONTENT_TYPE_PRIORITY[b.type];
|
||||
if (typeDiff !== 0) return typeDiff;
|
||||
|
||||
return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function sortContentSections(sections: CourseContentSection[]): CourseContentSection[] {
|
||||
return [...sections].sort((a, b) => {
|
||||
const orderDiff = (a.order ?? 0) - (b.order ?? 0);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
function mapCourseContentItems(rows: CourseContentItemRow[]): CourseContentItem[] {
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
type: row.type as ContentType,
|
||||
title: row.title ?? '',
|
||||
order: row.order ?? null,
|
||||
createdAt: row.createdAt ?? null,
|
||||
sectionId: row.sectionId ?? null,
|
||||
isUnlocked: row.isUnlocked ?? null,
|
||||
isComplete: row.isComplete ?? null,
|
||||
lessonAt: row.lessonAt ?? null,
|
||||
callUrl: row.callUrl ?? null,
|
||||
hasNoteContent: row.hasNoteContent ?? null,
|
||||
hasSlideContent: row.hasSlideContent ?? null,
|
||||
videosCount: row.videosCount ?? null,
|
||||
documentsCount: row.documentsCount ?? null,
|
||||
questionCount: row.questionCount ?? null,
|
||||
dueBy: row.dueBy ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildCourseContent(rows: CourseContentItemRow[], isContentGroupingEnabled: boolean): CourseContent {
|
||||
const items = mapCourseContentItems(rows);
|
||||
const contentItems = items.filter((item) => item.type !== ContentType.Section);
|
||||
|
||||
if (!isContentGroupingEnabled) {
|
||||
return {
|
||||
grouped: false,
|
||||
sections: [],
|
||||
items: sortContentItems(contentItems)
|
||||
};
|
||||
}
|
||||
|
||||
const sectionItems = items.filter((item) => item.type === ContentType.Section);
|
||||
const sectionMap = new Map<string, CourseContentItem[]>();
|
||||
const ungroupedItems: CourseContentItem[] = [];
|
||||
|
||||
for (const item of contentItems) {
|
||||
if (!item.sectionId) {
|
||||
ungroupedItems.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sectionMap.has(item.sectionId)) {
|
||||
sectionMap.set(item.sectionId, []);
|
||||
}
|
||||
|
||||
sectionMap.get(item.sectionId)!.push(item);
|
||||
}
|
||||
|
||||
const sections = sortContentSections(
|
||||
sectionItems.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title || 'Untitled section',
|
||||
order: section.order ?? null,
|
||||
items: sortContentItems(sectionMap.get(section.id) ?? [])
|
||||
}))
|
||||
);
|
||||
|
||||
if (ungroupedItems.length > 0) {
|
||||
sections.push({
|
||||
id: 'ungrouped',
|
||||
title: 'Ungrouped',
|
||||
order: sections.length + 1,
|
||||
items: sortContentItems(ungroupedItems)
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
grouped: true,
|
||||
sections,
|
||||
items: []
|
||||
};
|
||||
}
|
||||
|
||||
export function calcPercentageWithRounding(a: number, b: number): number {
|
||||
if (b === 0) return 0;
|
||||
return Math.round((a / b) * 100);
|
||||
}
|
||||
|
||||
export function formatLastSeen(lastLoginDate: string | null | undefined): string {
|
||||
if (!lastLoginDate) return 'Never';
|
||||
|
||||
const lastLogin = new Date(lastLoginDate);
|
||||
const now = new Date();
|
||||
const diffInHours = Math.floor((now.getTime() - lastLogin.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffInHours < 1) {
|
||||
return 'Just now';
|
||||
}
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours} hours ago`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
return `${diffInDays} days ago`;
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '../../utils/sanitize-html';
|
||||
import type { TExercise, TExerciseTemplate, TNewOption, TNewQuestion } from '@cio/db/types';
|
||||
import type { TExerciseCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
|
||||
import { getLessonById } from '@cio/db/queries/lesson';
|
||||
import {
|
||||
createExercises,
|
||||
syncOptionIdSequence,
|
||||
syncQuestionIdSequence,
|
||||
createOptions,
|
||||
createQuestions,
|
||||
deleteExercise,
|
||||
deleteOptionsByIds,
|
||||
deleteQuestionsByIds,
|
||||
getExerciseById,
|
||||
getExerciseCompletionByProfile,
|
||||
getExerciseCompletionsByProfile,
|
||||
getExerciseSectionsByExerciseId,
|
||||
getExerciseWithRelationsOptimized,
|
||||
getExercisesByCourseId,
|
||||
getLMSExercises,
|
||||
createExerciseSections,
|
||||
deleteExerciseSectionsByIds,
|
||||
updateExerciseSection,
|
||||
updateExercise,
|
||||
updateOptions,
|
||||
updateQuestions
|
||||
} from '@cio/db/queries/exercise';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
import { resolveItemSlug } from '../course/slug';
|
||||
import { guardNonAutoGradableQuestionsForCourseType } from '../course/public-course-guard';
|
||||
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import type { DbOrTxClient } from '@cio/db/drizzle';
|
||||
import {
|
||||
buildExerciseUpdateFields,
|
||||
computeExerciseDiff,
|
||||
createNewQuestionsWithOptions,
|
||||
fetchQuestionsAndOptions,
|
||||
transformQuestions,
|
||||
type QuestionWithRelations
|
||||
} from './utils';
|
||||
|
||||
type ExerciseWithQuestions = TExercise & {
|
||||
isComplete: boolean;
|
||||
questions?: QuestionWithRelations[];
|
||||
sections?: ExerciseSectionWithQuestions[];
|
||||
};
|
||||
|
||||
type ExerciseSectionWithQuestions = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
colorTheme: string;
|
||||
afterBehavior: unknown;
|
||||
questions: QuestionWithRelations[];
|
||||
};
|
||||
|
||||
type ExerciseWithCompletion = TExercise & {
|
||||
isComplete: boolean;
|
||||
};
|
||||
|
||||
function sanitizeExerciseQuestions(questions: TExerciseCreate['questions'] | TExerciseUpdate['questions']) {
|
||||
return questions?.map((question) => ({
|
||||
...question,
|
||||
question: sanitizeHtml(question.question),
|
||||
settings: question.settings ? sanitizeUnknownStrings(question.settings) : question.settings,
|
||||
options: question.options?.map((option) => ({
|
||||
...option,
|
||||
label: sanitizeHtml(option.label),
|
||||
settings: option.settings ? sanitizeUnknownStrings(option.settings) : option.settings
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
function sanitizeExercisePayload(data: TExerciseCreate): TExerciseCreate;
|
||||
function sanitizeExercisePayload(data: TExerciseUpdate): TExerciseUpdate;
|
||||
function sanitizeExercisePayload(data: TExerciseCreate | TExerciseUpdate): TExerciseCreate | TExerciseUpdate {
|
||||
return {
|
||||
...data,
|
||||
description: sanitizeOptionalHtml(data.description),
|
||||
sections:
|
||||
'sections' in data
|
||||
? data.sections?.map((section) => ({
|
||||
...section,
|
||||
title: sanitizeHtml(section.title),
|
||||
description: sanitizeOptionalHtml(section.description ?? undefined)
|
||||
}))
|
||||
: undefined,
|
||||
questions: sanitizeExerciseQuestions(data.questions)
|
||||
};
|
||||
}
|
||||
|
||||
function groupQuestionsBySections(
|
||||
sections: Awaited<ReturnType<typeof getExerciseSectionsByExerciseId>>,
|
||||
questions: QuestionWithRelations[]
|
||||
) {
|
||||
const questionsBySectionId = new Map<string, QuestionWithRelations[]>();
|
||||
const unsectionedQuestions: QuestionWithRelations[] = [];
|
||||
|
||||
for (const question of questions) {
|
||||
if (question.exerciseSectionId) {
|
||||
const sectionQuestions = questionsBySectionId.get(question.exerciseSectionId) ?? [];
|
||||
sectionQuestions.push(question);
|
||||
questionsBySectionId.set(question.exerciseSectionId, sectionQuestions);
|
||||
continue;
|
||||
}
|
||||
|
||||
unsectionedQuestions.push(question);
|
||||
}
|
||||
|
||||
return {
|
||||
sections: sections.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
questions: questionsBySectionId.get(section.id) ?? []
|
||||
})),
|
||||
unsectionedQuestions
|
||||
};
|
||||
}
|
||||
|
||||
async function syncExerciseSections(
|
||||
exerciseId: string,
|
||||
sections: NonNullable<TExerciseUpdate['sections']>,
|
||||
txClient: DbOrTxClient
|
||||
) {
|
||||
const currentSections = await getExerciseSectionsByExerciseId(exerciseId, txClient);
|
||||
const currentSectionIds = new Set(currentSections.map((section) => section.id));
|
||||
const incomingSectionIds = new Set(sections.map((section) => section.id).filter((id): id is string => !!id));
|
||||
|
||||
for (const section of sections) {
|
||||
if (section.afterBehavior.action !== 'go_to_section') continue;
|
||||
if (!incomingSectionIds.has(section.afterBehavior.exerciseSectionId)) {
|
||||
throw new AppError('Section routing target must exist in this exercise', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const sectionsToDelete = currentSections
|
||||
.filter((section) => !incomingSectionIds.has(section.id))
|
||||
.map((section) => section.id);
|
||||
|
||||
if (sectionsToDelete.length > 0) {
|
||||
await deleteExerciseSectionsByIds(sectionsToDelete, txClient);
|
||||
}
|
||||
|
||||
const sectionsToCreate = sections
|
||||
.filter((section) => !section.id || !currentSectionIds.has(section.id))
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
exerciseId,
|
||||
title: section.title,
|
||||
description: section.description ?? null,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior
|
||||
}));
|
||||
|
||||
if (sectionsToCreate.length > 0) {
|
||||
await createExerciseSections(sectionsToCreate, txClient);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
sections
|
||||
.filter((section) => section.id && currentSectionIds.has(section.id))
|
||||
.map((section) =>
|
||||
updateExerciseSection(
|
||||
section.id!,
|
||||
{
|
||||
title: section.title,
|
||||
description: section.description ?? null,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior
|
||||
},
|
||||
txClient
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function validateSectionQuestionAssignments(data: TExerciseUpdate) {
|
||||
if (!data.sections || data.sections.length === 0 || !data.questions) return;
|
||||
|
||||
const sectionIds = new Set(data.sections.map((section) => section.id).filter((id): id is string => !!id));
|
||||
const unassignedQuestion = data.questions.find(
|
||||
(question) => !question.deletedAt && (!question.exerciseSectionId || !sectionIds.has(question.exerciseSectionId))
|
||||
);
|
||||
|
||||
if (unassignedQuestion) {
|
||||
throw new AppError(
|
||||
'All active questions must be assigned to an exercise section',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveExerciseCourseId(exercise: TExercise): Promise<string | null> {
|
||||
if (exercise.courseId) {
|
||||
return exercise.courseId;
|
||||
}
|
||||
|
||||
if (!exercise.lessonId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lesson = await getLessonById(exercise.lessonId);
|
||||
|
||||
return lesson?.courseId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new exercise with optional questions and options
|
||||
* @param data Exercise creation data
|
||||
* @returns Created exercise
|
||||
*/
|
||||
export async function createExercise(data: TExerciseCreate): Promise<TExercise> {
|
||||
try {
|
||||
const sanitizedData = sanitizeExercisePayload(data);
|
||||
|
||||
await guardNonAutoGradableQuestionsForCourseType({
|
||||
courseId: sanitizedData.courseId,
|
||||
questionTypeIds: (sanitizedData.questions ?? []).map((question) => question.questionTypeId)
|
||||
});
|
||||
|
||||
const slug = await resolveItemSlug({
|
||||
courseId: sanitizedData.courseId,
|
||||
title: sanitizedData.title,
|
||||
requestedSlug: sanitizedData.slug
|
||||
});
|
||||
|
||||
const exerciseData = {
|
||||
title: sanitizedData.title,
|
||||
description: sanitizedData.description ?? null,
|
||||
lessonId: sanitizedData.lessonId || null,
|
||||
courseId: sanitizedData.courseId,
|
||||
sectionId: sanitizedData.sectionId || null,
|
||||
order: sanitizedData.order ?? null,
|
||||
dueBy: sanitizedData.dueBy || null,
|
||||
slug
|
||||
};
|
||||
|
||||
const [exercise] = await createExercises([exerciseData]);
|
||||
if (!exercise) {
|
||||
throw new AppError('Failed to create exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
// Create questions and options if provided
|
||||
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
|
||||
await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
const questionsData: TNewQuestion[] = sanitizedData.questions!.map((q) => ({
|
||||
exerciseId: exercise.id,
|
||||
title: q.question,
|
||||
questionTypeId: q.questionTypeId || 1,
|
||||
points: q.points || 0,
|
||||
order: typeof q.order === 'number' ? q.order : undefined,
|
||||
settings: q.settings ?? {}
|
||||
}));
|
||||
|
||||
const questions = await createQuestions(questionsData, txClient);
|
||||
const hasOptions = sanitizedData.questions!.some((question) => (question.options?.length ?? 0) > 0);
|
||||
|
||||
if (hasOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
// Create options for each question
|
||||
for (const [index, questionData] of sanitizedData.questions!.entries()) {
|
||||
const question = questions[index];
|
||||
if (!question) continue;
|
||||
|
||||
const optionsData: TNewOption[] = (questionData.options ?? []).map((opt) => ({
|
||||
questionId: question.id,
|
||||
label: opt.label,
|
||||
isCorrect: opt.isCorrect,
|
||||
settings: opt.settings ?? {}
|
||||
}));
|
||||
|
||||
await createOptions(optionsData, txClient);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch the complete exercise with questions and options
|
||||
const result = await getExercise(exercise.id);
|
||||
|
||||
if (sanitizedData.courseId) {
|
||||
await touchCourseUpdatedAt(sanitizedData.courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('createExercise error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create exercise',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an exercise by ID with questions and options
|
||||
* @param exerciseId Exercise ID
|
||||
* @returns Exercise with questions and options
|
||||
*/
|
||||
export async function getExercise(
|
||||
exerciseId: string,
|
||||
dbClient?: DbOrTxClient,
|
||||
profileId?: string
|
||||
): Promise<ExerciseWithQuestions> {
|
||||
try {
|
||||
// Optimized: Fetch exercise + questions + questionTypes + options in one JOIN query
|
||||
const { exercise, questions } = await getExerciseWithRelationsOptimized(exerciseId, dbClient);
|
||||
|
||||
const isComplete = profileId ? await getExerciseCompletionByProfile(exerciseId, profileId, dbClient) : false;
|
||||
|
||||
// Build questionTypeMap from questions (already fetched with questionType)
|
||||
const questionTypeMap = new Map(questions.map((q) => [q.questionType.id, q.questionType]));
|
||||
|
||||
// Extract options from questions and transform
|
||||
const allOptions = questions.flatMap((q) => q.options || []);
|
||||
|
||||
// Transform questions (strip questionType and options since they're handled in transform)
|
||||
const questionsForTransform = questions.map(({ questionType, options, ...q }) => q);
|
||||
const questionsWithOptions = transformQuestions(questionsForTransform, allOptions, questionTypeMap);
|
||||
const sections = await getExerciseSectionsByExerciseId(exerciseId, dbClient);
|
||||
const sectionGroups = groupQuestionsBySections(sections, questionsWithOptions);
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
isComplete,
|
||||
questions: questionsWithOptions,
|
||||
sections: sectionGroups.sections
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch exercise',
|
||||
ErrorCodes.EXERCISE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an exercise with questions and options.
|
||||
* Only updates what has changed by querying the current state first and computing a diff.
|
||||
* @param exerciseId Exercise ID
|
||||
* @param data Partial exercise update data
|
||||
* @returns Updated exercise
|
||||
*/
|
||||
export async function updateExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
|
||||
try {
|
||||
const sanitizedData = sanitizeExercisePayload(data);
|
||||
validateSectionQuestionAssignments(sanitizedData);
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
if (existing?.courseId) {
|
||||
await guardNonAutoGradableQuestionsForCourseType({
|
||||
courseId: existing.courseId,
|
||||
questionTypeIds: (sanitizedData.questions ?? [])
|
||||
.filter((question) => !('deletedAt' in question && question.deletedAt))
|
||||
.map((question) => question.questionTypeId)
|
||||
});
|
||||
}
|
||||
|
||||
if (sanitizedData.slug !== undefined && existing?.courseId) {
|
||||
sanitizedData.slug = await resolveItemSlug({
|
||||
courseId: existing.courseId,
|
||||
title: sanitizedData.title ?? existing.title,
|
||||
requestedSlug: sanitizedData.slug,
|
||||
ignoreItemSlug: existing.slug ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
|
||||
// Update exercise basic fields only if they changed
|
||||
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
|
||||
if (Object.keys(exerciseUpdate).length > 0) {
|
||||
const updated = await updateExercise(exerciseId, exerciseUpdate, txClient);
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedData.sections !== undefined) {
|
||||
await syncExerciseSections(exerciseId, sanitizedData.sections, txClient);
|
||||
}
|
||||
|
||||
// Process questions - only update what changed
|
||||
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
|
||||
// Query current state from DB
|
||||
const { questions: currentQuestions, options: currentOptions } = await fetchQuestionsAndOptions(
|
||||
exerciseId,
|
||||
txClient
|
||||
);
|
||||
|
||||
// Build current state with options mapped to their questions
|
||||
const currentQuestionsWithOptions = currentQuestions.map((q) => ({
|
||||
...q,
|
||||
options: currentOptions.filter((opt) => opt.questionId === q.id)
|
||||
}));
|
||||
|
||||
// Compute diff between current DB state and incoming data
|
||||
const diff = computeExerciseDiff(currentQuestionsWithOptions, sanitizedData.questions);
|
||||
|
||||
const hasNewQuestions = diff.questions.newQuestions.length > 0;
|
||||
const hasNewOptions = diff.options.creates.length > 0 || hasNewQuestions;
|
||||
|
||||
if (hasNewQuestions) {
|
||||
await syncQuestionIdSequence(txClient);
|
||||
}
|
||||
|
||||
if (hasNewOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
// Apply question/option graph changes in dependency order. Question deletes cascade
|
||||
// options, while new question creation can also create options.
|
||||
if (diff.questions.deletedIds.length > 0) {
|
||||
await deleteQuestionsByIds(diff.questions.deletedIds, txClient);
|
||||
}
|
||||
|
||||
if (diff.options.deletedIds.length > 0) {
|
||||
await deleteOptionsByIds(diff.options.deletedIds, txClient);
|
||||
}
|
||||
|
||||
if (diff.questions.updates.length > 0) {
|
||||
await updateQuestions(diff.questions.updates, txClient);
|
||||
}
|
||||
|
||||
if (diff.options.updates.length > 0) {
|
||||
await updateOptions(diff.options.updates, txClient);
|
||||
}
|
||||
|
||||
if (diff.options.creates.length > 0) {
|
||||
await createOptions(diff.options.creates, txClient);
|
||||
}
|
||||
|
||||
if (hasNewQuestions) {
|
||||
await createNewQuestionsWithOptions(exerciseId, diff.questions.newQuestions, txClient);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated exercise
|
||||
return await getExercise(exerciseId, txClient);
|
||||
});
|
||||
|
||||
const courseId = existing?.courseId;
|
||||
if (courseId) {
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('updateExerciseService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError('Failed to update exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateExerciseSectionMetadataService(
|
||||
exerciseId: string,
|
||||
exerciseSectionId: string,
|
||||
data: { title?: string; description?: string | null }
|
||||
) {
|
||||
try {
|
||||
const sections = await getExerciseSectionsByExerciseId(exerciseId);
|
||||
const belongs = sections.some((section) => section.id === exerciseSectionId);
|
||||
|
||||
if (!belongs) {
|
||||
throw new AppError(
|
||||
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const patch: { title?: string; description?: string | null } = {};
|
||||
|
||||
if (data.title !== undefined) {
|
||||
patch.title = sanitizeHtml(data.title);
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
patch.description = data.description === '' ? null : (sanitizeOptionalHtml(data.description) ?? null);
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new AppError('No fields to update', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const updated = await updateExerciseSection(exerciseSectionId, patch);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update exercise section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
if (existing?.courseId) {
|
||||
await touchCourseUpdatedAt(existing.courseId);
|
||||
}
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
description: updated.description ?? null,
|
||||
exerciseId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('updateExerciseSectionMetadataService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update exercise section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createExerciseSectionService(
|
||||
exerciseId: string,
|
||||
data: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
order: number;
|
||||
colorTheme?: 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
|
||||
afterBehavior?:
|
||||
| { action: 'continue' }
|
||||
| { action: 'submit' }
|
||||
| { action: 'go_to_section'; exerciseSectionId: string };
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const sections = await getExerciseSectionsByExerciseId(exerciseId);
|
||||
const afterBehavior = data.afterBehavior;
|
||||
|
||||
if (afterBehavior?.action === 'go_to_section') {
|
||||
const targetExists = sections.some((section) => section.id === afterBehavior.exerciseSectionId);
|
||||
|
||||
if (!targetExists) {
|
||||
throw new AppError(
|
||||
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
404
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [created] = await createExerciseSections([
|
||||
{
|
||||
exerciseId,
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
order: data.order,
|
||||
colorTheme: data.colorTheme ?? 'blue',
|
||||
afterBehavior: data.afterBehavior ?? { action: 'continue' }
|
||||
}
|
||||
]);
|
||||
|
||||
if (!created) {
|
||||
throw new AppError('Failed to create exercise section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
if (existing?.courseId) {
|
||||
await touchCourseUpdatedAt(existing.courseId);
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
exerciseId: created.exerciseId,
|
||||
title: created.title,
|
||||
description: created.description ?? null,
|
||||
order: created.order,
|
||||
colorTheme: created.colorTheme,
|
||||
afterBehavior: created.afterBehavior
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('createExerciseSectionService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create exercise section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an exercise's questions and options with the provided payload.
|
||||
* Existing questions are deleted and recreated from the replacement payload.
|
||||
*/
|
||||
export async function replaceExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
|
||||
try {
|
||||
const sanitizedData = sanitizeExercisePayload(data);
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
|
||||
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
|
||||
if (Object.keys(exerciseUpdate).length > 0) {
|
||||
const updated = await updateExercise(exerciseId, exerciseUpdate, txClient);
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const { questions: currentQuestions } = await fetchQuestionsAndOptions(exerciseId, txClient);
|
||||
const currentQuestionIds = currentQuestions
|
||||
.map((question) => question.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
|
||||
if (currentQuestionIds.length > 0) {
|
||||
await deleteQuestionsByIds(currentQuestionIds, txClient);
|
||||
}
|
||||
|
||||
const replacementQuestions = sanitizedData.questions ?? [];
|
||||
const hasReplacementOptions = replacementQuestions.some((question) => (question.options?.length ?? 0) > 0);
|
||||
|
||||
if (replacementQuestions.length > 0) {
|
||||
await syncQuestionIdSequence(txClient);
|
||||
|
||||
if (hasReplacementOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
await createNewQuestionsWithOptions(exerciseId, replacementQuestions, txClient);
|
||||
}
|
||||
|
||||
return await getExercise(exerciseId, txClient);
|
||||
});
|
||||
|
||||
const courseId = existing?.courseId;
|
||||
if (courseId) {
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('replaceExerciseService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError('Failed to replace exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an exercise (cascade deletes questions and options)
|
||||
* @param exerciseId Exercise ID
|
||||
* @returns Deleted exercise
|
||||
*/
|
||||
export async function deleteExerciseService(exerciseId: string): Promise<TExercise> {
|
||||
try {
|
||||
const exercise = await getExerciseById(exerciseId);
|
||||
if (!exercise) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const deleted = await deleteExercise(exerciseId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
if (exercise.courseId) {
|
||||
await touchCourseUpdatedAt(exercise.courseId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete exercise',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteExerciseForCourseService(courseId: string, exerciseId: string): Promise<TExercise> {
|
||||
try {
|
||||
const exercise = await getExerciseById(exerciseId);
|
||||
if (!exercise) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const exerciseCourseId = await resolveExerciseCourseId(exercise);
|
||||
if (exerciseCourseId !== courseId) {
|
||||
throw new AppError('Exercise does not belong to this course', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const deleted = await deleteExercise(exerciseId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete exercise',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists exercises for a course
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Optional lesson ID filter
|
||||
* @returns Array of exercises
|
||||
*/
|
||||
export async function listExercises(
|
||||
courseId: string,
|
||||
filters: { lessonId?: string; sectionId?: string } = {},
|
||||
profileId?: string
|
||||
): Promise<ExerciseWithCompletion[]> {
|
||||
try {
|
||||
const exercises = await getExercisesByCourseId(courseId, filters);
|
||||
|
||||
if (!profileId) {
|
||||
return exercises.map((exercise) => ({
|
||||
...exercise,
|
||||
isComplete: false
|
||||
}));
|
||||
}
|
||||
|
||||
const completedIds = await getExerciseCompletionsByProfile(
|
||||
exercises.map((exercise) => exercise.id),
|
||||
profileId
|
||||
);
|
||||
const completedSet = new Set(completedIds);
|
||||
|
||||
return exercises.map((exercise) => ({
|
||||
...exercise,
|
||||
isComplete: completedSet.has(exercise.id)
|
||||
}));
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list exercises',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an exercise from a template
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Lesson ID
|
||||
* @param template Exercise template with questionnaire
|
||||
* @returns Created exercise with questions and options
|
||||
*/
|
||||
export async function createExerciseFromTemplate(
|
||||
courseId: string,
|
||||
lessonId: string | undefined,
|
||||
sectionId: string | undefined,
|
||||
order: number | undefined,
|
||||
template: TExerciseTemplate
|
||||
): Promise<TExercise> {
|
||||
try {
|
||||
// Create the exercise
|
||||
const exerciseData: TExerciseCreate = {
|
||||
title: template.title!,
|
||||
description: template.description ?? undefined,
|
||||
lessonId,
|
||||
sectionId,
|
||||
order,
|
||||
courseId
|
||||
};
|
||||
|
||||
const exercise = await createExercise(exerciseData);
|
||||
|
||||
// Process the questionnaire to create questions and options
|
||||
const { questions } = template.questionnaire!;
|
||||
|
||||
if (questions && questions.length > 0) {
|
||||
await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
const hasTemplateOptions = questions.some(
|
||||
(question) => question.question_type.id !== 3 && (question.options?.length ?? 0) > 0
|
||||
);
|
||||
|
||||
if (hasTemplateOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
for (const question of questions) {
|
||||
const { title, question_type, options, order, points } = question;
|
||||
const questionSettings = sanitizeUnknownStrings(
|
||||
(question as { settings?: Record<string, unknown> }).settings ?? {}
|
||||
);
|
||||
|
||||
// Create question
|
||||
const [newQuestion] = await createQuestions(
|
||||
[
|
||||
{
|
||||
exerciseId: exercise.id,
|
||||
title: sanitizeHtml(title),
|
||||
questionTypeId: question_type.id,
|
||||
points: points || 0,
|
||||
order: order || 0,
|
||||
settings: questionSettings
|
||||
}
|
||||
],
|
||||
txClient
|
||||
);
|
||||
|
||||
if (!newQuestion) continue;
|
||||
|
||||
// Create options if not TEXTAREA type
|
||||
if (question_type.id !== 3 && options && options.length > 0) {
|
||||
// Filter out deleted options
|
||||
|
||||
// value column is UUID (default gen_random_uuid()); label is the display text only
|
||||
const optionsData: TNewOption[] = options.map((opt) => ({
|
||||
questionId: newQuestion.id,
|
||||
label: sanitizeHtml(opt.label || ''),
|
||||
isCorrect: opt.is_correct || false,
|
||||
settings: sanitizeUnknownStrings((opt as { settings?: Record<string, unknown> }).settings ?? {})
|
||||
}));
|
||||
|
||||
await createOptions(optionsData, txClient);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch the complete exercise with questions and options
|
||||
return await getExercise(exercise.id);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create exercise from template',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets LMS exercises for a student in an organization
|
||||
* Returns exercises from unlocked lessons in courses where the student is a member
|
||||
* @param profileId - The profile ID of the student
|
||||
* @param orgId - The organization ID
|
||||
* @returns Array of exercises with related data
|
||||
*/
|
||||
export async function getLMSExercisesService(profileId: string, orgId: string) {
|
||||
try {
|
||||
const exercises = await getLMSExercises(profileId, orgId);
|
||||
return exercises;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch LMS exercises',
|
||||
ErrorCodes.EXERCISE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import type { TExercise, TNewOption, TNewQuestion, TQuestionAnswer, TQuestionType } from '@cio/db/types';
|
||||
import {
|
||||
createOptions,
|
||||
createQuestions,
|
||||
deleteOptionsByIds,
|
||||
deleteQuestionsByIds,
|
||||
getOptionsByQuestionIds,
|
||||
getQuestionTypesByIds,
|
||||
getQuestionsByExerciseIds,
|
||||
updateOptions,
|
||||
updateQuestions
|
||||
} from '@cio/db/queries/exercise';
|
||||
|
||||
import type { DbOrTxClient } from '@cio/db/drizzle';
|
||||
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
|
||||
|
||||
type CurrentQuestion = TNewQuestion & { options?: TNewOption[] };
|
||||
type CurrentOption = TNewOption;
|
||||
|
||||
type ExerciseAnswer = Pick<
|
||||
TQuestionAnswer,
|
||||
'id' | 'answerData' | 'point' | 'questionId' | 'submissionId' | 'groupMemberId'
|
||||
> & { id?: number };
|
||||
|
||||
export type QuestionWithRelations = {
|
||||
id: number | string;
|
||||
exerciseSectionId?: string | null;
|
||||
value: string;
|
||||
name: string;
|
||||
title: string;
|
||||
type: number;
|
||||
points: number;
|
||||
order: number;
|
||||
questionType: {
|
||||
id: number;
|
||||
label: string;
|
||||
};
|
||||
questionTypeId: number;
|
||||
settings?: Record<string, unknown>;
|
||||
code?: string;
|
||||
answers?: ExerciseAnswer[];
|
||||
deletedAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
options: {
|
||||
id: number | string;
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
isCorrect: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
deletedAt?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
function normalizeSettings(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function areSettingsEqual(left: unknown, right: unknown): boolean {
|
||||
const leftNormalized = normalizeSettings(left);
|
||||
const rightNormalized = normalizeSettings(right);
|
||||
|
||||
return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts exercise-level fields from the update payload
|
||||
*/
|
||||
export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExercise> {
|
||||
const exerciseUpdate: Partial<TExercise> = {};
|
||||
if (data.title !== undefined) exerciseUpdate.title = data.title;
|
||||
if (data.description !== undefined) exerciseUpdate.description = data.description;
|
||||
if (data.lessonId !== undefined) exerciseUpdate.lessonId = data.lessonId || null;
|
||||
if (data.sectionId !== undefined) exerciseUpdate.sectionId = data.sectionId || null;
|
||||
if (data.order !== undefined) exerciseUpdate.order = data.order;
|
||||
if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked;
|
||||
if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null;
|
||||
if (data.allowMultipleAttempts !== undefined) exerciseUpdate.allowMultipleAttempts = data.allowMultipleAttempts;
|
||||
if (data.sectionDisplayMode !== undefined) exerciseUpdate.sectionDisplayMode = data.sectionDisplayMode;
|
||||
if (data.slug !== undefined) exerciseUpdate.slug = data.slug;
|
||||
return exerciseUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorizes questions into deleted, existing, and new
|
||||
*/
|
||||
export function categorizeQuestions(questions: NonNullable<TExerciseUpdate['questions']>) {
|
||||
const deletedIds: number[] = [];
|
||||
const existingQuestions: typeof questions = [];
|
||||
const newQuestions: typeof questions = [];
|
||||
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
|
||||
|
||||
for (const q of questions) {
|
||||
if (q.deletedAt && q.id) {
|
||||
deletedIds.push(q.id);
|
||||
} else if (q.id) {
|
||||
existingQuestions.push(q);
|
||||
questionUpdates.push({
|
||||
id: q.id,
|
||||
data: {
|
||||
title: q.question,
|
||||
points: q.points,
|
||||
questionTypeId: q.questionTypeId,
|
||||
settings: normalizeSettings(q.settings)
|
||||
}
|
||||
});
|
||||
} else {
|
||||
newQuestions.push(q);
|
||||
}
|
||||
}
|
||||
|
||||
return { deletedIds, existingQuestions, newQuestions, questionUpdates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorizes options for a single question into deleted, existing, and new
|
||||
*/
|
||||
export function categorizeOptions(
|
||||
questionId: number,
|
||||
options: NonNullable<TExerciseUpdate['questions']>[number]['options']
|
||||
) {
|
||||
const deletedIds: number[] = [];
|
||||
const existingUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
|
||||
const newOptions: TNewOption[] = [];
|
||||
|
||||
if (!options || options.length === 0) {
|
||||
return { deletedIds, existingUpdates, newOptions };
|
||||
}
|
||||
|
||||
for (const opt of options) {
|
||||
if (opt.deletedAt && opt.id) {
|
||||
deletedIds.push(opt.id);
|
||||
} else if (opt.id) {
|
||||
existingUpdates.push({
|
||||
id: opt.id,
|
||||
data: {
|
||||
label: opt.label,
|
||||
isCorrect: opt.isCorrect,
|
||||
settings: normalizeSettings(opt.settings)
|
||||
}
|
||||
});
|
||||
} else {
|
||||
newOptions.push({
|
||||
questionId,
|
||||
label: opt.label,
|
||||
isCorrect: opt.isCorrect,
|
||||
settings: normalizeSettings(opt.settings)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { deletedIds, existingUpdates, newOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares current question with incoming question data and returns only changed fields
|
||||
*/
|
||||
function diffQuestion(
|
||||
current: CurrentQuestion,
|
||||
incoming: NonNullable<TExerciseUpdate['questions']>[number]
|
||||
): Partial<TNewQuestion> | null {
|
||||
const changes: Partial<TNewQuestion> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
if (incoming.question !== current.title) {
|
||||
changes.title = incoming.question;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (incoming.points !== undefined && incoming.points !== current.points) {
|
||||
changes.points = incoming.points;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (incoming.questionTypeId !== undefined && incoming.questionTypeId !== current.questionTypeId) {
|
||||
changes.questionTypeId = incoming.questionTypeId;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (incoming.order !== undefined && incoming.order !== (current.order ?? undefined)) {
|
||||
changes.order = incoming.order;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (incoming.exerciseSectionId !== undefined && incoming.exerciseSectionId !== current.exerciseSectionId) {
|
||||
changes.exerciseSectionId = incoming.exerciseSectionId;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (!areSettingsEqual(current.settings, incoming.settings)) {
|
||||
changes.settings = normalizeSettings(incoming.settings);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
return hasChanges ? changes : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares current option with incoming option data and returns only changed fields
|
||||
*/
|
||||
function diffOption(
|
||||
current: CurrentOption,
|
||||
incoming: NonNullable<NonNullable<TExerciseUpdate['questions']>[number]['options']>[number]
|
||||
): Partial<TNewOption> | null {
|
||||
const changes: Partial<TNewOption> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
if (incoming.label !== current.label) {
|
||||
changes.label = incoming.label;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (incoming.isCorrect !== current.isCorrect) {
|
||||
changes.isCorrect = incoming.isCorrect;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (!areSettingsEqual(current.settings, incoming.settings)) {
|
||||
changes.settings = normalizeSettings(incoming.settings);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
return hasChanges ? changes : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes diff between current exercise state and incoming update data
|
||||
* Returns only the changes that need to be applied
|
||||
*/
|
||||
export function computeExerciseDiff(
|
||||
currentQuestions: CurrentQuestion[],
|
||||
incomingQuestions: NonNullable<TExerciseUpdate['questions']>
|
||||
) {
|
||||
const questionMap = new Map(currentQuestions.map((q) => [q.id, q]));
|
||||
|
||||
const deletedQuestionIds: number[] = [];
|
||||
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
|
||||
const newQuestions: NonNullable<TExerciseUpdate['questions']> = [];
|
||||
|
||||
// Process incoming questions
|
||||
for (const incoming of incomingQuestions) {
|
||||
if (incoming.deletedAt && incoming.id) {
|
||||
deletedQuestionIds.push(incoming.id);
|
||||
} else if (incoming.id) {
|
||||
const current = questionMap.get(incoming.id);
|
||||
if (current) {
|
||||
const changes = diffQuestion(current, incoming);
|
||||
if (changes) {
|
||||
questionUpdates.push({ id: incoming.id, data: changes });
|
||||
}
|
||||
} else {
|
||||
// Question ID exists but not in current state - treat as new
|
||||
newQuestions.push(incoming);
|
||||
}
|
||||
} else {
|
||||
newQuestions.push(incoming);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute option diffs for existing questions
|
||||
const optionDeletes: number[] = [];
|
||||
const optionUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
|
||||
const optionCreates: TNewOption[] = [];
|
||||
|
||||
for (const incoming of incomingQuestions) {
|
||||
if (!incoming.id || incoming.deletedAt) continue;
|
||||
|
||||
const current = questionMap.get(incoming.id);
|
||||
if (!current) continue;
|
||||
|
||||
const currentOptionMap = new Map((current.options || []).map((opt) => [opt.id, opt]));
|
||||
const incomingOptions = incoming.options || [];
|
||||
const processedIncomingOptionIds = new Set<number>();
|
||||
|
||||
for (const incomingOpt of incomingOptions) {
|
||||
if (incomingOpt.deletedAt && incomingOpt.id) {
|
||||
optionDeletes.push(incomingOpt.id);
|
||||
|
||||
processedIncomingOptionIds.add(incomingOpt.id);
|
||||
} else if (incomingOpt.id) {
|
||||
processedIncomingOptionIds.add(incomingOpt.id);
|
||||
|
||||
const currentOpt = currentOptionMap.get(incomingOpt.id);
|
||||
if (currentOpt) {
|
||||
const changes = diffOption(currentOpt, incomingOpt);
|
||||
if (changes) {
|
||||
optionUpdates.push({ id: incomingOpt.id, data: changes });
|
||||
}
|
||||
} else {
|
||||
// Option ID exists in incoming but not in current - treat as create
|
||||
optionCreates.push({
|
||||
questionId: incoming.id,
|
||||
label: incomingOpt.label,
|
||||
isCorrect: incomingOpt.isCorrect,
|
||||
settings: normalizeSettings(incomingOpt.settings),
|
||||
value: crypto.randomUUID()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// New option without ID
|
||||
optionCreates.push({
|
||||
questionId: incoming.id,
|
||||
label: incomingOpt.label,
|
||||
isCorrect: incomingOpt.isCorrect,
|
||||
settings: normalizeSettings(incomingOpt.settings),
|
||||
value: crypto.randomUUID()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for options that were deleted (exist in current but not in incoming)
|
||||
for (const [optId] of currentOptionMap) {
|
||||
if (optId !== undefined && !processedIncomingOptionIds.has(optId)) {
|
||||
optionDeletes.push(optId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
questions: {
|
||||
deletedIds: deletedQuestionIds,
|
||||
updates: questionUpdates,
|
||||
newQuestions
|
||||
},
|
||||
options: {
|
||||
deletedIds: optionDeletes,
|
||||
updates: optionUpdates,
|
||||
creates: optionCreates
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new questions and their options in batch
|
||||
*/
|
||||
export async function createNewQuestionsWithOptions(
|
||||
exerciseId: string,
|
||||
newQuestions: NonNullable<TExerciseUpdate['questions']>,
|
||||
txClient: DbOrTxClient
|
||||
): Promise<void> {
|
||||
const createdQuestions = await createQuestions(
|
||||
newQuestions.map((q) => ({
|
||||
exerciseId,
|
||||
title: q.question,
|
||||
questionTypeId: q.questionTypeId || 1,
|
||||
points: q.points || 0,
|
||||
order: typeof q.order === 'number' ? q.order : undefined,
|
||||
exerciseSectionId: q.exerciseSectionId ?? null,
|
||||
settings: normalizeSettings(q.settings)
|
||||
})),
|
||||
txClient
|
||||
);
|
||||
|
||||
const newOptions: TNewOption[] = [];
|
||||
createdQuestions.forEach((newQuestion, index) => {
|
||||
const questionData = newQuestions[index];
|
||||
if (newQuestion && questionData?.options && questionData.options.length > 0) {
|
||||
// Only include non-deleted options for new questions
|
||||
for (const opt of questionData.options) {
|
||||
if (!opt.deletedAt) {
|
||||
newOptions.push({
|
||||
questionId: newQuestion.id,
|
||||
label: opt.label,
|
||||
isCorrect: opt.isCorrect,
|
||||
settings: normalizeSettings(opt.settings)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (newOptions.length > 0) {
|
||||
await createOptions(newOptions, txClient);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches questions and their options for an exercise
|
||||
*/
|
||||
export async function fetchQuestionsAndOptions(exerciseId: string, dbClient?: DbOrTxClient) {
|
||||
const questions = await getQuestionsByExerciseIds([exerciseId], dbClient);
|
||||
const questionIds = questions.map((q) => q.id).filter((id): id is number => id !== undefined);
|
||||
const options = questionIds.length > 0 ? await getOptionsByQuestionIds(questionIds, dbClient) : [];
|
||||
return { questions, options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches question types and creates a lookup map
|
||||
*/
|
||||
export async function fetchQuestionTypesMap(
|
||||
questions: TNewQuestion[],
|
||||
dbClient?: DbOrTxClient
|
||||
): Promise<Map<number, TQuestionType>> {
|
||||
const questionTypeIds = [
|
||||
...new Set(questions.map((q) => q.questionTypeId).filter((id): id is number => id !== undefined))
|
||||
];
|
||||
const questionTypes: TQuestionType[] =
|
||||
questionTypeIds.length > 0 ? await getQuestionTypesByIds(questionTypeIds, dbClient) : [];
|
||||
return new Map(questionTypes.map((qt) => [qt.id, qt]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a single question with its options to frontend format
|
||||
*/
|
||||
export function transformQuestion(
|
||||
question: TNewQuestion,
|
||||
options: TNewOption[],
|
||||
questionTypeMap: Map<number, TQuestionType>
|
||||
): QuestionWithRelations {
|
||||
const questionType = questionTypeMap.get(question.questionTypeId);
|
||||
const questionTypeId = question.questionTypeId;
|
||||
const questionOptions = options
|
||||
.filter((opt) => opt.questionId === question.id)
|
||||
.map((opt) => ({
|
||||
id: opt.id!,
|
||||
label: opt.label ?? null,
|
||||
value: opt.value ?? null,
|
||||
isCorrect: opt.isCorrect ?? false,
|
||||
settings: normalizeSettings(opt.settings)
|
||||
}));
|
||||
|
||||
return {
|
||||
id: question.id!,
|
||||
exerciseSectionId: question.exerciseSectionId ?? null,
|
||||
value: question.title || '', // value field defaults to title
|
||||
name: question.name || '',
|
||||
title: question.title,
|
||||
type: questionTypeId, // type field (same as questionTypeId)
|
||||
points: question.points || 0,
|
||||
order: question.order || 0,
|
||||
questionType: questionType
|
||||
? {
|
||||
id: questionType.id,
|
||||
label: questionType.label
|
||||
}
|
||||
: {
|
||||
id: questionTypeId,
|
||||
label: ''
|
||||
},
|
||||
questionTypeId: questionTypeId,
|
||||
settings: normalizeSettings(question.settings),
|
||||
createdAt: question.createdAt || undefined,
|
||||
updatedAt: question.updatedAt || undefined,
|
||||
options: questionOptions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms all questions to frontend format
|
||||
*/
|
||||
export function transformQuestions(
|
||||
questions: TNewQuestion[],
|
||||
options: TNewOption[],
|
||||
questionTypeMap: Map<number, TQuestionType>
|
||||
): QuestionWithRelations[] {
|
||||
return questions.map((question) => transformQuestion(question, options, questionTypeMap));
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import { JOB_STATUS, type JobEnvelope, type JobStatus, suggestNextPollMs } from '@cio/jobs/status';
|
||||
import {
|
||||
createMediaJob,
|
||||
getMediaJobById,
|
||||
listJobStepsByRun,
|
||||
listLatestMediaJobsByAsset,
|
||||
listMediaJobsByIds,
|
||||
requestMediaJobCancel,
|
||||
updateMediaJob,
|
||||
hasActiveMediaJobForAsset
|
||||
} from '@cio/db/queries';
|
||||
import { getAssetById } from '@cio/db/queries/assets';
|
||||
import {
|
||||
enqueueGenerateThumbnailOnly,
|
||||
enqueueLessonVideoPipeline,
|
||||
enqueueTranscriptionOnly,
|
||||
isRedisConfigured
|
||||
} from '@cio/jobs';
|
||||
|
||||
import { logRedisUnavailableOnce } from '../../utils/redis/redis';
|
||||
import type { TMediaJob } from '@cio/db/types';
|
||||
|
||||
export interface StartTranscriptionOnlyMediaJobInput {
|
||||
organizationId: string;
|
||||
assetId: string;
|
||||
triggeredByProfileId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual transcription: new `media_job` + extract-audio → transcribe-audio only.
|
||||
* Requires OpenAI key, upload video asset, and no other active media_job for the asset.
|
||||
*/
|
||||
export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOnlyMediaJobInput): Promise<TMediaJob> {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new AppError('Transcription not configured', ErrorCodes.OPENAI_KEY_MISSING, 503);
|
||||
}
|
||||
|
||||
const asset = await getAssetById(input.assetId);
|
||||
if (!asset || asset.organizationId !== input.organizationId) {
|
||||
throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
// HLS uploads carry their pre-extracted audio in `hls_audio_key` and leave
|
||||
// `storage_key` null so lesson rendering doesn't sign the audio file as
|
||||
// the canonical video URL. Raw MP4 uploads continue to use `storage_key`.
|
||||
const transcriptionSourceKey = asset.hlsAudioKey ?? asset.storageKey;
|
||||
|
||||
if (asset.kind !== 'video' || asset.provider !== 'upload' || !transcriptionSourceKey) {
|
||||
throw new AppError('Asset cannot be transcribed', ErrorCodes.ASSET_NOT_TRANSCRIBABLE, 400);
|
||||
}
|
||||
|
||||
const hasActive = await hasActiveMediaJobForAsset(input.assetId, input.organizationId);
|
||||
if (hasActive) {
|
||||
throw new AppError('A media job is already running for this asset', ErrorCodes.CONFLICT, 409);
|
||||
}
|
||||
|
||||
const job = await createMediaJob({
|
||||
organizationId: input.organizationId,
|
||||
assetId: input.assetId,
|
||||
triggeredByProfileId: input.triggeredByProfileId,
|
||||
status: 'queued',
|
||||
stage: 'transcribing',
|
||||
progressPercent: 0
|
||||
});
|
||||
|
||||
if (!isRedisConfigured()) {
|
||||
logRedisUnavailableOnce(
|
||||
'Redis not configured: transcription media job created but no BullMQ flow enqueued. ' +
|
||||
'Set REDIS_URL and run apps/jobs to process this job.'
|
||||
);
|
||||
return job;
|
||||
}
|
||||
|
||||
try {
|
||||
const enqueueResult = await enqueueTranscriptionOnly({
|
||||
mediaJobId: job.id,
|
||||
assetId: input.assetId,
|
||||
storageKey: transcriptionSourceKey,
|
||||
actorContext: {
|
||||
userId: input.triggeredByProfileId,
|
||||
organizationId: input.organizationId
|
||||
}
|
||||
});
|
||||
|
||||
const updated = await updateMediaJob(job.id, {
|
||||
rootJobId: enqueueResult.rootJobId,
|
||||
jobIds: enqueueResult.jobIds
|
||||
});
|
||||
|
||||
return updated ?? job;
|
||||
} catch (error) {
|
||||
console.error('startTranscriptionOnlyMediaJob enqueue failed:', error);
|
||||
await updateMediaJob(job.id, {
|
||||
status: 'failed',
|
||||
stage: 'failed',
|
||||
error: {
|
||||
code: 'ENQUEUE_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Failed to enqueue transcription job'
|
||||
}
|
||||
});
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error : 'Failed to enqueue transcription job',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface StartThumbnailRegenInput {
|
||||
organizationId: string;
|
||||
assetId: string;
|
||||
triggeredByProfileId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual thumbnail regeneration: create a new `media_job` row and enqueue only
|
||||
* the `generate-thumbnail` step. Rejects when the asset has no usable storage
|
||||
* key or when another media job is already running for it.
|
||||
*/
|
||||
export async function startThumbnailRegenJob(input: StartThumbnailRegenInput): Promise<TMediaJob> {
|
||||
const asset = await getAssetById(input.assetId);
|
||||
if (!asset || asset.organizationId !== input.organizationId) {
|
||||
throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (asset.kind !== 'video' || asset.provider !== 'upload' || !asset.storageKey) {
|
||||
throw new AppError('Asset cannot be thumbnailed', ErrorCodes.ASSET_NOT_TRANSCRIBABLE, 400);
|
||||
}
|
||||
|
||||
const hasActive = await hasActiveMediaJobForAsset(input.assetId, input.organizationId);
|
||||
if (hasActive) {
|
||||
throw new AppError('A media job is already running for this asset', ErrorCodes.CONFLICT, 409);
|
||||
}
|
||||
|
||||
const job = await createMediaJob({
|
||||
organizationId: input.organizationId,
|
||||
assetId: input.assetId,
|
||||
triggeredByProfileId: input.triggeredByProfileId,
|
||||
status: 'queued',
|
||||
stage: 'thumbnailing',
|
||||
progressPercent: 0
|
||||
});
|
||||
|
||||
if (!isRedisConfigured()) {
|
||||
logRedisUnavailableOnce(
|
||||
'Redis not configured: thumbnail regen media job created but no BullMQ flow enqueued. ' +
|
||||
'Set REDIS_URL and run apps/jobs to process this job.'
|
||||
);
|
||||
return job;
|
||||
}
|
||||
|
||||
try {
|
||||
const enqueueResult = await enqueueGenerateThumbnailOnly({
|
||||
mediaJobId: job.id,
|
||||
assetId: input.assetId,
|
||||
storageKey: asset.storageKey,
|
||||
actorContext: {
|
||||
userId: input.triggeredByProfileId,
|
||||
organizationId: input.organizationId
|
||||
}
|
||||
});
|
||||
|
||||
const updated = await updateMediaJob(job.id, {
|
||||
rootJobId: enqueueResult.rootJobId,
|
||||
jobIds: enqueueResult.jobIds
|
||||
});
|
||||
|
||||
return updated ?? job;
|
||||
} catch (error) {
|
||||
console.error('startThumbnailRegenJob enqueue failed:', error);
|
||||
await updateMediaJob(job.id, {
|
||||
status: 'failed',
|
||||
stage: 'failed',
|
||||
error: {
|
||||
code: 'ENQUEUE_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Failed to enqueue thumbnail job'
|
||||
}
|
||||
});
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error : 'Failed to enqueue thumbnail job',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface StartMediaJobInput {
|
||||
organizationId: string;
|
||||
assetId: string;
|
||||
storageKey: string;
|
||||
triggeredByProfileId: string | null;
|
||||
/** Set to false for assets without an OpenAI key (skip transcribe leg). */
|
||||
withTranscription?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `media_job` row and enqueue the BullMQ flow. Falls back to a
|
||||
* persisted `queued` row with no BullMQ job id when Redis is unavailable so
|
||||
* the API returns a stable status envelope and a follow-up `start` worker
|
||||
* can pick it up later. The enqueue itself fails loudly (the DB row stays
|
||||
* around so an operator can replay it).
|
||||
*/
|
||||
export async function startMediaJob(input: StartMediaJobInput): Promise<TMediaJob> {
|
||||
const job = await createMediaJob({
|
||||
organizationId: input.organizationId,
|
||||
assetId: input.assetId,
|
||||
triggeredByProfileId: input.triggeredByProfileId,
|
||||
status: 'queued',
|
||||
stage: 'queued',
|
||||
progressPercent: 0
|
||||
});
|
||||
|
||||
if (!isRedisConfigured()) {
|
||||
logRedisUnavailableOnce(
|
||||
'Redis not configured: media job created but no BullMQ flow enqueued. ' +
|
||||
'Set REDIS_URL and run apps/jobs to process this job.'
|
||||
);
|
||||
return job;
|
||||
}
|
||||
|
||||
try {
|
||||
const enqueueResult = await enqueueLessonVideoPipeline({
|
||||
mediaJobId: job.id,
|
||||
assetId: input.assetId,
|
||||
storageKey: input.storageKey,
|
||||
actorContext: {
|
||||
userId: input.triggeredByProfileId,
|
||||
organizationId: input.organizationId
|
||||
},
|
||||
withTranscription: input.withTranscription
|
||||
});
|
||||
|
||||
const updated = await updateMediaJob(job.id, {
|
||||
rootJobId: enqueueResult.rootJobId,
|
||||
jobIds: enqueueResult.jobIds
|
||||
});
|
||||
|
||||
return updated ?? job;
|
||||
} catch (error) {
|
||||
console.error('startMediaJob enqueue failed:', error);
|
||||
await updateMediaJob(job.id, {
|
||||
status: 'failed',
|
||||
stage: 'failed',
|
||||
error: {
|
||||
code: 'ENQUEUE_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Failed to enqueue media job'
|
||||
}
|
||||
});
|
||||
|
||||
throw new AppError(error instanceof Error ? error : 'Failed to enqueue media job', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetMediaJobEnvelopeOptions {
|
||||
pollCount?: number;
|
||||
}
|
||||
|
||||
function toEnvelope(job: TMediaJob, options: GetMediaJobEnvelopeOptions = {}): JobEnvelope {
|
||||
const status = job.status as JobStatus;
|
||||
const pollCount = options.pollCount ?? 0;
|
||||
|
||||
return {
|
||||
job: {
|
||||
id: job.id,
|
||||
domain: 'media',
|
||||
status,
|
||||
stage: job.stage,
|
||||
progressPercent: job.progressPercent,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
result: job.result ?? null,
|
||||
error: job.error ?? null,
|
||||
warnings: job.warnings ?? [],
|
||||
cancelRequestedAt: job.cancelRequestedAt,
|
||||
attempt: job.attempt,
|
||||
maxAttempts: job.maxAttempts
|
||||
},
|
||||
events: [],
|
||||
nextPollMs: suggestNextPollMs(status, pollCount)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMediaJobEnvelope(
|
||||
jobId: string,
|
||||
orgId: string,
|
||||
options: GetMediaJobEnvelopeOptions = {}
|
||||
): Promise<JobEnvelope> {
|
||||
const job = await getMediaJobById(jobId, orgId);
|
||||
if (!job) {
|
||||
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return toEnvelope(job, options);
|
||||
}
|
||||
|
||||
export async function listMediaJobEnvelopes(jobIds: string[], orgId: string): Promise<Record<string, JobEnvelope>> {
|
||||
if (jobIds.length === 0) return {};
|
||||
|
||||
const rows = await listMediaJobsByIds(jobIds, orgId);
|
||||
const map: Record<string, JobEnvelope> = {};
|
||||
for (const row of rows) {
|
||||
map[row.id] = toEnvelope(row);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function listMediaJobsForAssetEnvelopes(assetId: string, orgId: string): Promise<JobEnvelope[]> {
|
||||
const rows = await listLatestMediaJobsByAsset(assetId, orgId);
|
||||
return rows.map((row) => toEnvelope(row));
|
||||
}
|
||||
|
||||
export async function cancelMediaJob(jobId: string, orgId: string): Promise<JobEnvelope> {
|
||||
const job = await getMediaJobById(jobId, orgId);
|
||||
if (!job) {
|
||||
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.completed || job.status === JOB_STATUS.failed || job.status === JOB_STATUS.canceled) {
|
||||
return toEnvelope(job);
|
||||
}
|
||||
|
||||
const updated = await requestMediaJobCancel(jobId, orgId);
|
||||
return toEnvelope(updated ?? job);
|
||||
}
|
||||
|
||||
export async function getMediaJobSteps(jobId: string, orgId: string) {
|
||||
const job = await getMediaJobById(jobId, orgId);
|
||||
if (!job) {
|
||||
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return listJobStepsByRun('media', jobId);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import { sanitizeOptionalHtml } from '../utils/sanitize-html';
|
||||
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@cio/db/types';
|
||||
import {
|
||||
deleteLessonLanguage,
|
||||
getLessonLanguageByLessonIdAndLocale,
|
||||
getLessonLanguagesByLessonId,
|
||||
updateLessonLanguage,
|
||||
upsertLessonLanguage
|
||||
} from '@cio/db/queries/lesson/language';
|
||||
|
||||
import { getLessonById } from '@cio/db/queries/lesson/lesson';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
|
||||
/**
|
||||
* Gets all language translations for a lesson
|
||||
* @param lessonId - The lesson ID
|
||||
* @returns Array of lesson languages
|
||||
*/
|
||||
export async function listLessonLanguages(lessonId: string): Promise<TLessonLanguage[]> {
|
||||
try {
|
||||
const languages = await getLessonLanguagesByLessonId(lessonId);
|
||||
return languages;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch lesson languages',
|
||||
ErrorCodes.LESSON_LANGUAGE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a single lesson language by locale
|
||||
* @param lessonId - The lesson ID
|
||||
* @param locale - The locale
|
||||
* @returns Lesson language or null if not found
|
||||
*/
|
||||
export async function getLessonLanguage(lessonId: string, locale: TLocale): Promise<TLessonLanguage | null> {
|
||||
try {
|
||||
const language = await getLessonLanguageByLessonIdAndLocale(lessonId, locale);
|
||||
return language;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch lesson language',
|
||||
ErrorCodes.LESSON_LANGUAGE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a lesson language translation
|
||||
* @param lessonId - The lesson ID
|
||||
* @param data - Lesson language data
|
||||
* @returns Created or updated lesson language
|
||||
*/
|
||||
export async function upsertLessonLanguageService(
|
||||
lessonId: string,
|
||||
data: Omit<TNewLessonLanguage, 'lessonId'>
|
||||
): Promise<TLessonLanguage> {
|
||||
try {
|
||||
// Verify lesson exists
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const language = await upsertLessonLanguage({
|
||||
...data,
|
||||
content: sanitizeOptionalHtml(data.content),
|
||||
lessonId
|
||||
});
|
||||
|
||||
if (lesson.courseId) {
|
||||
await touchCourseUpdatedAt(lesson.courseId);
|
||||
}
|
||||
|
||||
return language;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create or update lesson language',
|
||||
ErrorCodes.LESSON_LANGUAGE_CREATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a lesson language translation
|
||||
* @param lessonId - The lesson ID
|
||||
* @param locale - The locale
|
||||
* @param data - Lesson language update data
|
||||
* @returns Updated lesson language
|
||||
*/
|
||||
export async function updateLessonLanguageService(
|
||||
lessonId: string,
|
||||
locale: TLocale,
|
||||
data: Partial<TNewLessonLanguage>
|
||||
): Promise<TLessonLanguage> {
|
||||
try {
|
||||
// Verify lesson exists
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const existing = await getLessonLanguageByLessonIdAndLocale(lessonId, locale);
|
||||
if (!existing) {
|
||||
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const language = await updateLessonLanguage(lessonId, locale, {
|
||||
...data,
|
||||
content: sanitizeOptionalHtml(data.content)
|
||||
});
|
||||
|
||||
if (lesson.courseId) {
|
||||
await touchCourseUpdatedAt(lesson.courseId);
|
||||
}
|
||||
|
||||
return language;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update lesson language',
|
||||
ErrorCodes.LESSON_LANGUAGE_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLessonLanguageService(lessonId: string, locale: TLocale): Promise<TLessonLanguage> {
|
||||
try {
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const existing = await getLessonLanguageByLessonIdAndLocale(lessonId, locale);
|
||||
if (!existing) {
|
||||
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const deleted = await deleteLessonLanguage(lessonId, locale);
|
||||
if (!deleted) {
|
||||
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (lesson.courseId) {
|
||||
await touchCourseUpdatedAt(lesson.courseId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete lesson language',
|
||||
ErrorCodes.LESSON_LANGUAGE_DELETE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html';
|
||||
import type { TLesson, TNewLessonComment, TNewLessonCompletion } from '@cio/db/types';
|
||||
import type { TLessonCreate, TLessonReorder, TLessonUpdate } from '@cio/utils/validation/lesson';
|
||||
import {
|
||||
createLessonComment,
|
||||
createLessons,
|
||||
deleteLesson,
|
||||
deleteLessonComment,
|
||||
getLessonById,
|
||||
getLessonCommentsByLessonId,
|
||||
getLessonCommentsByLessonIdPaginated,
|
||||
getLessonCompletion,
|
||||
getLessonVersionHistory,
|
||||
getLessonsByCourseId,
|
||||
updateLesson,
|
||||
updateLessonComment,
|
||||
upsertLessonCompletion,
|
||||
type LessonById
|
||||
} from '@cio/db/queries/lesson';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
import { enrichLessonWithPresignedUrls } from '../../utils/lesson-media';
|
||||
import { resolveItemSlug } from '../course/slug';
|
||||
|
||||
/**
|
||||
* Creates a new lesson
|
||||
* @param courseId Course ID
|
||||
* @param data Lesson creation data
|
||||
* @returns Created lesson
|
||||
*/
|
||||
export async function createLesson(courseId: string, data: TLessonCreate): Promise<TLesson> {
|
||||
try {
|
||||
const slug = await resolveItemSlug({
|
||||
courseId,
|
||||
title: data.title,
|
||||
requestedSlug: data.slug
|
||||
});
|
||||
|
||||
const lessonData = {
|
||||
...data,
|
||||
courseId,
|
||||
slug
|
||||
};
|
||||
|
||||
const [lesson] = await createLessons([lessonData]);
|
||||
if (!lesson) {
|
||||
throw new AppError('Failed to create lesson', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
|
||||
return lesson;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create lesson',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a lesson by ID
|
||||
* @param lessonId Lesson ID
|
||||
* @returns Lesson or throws error if not found
|
||||
*/
|
||||
export async function getLesson(lessonId: string): Promise<LessonById> {
|
||||
try {
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return enrichLessonWithPresignedUrls(lesson);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Log error with context for debugging
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch lesson';
|
||||
console.error(`Error fetching lesson ${lessonId}:`, errorMessage, error);
|
||||
|
||||
throw new AppError(errorMessage, ErrorCodes.LESSON_FETCH_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a lesson
|
||||
* @param lessonId Lesson ID
|
||||
* @param data Partial lesson update data
|
||||
* @returns Updated lesson
|
||||
*/
|
||||
export async function updateLessonService(lessonId: string, data: TLessonUpdate): Promise<TLesson> {
|
||||
try {
|
||||
const payload: TLessonUpdate = { ...data };
|
||||
|
||||
if (payload.slug !== undefined) {
|
||||
const existing = await getLessonById(lessonId);
|
||||
if (!existing) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
if (!existing.courseId) {
|
||||
throw new AppError('Lesson has no course', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
payload.slug = await resolveItemSlug({
|
||||
courseId: existing.courseId,
|
||||
title: payload.title ?? existing.title,
|
||||
requestedSlug: payload.slug,
|
||||
ignoreItemSlug: existing.slug ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await updateLesson(lessonId, payload);
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update lesson', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
if (updated.courseId) {
|
||||
await touchCourseUpdatedAt(updated.courseId);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update lesson',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a lesson
|
||||
* @param lessonId Lesson ID
|
||||
* @returns Deleted lesson
|
||||
*/
|
||||
export async function deleteLessonService(lessonId: string): Promise<TLesson> {
|
||||
try {
|
||||
const deleted = await deleteLesson(lessonId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Failed to delete lesson', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
if (deleted.courseId) {
|
||||
await touchCourseUpdatedAt(deleted.courseId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete lesson',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists lessons for a course
|
||||
* @param courseId Course ID
|
||||
* @param sectionId Optional section ID filter
|
||||
* @returns Array of lessons
|
||||
*/
|
||||
export async function listLessons(courseId: string, sectionId?: string): Promise<TLesson[]> {
|
||||
try {
|
||||
const lessons = await getLessonsByCourseId(courseId);
|
||||
|
||||
if (sectionId) {
|
||||
return lessons.filter((lesson) => lesson.sectionId === sectionId);
|
||||
}
|
||||
|
||||
return lessons;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list lessons',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders lessons
|
||||
* @param lessons Array of lesson IDs, orders, and optional sectionIds
|
||||
* @returns Updated lessons
|
||||
*/
|
||||
export async function reorderLessons(lessons: TLessonReorder['lessons']): Promise<TLesson[]> {
|
||||
try {
|
||||
// Update each lesson's order and optionally sectionId
|
||||
const updatePromises = lessons.map(({ id, order, sectionId }) =>
|
||||
updateLesson(id, { order, ...(sectionId !== undefined && { sectionId }) })
|
||||
);
|
||||
|
||||
const updated = await Promise.all(updatePromises);
|
||||
const validLessons = updated.filter((l): l is TLesson => l !== null);
|
||||
|
||||
const courseId = validLessons[0]?.courseId;
|
||||
if (courseId) {
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
}
|
||||
|
||||
return validLessons;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to reorder lessons',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Lesson Comment Services
|
||||
|
||||
/**
|
||||
* Gets comments for a lesson
|
||||
* @param lessonId Lesson ID
|
||||
* @returns Array of comments
|
||||
*/
|
||||
export async function getLessonComments(lessonId: string) {
|
||||
try {
|
||||
return getLessonCommentsByLessonId(lessonId);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get lesson comments',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets paginated comments for a lesson
|
||||
* @param lessonId Lesson ID
|
||||
* @param options Pagination options (cursor, limit)
|
||||
* @returns Paginated comments with metadata
|
||||
*/
|
||||
export async function getLessonCommentsPaginated(lessonId: string, options: { cursor?: string; limit: number }) {
|
||||
try {
|
||||
return getLessonCommentsByLessonIdPaginated(lessonId, options);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get paginated lesson comments',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lesson comment
|
||||
* @param lessonId Lesson ID
|
||||
* @param groupMemberId Group member ID (author)
|
||||
* @param comment Comment text
|
||||
* @returns Created comment
|
||||
*/
|
||||
export async function createLessonCommentService(lessonId: string, groupMemberId: string, comment: string) {
|
||||
try {
|
||||
const commentData: TNewLessonComment = {
|
||||
lessonId,
|
||||
groupmemberId: groupMemberId,
|
||||
comment: sanitizeHtml(comment)
|
||||
};
|
||||
|
||||
return await createLessonComment(commentData);
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create lesson comment',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a lesson comment
|
||||
* @param commentId Comment ID
|
||||
* @param comment Updated comment text
|
||||
* @returns Updated comment
|
||||
*/
|
||||
export async function updateLessonCommentService(commentId: number, comment: string) {
|
||||
try {
|
||||
const updated = await updateLessonComment(commentId, sanitizeHtml(comment));
|
||||
if (!updated) {
|
||||
throw new AppError('Comment not found', ErrorCodes.LESSON_COMMENT_UPDATE_FAILED, 404);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update lesson comment',
|
||||
ErrorCodes.LESSON_COMMENT_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a lesson comment
|
||||
* @param commentId Comment ID
|
||||
* @returns Deleted comment
|
||||
*/
|
||||
export async function deleteLessonCommentService(commentId: number) {
|
||||
try {
|
||||
const deleted = await deleteLessonComment(commentId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Comment not found', ErrorCodes.COMMENT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete lesson comment',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Lesson Completion Services
|
||||
|
||||
/**
|
||||
* Gets lesson completion status
|
||||
* @param lessonId Lesson ID
|
||||
* @param profileId Profile ID
|
||||
* @returns Completion record or null
|
||||
*/
|
||||
export async function getLessonCompletionService(lessonId: string, profileId: string) {
|
||||
try {
|
||||
return await getLessonCompletion(lessonId, profileId);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get lesson completion',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates lesson completion
|
||||
* @param lessonId Lesson ID
|
||||
* @param profileId Profile ID
|
||||
* @param isComplete Completion status
|
||||
* @returns Completion record
|
||||
*/
|
||||
export async function upsertLessonCompletionService(lessonId: string, profileId: string, isComplete: boolean) {
|
||||
try {
|
||||
const completionData: TNewLessonCompletion = {
|
||||
lessonId,
|
||||
profileId,
|
||||
isComplete
|
||||
};
|
||||
|
||||
return await upsertLessonCompletion(completionData);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update lesson completion',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets lesson version history for a lesson and locale
|
||||
* @param lessonId Lesson ID
|
||||
* @param locale Locale
|
||||
* @param endRange End range for pagination (0-indexed, inclusive)
|
||||
* @returns Array of lesson version history entries
|
||||
*/
|
||||
export async function getLessonHistoryService(lessonId: string, locale: string, endRange: number) {
|
||||
try {
|
||||
return getLessonVersionHistory(lessonId, locale, endRange);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get lesson history',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { enrichObjectsWithUrls, extractKeysFromObjects } from './presigned-urls';
|
||||
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from './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 assetMetadata =
|
||||
typeof asset.metadata === 'object' && asset.metadata !== null && !Array.isArray(asset.metadata)
|
||||
? (asset.metadata as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
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,
|
||||
...(assetMetadata.format === 'hls' ? { hls: true } : {}),
|
||||
...(typeof assetMetadata.sourceWidth === 'number' ? { sourceWidth: assetMetadata.sourceWidth } : {}),
|
||||
...(typeof assetMetadata.sourceHeight === 'number' ? { sourceHeight: assetMetadata.sourceHeight } : {}),
|
||||
...(Array.isArray(assetMetadata.hlsRenditions) ? { hlsRenditions: assetMetadata.hlsRenditions } : {}),
|
||||
...(typeof assetMetadata.hls1080Status === 'string' ? { hls1080Status: assetMetadata.hls1080Status } : {})
|
||||
};
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Pure (no HTTP context) redis key helpers used by the agent runtime.
|
||||
*
|
||||
* The full key-generator set lives in `apps/api/src/utils/redis/key-generators.ts`
|
||||
* because most of them take a Hono `Context` to derive client identity. The
|
||||
* agent runtime only needs the context-free subset, kept here so this package
|
||||
* stays free of hono deps.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Redis key for storing extracted document text from AI assistant uploads.
|
||||
* TTL: 3600 seconds (1 hour).
|
||||
*/
|
||||
export const agentDocumentKey = (documentId: string): string => {
|
||||
return `agent:document:${documentId}`;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createClient, type RedisClientType } from 'redis';
|
||||
import { env } from '../../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;
|
||||
@@ -0,0 +1,215 @@
|
||||
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 '../config/storage';
|
||||
import { logRedisUnavailableOnce, redis } from './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);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,34 @@
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user