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:
@@ -2,3 +2,4 @@ export * from './profile';
|
||||
export * from './workspaces';
|
||||
export * from './quota';
|
||||
export * from './grace';
|
||||
export * from './view-as-student';
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getProfileByEmail, getProfileById, updateProfile } from '@cio/db/querie
|
||||
import type { OrganizationWithMemberAndPlans } from '@cio/db/queries/organization/types';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { TProfile } from '@cio/db/types';
|
||||
import { env } from '@api/config/env';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { getLicenseStatus } from '@api/services/license';
|
||||
|
||||
export type GetAccountDataResult = {
|
||||
@@ -26,10 +26,12 @@ export type GetAccountDataResult = {
|
||||
* @returns Account data with profile and organizations
|
||||
*/
|
||||
export async function getAccountData(userId: string): Promise<GetAccountDataResult> {
|
||||
const isSelfHosted = env.PUBLIC_IS_SELFHOSTED === 'true';
|
||||
|
||||
let [profile, organizations, licenseStatus] = await Promise.all([
|
||||
getProfileById(userId),
|
||||
getOrganizationByProfileId(userId),
|
||||
getLicenseStatus()
|
||||
isSelfHosted ? getLicenseStatus() : Promise.resolve({ valid: true, features: [] as string[] })
|
||||
]);
|
||||
|
||||
if (!profile) {
|
||||
@@ -59,7 +61,7 @@ export async function getAccountData(userId: string): Promise<GetAccountDataResu
|
||||
return {
|
||||
profile,
|
||||
organizations,
|
||||
licenseFeatures: licenseStatus.features
|
||||
licenseFeatures: isSelfHosted ? licenseStatus.features : []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { mintLoginLinkToken } from '@cio/db/auth';
|
||||
|
||||
/**
|
||||
* Login-link tokens for the "View as student" handoff are consumed within seconds
|
||||
* of being minted (the client immediately navigates to the org domain), so they
|
||||
* are intentionally short-lived.
|
||||
*/
|
||||
const VIEW_AS_STUDENT_TTL_MINUTES = 5;
|
||||
|
||||
/**
|
||||
* Mint a short-lived login-link token for the **current** user so they can be
|
||||
* auto-signed-in on the org domain (the "View as student" cross-host handoff).
|
||||
*
|
||||
* Self sign-in only: the caller must pass the already-authenticated user. We never
|
||||
* accept a target user id from the client, so this can't be used to impersonate
|
||||
* anyone else.
|
||||
*/
|
||||
export async function createViewAsStudentToken(user: { id: string; email: string }): Promise<string> {
|
||||
return mintLoginLinkToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
ttlMinutes: VIEW_AS_STUDENT_TTL_MINUTES
|
||||
});
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
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']);
|
||||
|
||||
const agentTemplateFormFieldBase = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
placeholder: z.string().optional()
|
||||
});
|
||||
|
||||
const agentTemplateFormFieldParam = z.discriminatedUnion('type', [
|
||||
agentTemplateFormFieldBase.extend({ type: z.literal('text') }),
|
||||
agentTemplateFormFieldBase.extend({ type: z.literal('textarea') }),
|
||||
agentTemplateFormFieldBase.extend({ type: z.literal('url') }),
|
||||
agentTemplateFormFieldBase.extend({
|
||||
type: z.literal('select'),
|
||||
options: z.array(z.object({ value: z.string().min(1), label: z.string().min(1) })).min(1)
|
||||
})
|
||||
]);
|
||||
|
||||
export const askTemplateQuestionsParam = z.object({
|
||||
templateId: courseTemplateIdParam,
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
fields: z.array(agentTemplateFormFieldParam).min(1)
|
||||
});
|
||||
|
||||
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')
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { getDocumentText } from '@api/services/agent/document';
|
||||
import { redis } from '@api/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;
|
||||
}
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
|
||||
const MAX_PERSISTED_TOOL_OUTPUT_CHARS = 2000;
|
||||
const DROPPED_PART_TYPES = new Set(['step-start', 'reasoning']);
|
||||
const TOOLS_WITH_FULL_OUTPUT = new Set(['generate_course_plan', 'ask_template_questions', 'fetch_documentation_url']);
|
||||
const TOOLS_WITH_FULL_OUTPUT = new Set([
|
||||
'generate_course_plan',
|
||||
'ask_template_questions',
|
||||
'fetch_documentation_url',
|
||||
'get_course_structure',
|
||||
'get_exercise_details'
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
@@ -1,710 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { CoursePlanSchema } from '@cio/ai-assistant';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
|
||||
import { getCourseContentItems } from '@cio/db/queries/course/content';
|
||||
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise';
|
||||
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
import { listCourseSections, createCourseSection, updateCourseSectionService } from '@api/services/course/section';
|
||||
import { createLesson, getLesson, updateLessonService } from '@api/services/lesson/lesson';
|
||||
import { upsertLessonLanguageService } from '@api/services/lesson-language';
|
||||
import {
|
||||
createExercise,
|
||||
getExercise,
|
||||
createExerciseSectionService,
|
||||
updateExerciseService,
|
||||
updateExerciseSectionMetadataService
|
||||
} from '@api/services/exercise/exercise';
|
||||
import { reorderCourseContent } from '@api/services/course/content';
|
||||
import { normalizeAgentLessonContent } from '@api/services/agent/lesson-content';
|
||||
import { buildUpdatedQuestions } from '@api/services/agent/question-update';
|
||||
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
|
||||
import { getCourseGoLiveReadiness, publishCourseWhenReady } from '@api/services/course/go-live-readiness';
|
||||
import {
|
||||
assertValidUuid,
|
||||
verifyExerciseBelongsToCourse,
|
||||
verifyLessonBelongsToCourse,
|
||||
verifySectionBelongsToCourse
|
||||
} from '@api/services/agent/chat-context';
|
||||
import {
|
||||
addQuestionsParam,
|
||||
askTemplateQuestionsParam,
|
||||
coursePlanParam,
|
||||
createExerciseParam,
|
||||
createExerciseSectionParam,
|
||||
createLessonParam,
|
||||
createSectionParam,
|
||||
emptyParam,
|
||||
exerciseReadParam,
|
||||
fetchDocumentationUrlParam,
|
||||
goLiveParam,
|
||||
lessonReadParam,
|
||||
reorderContentParam,
|
||||
updateContentParam,
|
||||
updateCourseLandingPageParam,
|
||||
updateExerciseParam,
|
||||
updateExerciseSectionParam,
|
||||
updateLessonParam,
|
||||
updateQuestionsParam,
|
||||
updateSectionParam
|
||||
} from '@api/services/agent/agent-tool-schemas';
|
||||
import { fetchDocumentationUrl } from '@api/services/agent/fetch-url';
|
||||
|
||||
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
|
||||
if (value == null) return value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.length > 200 ? `${value.slice(0, 200)}… (${value.length} chars)` : value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (depth >= 2) {
|
||||
return `[Array(${value.length})]`;
|
||||
}
|
||||
|
||||
return {
|
||||
count: value.length,
|
||||
items: value.slice(0, 3).map((item) => summarizeAgentDebugValue(item, depth + 1))
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (depth >= 2) {
|
||||
return '[Object]';
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, nestedValue]) => [
|
||||
key,
|
||||
summarizeAgentDebugValue(nestedValue, depth + 1)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function logAgentToolDebug(
|
||||
phase: 'start' | 'success' | 'error',
|
||||
toolName: string,
|
||||
details: { courseId: string; userId: string; args?: unknown; result?: unknown; error?: unknown }
|
||||
) {
|
||||
const base = `[agent-tool:${phase}] ${toolName}`;
|
||||
|
||||
if (phase === 'error') {
|
||||
console.error(base, {
|
||||
courseId: details.courseId,
|
||||
userId: details.userId,
|
||||
args: summarizeAgentDebugValue(details.args),
|
||||
error:
|
||||
details.error instanceof Error
|
||||
? {
|
||||
message: details.error.message,
|
||||
stack: details.error.stack
|
||||
}
|
||||
: summarizeAgentDebugValue(details.error)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(base, {
|
||||
courseId: details.courseId,
|
||||
userId: details.userId,
|
||||
args: summarizeAgentDebugValue(details.args),
|
||||
result: summarizeAgentDebugValue(details.result)
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeToolError(toolName: string, error: unknown): Error {
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const looksLikeSqlLeak =
|
||||
/^Failed query:/i.test(raw) ||
|
||||
/\bparams:\s/i.test(raw) ||
|
||||
/\binvalid input syntax for type uuid\b/i.test(raw) ||
|
||||
/\bselect\s+.+\bfrom\s+/i.test(raw);
|
||||
|
||||
if (looksLikeSqlLeak) {
|
||||
return new Error(
|
||||
`${toolName} failed because of an invalid or unknown ID. Call get_course_structure to fetch the current IDs and retry — never invent UUIDs.`
|
||||
);
|
||||
}
|
||||
|
||||
return error instanceof Error ? error : new Error(raw);
|
||||
}
|
||||
|
||||
async function executeAgentTool<TArgs, TResult>(
|
||||
toolName: string,
|
||||
params: { orgId: string; userId: string; courseId: string; args?: TArgs },
|
||||
execute: () => Promise<TResult>
|
||||
): Promise<TResult> {
|
||||
trackAgentEvent(AgentEvent.TOOL_CALLED, {
|
||||
orgId: params.orgId,
|
||||
userId: params.userId,
|
||||
courseId: params.courseId,
|
||||
toolName
|
||||
});
|
||||
logAgentToolDebug('start', toolName, {
|
||||
courseId: params.courseId,
|
||||
userId: params.userId,
|
||||
args: params.args
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await execute();
|
||||
|
||||
trackAgentEvent(AgentEvent.TOOL_COMPLETED, {
|
||||
orgId: params.orgId,
|
||||
userId: params.userId,
|
||||
courseId: params.courseId,
|
||||
toolName,
|
||||
success: true
|
||||
});
|
||||
logAgentToolDebug('success', toolName, {
|
||||
courseId: params.courseId,
|
||||
userId: params.userId,
|
||||
args: params.args,
|
||||
result
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logAgentToolDebug('error', toolName, {
|
||||
courseId: params.courseId,
|
||||
userId: params.userId,
|
||||
args: params.args,
|
||||
error
|
||||
});
|
||||
|
||||
throw sanitizeToolError(toolName, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentTools(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
courseId: string,
|
||||
priorMessages: unknown[],
|
||||
_options?: { isOrgOnPaidPlan?: boolean }
|
||||
) {
|
||||
return {
|
||||
get_course_structure: tool({
|
||||
description:
|
||||
'Get the full course structure including sections, lessons, and exercises as a tree. The courseId is automatically set — do not pass it.',
|
||||
inputSchema: emptyParam,
|
||||
execute: async () => {
|
||||
return executeAgentTool('get_course_structure', { orgId, userId, courseId }, async () => {
|
||||
const [items, sections] = await Promise.all([getCourseContentItems(courseId), listCourseSections(courseId)]);
|
||||
return { sections, items };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
get_lesson_content: tool({
|
||||
description: 'Get the HTML content of a specific lesson in this course.',
|
||||
inputSchema: lessonReadParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('get_lesson_content', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||
const lesson = await getLesson(args.lessonId);
|
||||
const lessonWithLangs = lesson as {
|
||||
id: string;
|
||||
title: string;
|
||||
lessonLanguages?: Array<{ locale: string; content: string | null }>;
|
||||
};
|
||||
const langContent = lessonWithLangs.lessonLanguages?.find((ll) => ll.locale === args.locale);
|
||||
return { id: lesson.id, title: lesson.title, content: langContent?.content || null, locale: args.locale };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
get_exercise_details: tool({
|
||||
description:
|
||||
'Get an exercise with sections, questions, and answer options from this course. Each sections entry has an id — use it with update_exercise_section to change that question block title or description.',
|
||||
inputSchema: exerciseReadParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('get_exercise_details', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
return getExercise(args.exerciseId);
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create_section: tool({
|
||||
description: 'Create a new section in this course.',
|
||||
inputSchema: createSectionParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('create_section', { orgId, userId, courseId, args }, async () => {
|
||||
const section = await createCourseSection(courseId, { title: args.title, courseId, order: args.order });
|
||||
return { id: section.id, title: section.title, order: section.order };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_section: tool({
|
||||
description:
|
||||
'Update metadata for an existing section in this course. Use this to rename a section or change its order instead of creating a new section.',
|
||||
inputSchema: updateSectionParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_section', { orgId, userId, courseId, args }, async () => {
|
||||
await verifySectionBelongsToCourse(args.sectionId, courseId);
|
||||
const section = await updateCourseSectionService(args.sectionId, {
|
||||
...(args.title !== undefined ? { title: args.title } : {}),
|
||||
...(args.order !== undefined ? { order: args.order } : {})
|
||||
});
|
||||
|
||||
return { id: section.id, title: section.title, updated: true };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create_lesson: tool({
|
||||
description:
|
||||
'Create a new lesson within a section of this course. Use update_lesson_content after to add content.',
|
||||
inputSchema: createLessonParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('create_lesson', { orgId, userId, courseId, args }, async () => {
|
||||
await verifySectionBelongsToCourse(args.sectionId, courseId);
|
||||
const lesson = await createLesson(courseId, {
|
||||
title: args.title,
|
||||
courseId,
|
||||
sectionId: args.sectionId,
|
||||
order: args.order
|
||||
});
|
||||
return { id: lesson.id, title: lesson.title, order: lesson.order };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_lesson: tool({
|
||||
description:
|
||||
'Update metadata for an existing lesson in this course. Use this to rename, move, reorder, schedule, or change visibility/unlock settings instead of creating a new lesson.',
|
||||
inputSchema: updateLessonParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_lesson', { orgId, userId, courseId, args }, async () => {
|
||||
if (args.sectionId) {
|
||||
await verifySectionBelongsToCourse(args.sectionId, courseId);
|
||||
}
|
||||
|
||||
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||
const lesson = await updateLessonService(args.lessonId, {
|
||||
...(args.title !== undefined ? { title: args.title } : {}),
|
||||
...(args.sectionId !== undefined ? { sectionId: args.sectionId } : {}),
|
||||
...(args.order !== undefined ? { order: args.order } : {}),
|
||||
...(args.lessonAt !== undefined ? { lessonAt: args.lessonAt } : {}),
|
||||
...(args.callUrl !== undefined ? { callUrl: args.callUrl } : {}),
|
||||
...(args.isUnlocked !== undefined ? { isUnlocked: args.isUnlocked } : {}),
|
||||
...(args.public !== undefined ? { public: args.public } : {})
|
||||
});
|
||||
|
||||
return { id: lesson.id, title: lesson.title, updated: true };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_lesson_content: tool({
|
||||
description:
|
||||
'Update the text content of a lesson in this course. Replaces full lesson HTML for the given locale. For lesson HTML, put only the lesson body in the content. Do not include the lesson title. Do not use h1 or h2 anywhere in lesson HTML. Start headings at h3 because that is the highest heading level allowed in lesson content.',
|
||||
inputSchema: updateContentParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_lesson_content', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||
const lesson = await getLesson(args.lessonId);
|
||||
const normalizedContent = normalizeAgentLessonContent(args.content, lesson.title);
|
||||
|
||||
await upsertLessonLanguageService(args.lessonId, {
|
||||
locale: args.locale as 'en',
|
||||
content: normalizedContent
|
||||
});
|
||||
return {
|
||||
lessonId: args.lessonId,
|
||||
lessonTitle: lesson.title,
|
||||
locale: args.locale,
|
||||
contentLength: normalizedContent.length,
|
||||
updated: true
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create_exercise: tool({
|
||||
description: 'Create a new exercise with questions and answer options in this course.',
|
||||
inputSchema: createExerciseParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('create_exercise', { orgId, userId, courseId, args }, async () => {
|
||||
if (args.lessonId) {
|
||||
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||
}
|
||||
|
||||
if (args.sectionId) {
|
||||
await verifySectionBelongsToCourse(args.sectionId, courseId);
|
||||
}
|
||||
|
||||
const exercise = await createExercise({
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
courseId,
|
||||
lessonId: args.lessonId,
|
||||
sectionId: args.sectionId,
|
||||
order: args.order,
|
||||
questions: args.questions.map((q, i) => ({
|
||||
question: q.question,
|
||||
questionTypeId: q.questionTypeId,
|
||||
points: q.points,
|
||||
order: q.order ?? i,
|
||||
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
|
||||
}))
|
||||
});
|
||||
return { id: exercise.id, title: exercise.title, questionCount: args.questions.length };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_exercise: tool({
|
||||
description:
|
||||
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this for editing the exercise itself — not for changing its questions. To edit questions, use update_questions; to add questions, use add_questions.",
|
||||
inputSchema: updateExerciseParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_exercise', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
|
||||
if (args.lessonId) {
|
||||
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||
}
|
||||
|
||||
if (args.sectionId) {
|
||||
await verifySectionBelongsToCourse(args.sectionId, courseId);
|
||||
}
|
||||
|
||||
const updated = await updateExerciseService(args.exerciseId, {
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
lessonId: args.lessonId,
|
||||
sectionId: args.sectionId,
|
||||
order: args.order,
|
||||
dueBy: args.dueBy,
|
||||
isUnlocked: args.isUnlocked,
|
||||
allowMultipleAttempts: args.allowMultipleAttempts
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
description: updated.description ?? null,
|
||||
dueBy: updated.dueBy ?? null,
|
||||
updated: true
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_exercise_section: tool({
|
||||
description:
|
||||
'Update the title and/or description of one section inside an exercise (question groups). This is not the same as update_section, which edits course outline sections. Always use ids from get_exercise_details `sections` for this tool.',
|
||||
inputSchema: updateExerciseSectionParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_exercise_section', { orgId, userId, courseId, args }, async () => {
|
||||
assertValidUuid('Exercise', args.exerciseId);
|
||||
assertValidUuid('ExerciseSection', args.exerciseSectionId);
|
||||
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
|
||||
const updated = await updateExerciseSectionMetadataService(args.exerciseId, args.exerciseSectionId, {
|
||||
...(args.title !== undefined ? { title: args.title } : {}),
|
||||
...(args.description !== undefined ? { description: args.description } : {})
|
||||
});
|
||||
|
||||
return { ...updated, updated: true };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create_exercise_section: tool({
|
||||
description:
|
||||
'Create a new section inside an existing exercise. Use this to add a question block before adding or moving questions into it.',
|
||||
inputSchema: createExerciseSectionParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('create_exercise_section', { orgId, userId, courseId, args }, async () => {
|
||||
assertValidUuid('Exercise', args.exerciseId);
|
||||
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
|
||||
const created = await createExerciseSectionService(args.exerciseId, {
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
order: args.order,
|
||||
colorTheme: args.colorTheme,
|
||||
afterBehavior: args.afterBehavior
|
||||
});
|
||||
|
||||
return { ...created, created: true };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
add_questions: tool({
|
||||
description:
|
||||
'Add questions to an existing exercise in this course. When get_exercise_details lists in-exercise sections, pass exerciseSectionId so new questions are added to the correct block.',
|
||||
inputSchema: addQuestionsParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('add_questions', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
|
||||
if (args.exerciseSectionId !== undefined) {
|
||||
assertValidUuid('ExerciseSection', args.exerciseSectionId);
|
||||
|
||||
const sections = await getExerciseSectionsByExerciseId(args.exerciseId);
|
||||
const belongs = sections.some((section) => section.id === args.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.',
|
||||
'VALIDATION_ERROR',
|
||||
404
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingExercise = await getExercise(args.exerciseId);
|
||||
const existingQuestions = existingExercise.questions || [];
|
||||
const nextOrder = existingQuestions.length;
|
||||
|
||||
const newQuestions = args.questions.map((q, i) => ({
|
||||
question: q.question,
|
||||
questionTypeId: q.questionTypeId,
|
||||
points: q.points,
|
||||
order: q.order ?? nextOrder + i,
|
||||
...(args.exerciseSectionId !== undefined ? { exerciseSectionId: args.exerciseSectionId } : {}),
|
||||
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
|
||||
}));
|
||||
|
||||
const allQuestions = [
|
||||
...existingQuestions.map((eq) => ({
|
||||
id: Number(eq.id),
|
||||
question: eq.title,
|
||||
questionTypeId: eq.questionTypeId,
|
||||
points: eq.points,
|
||||
order: eq.order,
|
||||
options: eq.options.map((o) => ({ id: Number(o.id), label: o.label || '', isCorrect: o.isCorrect }))
|
||||
})),
|
||||
...newQuestions
|
||||
];
|
||||
|
||||
await updateExerciseService(args.exerciseId, { questions: allQuestions });
|
||||
return {
|
||||
exerciseId: args.exerciseId,
|
||||
exerciseTitle: existingExercise.title,
|
||||
addedCount: args.questions.length,
|
||||
totalCount: allQuestions.length
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_questions: tool({
|
||||
description:
|
||||
'Update existing questions in an exercise. Pass only fields you want to change; `id` identifies the question. Optional `exerciseSectionId` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. For NUMERIC, the correct answer is `settings.correctValue` (number) — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue`. For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect` (include option `id` to edit, omit `id` to add). `settings` is shallow-merged with existing settings. Omit `options` entirely to leave existing options untouched.',
|
||||
inputSchema: updateQuestionsParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_questions', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
|
||||
const sectionIdsInPatches = new Set<string>();
|
||||
for (const patch of args.questions) {
|
||||
if (patch.exerciseSectionId === undefined || patch.exerciseSectionId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assertValidUuid('ExerciseSection', patch.exerciseSectionId);
|
||||
sectionIdsInPatches.add(patch.exerciseSectionId);
|
||||
}
|
||||
|
||||
if (sectionIdsInPatches.size > 0) {
|
||||
const sections = await getExerciseSectionsByExerciseId(args.exerciseId);
|
||||
const validIds = new Set(sections.map((section) => section.id));
|
||||
|
||||
for (const sectionId of sectionIdsInPatches) {
|
||||
if (!validIds.has(sectionId)) {
|
||||
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.',
|
||||
'VALIDATION_ERROR',
|
||||
404
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await getExercise(args.exerciseId);
|
||||
|
||||
const merged = buildUpdatedQuestions(
|
||||
(existing.questions || []).map((q) => ({
|
||||
id: q.id,
|
||||
title: q.title,
|
||||
questionTypeId: q.questionTypeId,
|
||||
points: q.points,
|
||||
order: q.order,
|
||||
exerciseSectionId: q.exerciseSectionId,
|
||||
settings: q.settings,
|
||||
options: q.options
|
||||
})),
|
||||
args.questions,
|
||||
args.exerciseId
|
||||
);
|
||||
|
||||
await updateExerciseService(args.exerciseId, { questions: merged });
|
||||
|
||||
return {
|
||||
exerciseId: args.exerciseId,
|
||||
exerciseTitle: existing.title,
|
||||
updatedCount: merged.length
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
reorder_content: tool({
|
||||
description:
|
||||
'Reorder sections, lessons, or exercises in this course. Can change the order of sections, change the order of lessons/exercises within a section, or move items between sections. Use get_course_structure first to see current order, and copy item IDs exactly from that response. Never rewrite or guess UUIDs.',
|
||||
inputSchema: reorderContentParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('reorder_content', { orgId, userId, courseId, args }, async () => {
|
||||
if (args.sections) {
|
||||
for (const section of args.sections) {
|
||||
await verifySectionBelongsToCourse(section.id, courseId);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.items) {
|
||||
for (const item of args.items) {
|
||||
if (item.type === 'LESSON') {
|
||||
await verifyLessonBelongsToCourse(item.id, courseId);
|
||||
} else {
|
||||
await verifyExerciseBelongsToCourse(item.id, courseId);
|
||||
}
|
||||
|
||||
if (item.sectionId) {
|
||||
await verifySectionBelongsToCourse(item.sectionId, courseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reorderCourseContent(courseId, {
|
||||
sections: args.sections,
|
||||
items: args.items
|
||||
});
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_course_landing_page: tool({
|
||||
description:
|
||||
'Update course-level landing page fields for this course (public title, course description, overview, goals, requirements, the Description section after Requirements, instructor bio, pricing, banner image). The top-level course description field is plain text only—no HTML. All other narrative sections (overview, metadata goals/requirements, metadata description for the block after Requirements, instructor description, etc.) are HTML: paragraphs, lists, line breaks, bold, and italic only—never heading tags (h1–h6) because the UI shows section titles. Title is plain text. The courseId is automatically set — do not pass it.',
|
||||
inputSchema: updateCourseLandingPageParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_course_landing_page', { orgId, userId, courseId, args }, async () => {
|
||||
const result = await updateCourseLandingPageService(courseId, args as TCourseLandingPageUpdate);
|
||||
|
||||
return {
|
||||
courseId: result.course.id,
|
||||
title: result.course.title,
|
||||
description: result.course.description,
|
||||
courseUrl: result.courseUrl,
|
||||
bannerImageUrl: result.bannerImageUrl,
|
||||
updated: true
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
check_course_go_live_readiness: tool({
|
||||
description:
|
||||
'Check whether this course is ready to go live. Returns blockers, warnings, suggested fixes, and the public course URL if available. This tool does not publish the course.',
|
||||
inputSchema: emptyParam,
|
||||
execute: async () => {
|
||||
return executeAgentTool('check_course_go_live_readiness', { orgId, userId, courseId }, async () => {
|
||||
return getCourseGoLiveReadiness(courseId);
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
go_live_course: tool({
|
||||
description:
|
||||
'Publish this course only after the teacher explicitly asks to go live. Runs the readiness checklist first, generates a slug when needed, and fails with blockers if the course is not ready.',
|
||||
inputSchema: goLiveParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('go_live_course', { orgId, userId, courseId, args }, async () => {
|
||||
const result = await publishCourseWhenReady(courseId);
|
||||
|
||||
return {
|
||||
courseId: result.course.id,
|
||||
title: result.course.title,
|
||||
slug: result.course.slug,
|
||||
isPublished: result.course.isPublished,
|
||||
readiness: result.readiness
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
generate_course_plan: tool({
|
||||
description:
|
||||
'Generate a structured course plan with sections and lessons. Always use this when asked to design or plan a course.',
|
||||
inputSchema: coursePlanParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('generate_course_plan', { orgId, userId, courseId, args }, async () => {
|
||||
const envelope = coursePlanParam.parse(args);
|
||||
const plan = CoursePlanSchema.parse(envelope.plan);
|
||||
const sectionCount = plan.sections.length;
|
||||
const itemCount = plan.sections.reduce((sum, section) => sum + section.items.length, 0);
|
||||
|
||||
trackAgentEvent(AgentEvent.PLAN_GENERATED, { orgId, userId, courseId, sectionCount, itemCount });
|
||||
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
ask_template_questions: tool({
|
||||
description:
|
||||
'Render a structured questionnaire card to the teacher. Pause until the teacher submits via metadata.template.action submit_template_answers or skips via skip_template_form.',
|
||||
inputSchema: askTemplateQuestionsParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool(
|
||||
'ask_template_questions',
|
||||
{ orgId, userId, courseId, args },
|
||||
async () =>
|
||||
({
|
||||
awaiting_user: true as const,
|
||||
templateId: args.templateId,
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
fields: args.fields
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
fetch_documentation_url: tool({
|
||||
description:
|
||||
'Fetch a public documentation URL via Jina Reader. Returns markdown wrapped as untrusted external content plus same-origin links for follow-up fetches.',
|
||||
inputSchema: fetchDocumentationUrlParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('fetch_documentation_url', { orgId, userId, courseId, args }, async () => {
|
||||
return fetchDocumentationUrl({
|
||||
url: args.url,
|
||||
orgId,
|
||||
courseId,
|
||||
priorMessages
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { ErrorCodes } from '@cio/utils/constants';
|
||||
import { getChatConversation, saveChatMessages } from '@api/services/agent/chat-history';
|
||||
import { summarizeConversation, type SummarizeOptions } from '@api/services/agent/summarize';
|
||||
import { summarizeConversation, type SummarizeOptions } from '@cio/core/services/agent/summarize';
|
||||
|
||||
export async function compactConversation(conversationId: string, userId: string): Promise<{ messages: unknown[] }> {
|
||||
const conversation = await getChatConversation(conversationId, userId);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { AppError } from '@api/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 '@api/utils/redis/key-generators';
|
||||
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
|
||||
import type { RedisClient } from '@api/utils/redis/redis';
|
||||
import { createChatDocument, getChatDocument } from '@cio/db/queries/agent';
|
||||
import { generateFileKey } from '@api/utils/upload';
|
||||
import { uploadToS3 } from '@api/utils/s3';
|
||||
import { getStorageConfig } from '@api/config/storage';
|
||||
import { createAssetFromUploadService } from '@api/services/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);
|
||||
}
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isIPv4, isIPv6 } from 'node:net';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { env } from '@api/config/env';
|
||||
import { redis, logRedisUnavailableOnce } from '@api/utils/redis/redis';
|
||||
import { isOrgOnPaidPlan } from '@api/services/agent/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
|
||||
};
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { getChatModelContext, upsertChatModelContext } from '@cio/db/queries/agent';
|
||||
import { trimMessageHistory } from '@api/services/agent/context-window';
|
||||
import { summarizeConversationDelta, type SummarizeOptions } from '@api/services/agent/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
|
||||
};
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { AppError } from '@api/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;
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import type { TAiAgentRun, TAiAgentRunEvent, TAiAgentRunStatus, TAiAgentRunStep } from '@cio/db/types';
|
||||
import { QUEUE_NAMES, enqueueAgentCourseGenerationRun, getQueue, isRedisConfigured } from '@cio/jobs';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
|
||||
const ACTIVE_RUN_STATUSES = new Set<TAiAgentRunStatus>(['queued', 'running', 'waiting_for_input', 'paused']);
|
||||
@@ -90,6 +91,106 @@ async function buildAgentRunDetail(run: TAiAgentRun): Promise<AgentRunDetail> {
|
||||
return { run, steps, events };
|
||||
}
|
||||
|
||||
function getBullmqJobId(run: TAiAgentRun): string | null {
|
||||
const bullmq = run.executionCursor.bullmq;
|
||||
|
||||
if (!bullmq || typeof bullmq !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jobId = (bullmq as Record<string, unknown>).jobId;
|
||||
|
||||
return typeof jobId === 'string' && jobId.length > 0 ? jobId : null;
|
||||
}
|
||||
|
||||
async function removeQueuedBullmqJob(run: TAiAgentRun): Promise<void> {
|
||||
if (!isRedisConfigured()) return;
|
||||
|
||||
const jobId = getBullmqJobId(run);
|
||||
|
||||
if (!jobId) return;
|
||||
|
||||
try {
|
||||
const removed = await getQueue(QUEUE_NAMES.agentCourseGeneration).remove(jobId);
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: run.id,
|
||||
eventType: 'run.queue_cancel_checked',
|
||||
message: removed === 1 ? 'Queued BullMQ job removed' : 'BullMQ job was already active or missing',
|
||||
payload: { jobId, removed }
|
||||
});
|
||||
} catch (error) {
|
||||
await createAgentRunEvent({
|
||||
runId: run.id,
|
||||
eventType: 'run.queue_cancel_failed',
|
||||
message: 'Could not remove queued BullMQ job; worker will stop at the next checkpoint',
|
||||
payload: { jobId, error: error instanceof Error ? error.message : String(error) }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueAgentRun(run: TAiAgentRun, reason: 'created' | 'resumed' | 'retried'): Promise<TAiAgentRun> {
|
||||
if (!isRedisConfigured()) {
|
||||
await createAgentRunEvent({
|
||||
runId: run.id,
|
||||
eventType: 'run.enqueue_skipped',
|
||||
message: 'Redis is not configured; run state was saved but no BullMQ job was enqueued',
|
||||
payload: { reason }
|
||||
});
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobId = await enqueueAgentCourseGenerationRun(
|
||||
{
|
||||
runId: run.id,
|
||||
requestedByUserId: run.userId,
|
||||
organizationId: run.orgId
|
||||
},
|
||||
{ attempt: run.attempt }
|
||||
);
|
||||
const updatedRun = await updateOwnedAgentRun(run.id, run.userId, {
|
||||
status: 'queued',
|
||||
executionCursor: {
|
||||
...run.executionCursor,
|
||||
bullmq: {
|
||||
queue: 'agent-course-generation',
|
||||
jobId: jobId ?? null,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
reason
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: run.id,
|
||||
eventType: 'run.enqueued',
|
||||
message: 'Run enqueued for background course generation',
|
||||
payload: { jobId: jobId ?? null, reason }
|
||||
});
|
||||
|
||||
return updatedRun;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to enqueue agent run';
|
||||
await updateOwnedAgentRun(run.id, run.userId, {
|
||||
status: 'failed',
|
||||
phase: 'failed',
|
||||
lastError: { code: 'AGENT_RUN_ENQUEUE_FAILED', message },
|
||||
finishedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: run.id,
|
||||
eventType: 'run.enqueue_failed',
|
||||
message: 'Failed to enqueue background course generation',
|
||||
payload: { reason, error: { code: 'AGENT_RUN_ENQUEUE_FAILED', message } }
|
||||
});
|
||||
|
||||
throw new AppError(message, 'AGENT_RUN_ENQUEUE_FAILED', 500);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateOwnedAgentRun(
|
||||
runId: string,
|
||||
userId: string,
|
||||
@@ -132,7 +233,9 @@ export async function createAgentRunState(input: CreateAgentRunStateInput): Prom
|
||||
payload: { phase: run.phase, status: run.status }
|
||||
});
|
||||
|
||||
return buildAgentRunDetail(run);
|
||||
const enqueuedRun = await enqueueAgentRun(run, 'created');
|
||||
|
||||
return buildAgentRunDetail(enqueuedRun);
|
||||
}
|
||||
|
||||
export async function listAgentRunStates(courseId: string, userId: string, orgId: string): Promise<TAiAgentRun[]> {
|
||||
@@ -158,6 +261,8 @@ export async function cancelAgentRunState(runId: string, userId: string, orgId:
|
||||
throw new AppError('Completed agent runs cannot be canceled', 'AGENT_RUN_ALREADY_COMPLETED', 409);
|
||||
}
|
||||
|
||||
await removeQueuedBullmqJob(run);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updatedRun = await updateOwnedAgentRun(runId, userId, {
|
||||
status: 'canceled',
|
||||
@@ -181,10 +286,16 @@ export async function cancelAgentRunState(runId: string, userId: string, orgId:
|
||||
export async function resumeAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||
|
||||
if (run.status === 'queued' || run.status === 'running') {
|
||||
if (run.status === 'running') {
|
||||
return buildAgentRunDetail(run);
|
||||
}
|
||||
|
||||
if (run.status === 'queued') {
|
||||
const enqueuedRun = await enqueueAgentRun(run, 'resumed');
|
||||
|
||||
return buildAgentRunDetail(enqueuedRun);
|
||||
}
|
||||
|
||||
if (run.status === 'completed') {
|
||||
throw new AppError('Completed agent runs cannot be resumed', 'AGENT_RUN_ALREADY_COMPLETED', 409);
|
||||
}
|
||||
@@ -212,7 +323,9 @@ export async function resumeAgentRunState(runId: string, userId: string, orgId:
|
||||
payload: { previousStatus: run.status, phase: run.phase }
|
||||
});
|
||||
|
||||
return buildAgentRunDetail(updatedRun);
|
||||
const enqueuedRun = await enqueueAgentRun(updatedRun, 'resumed');
|
||||
|
||||
return buildAgentRunDetail(enqueuedRun);
|
||||
}
|
||||
|
||||
export async function retryAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||
@@ -243,7 +356,9 @@ export async function retryAgentRunState(runId: string, userId: string, orgId: s
|
||||
payload: { previousAttempt: run.attempt, nextAttempt: updatedRun.attempt }
|
||||
});
|
||||
|
||||
return buildAgentRunDetail(updatedRun);
|
||||
const enqueuedRun = await enqueueAgentRun(updatedRun, 'retried');
|
||||
|
||||
return buildAgentRunDetail(enqueuedRun);
|
||||
}
|
||||
|
||||
export async function appendAgentRunInstructionState(
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -6,12 +6,12 @@ import { db } from '@cio/db';
|
||||
import * as schema from '@cio/db/schema';
|
||||
import type { AiTutorSettings } from '@cio/ai-assistant';
|
||||
|
||||
import { getLesson } from '@api/services/lesson/lesson';
|
||||
import { getExercise } from '@api/services/exercise/exercise';
|
||||
import { listCourseSections } from '@api/services/course/section';
|
||||
import { getLesson } from '@cio/core/services/lesson/lesson';
|
||||
import { getExercise } from '@cio/core/services/exercise/exercise';
|
||||
import { listCourseSections } from '@cio/core/services/course/section';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { AgentEvent, trackAgentEvent } from '@api/utils/tinybird';
|
||||
import { verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse } from './chat-context';
|
||||
import { AgentEvent, trackAgentEvent } from '@cio/core/utils/tinybird';
|
||||
import { verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse } from '@cio/core/services/agent/chat-context';
|
||||
|
||||
/**
|
||||
* Student agent tools — read-only, course-scoped.
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { generateText } from 'ai';
|
||||
import { createModel, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
||||
import { AppError } from '@api/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();
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { getEffectiveAiTutorSettings } from './tutor-config';
|
||||
import { enforceTokenBalance } from './usage';
|
||||
import { enforceTokenBalance } from '@cio/core/services/agent/usage';
|
||||
|
||||
export const STUDENT_TUTOR_CAP = STUDENT_TUTOR_MONTHLY_CAP;
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
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 '@api/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);
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
selectPopularCourseTypes
|
||||
} from '@cio/db/queries/analytics';
|
||||
import { DASH_ANALYTICS_TTL_SECONDS, dashAnalyticsKey } from '@api/utils/redis/key-generators';
|
||||
import { env } from '@api/config/env';
|
||||
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
|
||||
|
||||
export type LandingStats = {
|
||||
totals: {
|
||||
|
||||
@@ -1,539 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type {
|
||||
TAssetAttach,
|
||||
TAssetCreateUpload,
|
||||
TAssetDetach,
|
||||
TAssetExportQuery,
|
||||
TAssetListQuery,
|
||||
TAssetStorageQuery,
|
||||
TAssetUpdate,
|
||||
TYouTubeMetadataQuery
|
||||
} from '@cio/utils/validation/assets';
|
||||
import type { TTranscriptResponse } from '@cio/utils/validation/media';
|
||||
import {
|
||||
countAssetUsagesByAsset,
|
||||
createAssetUsage,
|
||||
createOrGetAssetByStorageKey,
|
||||
deleteAsset,
|
||||
deleteAssetUsage,
|
||||
getAssetById,
|
||||
getAssetStorageSummaryByOrg,
|
||||
listAssetsByOrg,
|
||||
listAssetsForExport,
|
||||
listAssetUsagesByAsset,
|
||||
updateAsset
|
||||
} from '@cio/db/queries/assets';
|
||||
import { getMediaTranscriptByAsset } from '@cio/db/queries';
|
||||
|
||||
import { generateTranscriptVttPresignedUrl, TRANSCRIPT_VTT_PRESIGN_SECONDS } from '@api/utils/s3';
|
||||
import { getStorageConfig } from '@api/config/storage';
|
||||
|
||||
const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/;
|
||||
|
||||
type YouTubeOEmbedResponse = {
|
||||
title?: unknown;
|
||||
thumbnail_url?: unknown;
|
||||
};
|
||||
|
||||
type YouTubeWatchPageMetadata = {
|
||||
title: string | null;
|
||||
durationSeconds: number | null;
|
||||
thumbnailUrl: string | null;
|
||||
};
|
||||
|
||||
function assertAssetExists<T>(asset: T | null): T {
|
||||
if (!asset) {
|
||||
throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): URL | null {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(trimmed);
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${trimmed}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractYouTubeVideoId(url: string): string | null {
|
||||
const parsedUrl = normalizeUrl(url);
|
||||
if (!parsedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = parsedUrl.hostname.toLowerCase();
|
||||
let videoId: string | null = null;
|
||||
|
||||
if (host === 'youtu.be' || host === 'www.youtu.be') {
|
||||
const [pathSegment] = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
videoId = pathSegment ?? null;
|
||||
} else if (
|
||||
host === 'youtube.com' ||
|
||||
host === 'www.youtube.com' ||
|
||||
host === 'm.youtube.com' ||
|
||||
host === 'music.youtube.com' ||
|
||||
host === 'youtube-nocookie.com' ||
|
||||
host === 'www.youtube-nocookie.com'
|
||||
) {
|
||||
if (parsedUrl.pathname === '/watch') {
|
||||
videoId = parsedUrl.searchParams.get('v');
|
||||
} else {
|
||||
const [firstSegment, secondSegment] = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
if (firstSegment === 'embed' || firstSegment === 'shorts' || firstSegment === 'live') {
|
||||
videoId = secondSegment ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoId || !YOUTUBE_VIDEO_ID_REGEX.test(videoId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return videoId;
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
function extractMetaContent(html: string, pattern: RegExp): string | null {
|
||||
const match = html.match(pattern);
|
||||
const value = match?.[1];
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decoded = decodeHtmlEntities(value.trim());
|
||||
return decoded.length ? decoded : null;
|
||||
}
|
||||
|
||||
function extractDurationSeconds(html: string): number | null {
|
||||
const lengthSecondsMatch = html.match(/"lengthSeconds":"(\d+)"/);
|
||||
if (lengthSecondsMatch?.[1]) {
|
||||
const parsed = Number.parseInt(lengthSecondsMatch[1], 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
const approxDurationMsMatch = html.match(/"approxDurationMs":"(\d+)"/);
|
||||
if (approxDurationMsMatch?.[1]) {
|
||||
const parsed = Number.parseInt(approxDurationMsMatch[1], 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return Math.round(parsed / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchYouTubeOEmbed(videoUrl: string): Promise<{ title: string | null; thumbnailUrl: string | null }> {
|
||||
try {
|
||||
const response = await fetch(`https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(videoUrl)}`);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
title: null,
|
||||
thumbnailUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as YouTubeOEmbedResponse;
|
||||
const title = typeof data.title === 'string' && data.title.trim().length ? data.title.trim() : null;
|
||||
const thumbnailUrl =
|
||||
typeof data.thumbnail_url === 'string' && data.thumbnail_url.trim().length ? data.thumbnail_url.trim() : null;
|
||||
|
||||
return {
|
||||
title,
|
||||
thumbnailUrl
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: null,
|
||||
thumbnailUrl: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchYouTubeWatchPageMetadata(videoId: string): Promise<YouTubeWatchPageMetadata> {
|
||||
try {
|
||||
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
title: null,
|
||||
durationSeconds: null,
|
||||
thumbnailUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const title =
|
||||
extractMetaContent(html, /<meta\s+property="og:title"\s+content="([^"]+)"/i) ??
|
||||
extractMetaContent(html, /<meta\s+name="title"\s+content="([^"]+)"/i);
|
||||
const thumbnailUrl = extractMetaContent(html, /<meta\s+property="og:image"\s+content="([^"]+)"/i);
|
||||
const durationSeconds = extractDurationSeconds(html);
|
||||
|
||||
return {
|
||||
title,
|
||||
durationSeconds,
|
||||
thumbnailUrl
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: null,
|
||||
durationSeconds: null,
|
||||
thumbnailUrl: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOrganizationAssetsService(orgId: string, query: TAssetListQuery) {
|
||||
try {
|
||||
return await listAssetsByOrg(orgId, query);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list assets',
|
||||
ErrorCodes.ASSET_LIST_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAssetFromUploadService(orgId: string, profileId: string, data: TAssetCreateUpload) {
|
||||
try {
|
||||
const asset = await createOrGetAssetByStorageKey({
|
||||
organizationId: orgId,
|
||||
kind: data.kind,
|
||||
provider: data.provider,
|
||||
storageProvider: data.storageProvider,
|
||||
storageKey: data.storageKey ?? null,
|
||||
sourceUrl: data.sourceUrl ?? null,
|
||||
mimeType: data.mimeType ?? null,
|
||||
byteSize: data.byteSize ?? null,
|
||||
checksum: data.checksum ?? null,
|
||||
title: data.title ?? null,
|
||||
description: data.description ?? null,
|
||||
thumbnailUrl: data.thumbnailUrl ?? null,
|
||||
durationSeconds: data.durationSeconds ?? null,
|
||||
aspectRatio: data.aspectRatio ?? null,
|
||||
isExternal: data.isExternal,
|
||||
status: 'active',
|
||||
metadata: data.metadata ?? {},
|
||||
createdByProfileId: profileId
|
||||
});
|
||||
|
||||
// Fire-and-forget: enqueue lesson-video post-processing for new uploads.
|
||||
// Errors are logged but never block the asset create response.
|
||||
if (asset.kind === 'video' && asset.provider === 'upload' && asset.storageKey) {
|
||||
void enqueueMediaPostProcessingForAsset({
|
||||
organizationId: orgId,
|
||||
assetId: asset.id,
|
||||
storageKey: asset.storageKey,
|
||||
triggeredByProfileId: profileId
|
||||
});
|
||||
}
|
||||
|
||||
return asset;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create asset',
|
||||
ErrorCodes.ASSET_CREATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueMediaPostProcessingForAsset(input: {
|
||||
organizationId: string;
|
||||
assetId: string;
|
||||
storageKey: string;
|
||||
triggeredByProfileId: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { startMediaJob } = await import('@api/services/jobs');
|
||||
await startMediaJob({
|
||||
organizationId: input.organizationId,
|
||||
assetId: input.assetId,
|
||||
storageKey: input.storageKey,
|
||||
triggeredByProfileId: input.triggeredByProfileId,
|
||||
withTranscription: Boolean(process.env.OPENAI_API_KEY)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('enqueueMediaPostProcessingForAsset failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAssetService(orgId: string, assetId: string) {
|
||||
try {
|
||||
const asset = await getAssetById(assetId, orgId);
|
||||
return assertAssetExists(asset);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch asset',
|
||||
ErrorCodes.ASSET_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAssetService(orgId: string, assetId: string, data: TAssetUpdate) {
|
||||
try {
|
||||
const updated = await updateAsset(assetId, orgId, data);
|
||||
return assertAssetExists(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update asset',
|
||||
ErrorCodes.ASSET_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the chosen thumbnail URL for an asset. Accepts URLs that are either
|
||||
* (a) one of the asset's auto-generated candidates, or (b) a freshly uploaded
|
||||
* URL on the org's media bucket. Anything else is rejected so callers can't
|
||||
* point the asset at an arbitrary remote image.
|
||||
*/
|
||||
export async function selectAssetThumbnailService(orgId: string, assetId: string, thumbnailUrl: string) {
|
||||
try {
|
||||
const asset = await getAssetById(assetId, orgId);
|
||||
assertAssetExists(asset);
|
||||
|
||||
const candidates = (asset?.thumbnailCandidates ?? []) as string[];
|
||||
const mediaBase = getStorageConfig().mediaPublicBaseUrl?.replace(/\/$/, '') ?? null;
|
||||
const isCandidate = candidates.includes(thumbnailUrl);
|
||||
const isOnMediaBucket = Boolean(mediaBase && thumbnailUrl.startsWith(`${mediaBase}/`));
|
||||
|
||||
if (!isCandidate && !isOnMediaBucket) {
|
||||
throw new AppError(
|
||||
'Thumbnail URL must be a generated candidate or a media-bucket upload',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await updateAsset(assetId, orgId, { thumbnailUrl });
|
||||
return assertAssetExists(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update asset thumbnail',
|
||||
ErrorCodes.ASSET_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAssetService(orgId: string, assetId: string) {
|
||||
try {
|
||||
await getAssetService(orgId, assetId);
|
||||
|
||||
const usageCount = await countAssetUsagesByAsset(assetId, orgId);
|
||||
if (usageCount > 0) {
|
||||
throw new AppError('Asset is still in use', ErrorCodes.ASSET_IN_USE, 409);
|
||||
}
|
||||
|
||||
const deleted = await deleteAsset(assetId, orgId);
|
||||
return assertAssetExists(deleted);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete asset',
|
||||
ErrorCodes.ASSET_DELETE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAssetUsageGraphService(orgId: string, assetId: string) {
|
||||
try {
|
||||
await getAssetService(orgId, assetId);
|
||||
const [usages, usageCount] = await Promise.all([
|
||||
listAssetUsagesByAsset(assetId, orgId),
|
||||
countAssetUsagesByAsset(assetId, orgId)
|
||||
]);
|
||||
|
||||
return {
|
||||
usageCount,
|
||||
usages
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch asset usage',
|
||||
ErrorCodes.ASSET_USAGE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function attachAssetService(orgId: string, assetId: string, profileId: string, input: TAssetAttach) {
|
||||
try {
|
||||
await getAssetService(orgId, assetId);
|
||||
return await createAssetUsage({
|
||||
organizationId: orgId,
|
||||
assetId,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
slotType: input.slotType,
|
||||
slotKey: input.slotKey ?? null,
|
||||
position: input.position ?? null,
|
||||
createdByProfileId: profileId
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to attach asset',
|
||||
ErrorCodes.ASSET_ATTACH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function detachAssetService(orgId: string, assetId: string, input: TAssetDetach) {
|
||||
try {
|
||||
await getAssetService(orgId, assetId);
|
||||
const detached = await deleteAssetUsage(orgId, assetId, input);
|
||||
if (!detached) {
|
||||
throw new AppError('Asset usage not found', ErrorCodes.ASSET_USAGE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return detached;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to detach asset',
|
||||
ErrorCodes.ASSET_DETACH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportOrganizationAssetsService(orgId: string, query: TAssetExportQuery) {
|
||||
try {
|
||||
return await listAssetsForExport(orgId, query);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to export assets',
|
||||
ErrorCodes.ASSET_EXPORT_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrganizationAssetStorageService(orgId: string, query: TAssetStorageQuery) {
|
||||
try {
|
||||
return await getAssetStorageSummaryByOrg(orgId, query);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch asset storage summary',
|
||||
ErrorCodes.ASSET_STORAGE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTranscriptForOrganizationAssetService(
|
||||
orgId: string,
|
||||
assetId: string
|
||||
): Promise<TTranscriptResponse | null> {
|
||||
try {
|
||||
const asset = assertAssetExists(await getAssetById(assetId));
|
||||
|
||||
if (asset.organizationId !== orgId) {
|
||||
throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const row = await getMediaTranscriptByAsset(assetId, orgId);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const vttUrl = await generateTranscriptVttPresignedUrl(row.vttStorageKey, row.vttBucket);
|
||||
const vttUrlExpiresAt = new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString();
|
||||
|
||||
return {
|
||||
language: row.language,
|
||||
segments: row.segments,
|
||||
vttUrl,
|
||||
vttUrlExpiresAt,
|
||||
durationSeconds: row.durationSeconds ?? null
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch transcript',
|
||||
ErrorCodes.ASSET_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getYouTubeMetadataService(_orgId: string, query: TYouTubeMetadataQuery) {
|
||||
const videoId = extractYouTubeVideoId(query.url);
|
||||
if (!videoId) {
|
||||
throw new AppError('Invalid YouTube URL', ErrorCodes.VALIDATION_ERROR, 400, 'url');
|
||||
}
|
||||
|
||||
const sourceUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
|
||||
try {
|
||||
const [oembed, watchPageMetadata] = await Promise.all([
|
||||
fetchYouTubeOEmbed(sourceUrl),
|
||||
fetchYouTubeWatchPageMetadata(videoId)
|
||||
]);
|
||||
|
||||
return {
|
||||
videoId,
|
||||
sourceUrl,
|
||||
title: oembed.title ?? watchPageMetadata.title ?? 'YouTube video',
|
||||
durationSeconds: watchPageMetadata.durationSeconds,
|
||||
thumbnailUrl:
|
||||
oembed.thumbnailUrl ?? watchPageMetadata.thumbnailUrl ?? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
|
||||
};
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch YouTube metadata',
|
||||
ErrorCodes.ASSET_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export * from './assets';
|
||||
export * from '@cio/core/services/assets/assets';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { env } from '@api/config/env';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { createCourse, updateCourse } from '@api/services/course/course';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { createCourse, updateCourse } from '@cio/core/services/course/course';
|
||||
import {
|
||||
createExercise,
|
||||
deleteExerciseService,
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
deleteCourseSectionService,
|
||||
listCourseSections,
|
||||
updateCourseSectionService
|
||||
} from '@api/services/course/section';
|
||||
} from '@cio/core/services/course/section';
|
||||
import { createLesson, listLessons, updateLessonService, deleteLessonService } from '@api/services/lesson';
|
||||
import {
|
||||
deleteLessonLanguageService,
|
||||
upsertLessonLanguageService,
|
||||
updateLessonLanguageService
|
||||
} from '@api/services/lesson-language';
|
||||
} from '@cio/core/services/lesson-language';
|
||||
import {
|
||||
createCourseImportDraft,
|
||||
getCourseImportDraftById,
|
||||
@@ -128,14 +128,6 @@ async function ensureCourseSlug(courseId: string, title: string | null | undefin
|
||||
return updated.slug ?? uniqueSlug;
|
||||
}
|
||||
|
||||
function buildCourseBaseUrl(organization: TOrganization) {
|
||||
if (organization.customDomain && organization.isCustomDomainVerified) {
|
||||
return `https://${organization.customDomain}`;
|
||||
}
|
||||
|
||||
return getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
}
|
||||
|
||||
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
|
||||
const organization = await getOrganizationById(organizationId);
|
||||
if (!organization) {
|
||||
@@ -143,7 +135,7 @@ async function resolveCourseUrl(organizationId: string, courseId: string, title:
|
||||
}
|
||||
|
||||
const slug = await ensureCourseSlug(courseId, title);
|
||||
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
|
||||
return `${getDashboardBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
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 as ContentfulStatusCode, 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,580 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/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';
|
||||
@@ -1,263 +0,0 @@
|
||||
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 '@api/utils/errors';
|
||||
import { updateCourse } from './course';
|
||||
import { buildCourseBaseUrl, 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 `${buildCourseBaseUrl(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
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import { ROLE } from '@cio/utils/constants';
|
||||
import type { TCreateCourseInvite } from '@cio/utils/validation/course/invite';
|
||||
import type { TNewCourseInviteAudit } from '@db/types';
|
||||
import crypto from 'node:crypto';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName } from '@cio/email';
|
||||
@@ -37,6 +37,16 @@ import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
||||
|
||||
/**
|
||||
* Minimal org-url bundle threaded through invite/email paths so every URL
|
||||
* we generate respects the org's verified custom domain.
|
||||
*/
|
||||
type OrgUrlInfo = {
|
||||
siteName: string | null;
|
||||
customDomain: string | null;
|
||||
isCustomDomainVerified: boolean | null;
|
||||
};
|
||||
|
||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
||||
|
||||
@@ -189,11 +199,12 @@ function getInviteStatus(invite: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the course enroll URL for invite links (emails, etc.).
|
||||
* Uses DASHBOARD_URL when set (self-hosted), otherwise org subdomain in production.
|
||||
* Builds the course enroll URL for invite links (emails, etc.). Respects the
|
||||
* org's verified custom domain when present, otherwise falls back to the
|
||||
* tenant subdomain.
|
||||
*/
|
||||
export function buildEnrollLink(courseSlug: string, token: string, orgSiteName?: string): string {
|
||||
const base = getDashboardBaseUrl(orgSiteName);
|
||||
export function buildEnrollLink(courseSlug: string, token: string, org?: OrgUrlInfo | null): string {
|
||||
const base = getDashboardBaseUrl(org ?? undefined);
|
||||
return `${base}/course/${encodeURIComponent(courseSlug)}/enroll?invite_token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
@@ -308,7 +319,7 @@ async function createSingleInvite(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
targetEmail: string | null,
|
||||
courseSlug: string,
|
||||
orgSiteName: string | null
|
||||
org: OrgUrlInfo
|
||||
) {
|
||||
const token = generateToken();
|
||||
const tokenHash = hashToken(token);
|
||||
@@ -338,7 +349,7 @@ async function createSingleInvite(
|
||||
|
||||
return {
|
||||
...created,
|
||||
inviteLink: buildEnrollLink(courseSlug, token, orgSiteName ?? undefined)
|
||||
inviteLink: buildEnrollLink(courseSlug, token, org)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -346,12 +357,12 @@ async function sendStudentJoinEmails(input: {
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
orgName: string;
|
||||
orgSiteName: string | null;
|
||||
org: OrgUrlInfo;
|
||||
studentId: string;
|
||||
studentEmail: string;
|
||||
}) {
|
||||
try {
|
||||
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
|
||||
const loginUrl = getDashboardBaseUrl(input.org);
|
||||
await enqueueTransactionalEmail('studentCourseWelcome', {
|
||||
to: input.studentEmail,
|
||||
fields: {
|
||||
@@ -402,7 +413,7 @@ async function createEmailInviteAndSend(input: {
|
||||
orgName: string;
|
||||
courseName: string;
|
||||
courseSlug: string;
|
||||
orgSiteName: string | null;
|
||||
org: OrgUrlInfo;
|
||||
metadata?: Record<string, unknown>;
|
||||
shouldSend: boolean;
|
||||
}) {
|
||||
@@ -421,7 +432,7 @@ async function createEmailInviteAndSend(input: {
|
||||
},
|
||||
input.recipientEmail,
|
||||
input.courseSlug,
|
||||
input.orgSiteName
|
||||
input.org
|
||||
);
|
||||
|
||||
if (!input.shouldSend) {
|
||||
@@ -503,7 +514,11 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
|
||||
|
||||
const courseTitle = course[0].title;
|
||||
const courseSlug = course[0].slug ?? (await ensureCourseSlug(course[0].id, courseTitle));
|
||||
const orgSiteName = courseOrgData.orgSiteName ?? null;
|
||||
const orgUrlInfo: OrgUrlInfo = {
|
||||
siteName: courseOrgData.orgSiteName ?? null,
|
||||
customDomain: courseOrgData.orgCustomDomain ?? null,
|
||||
isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified ?? null
|
||||
};
|
||||
|
||||
if (recipients.valid.length === 0) {
|
||||
const createdInvite = await createSingleInvite(
|
||||
@@ -515,7 +530,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
|
||||
data.metadata,
|
||||
null,
|
||||
courseSlug,
|
||||
orgSiteName
|
||||
orgUrlInfo
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -545,7 +560,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
|
||||
orgName,
|
||||
courseName,
|
||||
courseSlug,
|
||||
orgSiteName,
|
||||
org: orgUrlInfo,
|
||||
metadata: data.metadata,
|
||||
shouldSend: data.sendEmail
|
||||
})
|
||||
@@ -659,7 +674,7 @@ export async function enrollInCourse(
|
||||
courseId,
|
||||
courseName: title,
|
||||
orgName: org.name,
|
||||
orgSiteName: org.siteName,
|
||||
org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified },
|
||||
studentId: user.id,
|
||||
studentEmail: normalizedEmail
|
||||
});
|
||||
@@ -925,7 +940,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
courseId: course.id,
|
||||
courseName: course.title,
|
||||
orgName: organization.name,
|
||||
orgSiteName: organization.siteName
|
||||
orgSiteName: organization.siteName,
|
||||
orgCustomDomain: organization.customDomain,
|
||||
orgIsCustomDomainVerified: organization.isCustomDomainVerified
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1010,7 +1027,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
courseId: course.id,
|
||||
courseName: course.title,
|
||||
orgName: organization.name,
|
||||
orgSiteName: organization.siteName
|
||||
orgSiteName: organization.siteName,
|
||||
orgCustomDomain: organization.customDomain,
|
||||
orgIsCustomDomainVerified: organization.isCustomDomainVerified
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1021,7 +1040,11 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
courseId: result.courseId,
|
||||
courseName: result.courseName,
|
||||
orgName: result.orgName,
|
||||
orgSiteName: result.orgSiteName,
|
||||
org: {
|
||||
siteName: result.orgSiteName,
|
||||
customDomain: result.orgCustomDomain,
|
||||
isCustomDomainVerified: result.orgIsCustomDomainVerified
|
||||
},
|
||||
studentId: user.id,
|
||||
studentEmail: normalizedEmail
|
||||
});
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TCourse, TOrganization } from '@db/types';
|
||||
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { env } from '@api/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 '@api/config/dashboard-url';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import { updateCourse } from '@api/services/course/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 function buildCourseBaseUrl(
|
||||
organization: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'>
|
||||
) {
|
||||
if (organization.customDomain && organization.isCustomDomainVerified) {
|
||||
return `https://${organization.customDomain}`;
|
||||
}
|
||||
|
||||
return getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
}
|
||||
|
||||
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 `${buildCourseBaseUrl(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
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
|
||||
import type { TGroupmember } from '@cio/db/types';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { getCourseWithOrgData } from '@cio/db/queries/course';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName } from '@cio/email';
|
||||
@@ -68,7 +68,11 @@ export async function addMember(
|
||||
if (courseOrgData) {
|
||||
const courseName = courseOrgData.courseTitle || '';
|
||||
const orgName = courseOrgData.orgName || 'ClassroomIO';
|
||||
const loginUrl = getDashboardBaseUrl(courseOrgData.orgSiteName ?? undefined);
|
||||
const loginUrl = getDashboardBaseUrl({
|
||||
siteName: courseOrgData.orgSiteName,
|
||||
customDomain: courseOrgData.orgCustomDomain,
|
||||
isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified
|
||||
});
|
||||
|
||||
// Get student profile data
|
||||
let studentEmail: string | null = null;
|
||||
@@ -190,7 +194,11 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
||||
}
|
||||
|
||||
// Send welcome emails for teachers (TUTOR role = 2, ADMIN role = 1)
|
||||
const baseUrl = getDashboardBaseUrl(orgSiteName);
|
||||
const baseUrl = getDashboardBaseUrl({
|
||||
siteName: orgSiteName,
|
||||
customDomain: courseOrgData.orgCustomDomain,
|
||||
isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified
|
||||
});
|
||||
const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`;
|
||||
|
||||
const teacherEmailPromises: Promise<unknown>[] = [];
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/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'
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import {
|
||||
generateTranscriptVttPresignedUrl,
|
||||
generateVideoDownloadPresignedUrls,
|
||||
TRANSCRIPT_VTT_PRESIGN_SECONDS
|
||||
} from '@api/utils/s3';
|
||||
} from '@cio/core/utils/s3';
|
||||
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
|
||||
import { getMediaTranscriptByAsset } from '@cio/db/queries';
|
||||
import { getAssetsByIds } from '@cio/db/queries/assets';
|
||||
import { mintHlsToken } from '@cio/core/services/assets/assets';
|
||||
import {
|
||||
getCourseRowBySlug,
|
||||
getPublicCourseItem as getPublicCourseItemQuery,
|
||||
@@ -72,6 +73,43 @@ export interface PublicExerciseQuestion {
|
||||
* For exercises, questions + options are returned with answer keys included
|
||||
* because PUBLIC courses grade entirely on the client.
|
||||
*/
|
||||
/**
|
||||
* Mint a `cio_hls` cookie for an anonymous learner viewing a public
|
||||
* lesson. The asset id is **derived from the public course tree**, not
|
||||
* accepted from the caller — so a learner who only knows a random asset
|
||||
* UUID cannot mint a cookie. They have to know a public course slug +
|
||||
* lesson slug whose video actually exposes the asset.
|
||||
*/
|
||||
export async function issuePublicHlsCookieService(courseSlug: string, itemSlug: string) {
|
||||
try {
|
||||
const item = await getPublicCourseItemQuery(courseSlug, itemSlug);
|
||||
if (!item || item.kind !== 'lesson') {
|
||||
throw new AppError('Public lesson not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const video = item.video;
|
||||
if (video?.type !== 'upload' || !video.assetId) {
|
||||
throw new AppError('Lesson has no playable HLS asset', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const assetId = video.assetId;
|
||||
const assets = await getAssetsByIds([assetId], item.courseOrganizationId ?? undefined);
|
||||
const asset = assets[0];
|
||||
if (!asset?.hlsManifestKey) {
|
||||
throw new AppError('Lesson has no playable HLS asset', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
return await mintHlsToken(assetId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to issue HLS cookie',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPublicCourseItemService(
|
||||
courseSlug: string,
|
||||
itemSlug: string
|
||||
@@ -87,17 +125,30 @@ export async function getPublicCourseItemService(
|
||||
|
||||
if (video?.type === 'upload') {
|
||||
let storageKey: string | undefined;
|
||||
let hlsManifestKey: string | null = null;
|
||||
|
||||
if (video.assetId) {
|
||||
const assets = await getAssetsByIds([video.assetId], courseOrganizationId ?? undefined);
|
||||
storageKey = assets[0]?.storageKey ?? undefined;
|
||||
hlsManifestKey = assets[0]?.hlsManifestKey ?? null;
|
||||
}
|
||||
|
||||
if (!storageKey && video.key) {
|
||||
storageKey = video.key;
|
||||
}
|
||||
|
||||
if (storageKey) {
|
||||
// HLS assets: serve the manifest URL via the api route (auth'd by
|
||||
// the cio_hls cookie that the public-cookie endpoint mints when the
|
||||
// learner loads the lesson). Don't presign storageKey — for HLS
|
||||
// it's null anyway.
|
||||
if (hlsManifestKey) {
|
||||
const { key: _omitKey, ...rest } = video;
|
||||
video = {
|
||||
...rest,
|
||||
link: `/hls/${hlsManifestKey}`.replace(/\/{2,}/g, '/'),
|
||||
hls: true
|
||||
};
|
||||
} else if (storageKey) {
|
||||
const urls = await generateVideoDownloadPresignedUrls([storageKey]);
|
||||
const signedLink = urls[storageKey];
|
||||
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import type { TCourseSection } from '@cio/db/types';
|
||||
import {
|
||||
createCourseSections,
|
||||
deleteCourseSection,
|
||||
getCourseSectionById,
|
||||
getCourseSectionsByCourseId,
|
||||
updateCourseSection,
|
||||
updateUngroupedLessonsSectionId
|
||||
} from '@cio/db/queries/course';
|
||||
import { AppError, ErrorCodes } from '@api/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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { env } from '@api/config/env';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import {
|
||||
getCourseStats,
|
||||
getDashOrgStats,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getTotalCertificatesIssued
|
||||
} from '@cio/db/queries/dash';
|
||||
import { dashLoginActivityKey } from '@api/utils/redis/key-generators';
|
||||
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
|
||||
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
|
||||
|
||||
import { OrganisationAnalytics } from '@api/types';
|
||||
import { getOrgIdBySiteName } from '@cio/db/queries';
|
||||
|
||||
@@ -1,903 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/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 '@api/services/course/slug';
|
||||
import { guardNonAutoGradableQuestionsForCourseType } from '@api/services/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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export * from './exercise';
|
||||
export * from '@cio/core/services/exercise/exercise';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getStorageConfig } from '@api/config/storage';
|
||||
import { generateVideoDownloadPresignedUrls, generateVideoUploadPresignedUrl } from '@api/utils/s3';
|
||||
import { getStorageConfig } from '@cio/core/config/storage';
|
||||
import { generateVideoDownloadPresignedUrls, generateVideoUploadPresignedUrl } from '@cio/core/utils/s3';
|
||||
import { createAsset, getAssetById, updateAsset } from '@cio/db/queries/assets';
|
||||
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
|
||||
import { getQuestionAnswersBySubmissionId, getSubmissionById } from '@cio/db/queries/submission';
|
||||
|
||||
@@ -9,4 +9,4 @@ export * from './newsfeed';
|
||||
export * from './onboarding';
|
||||
export * from './submission';
|
||||
export * from './dash';
|
||||
export * from './course/course';
|
||||
export * from '@cio/core/services/course/course';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { enqueueEmailSend, isRedisConfigured } from '@cio/jobs';
|
||||
import { EmailRegistry, type EmailId, type EmailSchemaFor } from '@cio/email';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
|
||||
import { logRedisUnavailableOnce } from '@cio/core/utils/redis/redis';
|
||||
|
||||
type Recipient = string | string[];
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
isRedisConfigured
|
||||
} from '@cio/jobs';
|
||||
|
||||
import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
|
||||
import { logRedisUnavailableOnce } from '@cio/core/utils/redis/redis';
|
||||
import type { TMediaJob } from '@db/types';
|
||||
|
||||
export interface StartTranscriptionOnlyMediaJobInput {
|
||||
@@ -41,7 +41,11 @@ export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOn
|
||||
throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (asset.kind !== 'video' || asset.provider !== 'upload' || !asset.storageKey) {
|
||||
// HLS uploads carry their pre-extracted audio in `hls_audio_key` and leave
|
||||
// `storage_key` null. Raw MP4 uploads keep using `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);
|
||||
}
|
||||
|
||||
@@ -71,7 +75,7 @@ export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOn
|
||||
const enqueueResult = await enqueueTranscriptionOnly({
|
||||
mediaJobId: job.id,
|
||||
assetId: input.assetId,
|
||||
storageKey: asset.storageKey,
|
||||
storageKey: transcriptionSourceKey,
|
||||
actorContext: {
|
||||
userId: input.triggeredByProfileId,
|
||||
organizationId: input.organizationId
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeOptionalHtml } from '@api/utils/sanitize-html';
|
||||
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export * from './lesson';
|
||||
export * from '@cio/core/services/lesson/lesson';
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeHtml } from '@api/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 '@api/utils/lesson-media';
|
||||
import { resolveItemSlug } from '@api/services/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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from '@api/config/env';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { LICENSE_FEATURE, type LicenseFeatureId } from '@cio/utils/license';
|
||||
|
||||
export type LicenseStatus = {
|
||||
|
||||
@@ -2,9 +2,9 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { MAX_IMAGE_SIZE } from '@api/constants/upload';
|
||||
|
||||
import { ALLOWED_IMAGE_TYPES } from '@cio/utils/validation';
|
||||
import { getStorageConfig } from '@api/config/storage';
|
||||
import { generateFileKey } from '@api/utils/upload';
|
||||
import { uploadToS3 } from '@api/utils/s3';
|
||||
import { getStorageConfig } from '@cio/core/config/storage';
|
||||
import { generateFileKey } from '@cio/core/utils/upload';
|
||||
import { uploadToS3 } from '@cio/core/utils/s3';
|
||||
|
||||
/**
|
||||
* Uploads an image file to object storage and returns the public URL
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeHtml } from '@api/utils/sanitize-html';
|
||||
import { sanitizeHtml } from '@cio/core/utils/sanitize-html';
|
||||
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants/domains';
|
||||
import type { TCourseNewsfeed, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@cio/db/types';
|
||||
import type { TNewsfeedCreate, TNewsfeedUpdate } from '@cio/utils/validation/newsfeed';
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
updateNewsfeedComment
|
||||
} from '@cio/db/queries/newsfeed';
|
||||
|
||||
import { env } from '@api/config/env';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { buildEmailFromName } from '@cio/email';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
|
||||
@@ -358,7 +359,7 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
|
||||
|
||||
const orgName = feedData.organization?.name || 'ClassroomIO';
|
||||
const orgSiteName = feedData.organization?.siteName || 'app';
|
||||
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
|
||||
const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
|
||||
|
||||
const recipients = feedData.courseMembers
|
||||
.map((member) => member.email)
|
||||
@@ -402,7 +403,7 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
|
||||
|
||||
const orgName = feedData.organization?.name || 'ClassroomIO';
|
||||
const orgSiteName = feedData.organization?.siteName || 'app';
|
||||
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
|
||||
const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
|
||||
|
||||
await enqueueTransactionalEmail('newsfeedComment', {
|
||||
to: feedData.author.email,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@cio/db/queries';
|
||||
import { getProfileById, updateProfile } from '@cio/db/queries/auth';
|
||||
|
||||
import { env } from '@api/config/env';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
|
||||
import { env } from '@api/config/env';
|
||||
import { env } from '@cio/core/config/env';
|
||||
|
||||
export type DomainSetupStatus =
|
||||
| 'reconnect_required'
|
||||
|
||||
@@ -49,6 +49,7 @@ import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/inv
|
||||
import { trustCustomDomainHostname, untrustCustomDomainHostname } from '@cio/db/utils';
|
||||
|
||||
const PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT = 4;
|
||||
const ORG_COURSES_PAGE_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Creates a new organization with the current user as owner
|
||||
@@ -252,7 +253,11 @@ export async function removeAudienceMember(orgId: string, memberId: number, remo
|
||||
* @param siteName - The organization siteName
|
||||
* @returns Array of published courses with lesson counts
|
||||
*/
|
||||
export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
|
||||
export async function getPublicCourses(
|
||||
siteName: string,
|
||||
tagSlugs?: string[],
|
||||
pagination?: { page?: number; limit?: number }
|
||||
) {
|
||||
try {
|
||||
const orgResult = await getOrgIdBySiteName(siteName);
|
||||
const org = orgResult[0];
|
||||
@@ -261,23 +266,28 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const isPaginated = pagination !== undefined;
|
||||
const limit = isPaginated ? (pagination.limit ?? ORG_COURSES_PAGE_SIZE) : PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT;
|
||||
const page = isPaginated ? (pagination.page ?? 1) : 1;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let filteredCourseIds: string[] | undefined = undefined;
|
||||
if (tagSlugs && tagSlugs.length > 0) {
|
||||
filteredCourseIds = await getCourseIdsByTagSlugs(org.id, tagSlugs);
|
||||
if (filteredCourseIds.length === 0) {
|
||||
return {
|
||||
courses: [],
|
||||
hasMoreCourses: false
|
||||
hasMoreCourses: false,
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const totalCourses = await countPublishedCoursesBySiteName(siteName, filteredCourseIds);
|
||||
const courses = await getPublishedCoursesBySiteName(
|
||||
siteName,
|
||||
filteredCourseIds,
|
||||
PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT
|
||||
);
|
||||
const courses = await getPublishedCoursesBySiteName(siteName, filteredCourseIds, limit, offset);
|
||||
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
|
||||
org.id,
|
||||
courses.map((course) => course.id)
|
||||
@@ -288,7 +298,11 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
|
||||
...course,
|
||||
tags: tagsByCourseId[course.id] ?? []
|
||||
})),
|
||||
hasMoreCourses: totalCourses > courses.length
|
||||
hasMoreCourses: offset + courses.length < totalCourses,
|
||||
total: totalCourses,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalCourses / limit)
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
@@ -555,11 +569,7 @@ async function resolveOrgPlanTriggeredBy(params: {
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
'Could not resolve organization member for plan',
|
||||
ErrorCodes.ORG_PLAN_CREATE_FAILED,
|
||||
400
|
||||
);
|
||||
throw new AppError('Could not resolve organization member for plan', ErrorCodes.ORG_PLAN_CREATE_FAILED, 400);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,7 @@ import { updateOrganizationAudienceMember } from '@cio/db/queries/organization';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import crypto from 'node:crypto';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { getProfilesByEmails } from '@cio/db/queries/auth';
|
||||
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
|
||||
|
||||
@@ -195,7 +195,7 @@ async function enrollAudienceStudentProfilesInCourses(
|
||||
}
|
||||
|
||||
let emailsSent = 0;
|
||||
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
const loginUrl = getDashboardBaseUrl(organization);
|
||||
|
||||
if (shouldSendEmail && toInsert.length > 0) {
|
||||
const emailPromises = toInsert
|
||||
@@ -254,7 +254,7 @@ async function enrollAudienceStudentProfilesInPrograms(
|
||||
}
|
||||
|
||||
const programNameById = new Map(programs.map((program) => [program.id, program.name || 'Program']));
|
||||
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
const loginUrl = getDashboardBaseUrl(organization);
|
||||
const validProgramIds = programs.map((program) => program.id);
|
||||
const validProfiles = uniqueProfileIds.filter((profileId) => validProfileIds.has(profileId));
|
||||
const pairs = validProfiles.flatMap((profileId) => validProgramIds.map((programId) => ({ programId, profileId })));
|
||||
@@ -354,7 +354,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
});
|
||||
|
||||
const invites = await createOrganizationInvites(inviteInputs.map((i) => i.row));
|
||||
const inviteByEmail = new Map(invites.map((inv) => [inv.email.toLowerCase(), inv]));
|
||||
const inviteByEmail = new Map(invites.map((inv) => [(inv.email ?? '').toLowerCase(), inv]));
|
||||
const tokenByEmail = new Map(inviteInputs.map((i) => [i.email.toLowerCase(), i.token]));
|
||||
|
||||
await createOrganizationInviteAudits(
|
||||
|
||||
@@ -6,14 +6,18 @@ import {
|
||||
createOrganizationInviteAudit,
|
||||
createOrganizationMember,
|
||||
createOrganizationMembers,
|
||||
createLinkInvite,
|
||||
getActivePendingOrgInviteForEmail,
|
||||
getOrganizationById,
|
||||
getOrganizationInviteByTokenHash,
|
||||
getOrgLinkInvite,
|
||||
getOrgLinkInviteWithOrg,
|
||||
revokeActiveOrganizationInvitesByEmails,
|
||||
selectOrganizationInviteWithOrgByInviteId,
|
||||
selectOrganizationInviteWithOrgByTokenHash,
|
||||
selectOrganizationMemberByOrgAndNormalizedEmail,
|
||||
selectOrganizationMemberByOrgAndProfile,
|
||||
setLinkInviteRevoked,
|
||||
updateOrganizationMemberById
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||
@@ -24,7 +28,7 @@ import { ROLE } from '@cio/utils/constants';
|
||||
import type { TNewOrganizationInviteAudit } from '@db/types';
|
||||
import crypto from 'node:crypto';
|
||||
import { db, type DbOrTxClient } from '@cio/db/drizzle';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
|
||||
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
@@ -338,7 +342,8 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
if (row.invite.email.toLowerCase().trim() !== normalizedEmail) {
|
||||
const inviteEmail = row.invite.email ?? '';
|
||||
if (inviteEmail.toLowerCase().trim() !== normalizedEmail) {
|
||||
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
@@ -447,6 +452,151 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Link Invite ────────────────────────────────────────────────────────────
|
||||
|
||||
const LINK_INVITE_FAR_FUTURE_MS = 100 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Returns the current org link invite if one already exists, otherwise creates one.
|
||||
* One link invite per org — subsequent calls return the same record.
|
||||
*/
|
||||
export async function getOrCreateLinkInvite(orgId: string, roleId: number, profileId: string) {
|
||||
if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) {
|
||||
throw new AppError('Invalid organization role for link invite', ErrorCodes.VALIDATION_ERROR, 400, 'roleId');
|
||||
}
|
||||
|
||||
const existing = await getOrgLinkInvite(orgId);
|
||||
|
||||
if (existing) {
|
||||
const token = (existing.metadata as Record<string, unknown>)?.token as string | undefined;
|
||||
return { id: existing.id, token: token ?? '', roleId: existing.roleId, isRevoked: existing.isRevoked };
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const tokenHash = hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + LINK_INVITE_FAR_FUTURE_MS).toISOString();
|
||||
|
||||
const invite = await createLinkInvite({
|
||||
organizationId: orgId,
|
||||
roleId,
|
||||
tokenHash,
|
||||
createdByProfileId: profileId,
|
||||
expiresAt,
|
||||
metadata: { token, source: 'ORG_SETTINGS_LINK_INVITE' }
|
||||
});
|
||||
|
||||
await recordOrganizationInviteAudit(invite.id, orgId, 'CREATED', {
|
||||
actorProfileId: profileId,
|
||||
metadata: { roleId, type: 'LINK' }
|
||||
});
|
||||
|
||||
return { id: invite.id, token, roleId: invite.roleId, isRevoked: invite.isRevoked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current link invite for the org (for GET endpoint — returns null if none created yet).
|
||||
*/
|
||||
export async function fetchOrgLinkInvite(orgId: string) {
|
||||
const row = await getOrgLinkInvite(orgId);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const token = (row.metadata as Record<string, unknown>)?.token as string | undefined;
|
||||
return { id: row.id, token: token ?? '', roleId: row.roleId, isRevoked: row.isRevoked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the org link invite (toggle isRevoked).
|
||||
*/
|
||||
export async function toggleOrgLinkInvite(orgId: string, isRevoked: boolean, profileId: string) {
|
||||
const updated = await setLinkInviteRevoked(orgId, isRevoked, profileId);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Link invite not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const token = (updated.metadata as Record<string, unknown>)?.token as string | undefined;
|
||||
return { id: updated.id, token: token ?? '', roleId: updated.roleId, isRevoked: updated.isRevoked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-only preview for a link invite (API-key protected at route layer).
|
||||
*/
|
||||
export async function previewLinkInvite(token: string, context: TInviteRequestContext = {}) {
|
||||
const tokenHash = hashToken(token);
|
||||
const data = await getOrgLinkInviteWithOrg(db, tokenHash);
|
||||
|
||||
if (!data) {
|
||||
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
await recordOrganizationInviteAudit(data.invite.id, data.invite.organizationId, 'PREVIEWED', {
|
||||
ipAddress: context.ipAddress,
|
||||
userAgent: context.userAgent
|
||||
});
|
||||
|
||||
return {
|
||||
invite: {
|
||||
id: data.invite.id,
|
||||
roleId: data.invite.roleId,
|
||||
roleLabel: getRoleLabel(data.invite.roleId),
|
||||
isRevoked: data.invite.isRevoked
|
||||
},
|
||||
organization: data.organization
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a link invite — adds the authenticated user to the org.
|
||||
* The invite is NOT consumed (reusable). Anyone with the link can join.
|
||||
*/
|
||||
export async function acceptLinkInvite(token: string, user: TAuthUser, context: TInviteRequestContext = {}) {
|
||||
if (!user.id || !user.email) {
|
||||
throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401);
|
||||
}
|
||||
|
||||
const normalizedEmail = user.email.toLowerCase().trim();
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const row = await getOrgLinkInviteWithOrg(tx, tokenHash);
|
||||
|
||||
if (!row) {
|
||||
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (row.invite.isRevoked) {
|
||||
throw new AppError('This invite link has been disabled', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
await syncOrgMemberForOrgInvite(tx, {
|
||||
organizationId: row.invite.organizationId,
|
||||
roleId: row.invite.roleId,
|
||||
normalizedEmail,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
return { organization: row.organization, roleId: row.invite.roleId, inviteId: row.invite.id };
|
||||
});
|
||||
|
||||
await recordOrganizationInviteAudit(result.inviteId, result.organization.id, 'ACCEPTED', {
|
||||
actorProfileId: user.id,
|
||||
targetEmail: normalizedEmail,
|
||||
ipAddress: context.ipAddress,
|
||||
userAgent: context.userAgent,
|
||||
metadata: { source: 'LINK_INVITE' }
|
||||
});
|
||||
|
||||
const siteName = result.organization.siteName || '';
|
||||
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
|
||||
|
||||
return { organizationId: result.organization.id, roleId: result.roleId, redirectTo };
|
||||
}
|
||||
|
||||
// ─── End Link Invite ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the pending org invite for the currently logged-in user, if one exists.
|
||||
* Used by the LMS dashboard to prompt the student to accept on first load.
|
||||
@@ -503,7 +653,8 @@ export async function acceptOrganizationInviteById(
|
||||
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
if (row.invite.email.toLowerCase().trim() !== normalizedEmail) {
|
||||
const inviteEmailById = row.invite.email ?? '';
|
||||
if (inviteEmailById.toLowerCase().trim() !== normalizedEmail) {
|
||||
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import {
|
||||
countAssignmentsByStatus,
|
||||
createProgramGoal,
|
||||
@@ -408,7 +408,11 @@ export async function runProgramGoalReminderScan(): Promise<{
|
||||
|
||||
if (!matchesReminder) continue;
|
||||
|
||||
const loginUrl = getDashboardBaseUrl(row.organizationSiteName ?? undefined);
|
||||
const loginUrl = getDashboardBaseUrl({
|
||||
siteName: row.organizationSiteName,
|
||||
customDomain: row.organizationCustomDomain,
|
||||
isCustomDomainVerified: row.organizationIsCustomDomainVerified
|
||||
});
|
||||
const dayKey = now.toISOString().slice(0, 10);
|
||||
|
||||
try {
|
||||
|
||||
@@ -41,8 +41,8 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from
|
||||
|
||||
import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types';
|
||||
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@api/utils/s3';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@cio/core/utils/s3';
|
||||
import { syncComplianceProgressFromSubmission } from '@api/services/course/compliance';
|
||||
|
||||
type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed';
|
||||
|
||||
@@ -114,6 +114,10 @@ export async function getPublicOrganizationTagGroups(siteName: string) {
|
||||
|
||||
for (const group of groups) {
|
||||
for (const tag of group.tags) {
|
||||
if (tag.courseCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupId = tag.groupId || group.id;
|
||||
const existing = tagsByGroupId.get(groupId) ?? [];
|
||||
|
||||
@@ -124,10 +128,12 @@ export async function getPublicOrganizationTagGroups(siteName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
return groups.map((group) => ({
|
||||
...group,
|
||||
tags: tagsByGroupId.get(group.id) ?? []
|
||||
}));
|
||||
return groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
tags: tagsByGroupId.get(group.id) ?? []
|
||||
}))
|
||||
.filter((group) => group.tags.length > 0);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
deleteCourse as deleteCourseService,
|
||||
getCourse,
|
||||
updateCourse as updateCourseService
|
||||
} from '@api/services/course/course';
|
||||
} from '@cio/core/services/course/course';
|
||||
import {
|
||||
createCourseImportDraftService,
|
||||
getCourseImportStructureService,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@cio/utils/validation/widget';
|
||||
import type { TWidget } from '@db/types';
|
||||
import * as csstree from 'css-tree';
|
||||
import { env } from '@api/config/env';
|
||||
import { env } from '@cio/core/config/env';
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
const DEFAULT_EMBED_CDN_BASE_URL = 'https://assets.cdn.clsrio.com';
|
||||
|
||||
Reference in New Issue
Block a user