feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+37
View File
@@ -0,0 +1,37 @@
import {
clearSecondariesReadOnly,
findAccountsOverAllowance,
setSecondariesReadOnlyUntil
} from '@cio/db/queries/account';
import { WORKSPACES_INCLUDED } from '@cio/utils/plans';
const GRACE_PERIOD_DAYS = 7;
export type GraceCheckResult = {
scanned: number;
marked: Array<{ primaryOrgId: string; planName: string | null; workspaceCount: number; secondariesAffected: number }>;
};
/**
* Daily check: any account whose total workspace count exceeds the allowance
* for its current plan gets all of its secondary workspaces marked read-only
* for the next 7 days. The primary workspace is never locked.
*/
export async function runWorkspaceGraceCheck(): Promise<GraceCheckResult> {
const offenders = await findAccountsOverAllowance(WORKSPACES_INCLUDED);
const until = new Date(Date.now() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000);
const marked: GraceCheckResult['marked'] = [];
for (const offender of offenders) {
const affected = await setSecondariesReadOnlyUntil(offender.primaryOrgId, until);
marked.push({ ...offender, secondariesAffected: affected });
}
return { scanned: offenders.length, marked };
}
export async function clearWorkspaceGraceForAccount(primaryOrgId: string): Promise<number> {
return clearSecondariesReadOnly(primaryOrgId);
}
+4
View File
@@ -0,0 +1,4 @@
export * from './profile';
export * from './workspaces';
export * from './quota';
export * from './grace';
+84
View File
@@ -0,0 +1,84 @@
import * as schema from '@db/schema';
import { and, count, eq, gte, inArray, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import { listAccountOrgIds } from '@cio/db/queries/account';
export type AccountUsage = {
workspaceCount: number;
learnerCount: number;
tokensUsedThisMonth: number;
perWorkspace: Array<{
orgId: string;
learnerCount: number;
tokensUsedThisMonth: number;
}>;
};
function startOfCurrentMonth(): Date {
const date = new Date();
date.setDate(1);
date.setHours(0, 0, 0, 0);
return date;
}
export async function getAccountUsage(primaryOrgId: string): Promise<AccountUsage> {
const orgIds = await listAccountOrgIds(primaryOrgId);
if (orgIds.length === 0) {
return { workspaceCount: 0, learnerCount: 0, tokensUsedThisMonth: 0, perWorkspace: [] };
}
const since = startOfCurrentMonth().toISOString();
const [learnerRows, tokenRows] = await Promise.all([
db
.select({
organizationId: schema.organizationmember.organizationId,
c: count()
})
.from(schema.organizationmember)
.where(
and(
inArray(schema.organizationmember.organizationId, orgIds),
eq(schema.organizationmember.roleId, ROLE.STUDENT)
)
)
.groupBy(schema.organizationmember.organizationId),
db
.select({
orgId: schema.aiTokenUsage.orgId,
tokens: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`
})
.from(schema.aiTokenUsage)
.where(and(inArray(schema.aiTokenUsage.orgId, orgIds), gte(schema.aiTokenUsage.createdAt, since)))
.groupBy(schema.aiTokenUsage.orgId)
]);
const learnerByOrg = new Map<string, number>();
for (const row of learnerRows) {
learnerByOrg.set(row.organizationId, row.c);
}
const tokensByOrg = new Map<string, number>();
for (const row of tokenRows) {
if (row.orgId) tokensByOrg.set(row.orgId, Number(row.tokens) || 0);
}
const perWorkspace = orgIds.map((orgId) => ({
orgId,
learnerCount: learnerByOrg.get(orgId) ?? 0,
tokensUsedThisMonth: tokensByOrg.get(orgId) ?? 0
}));
const learnerCount = perWorkspace.reduce((sum, row) => sum + row.learnerCount, 0);
const tokensUsedThisMonth = perWorkspace.reduce((sum, row) => sum + row.tokensUsedThisMonth, 0);
return {
workspaceCount: orgIds.length,
learnerCount,
tokensUsedThisMonth,
perWorkspace
};
}
+127
View File
@@ -0,0 +1,127 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
countWorkspacesForAccount,
createSecondaryWorkspace,
deleteSecondaryWorkspace,
getAccountPrimary,
getActivePlanForPrimary,
listWorkspacesForAccount
} from '@cio/db/queries/account';
import { canCreateWorkspaces, getWorkspaceAllowance } from '@cio/utils/plans';
import { ROLE } from '@cio/utils/constants';
import type { TOrganization } from '@cio/db/types';
import { checkSiteNameExists } from '@cio/db/queries/organization';
export type AccountWorkspace = TOrganization & {
isPrimary: boolean;
};
async function resolvePrimaryOrThrow(activeOrgId: string): Promise<TOrganization> {
const primary = await getAccountPrimary(activeOrgId);
if (!primary) {
throw new AppError('Active organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
if (primary.id !== activeOrgId) {
throw new AppError(
'Account workspace operations must be performed from the primary workspace',
ErrorCodes.NOT_PRIMARY_WORKSPACE,
403
);
}
return primary;
}
export async function listAccountWorkspaces(activeOrgId: string): Promise<AccountWorkspace[]> {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const rows = await listWorkspacesForAccount(primary.id);
return rows.map((row) => ({
...row,
isPrimary: row.parentOrganizationId === null
}));
}
export async function createWorkspaceForAccount(
activeOrgId: string,
ownerProfileId: string,
input: { name: string; siteName: string }
): Promise<AccountWorkspace> {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const planName = await getActivePlanForPrimary(primary.id);
if (!canCreateWorkspaces(planName)) {
throw new AppError('Multi-workspace requires the Enterprise plan', ErrorCodes.PLAN_UPGRADE_REQUIRED, 402);
}
const allowance = getWorkspaceAllowance(planName);
const current = await countWorkspacesForAccount(primary.id);
if (current >= allowance) {
throw new AppError(
`Workspace limit reached (${allowance} per Enterprise account)`,
ErrorCodes.WORKSPACE_LIMIT_REACHED,
409
);
}
const exists = await checkSiteNameExists(input.siteName);
if (exists) {
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
try {
const created = await createSecondaryWorkspace({
parentOrganizationId: primary.id,
name: input.name,
siteName: input.siteName,
ownerProfileId,
ownerRoleId: ROLE.ADMIN
});
return { ...created, isPrimary: false };
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && (error as { code: unknown }).code === '23505') {
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
throw new AppError(error instanceof Error ? error : new Error('Unknown error'), ErrorCodes.ORG_CREATE_FAILED, 500);
}
}
export async function deleteWorkspaceFromAccount(activeOrgId: string, targetWorkspaceId: string): Promise<void> {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const workspaces = await listWorkspacesForAccount(primary.id);
const target = workspaces.find((row) => row.id === targetWorkspaceId);
if (!target) {
throw new AppError('Workspace not found in account', ErrorCodes.WORKSPACE_NOT_FOUND, 404);
}
if (!target.parentOrganizationId) {
throw new AppError('Cannot delete the primary workspace', ErrorCodes.WORKSPACE_IS_PRIMARY, 400);
}
const result = await deleteSecondaryWorkspace(targetWorkspaceId);
if (!result.deleted) {
if (result.reason === 'IS_PRIMARY') {
throw new AppError('Cannot delete the primary workspace', ErrorCodes.WORKSPACE_IS_PRIMARY, 400);
}
throw new AppError('Workspace not found', ErrorCodes.WORKSPACE_NOT_FOUND, 404);
}
}
export async function getWorkspaceLimits(activeOrgId: string) {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const planName = await getActivePlanForPrimary(primary.id);
const used = await countWorkspacesForAccount(primary.id);
const allowance = getWorkspaceAllowance(planName);
return {
planName,
canCreate: canCreateWorkspaces(planName) && used < allowance,
used,
allowance,
requiresUpgrade: !canCreateWorkspaces(planName)
};
}
+123 -20
View File
@@ -1,5 +1,11 @@
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';
@@ -56,7 +62,14 @@ export const updateContentParam = z.object({
export const questionSchema = z.object({
question: z.string().min(1),
questionTypeId: z.number().int().min(1).max(14).default(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() }))
@@ -150,30 +163,62 @@ export const addQuestionsParam = z.object({
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: z.record(z.string(), z.unknown()).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(14).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: z.record(z.string(), z.unknown()).optional(),
options: z.array(updateOptionSchema).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(),
@@ -181,6 +226,37 @@ export const updateQuestionsParam = z.object({
});
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(
@@ -190,9 +266,36 @@ export const agentLessonTabsOrder = z.array(
})
);
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({
lessonTabsOrder: agentLessonTabsOrder.optional()
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) =>
+26 -3
View File
@@ -3,7 +3,7 @@ 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 { CoursePlanSchema } from '@cio/ai-assistant';
import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant';
type ResourceOwnershipRow = {
courseId: string | null;
@@ -137,7 +137,7 @@ type PlanMetadataMessage = {
};
};
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanSchema> | undefined {
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;
@@ -149,7 +149,7 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof
continue;
}
const parsedPlan = CoursePlanSchema.safeParse(message.metadata.plan.payload);
const parsedPlan = CoursePlanFieldsSchema.safeParse(message.metadata.plan.payload);
if (parsedPlan.success) {
return parsedPlan.data;
@@ -158,3 +158,26 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof
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;
}
+134 -4
View File
@@ -2,23 +2,153 @@ import {
listChatConversations,
getChatConversation,
createChatConversation,
saveChatMessages,
saveChatMessages as saveDbChatMessages,
deleteChatConversation,
updateConversationTitle,
getChatHistory,
saveChatHistory,
saveChatHistory as saveDbChatHistory,
deleteChatHistory
} from '@cio/db/queries/agent';
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']);
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function serializeValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value);
} catch {
return '';
}
}
function getToolName(part: Record<string, unknown>): string | null {
const type = part.type;
if (type === 'tool-invocation') {
const toolInvocation = part.toolInvocation;
return isRecord(toolInvocation) && typeof toolInvocation.toolName === 'string' ? toolInvocation.toolName : null;
}
if (typeof type === 'string' && type.startsWith('tool-')) {
return type.slice(5);
}
return null;
}
function buildTruncatedOutput(output: unknown): unknown {
const serialized = serializeValue(output);
if (serialized.length <= MAX_PERSISTED_TOOL_OUTPUT_CHARS) {
return output;
}
const preview = serialized.slice(0, MAX_PERSISTED_TOOL_OUTPUT_CHARS) + '...[truncated]';
if (typeof output === 'string') {
return preview;
}
if (!isRecord(output)) {
return preview;
}
const preservedScalars = Object.fromEntries(
Object.entries(output).filter(
([, value]) => value == null || ['string', 'number', 'boolean'].includes(typeof value)
)
);
return {
...preservedScalars,
truncated: true,
preview
};
}
function sanitizeMessagePart(part: unknown): unknown | null {
if (!isRecord(part)) {
return part;
}
const type = typeof part.type === 'string' ? part.type : '';
if (DROPPED_PART_TYPES.has(type)) {
return null;
}
const toolName = getToolName(part);
if (!toolName || TOOLS_WITH_FULL_OUTPUT.has(toolName)) {
return part;
}
if (part.type === 'tool-invocation' && isRecord(part.toolInvocation)) {
return {
...part,
toolInvocation: {
...part.toolInvocation,
result: buildTruncatedOutput(part.toolInvocation.result)
}
};
}
if (part.state !== 'output-available' || part.output == null) {
return part;
}
return {
...part,
output: buildTruncatedOutput(part.output)
};
}
function sanitizeChatMessages(messages: unknown[]): unknown[] {
return messages.map((message) => {
if (!isRecord(message) || !Array.isArray(message.parts)) {
return message;
}
const sanitizedParts = message.parts.map(sanitizeMessagePart).filter((part) => part !== null);
return {
...message,
parts: sanitizedParts
};
});
}
export async function saveChatMessages(
conversationId: string,
userId: string,
messages: unknown[],
title?: string
): Promise<void> {
await saveDbChatMessages(conversationId, userId, sanitizeChatMessages(messages), title);
}
async function persistChatHistory(courseId: string, userId: string, messages: unknown[]): Promise<void> {
await saveDbChatHistory(courseId, userId, sanitizeChatMessages(messages));
}
export {
listChatConversations,
getChatConversation,
createChatConversation,
saveChatMessages,
deleteChatConversation,
updateConversationTitle,
// Legacy compat
getChatHistory as fetchChatHistory,
saveChatHistory as persistChatHistory,
persistChatHistory,
deleteChatHistory as clearChatHistory
};
+53 -4
View File
@@ -28,6 +28,7 @@ import {
} from '@api/services/agent/chat-context';
import {
addQuestionsParam,
askTemplateQuestionsParam,
coursePlanParam,
createExerciseParam,
createExerciseSectionParam,
@@ -35,6 +36,7 @@ import {
createSectionParam,
emptyParam,
exerciseReadParam,
fetchDocumentationUrlParam,
goLiveParam,
lessonReadParam,
reorderContentParam,
@@ -46,6 +48,7 @@ import {
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;
@@ -184,7 +187,7 @@ async function executeAgentTool<TArgs, TResult>(
}
}
export function buildAgentTools(orgId: string, userId: string, courseId: string) {
export function buildAgentTools(orgId: string, userId: string, courseId: string, priorMessages: unknown[]) {
return {
get_course_structure: tool({
description:
@@ -316,6 +319,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
});
return {
lessonId: args.lessonId,
lessonTitle: lesson.title,
locale: args.locale,
contentLength: normalizedContent.length,
updated: true
@@ -488,7 +492,12 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
];
await updateExerciseService(args.exerciseId, { questions: allQuestions });
return { exerciseId: args.exerciseId, addedCount: args.questions.length, totalCount: allQuestions.length };
return {
exerciseId: args.exerciseId,
exerciseTitle: existingExercise.title,
addedCount: args.questions.length,
totalCount: allQuestions.length
};
});
}
}),
@@ -545,7 +554,11 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
await updateExerciseService(args.exerciseId, { questions: merged });
return { exerciseId: args.exerciseId, updatedCount: merged.length };
return {
exerciseId: args.exerciseId,
exerciseTitle: existing.title,
updatedCount: merged.length
};
});
}
}),
@@ -586,7 +599,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
update_course_landing_page: tool({
description:
'Update course-level landing page fields for this course, including public title, description, overview, goals, requirements, instructor metadata, pricing, and banner image. The courseId is automatically set — do not pass it.',
'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 () => {
@@ -650,6 +663,42 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
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
});
});
}
})
};
}
+38
View File
@@ -0,0 +1,38 @@
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';
export async function compactConversation(conversationId: string, userId: string): Promise<{ messages: unknown[] }> {
const conversation = await getChatConversation(conversationId, userId);
if (!conversation) {
throw new AppError('Conversation not found', 'CONVERSATION_NOT_FOUND', 404);
}
const storedMessages = (conversation.messages ?? []) as unknown[];
if (storedMessages.length === 0) {
throw new AppError('No messages to compact', ErrorCodes.NO_MESSAGES_TO_COMPACT, 400);
}
const summary = await summarizeConversation({ messages: storedMessages as SummarizeOptions['messages'] });
const seed = {
id: randomUUID(),
role: 'user' as const,
parts: [{ type: 'text' as const, text: summary }],
metadata: {
compaction: {
compactedAt: new Date().toISOString(),
originalMessageCount: storedMessages.length
}
}
};
await saveChatMessages(conversationId, userId, [seed]);
return { messages: [seed] };
}
+343
View File
@@ -0,0 +1,343 @@
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
return `<external_untrusted_document src="${safeSrc}">\n${markdown}\n</external_untrusted_document>`;
}
export function countCompletedFetchDocumentationCalls(messages: unknown[]): number {
let count = 0;
for (const msg of messages as { parts?: unknown[] }[]) {
const parts = msg.parts;
if (!parts) {
continue;
}
for (const part of parts) {
if (!part || typeof part !== 'object') {
continue;
}
const record = part as Record<string, unknown>;
const type = typeof record.type === 'string' ? record.type : '';
let toolName: string | null = null;
if (type === 'tool-invocation') {
toolName = (record.toolInvocation as { toolName?: string } | undefined)?.toolName ?? null;
} else if (type.startsWith('tool-')) {
toolName = type.slice(5);
}
if (toolName !== 'fetch_documentation_url') {
continue;
}
const state = record.state;
if (state === 'result' || state === 'output-available' || state === 'output-error') {
count += 1;
}
}
}
return count;
}
async function redisSafeGet(key: string): Promise<string | null> {
try {
if (!redis.isOpen) {
return null;
}
return await redis.get(key);
} catch (error) {
logRedisUnavailableOnce('fetch-url cache get failed', error);
return null;
}
}
async function redisSafeSet(key: string, value: string, ttlSec: number): Promise<void> {
try {
if (!redis.isOpen) {
return;
}
await redis.set(key, value, { EX: ttlSec });
} catch (error) {
logRedisUnavailableOnce('fetch-url cache set failed', error);
}
}
async function incrementOrgDailyFetchQuota(orgId: string, maxPerDay: number): Promise<void> {
const dayKey = new Date().toISOString().slice(0, 10);
const key = `agent:fetch_url:quota:${orgId}:${dayKey}`;
try {
if (!redis.isOpen) {
return;
}
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, QUOTA_TTL_SEC);
}
if (count > maxPerDay) {
throw new AppError(
'Daily documentation fetch quota exceeded for this organization',
'AGENT_FETCH_QUOTA_EXCEEDED',
429
);
}
} catch (error) {
if (error instanceof AppError) {
throw error;
}
logRedisUnavailableOnce('fetch-url quota redis failed', error);
}
}
async function fetchMarkdownFromJina(url: string): Promise<{ markdown: string; status: number }> {
const readerHref = `${JINA_READER_PREFIX}${url}`;
const headers: Record<string, string> = {};
if (env.JINA_API_KEY) {
headers.Authorization = `Bearer ${env.JINA_API_KEY}`;
}
let response: Response;
try {
response = await fetch(readerHref, {
headers,
redirect: 'follow'
});
} catch {
throw new AppError('Failed to reach documentation reader', 'DOCUMENTATION_FETCH_FAILED', 502);
}
const status = response.status;
const bytesHint = response.headers.get('content-length');
if (!response.ok) {
console.info('[fetch_documentation_url]', { url, status, bytes: bytesHint });
throw new AppError(`Documentation fetch failed with status ${status}`, 'DOCUMENTATION_FETCH_FAILED', 502);
}
const markdown = await response.text();
console.info('[fetch_documentation_url]', { url, status, bytes: String(markdown.length) });
return { markdown, status };
}
export async function fetchDocumentationUrl(params: {
url: string;
orgId: string;
courseId: string;
priorMessages: unknown[];
}): Promise<FetchDocumentationUrlResult> {
const { url, orgId, courseId, priorMessages } = params;
const normalizedUrl = assertFetchableDocumentationUrl(url);
const normalizedHref = normalizedUrl.href;
const priorFetches = countCompletedFetchDocumentationCalls(priorMessages);
if (priorFetches >= env.AGENT_MAX_FETCHES_PER_CONVERSATION) {
throw new AppError('Maximum documentation fetches per conversation reached', 'AGENT_FETCH_CONVERSATION_LIMIT', 429);
}
const paid = await isOrgOnPaidPlan(orgId);
if (!paid) {
throw new AppError('Documentation fetching requires a paid plan', ErrorCodes.UPGRADE_REQUIRED, 403);
}
const cacheKeyRaw = `${orgId}:${normalizedHref}`;
const cacheKey = `agent:fetch_url:${createHash('sha256').update(cacheKeyRaw).digest('hex')}`;
const cachedRaw = await redisSafeGet(cacheKey);
if (cachedRaw) {
try {
const cached = JSON.parse(cachedRaw) as CachedFetchPayload;
const truncated = truncateMarkdown(cached.rawMarkdown);
return {
url: cached.url,
pageTitle: cached.pageTitle,
content: wrapUntrustedMarkdown(cached.url, truncated.text),
links: cached.links,
contentTokens: Math.ceil(truncated.text.length / 4),
fetchedAt: cached.fetchedAt,
cacheHit: true
};
} catch {
// fall through to refetch
}
}
await incrementOrgDailyFetchQuota(orgId, env.AGENT_MAX_FETCHES_PER_ORG_PER_DAY);
const { markdown } = await fetchMarkdownFromJina(normalizedHref);
const truncated = truncateMarkdown(markdown);
const pageTitle = guessPageTitle(truncated.text, normalizedHref);
const links = extractSameOriginLinks(truncated.text, normalizedUrl);
const fetchedAt = new Date().toISOString();
const cachePayload: CachedFetchPayload = {
url: normalizedHref,
pageTitle,
rawMarkdown: truncated.text,
links,
fetchedAt
};
await redisSafeSet(cacheKey, JSON.stringify(cachePayload), CACHE_TTL_SEC);
void courseId;
return {
url: normalizedHref,
pageTitle,
content: wrapUntrustedMarkdown(normalizedHref, truncated.text),
links,
contentTokens: Math.ceil(truncated.text.length / 4),
fetchedAt,
cacheHit: false
};
}
@@ -0,0 +1,138 @@
/**
* Anthropic strictly requires every `tool_use` block to be followed in the very
* next message by a `tool_result` block with a matching id. OpenAI tolerates
* mismatches; Anthropic 400s.
*
* The break-shapes this catches:
* (a) `convertToModelMessages` splits an assistant UIMessage with parts
* `[tool-call, text, text]` into TWO assistant ModelMessages plus a tool
* message — the wedged second assistant message separates tool_use from
* tool_result.
* (b) `trimMessageHistory` slices the array between an assistant tool-call
* turn and its tool-result message.
* (c) A tool call was aborted mid-stream and persisted without a result.
*
* Strategy: walk the ModelMessage[] once. For every assistant message that has
* tool-call parts, **immediately** emit a tool message containing matching
* tool-result parts — pulled from anywhere later in the array, or synthesized
* as an error result if missing entirely. Tool messages whose results have
* already been claimed by an earlier assistant turn are dropped (or trimmed)
* to avoid duplicates.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolCallPart = { type: 'tool-call' | 'dynamic-tool-call'; toolCallId: string; toolName: string };
type ToolResultPart = {
type: 'tool-result' | 'dynamic-tool-result';
toolCallId: string;
toolName?: string;
output?: unknown;
};
function isToolCallPart(part: unknown): part is ToolCallPart {
const type = (part as { type?: unknown })?.type;
return type === 'tool-call' || type === 'dynamic-tool-call';
}
function isToolResultPart(part: unknown): part is ToolResultPart {
const type = (part as { type?: unknown })?.type;
return type === 'tool-result' || type === 'dynamic-tool-result';
}
function syntheticErrorResult(tc: ToolCallPart): ToolResultPart {
return {
type: 'tool-result',
toolCallId: tc.toolCallId,
toolName: tc.toolName,
output: { type: 'error-text', value: 'Tool call was interrupted; no result captured.' }
};
}
/**
* Anthropic accepts assistant content in the canonical shape `[text*, tool_use*]`
* (text/reasoning first, then tool_uses at the end). When the assistant content
* has text AFTER tool_use blocks (`[tool_use, text, text]`), Anthropic's strict
* validator can reject it with the same "tool_result missing immediately after"
* error even though the next message IS a tool message — because it doesn't
* recognise the assistant turn as ending on tool_use. Reorder so tool-calls
* always come last within each assistant message.
*/
function reorderAssistantContent(content: any[]): any[] {
const toolCalls: any[] = [];
const nonToolCalls: any[] = [];
for (const part of content) {
if (isToolCallPart(part)) {
toolCalls.push(part);
} else {
nonToolCalls.push(part);
}
}
// Only return a new array if we actually moved something.
if (toolCalls.length === 0 || nonToolCalls.length === 0) return content;
return [...nonToolCalls, ...toolCalls];
}
export function sanitizeDanglingToolCalls(messages: any[]): any[] {
// Pass 1 — index every tool-result we can find anywhere in the conversation,
// keyed by toolCallId. If duplicates exist, last one wins (most recent).
const toolResultsByCallId = new Map<string, ToolResultPart>();
for (const m of messages) {
if (m?.role !== 'tool' || !Array.isArray(m.content)) continue;
for (const part of m.content) {
if (isToolResultPart(part)) {
toolResultsByCallId.set(part.toolCallId, part);
}
}
}
// Pass 2 — rebuild. After each assistant message with tool-calls, inject a
// tool message whose results match those calls in the same order. Skip the
// original tool messages whose parts we've already claimed. Also reorder
// assistant content so tool-calls always sit at the END.
const claimedResultIds = new Set<string>();
const result: any[] = [];
for (let i = 0; i < messages.length; i += 1) {
const message = messages[i];
// Filter or drop tool messages whose results were already pulled forward.
if (message?.role === 'tool' && Array.isArray(message.content)) {
const remaining = message.content.filter(
(part: unknown) => !isToolResultPart(part) || !claimedResultIds.has(part.toolCallId)
);
if (remaining.length > 0) {
result.push({ ...message, content: remaining });
}
continue;
}
if (message?.role !== 'assistant' || !Array.isArray(message.content)) {
result.push(message);
continue;
}
const reorderedContent = reorderAssistantContent(message.content);
const reorderedMessage = reorderedContent === message.content ? message : { ...message, content: reorderedContent };
result.push(reorderedMessage);
const toolCalls = reorderedContent.filter(isToolCallPart);
if (toolCalls.length === 0) continue;
const resultParts: ToolResultPart[] = toolCalls.map((tc: ToolCallPart) => {
claimedResultIds.add(tc.toolCallId);
const existing = toolResultsByCallId.get(tc.toolCallId);
return existing ?? syntheticErrorResult(tc);
});
result.push({ role: 'tool', content: resultParts });
}
return result;
}
@@ -0,0 +1,291 @@
import { and, asc, eq, ilike, or } from 'drizzle-orm';
import { tool } from 'ai';
import { z } from 'zod';
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 { AppError } from '@api/utils/errors';
import { AgentEvent, trackAgentEvent } from '@api/utils/tinybird';
import { verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse } from './chat-context';
/**
* Student agent tools — read-only, course-scoped.
*
* Every tool re-verifies the resource belongs to `courseId` so a poisoned model
* cannot reach into other courses or other learners' data. Exercise reads strip
* answer keys and per-question scoring data before returning.
*/
const SEARCH_SNIPPET_RADIUS = 80;
function logToolEvent(
phase: 'start' | 'success' | 'error',
toolName: string,
details: { orgId: string; userId: string; courseId: string; args?: unknown; error?: unknown }
) {
const base = `[student-tool:${phase}] ${toolName}`;
if (phase === 'error') {
console.error(base, {
courseId: details.courseId,
userId: details.userId,
args: details.args,
error: details.error instanceof Error ? details.error.message : details.error
});
return;
}
console.info(base, {
courseId: details.courseId,
userId: details.userId
});
}
async function executeStudentTool<T>(
toolName: string,
ctx: { orgId: string; userId: string; courseId: string; args?: unknown },
execute: () => Promise<T>
): Promise<T> {
trackAgentEvent(AgentEvent.TOOL_CALLED, {
orgId: ctx.orgId,
userId: ctx.userId,
courseId: ctx.courseId,
toolName
});
logToolEvent('start', toolName, ctx);
try {
const result = await execute();
trackAgentEvent(AgentEvent.TOOL_COMPLETED, {
orgId: ctx.orgId,
userId: ctx.userId,
courseId: ctx.courseId,
toolName,
success: true
});
logToolEvent('success', toolName, ctx);
return result;
} catch (error) {
logToolEvent('error', toolName, { ...ctx, error });
if (error instanceof AppError) throw error;
throw error instanceof Error ? error : new Error(String(error));
}
}
function stripAnswerKeysFromQuestion(question: {
id: number | string;
title: string;
questionTypeId: number;
order: number | null;
points: number | null;
options?: { label: string | null }[];
}): {
id: number | string;
question: string;
questionTypeId: number;
order: number | null;
options: { label: string }[];
} {
return {
id: question.id,
question: question.title,
questionTypeId: question.questionTypeId,
order: question.order,
options: (question.options ?? []).filter((o) => o.label != null).map((o) => ({ label: o.label as string }))
};
}
function makeSnippet(text: string, query: string, radius: number = SEARCH_SNIPPET_RADIUS): string {
if (!text) return '';
const normalized = text
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const lower = normalized.toLowerCase();
const idx = lower.indexOf(query.toLowerCase());
if (idx < 0) return normalized.slice(0, radius * 2) + (normalized.length > radius * 2 ? '…' : '');
const start = Math.max(0, idx - radius);
const end = Math.min(normalized.length, idx + query.length + radius);
const prefix = start > 0 ? '…' : '';
const suffix = end < normalized.length ? '…' : '';
return `${prefix}${normalized.slice(start, end)}${suffix}`;
}
const listCourseOutlineParam = z.object({});
const readLessonParam = z.object({ lessonId: z.string() });
const readExerciseParam = z.object({ exerciseId: z.string() });
const searchCourseParam = z.object({
query: z.string().min(1).max(200),
limit: z.number().int().min(1).max(20).default(8)
});
export function buildStudentAgentTools(orgId: string, userId: string, courseId: string, _settings: AiTutorSettings) {
return {
list_course_outline: tool({
description:
'List the course outline (sections with their lessons and exercises). The courseId is automatically scoped — do not pass it. Returns titles and ids only.',
inputSchema: listCourseOutlineParam,
execute: async () => {
return executeStudentTool('list_course_outline', { orgId, userId, courseId }, async () => {
const [sections, lessons, exercises] = await Promise.all([
listCourseSections(courseId),
db
.select({
id: schema.lesson.id,
title: schema.lesson.title,
sectionId: schema.lesson.sectionId,
order: schema.lesson.order
})
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseId))
.orderBy(asc(schema.lesson.order)),
db
.select({
id: schema.exercise.id,
title: schema.exercise.title,
lessonId: schema.exercise.lessonId,
sectionId: schema.exercise.sectionId,
order: schema.exercise.order
})
.from(schema.exercise)
.where(eq(schema.exercise.courseId, courseId))
.orderBy(asc(schema.exercise.order))
]);
return { sections, lessons, exercises };
});
}
}),
read_lesson: tool({
description:
'Read the title and body of a specific lesson in the current course. Use this when the learner refers to a lesson that is not already loaded in your context.',
inputSchema: readLessonParam,
execute: async (args) => {
return executeStudentTool('read_lesson', { orgId, userId, courseId, args }, async () => {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
const lesson = await getLesson(args.lessonId);
const lessonWithLangs = lesson as {
id: string;
title: string;
note?: string | null;
lessonLanguages?: Array<{ locale: string; content: string | null }>;
};
const content = lessonWithLangs.lessonLanguages?.find((ll) => ll.locale === 'en')?.content ?? null;
return {
id: lesson.id,
title: lesson.title,
content,
note: lessonWithLangs.note ?? null
};
});
}
}),
read_exercise: tool({
description:
'Read an exercise prompt — the question text and options visible to a student. Answer keys, correct flags, and marking schemes are stripped before returning.',
inputSchema: readExerciseParam,
execute: async (args) => {
return executeStudentTool('read_exercise', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
const exercise = await getExercise(args.exerciseId);
const questions = (exercise.questions ?? []).map((q) =>
stripAnswerKeysFromQuestion({
id: q.id,
title: q.title,
questionTypeId: q.questionTypeId,
order: q.order,
points: q.points ?? null,
options: q.options ?? []
})
);
return {
id: exercise.id,
title: exercise.title,
description: exercise.description,
questions
};
});
}
}),
search_course: tool({
description:
'Search this course for lessons and exercises whose title or body contains the query. Returns up to `limit` ranked snippets with ids you can pass to read_lesson / read_exercise.',
inputSchema: searchCourseParam,
execute: async (args) => {
return executeStudentTool('search_course', { orgId, userId, courseId, args }, async () => {
const pattern = `%${args.query}%`;
const limit = args.limit ?? 8;
const [lessonMatches, exerciseMatches] = await Promise.all([
db
.select({
id: schema.lesson.id,
title: schema.lesson.title,
content: schema.lessonLanguage.content
})
.from(schema.lesson)
.leftJoin(
schema.lessonLanguage,
and(eq(schema.lessonLanguage.lessonId, schema.lesson.id), eq(schema.lessonLanguage.locale, 'en'))
)
.where(
and(
eq(schema.lesson.courseId, courseId),
or(ilike(schema.lesson.title, pattern), ilike(schema.lessonLanguage.content, pattern))
)
)
.limit(limit),
db
.select({
id: schema.exercise.id,
title: schema.exercise.title,
description: schema.exercise.description
})
.from(schema.exercise)
.where(
and(
eq(schema.exercise.courseId, courseId),
or(ilike(schema.exercise.title, pattern), ilike(schema.exercise.description, pattern))
)
)
.limit(limit)
]);
const lessonsResult = lessonMatches.map((lm) => ({
type: 'lesson' as const,
id: lm.id,
title: lm.title,
snippet: makeSnippet(lm.content ?? lm.title ?? '', args.query)
}));
const exercisesResult = exerciseMatches.map((em) => ({
type: 'exercise' as const,
id: em.id,
title: em.title,
snippet: makeSnippet(em.description ?? em.title ?? '', args.query)
}));
const merged = [...lessonsResult, ...exercisesResult].slice(0, limit);
return { query: args.query, results: merged };
});
}
})
};
}
export type StudentAgentTools = ReturnType<typeof buildStudentAgentTools>;
+140
View File
@@ -0,0 +1,140 @@
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[];
}
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();
}
@@ -0,0 +1,99 @@
import { generateText } from 'ai';
import { AIProvider, type AIProviderConfig } from '@cio/ai-assistant';
import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
const TEXT_GEN_MODELS: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'gpt-4o-mini',
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001',
[AIProvider.GOOGLE]: 'gemini-3.1-flash-lite',
[AIProvider.MOONSHOT]: 'kimi-k2.6'
};
function createTextGenModel(config: AIProviderConfig) {
const modelName = TEXT_GEN_MODELS[config.provider];
switch (config.provider) {
case AIProvider.OPENAI: {
const openai = createOpenAI({ apiKey: config.apiKey });
return openai(modelName);
}
case AIProvider.ANTHROPIC: {
const anthropic = createAnthropic({ apiKey: config.apiKey });
return anthropic(modelName);
}
case AIProvider.GOOGLE: {
const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
return google(modelName);
}
case AIProvider.MOONSHOT: {
const moonshot = createOpenAI({ apiKey: config.apiKey, baseURL: 'https://api.moonshot.cn/v1' });
return moonshot(modelName);
}
default:
throw new Error(`Unsupported AI provider: ${config.provider}`);
}
}
export interface GenerateFieldTextResult {
text: string;
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
modelName: string;
}
export async function generateFieldText(
prompt: string,
tone: string,
format: 'plain' | 'html',
context: string | undefined,
providerConfig: AIProviderConfig
): Promise<GenerateFieldTextResult> {
const model = createTextGenModel(providerConfig);
const modelName = TEXT_GEN_MODELS[providerConfig.provider];
const contextLine = context ? `Context: ${context}.` : '';
const formatLine =
format === 'html'
? 'Return valid HTML (use <p>, <ul>, <li>, <strong> tags as appropriate). Never use heading tags (h1, h2, h3, h4, h5, h6). No surrounding code blocks or markdown.'
: 'Return plain text only — no HTML tags, no markdown, no quotes, no labels.';
const systemPrompt = [
'You are a content writer helping build a landing page for an online course platform.',
`Write text that matches the following description. Use a ${tone} tone.`,
contextLine,
formatLine
]
.filter(Boolean)
.join(' ');
const { text, usage } = await generateText(
providerConfig.provider === AIProvider.GOOGLE
? {
model,
system: systemPrompt,
prompt: prompt.slice(0, 1000),
maxOutputTokens: 512,
maxRetries: 0,
providerOptions: {
google: { thinkingConfig: { thinkingBudget: 0 } }
}
}
: {
model,
system: systemPrompt,
prompt: prompt.slice(0, 1000),
maxOutputTokens: 512,
maxRetries: 0
}
);
return {
text: text.trim(),
usage: {
promptTokens: usage.inputTokens ?? 0,
completionTokens: usage.outputTokens ?? 0,
totalTokens: usage.totalTokens ?? 0
},
modelName
};
}
@@ -11,7 +11,7 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google';
const TITLE_MODELS: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'gpt-4o-mini',
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001',
[AIProvider.GOOGLE]: 'gemini-2.5-flash',
[AIProvider.GOOGLE]: 'gemini-3.1-flash-lite',
[AIProvider.MOONSHOT]: 'kimi-k2.6'
};
@@ -55,7 +55,7 @@ export async function generateConversationTitle(
const truncatedMessage = firstMessageText.slice(0, 1000);
// Gemini 2.5 counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text.
// Gemini 3.x counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text.
const { text } = await generateText(
providerConfig.provider === AIProvider.GOOGLE
? {
@@ -0,0 +1,88 @@
import { defaultAiTutorSettings, mergeAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
import {
getCourseAiTutorOverride,
getOrgAiTutorSettings,
updateCourseAiTutorOverride,
updateOrgAiTutorSettings
} from '@cio/db/queries/agent';
import { AppError } from '@api/utils/errors';
/**
* Resolved AI tutor settings for a given (org, course) pair.
*
* Merge order (later overrides earlier):
* defaultAiTutorSettings → org.aiTutorSettings → course.metadata.aiTutor
*
* If the course override has `inheritFromOrg !== false`, the org defaults apply
* for every field the course did not explicitly set. The course can still flip
* individual fields (including `enabled`).
*/
export async function getEffectiveAiTutorSettings(orgId: string, courseId: string): Promise<AiTutorSettings> {
const [orgSettings, courseOverride] = await Promise.all([
getOrgAiTutorSettings(orgId),
getCourseAiTutorOverride(courseId)
]);
const orgResolved = mergeAiTutorSettings(defaultAiTutorSettings, orgSettings as Partial<AiTutorSettings> | null);
if (!courseOverride) return orgResolved;
if (courseOverride.inheritFromOrg === true) return orgResolved;
const { inheritFromOrg: _drop, ...override } = courseOverride;
return mergeAiTutorSettings(orgResolved, override as Partial<AiTutorSettings>);
}
export async function getResolvedOrgAiTutorSettings(orgId: string): Promise<AiTutorSettings> {
const orgSettings = await getOrgAiTutorSettings(orgId);
return mergeAiTutorSettings(defaultAiTutorSettings, orgSettings as Partial<AiTutorSettings> | null);
}
export async function updateOrgAiTutorSettingsService(
orgId: string,
patch: Partial<AiTutorSettings>
): Promise<AiTutorSettings> {
const updated = await updateOrgAiTutorSettings(orgId, patch);
if (!updated) {
throw new AppError('Organization not found', 'ORGANIZATION_NOT_FOUND', 404);
}
return mergeAiTutorSettings(defaultAiTutorSettings, updated as Partial<AiTutorSettings>);
}
export interface CourseAiTutorSettingsView {
org: AiTutorSettings;
override: (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null;
effective: AiTutorSettings;
}
export async function getCourseAiTutorSettingsView(
orgId: string,
courseId: string
): Promise<CourseAiTutorSettingsView> {
const [org, override, effective] = await Promise.all([
getResolvedOrgAiTutorSettings(orgId),
getCourseAiTutorOverride(courseId),
getEffectiveAiTutorSettings(orgId, courseId)
]);
return {
org,
override: (override as (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null) ?? null,
effective
};
}
export async function updateCourseAiTutorOverrideService(
courseId: string,
patch: Partial<AiTutorSettings> & { inheritFromOrg?: boolean }
): Promise<Partial<AiTutorSettings> & { inheritFromOrg?: boolean }> {
const updated = await updateCourseAiTutorOverride(courseId, patch);
if (!updated) {
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
}
return updated as Partial<AiTutorSettings> & { inheritFromOrg?: boolean };
}
+271
View File
@@ -0,0 +1,271 @@
import {
STUDENT_TUTOR_APPROACHING_THRESHOLD,
STUDENT_TUTOR_MONTHLY_CAP,
type AiTutorSettings
} from '@cio/ai-assistant/tutor-config';
import {
getCapStatusSummary,
getLearnerCapEvents,
getLearnerCourseBreakdown,
getLearnerDailyUsage,
getLearnerLeaderboard,
getMonthlyTutorCount,
incrementMonthlyTutorCount,
recordCapEvent
} from '@cio/db/queries/agent';
import { AppError } from '@api/utils/errors';
import { getEffectiveAiTutorSettings } from './tutor-config';
import { enforceTokenBalance } from './usage';
export const STUDENT_TUTOR_CAP = STUDENT_TUTOR_MONTHLY_CAP;
/**
* Whether per-learner cap enforcement is on for this environment.
* Phase 1 ships with this off (instrumentation only); Phase 3 flips it on.
*/
export function isCapEnforced(): boolean {
const raw = process.env.AI_TUTOR_CAP_ENFORCED;
if (raw == null) return false;
return raw === '1' || raw.toLowerCase() === 'true';
}
function startOfCurrentMonthUtc(): Date {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
}
function startOfPreviousMonthUtc(): Date {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0, 0));
}
function startOfLast90DaysUtc(): Date {
const now = new Date();
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
}
export function resolveTutorPeriod(period: 'current' | 'previous' | 'last90'): { start: Date; end?: Date } {
if (period === 'previous') {
const start = startOfPreviousMonthUtc();
const end = startOfCurrentMonthUtc();
return { start, end };
}
if (period === 'last90') {
return { start: startOfLast90DaysUtc() };
}
return { start: startOfCurrentMonthUtc() };
}
export interface TutorPolicyResult {
settings: AiTutorSettings;
messageCount: number;
capRemaining: number;
}
/**
* Order of checks (matches PRD §"Enforcement"):
* 1. Workspace AI tutor toggle (settings.enabled)
* 2. Org pooled quota (enforceTokenBalance)
* 3. Per-learner monthly cap (STUDENT_TUTOR_MONTHLY_CAP)
*
* Each failure logs a cap event so admins can see why a learner was blocked.
* Cap enforcement is gated behind `AI_TUTOR_CAP_ENFORCED` for Phase 1 rollout.
*/
export async function enforceStudentTutorPolicy(
orgId: string,
courseId: string,
userId: string
): Promise<TutorPolicyResult> {
const settings = await getEffectiveAiTutorSettings(orgId, courseId);
if (!settings.enabled) {
await recordCapEvent({ orgId, userId, courseId, eventType: 'tutor_disabled' });
throw new AppError('AI tutor is disabled for this workspace', 'AI_TUTOR_DISABLED', 403);
}
try {
await enforceTokenBalance(orgId);
} catch (error) {
if (error instanceof AppError && error.code === 'TOKEN_LIMIT_REACHED') {
await recordCapEvent({ orgId, userId, courseId, eventType: 'pool_exhausted' });
throw new AppError('AI tutor is taking a break', 'POOL_EXHAUSTED', 429);
}
throw error;
}
const monthly = await getMonthlyTutorCount(orgId, userId, startOfCurrentMonthUtc());
if (isCapEnforced() && monthly.messageCount >= STUDENT_TUTOR_CAP) {
if (!monthly.capHitAt) {
await recordCapEvent({ orgId, userId, courseId, eventType: 'cap_reached' });
}
throw new AppError('AI tutor is taking a break', 'LEARNER_CAP_REACHED', 429);
}
return {
settings,
messageCount: monthly.messageCount,
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - monthly.messageCount)
};
}
export async function incrementStudentTutorCount(
orgId: string,
userId: string,
courseId: string
): Promise<{ messageCount: number; capHit: boolean }> {
const result = await incrementMonthlyTutorCount({
orgId,
userId,
periodStart: startOfCurrentMonthUtc(),
cap: STUDENT_TUTOR_CAP
});
if (result.capHit && isCapEnforced()) {
await recordCapEvent({ orgId, userId, courseId, eventType: 'cap_reached' });
}
return result;
}
export async function getStudentTutorStatus(
orgId: string,
courseId: string,
userId: string
): Promise<{ enabled: boolean; cap: number; capRemaining: number; enforced: boolean }> {
const settings = await getEffectiveAiTutorSettings(orgId, courseId);
const monthly = await getMonthlyTutorCount(orgId, userId, startOfCurrentMonthUtc());
return {
enabled: settings.enabled,
cap: STUDENT_TUTOR_CAP,
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - monthly.messageCount),
enforced: isCapEnforced()
};
}
// ─── Admin reads (Fair-Use leaderboard + summary + per-learner detail) ───────
export interface AdminLeaderboardEntry {
userId: string;
fullname: string | null;
email: string | null;
avatarUrl: string | null;
messageCount: number;
tokens: number;
capRemaining: number;
capPct: number;
status: 'under' | 'approaching' | 'at_cap';
capHitAt: string | null;
lastMessageAt: string | null;
}
export async function getTutorLearnerLeaderboard(
orgId: string,
params: {
period: 'current' | 'previous' | 'last90';
search?: string;
sort?: 'messages' | 'tokens' | 'capPct';
page?: number;
limit?: number;
}
): Promise<{ entries: AdminLeaderboardEntry[]; total: number; cap: number; periodStart: string }> {
const { start, end } = resolveTutorPeriod(params.period);
const page = params.page ?? 1;
const limit = params.limit ?? 20;
const { rows, total } = await getLearnerLeaderboard(orgId, {
periodStart: start,
periodEnd: end,
search: params.search,
sort: params.sort,
limit,
offset: (page - 1) * limit
});
const entries: AdminLeaderboardEntry[] = rows.map((row) => {
const messageCount = row.messageCount;
const capPct = messageCount / STUDENT_TUTOR_CAP;
const status: AdminLeaderboardEntry['status'] =
messageCount >= STUDENT_TUTOR_CAP
? 'at_cap'
: capPct >= STUDENT_TUTOR_APPROACHING_THRESHOLD
? 'approaching'
: 'under';
return {
userId: row.userId,
fullname: row.fullname,
email: row.email,
avatarUrl: row.avatarUrl,
messageCount,
tokens: row.tokens,
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - messageCount),
capPct,
status,
capHitAt: row.capHitAt,
lastMessageAt: row.lastMessageAt
};
});
return { entries, total, cap: STUDENT_TUTOR_CAP, periodStart: start.toISOString() };
}
export async function getTutorCapStatusSummary(
orgId: string,
period: 'current' | 'previous' | 'last90' = 'current'
): Promise<{ atCap: number; approaching: number; totalActive: number; cap: number; periodStart: string }> {
const { start } = resolveTutorPeriod(period);
const summary = await getCapStatusSummary(orgId, start, STUDENT_TUTOR_CAP, STUDENT_TUTOR_APPROACHING_THRESHOLD);
return { ...summary, cap: STUDENT_TUTOR_CAP, periodStart: start.toISOString() };
}
export interface AdminLearnerDetail {
userId: string;
cap: number;
messageCount: number;
capRemaining: number;
status: AdminLeaderboardEntry['status'];
dailyUsage: { date: string; messages: number; tokens: number }[];
courseBreakdown: { courseId: string; courseTitle: string | null; tokens: number; messages: number }[];
capEvents: { id: number; eventType: string; occurredAt: string; courseId: string | null }[];
periodStart: string;
}
export async function getTutorLearnerDetail(
orgId: string,
userId: string,
period: 'current' | 'previous' | 'last90' = 'current'
): Promise<AdminLearnerDetail> {
const { start } = resolveTutorPeriod(period);
const monthly = await getMonthlyTutorCount(orgId, userId, start);
const [dailyUsage, courseBreakdown, capEvents] = await Promise.all([
getLearnerDailyUsage(orgId, userId, start),
getLearnerCourseBreakdown(orgId, userId, start),
getLearnerCapEvents(orgId, userId, start)
]);
const capPct = monthly.messageCount / STUDENT_TUTOR_CAP;
const status: AdminLeaderboardEntry['status'] =
monthly.messageCount >= STUDENT_TUTOR_CAP
? 'at_cap'
: capPct >= STUDENT_TUTOR_APPROACHING_THRESHOLD
? 'approaching'
: 'under';
return {
userId,
cap: STUDENT_TUTOR_CAP,
messageCount: monthly.messageCount,
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - monthly.messageCount),
status,
dailyUsage,
courseBreakdown,
capEvents,
periodStart: start.toISOString()
};
}
+7 -5
View File
@@ -32,13 +32,15 @@ const PLAN_TOKEN_ALLOWANCES: Record<string, number> = {
// ─── Model Cost Multipliers ───────────────────────────────────────────────────
// Blended multiplier vs Gemini 2.5 Flash baseline ($0.15/1M); ~80/20 input/output mix; unknown → 1×.
// 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-2.5-flash': 1,
'gpt-5.4-mini': 3,
'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,
'kimi-k2.6': 3
'claude-haiku-4-5-20251001': 1.5,
'kimi-k2.6': 4
};
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number {
+2
View File
@@ -0,0 +1,2 @@
export * from './ingest';
export * from './reads';
+51
View File
@@ -0,0 +1,51 @@
import { insertPageEvents } from '@cio/db/queries/analytics';
import type { TIngestBatch, TTrackEvent } from '@cio/utils/validation/dash';
type EnrichmentContext = {
country: string | null;
userAgent: string | null;
userId: string | null;
};
function deriveDeviceType(userAgent: string | null): string | null {
if (!userAgent) return null;
const ua = userAgent.toLowerCase();
if (/tablet|ipad/.test(ua)) return 'tablet';
if (/mobi|android|iphone|ipod/.test(ua)) return 'mobile';
if (/bot|crawler|spider|crawling/.test(ua)) return 'bot';
return 'desktop';
}
function clampCountry(raw: string | null): string | null {
if (!raw) return null;
const trimmed = raw.trim().toUpperCase();
if (trimmed.length !== 2) return null;
return trimmed;
}
export async function ingestEventBatch(batch: TIngestBatch, ctx: EnrichmentContext) {
const occurredAtFallback = new Date().toISOString();
const country = clampCountry(ctx.country);
const deviceType = deriveDeviceType(ctx.userAgent);
const rows = batch.events.map((event: TTrackEvent) => ({
occurredAt: event.occurredAt ?? occurredAtFallback,
sessionId: batch.sessionId,
eventType: event.eventType,
orgId: event.orgId ?? null,
userId: ctx.userId,
courseId: event.courseId ?? null,
path: event.path ?? null,
referrerHost: event.referrerHost ?? null,
utmSource: event.utmSource ?? null,
utmMedium: event.utmMedium ?? null,
utmCampaign: event.utmCampaign ?? null,
country,
deviceType,
locale: event.locale ?? null,
props: (event.props ?? {}) as Record<string, unknown>
}));
const inserted = await insertPageEvents(rows);
return { inserted };
}
+194
View File
@@ -0,0 +1,194 @@
import {
selectCountryBreakdown,
selectCourseDailyRange,
selectOrgDailyRange,
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';
export type LandingStats = {
totals: {
landingViews: number;
coursePageViews: number;
uniqueVisitors: number;
enrollments: number;
completions: number;
};
sparkline: Array<{ date: string; views: number; enrollments: number }>;
};
export type CountryBreakdown = Array<{ country: string; views: number; enrollments: number }>;
export type CourseFunnel = {
steps: Array<{
name: 'landing_view' | 'course_page_view' | 'enrollment_completed' | 'course_completed';
count: number;
conversionFromPrev: number | null;
}>;
};
export type PopularTypes = Array<{
type: string;
enrollments: number;
views: number;
completions: number;
courseCount: number;
}>;
function toDateString(date: Date): string {
return date.toISOString().slice(0, 10);
}
function computeRange(days: number): { fromDate: string; toDate: string } {
const today = new Date();
const from = new Date(today);
from.setUTCDate(from.getUTCDate() - (days - 1));
return { fromDate: toDateString(from), toDate: toDateString(today) };
}
async function readCache<T>(key: string): Promise<T | null> {
if (!env.REDIS_URL) return null;
try {
const raw = await redis.get(key);
if (!raw) return null;
return JSON.parse(raw) as T;
} catch (error) {
logRedisUnavailableOnce('Redis get failed for analytics cache, using database', error);
return null;
}
}
async function writeCache(key: string, value: unknown): Promise<void> {
if (!env.REDIS_URL) return;
try {
await redis.setEx(key, DASH_ANALYTICS_TTL_SECONDS, JSON.stringify(value));
} catch (error) {
logRedisUnavailableOnce('Redis set failed for analytics cache, continuing', error);
}
}
export async function getLandingStats(orgId: string, days: number, bustCache = false): Promise<LandingStats> {
const key = dashAnalyticsKey('landing', orgId, days);
if (!bustCache) {
const cached = await readCache<LandingStats>(key);
if (cached) return cached;
}
const { fromDate, toDate } = computeRange(days);
const rows = await selectOrgDailyRange(orgId, fromDate, toDate);
const totals = rows.reduce(
(acc, row) => ({
landingViews: acc.landingViews + row.landingViews,
coursePageViews: acc.coursePageViews + row.coursePageViews,
uniqueVisitors: acc.uniqueVisitors + row.uniqueVisitors,
enrollments: acc.enrollments + row.enrollments,
completions: acc.completions + row.completions
}),
{ landingViews: 0, coursePageViews: 0, uniqueVisitors: 0, enrollments: 0, completions: 0 }
);
const sparkline = rows.map((row) => ({
date: row.date,
views: row.landingViews + row.coursePageViews,
enrollments: row.enrollments
}));
const result: LandingStats = { totals, sparkline };
await writeCache(key, result);
return result;
}
export async function getCountryBreakdown(orgId: string, days: number, bustCache = false): Promise<CountryBreakdown> {
const key = dashAnalyticsKey('country', orgId, days);
if (!bustCache) {
const cached = await readCache<CountryBreakdown>(key);
if (cached) return cached;
}
const { fromDate, toDate } = computeRange(days);
const rows = await selectCountryBreakdown(orgId, fromDate, toDate);
const result: CountryBreakdown = rows.map((row) => ({
country: row.country,
views: row.views,
enrollments: row.enrollments
}));
await writeCache(key, result);
return result;
}
export async function getCourseFunnel(
orgId: string,
days: number,
courseId: string | undefined,
bustCache = false
): Promise<CourseFunnel> {
const key = dashAnalyticsKey('funnel', orgId, days, courseId);
if (!bustCache) {
const cached = await readCache<CourseFunnel>(key);
if (cached) return cached;
}
const { fromDate, toDate } = computeRange(days);
let landingViews = 0;
let coursePageViews = 0;
let enrollments = 0;
let completions = 0;
if (courseId) {
const rows = await selectCourseDailyRange(courseId, orgId, fromDate, toDate);
coursePageViews = rows.reduce((sum, r) => sum + r.views, 0);
enrollments = rows.reduce((sum, r) => sum + r.enrollments, 0);
completions = rows.reduce((sum, r) => sum + r.completions, 0);
} else {
const rows = await selectOrgDailyRange(orgId, fromDate, toDate);
landingViews = rows.reduce((sum, r) => sum + r.landingViews, 0);
coursePageViews = rows.reduce((sum, r) => sum + r.coursePageViews, 0);
enrollments = rows.reduce((sum, r) => sum + r.enrollments, 0);
completions = rows.reduce((sum, r) => sum + r.completions, 0);
}
const stepValues = [
{ name: 'landing_view' as const, count: landingViews },
{ name: 'course_page_view' as const, count: coursePageViews },
{ name: 'enrollment_completed' as const, count: enrollments },
{ name: 'course_completed' as const, count: completions }
];
const steps = stepValues
.filter((step) => (courseId ? step.name !== 'landing_view' : true))
.map((step, index, arr) => {
const prev = index > 0 ? arr[index - 1].count : null;
const conversionFromPrev = prev && prev > 0 ? step.count / prev : null;
return { ...step, conversionFromPrev };
});
const result: CourseFunnel = { steps };
await writeCache(key, result);
return result;
}
export async function getPopularTypes(orgId: string, days: number, bustCache = false): Promise<PopularTypes> {
const key = dashAnalyticsKey('popular-types', orgId, days);
if (!bustCache) {
const cached = await readCache<PopularTypes>(key);
if (cached) return cached;
}
const { fromDate, toDate } = computeRange(days);
const rows = await selectPopularCourseTypes(orgId, fromDate, toDate);
const result: PopularTypes = rows
.filter((row) => row.type)
.map((row) => ({
type: row.type as string,
enrollments: row.enrollments,
views: row.views,
completions: row.completions,
courseCount: row.courseCount
}));
await writeCache(key, result);
return result;
}
+74 -1
View File
@@ -9,6 +9,7 @@ import type {
TAssetUpdate,
TYouTubeMetadataQuery
} from '@cio/utils/validation/assets';
import type { TTranscriptResponse } from '@cio/utils/validation/media';
import {
countAssetUsagesByAsset,
createAssetUsage,
@@ -22,6 +23,9 @@ import {
listAssetUsagesByAsset,
updateAsset
} from '@cio/db/queries/assets';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { generateTranscriptVttPresignedUrl, TRANSCRIPT_VTT_PRESIGN_SECONDS } from '@api/utils/s3';
const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/;
@@ -210,7 +214,7 @@ export async function listOrganizationAssetsService(orgId: string, query: TAsset
export async function createAssetFromUploadService(orgId: string, profileId: string, data: TAssetCreateUpload) {
try {
return await createOrGetAssetByStorageKey({
const asset = await createOrGetAssetByStorageKey({
organizationId: orgId,
kind: data.kind,
provider: data.provider,
@@ -230,6 +234,19 @@ export async function createAssetFromUploadService(orgId: string, profileId: str
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',
@@ -239,6 +256,26 @@ export async function createAssetFromUploadService(orgId: string, profileId: str
}
}
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);
@@ -394,6 +431,42 @@ export async function getOrganizationAssetStorageService(orgId: string, query: T
}
}
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) {
@@ -0,0 +1,73 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseById, getOrganizationById, getProfileById, getCourseOrganizationId } from '@cio/db/queries';
import { resolveCertificateDesign, type CertificateRenderInput } from '@api/utils/certificate';
import type { TCertificateDownloadRequest } from '@cio/utils/validation/course';
/**
* Loads the design + render data for a given course/student pair so the API
* can hand it to `generateCertificatePdf` / `generateCertificatePng`.
*
* The client only sends `studentName` (+ optional studentId/issuedAt). Everything
* else comes from the database: title, description, org name + logo, design.
*/
export async function assembleCertificateRender(
courseId: string,
body: TCertificateDownloadRequest
): Promise<CertificateRenderInput> {
const [courseRow] = await getCourseById(courseId);
if (!courseRow) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const organizationId = await getCourseOrganizationId(courseId);
const organization = organizationId ? await getOrganizationById(organizationId) : null;
const design = resolveCertificateDesign(courseRow.certificate);
const issuedAtIso = body.issuedAt ?? new Date().toISOString();
const issuedAtDate = new Date(issuedAtIso);
const date = Number.isNaN(issuedAtDate.getTime())
? new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' })
: issuedAtDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' });
const certificateId = body.studentId
? formatCertificateId(design.idFormat, body.studentId, issuedAtDate)
: formatCertificateId(design.idFormat, fallbackSequence(issuedAtDate), issuedAtDate);
return {
design,
data: {
recipientName: body.studentName,
courseName: courseRow.title,
courseDescription: courseRow.description ?? '',
orgName: organization?.name ?? '',
orgLogoUrl: organization?.avatarUrl ?? undefined,
date,
certificateId
}
};
}
function formatCertificateId(format: string | undefined, seq: string, issuedAt: Date): string {
const year = issuedAt.getFullYear();
const month = String(issuedAt.getMonth() + 1).padStart(2, '0');
const tail = seq.replace(/-/g, '').slice(-4).toUpperCase() || '0001';
return (format ?? 'N° {seq}').replace('{seq}', tail).replace('{year}', String(year)).replace('{month}', month);
}
function fallbackSequence(issuedAt: Date): string {
return String(issuedAt.getTime()).slice(-6);
}
export async function assembleOwnerPreviewRender(
courseId: string,
userId: string,
body: TCertificateDownloadRequest
): Promise<CertificateRenderInput> {
const studentName = body.studentName.trim().length
? body.studentName
: (await getProfileById(userId))?.fullname || 'Preview Recipient';
return assembleCertificateRender(courseId, { ...body, studentName });
}
@@ -16,6 +16,7 @@ import {
type TCourseCertificationRow
} from '@cio/db/queries/course/course';
import { ROLE } from '@cio/utils/constants';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
export type CertificationBlocker = {
code:
@@ -240,6 +241,22 @@ export async function evaluateCourseCertification(
courseRow,
earnedAt
});
trackServerEvent({
eventType: SERVER_EVENTS.COURSE_COMPLETED,
userId: profileId,
courseId,
props: { path: 'standard', earnedAt }
});
if (courseRow.certificate?.isDownloadable) {
trackServerEvent({
eventType: SERVER_EVENTS.CERTIFICATE_ISSUED,
userId: profileId,
courseId,
props: { path: 'standard', earnedAt }
});
}
}
return { ...evaluation, certificateEarnedAt, isNewCompletion };
+108 -1
View File
@@ -6,11 +6,13 @@ import {
getCourseComplianceHistoryRows,
getCourseCurrentComplianceRows,
getLatestComplianceRecordsByProfiles,
getOrgComplianceLearnerRows,
getStudentCourseMembersForCompliance,
listComplianceRecordsReadyForExpiry,
listLatestComplianceRecordsForReminderScan,
updateCourseCertificateIssueStatusByRecordId,
updateCourseCompletionRecord
updateCourseCompletionRecord,
type OrgComplianceLearnerRow
} from '@cio/db/queries/course/compliance';
import { db } from '@cio/db/drizzle';
import { getCourseById } from '@cio/db/queries/course';
@@ -712,3 +714,108 @@ export async function runComplianceReminderScan() {
reminderEventCount
};
}
type OrgComplianceStatus = ComplianceStatus | 'no_record';
const STATUS_KEYS: OrgComplianceStatus[] = [
'compliant',
'expiring_soon',
'in_grace_period',
'non_compliant',
'waived',
'in_progress',
'not_started',
'no_record'
];
function emptyStatusCounts() {
const counts = {} as Record<OrgComplianceStatus, number>;
for (const status of STATUS_KEYS) counts[status] = 0;
return counts;
}
export type OrgComplianceOverview = {
summary: {
totalLearners: number;
totalCourses: number;
counts: Record<OrgComplianceStatus, number>;
};
courses: Array<{
courseId: string;
courseTitle: string;
learnerCount: number;
counts: Record<OrgComplianceStatus, number>;
}>;
learners: Array<{
groupMemberId: string;
profileId: string | null;
fullname: string | null;
email: string | null;
avatarUrl: string | null;
courseId: string;
courseTitle: string;
status: OrgComplianceStatus;
cycleNumber: number | null;
dueDate: string | null;
completedAt: string | null;
validUntil: string | null;
}>;
};
/**
* Aggregates compliance state across every COMPLIANCE course in an
* organization. Returns status tallies, per-course breakdown, and the
* flat learner-by-course list for the dashboard table.
*/
export async function getOrgComplianceOverview(orgId: string): Promise<OrgComplianceOverview> {
const rows = await getOrgComplianceLearnerRows(orgId);
const summaryCounts = emptyStatusCounts();
const courseMap = new Map<
string,
{ title: string; learnerCount: number; counts: Record<OrgComplianceStatus, number> }
>();
const uniqueLearners = new Set<string>();
for (const row of rows) {
const status = row.status as OrgComplianceStatus;
summaryCounts[status] = (summaryCounts[status] ?? 0) + 1;
if (row.profileId) uniqueLearners.add(row.profileId);
let courseAcc = courseMap.get(row.courseId);
if (!courseAcc) {
courseAcc = { title: row.courseTitle, learnerCount: 0, counts: emptyStatusCounts() };
courseMap.set(row.courseId, courseAcc);
}
courseAcc.learnerCount += 1;
courseAcc.counts[status] = (courseAcc.counts[status] ?? 0) + 1;
}
return {
summary: {
totalLearners: uniqueLearners.size,
totalCourses: courseMap.size,
counts: summaryCounts
},
courses: Array.from(courseMap.entries()).map(([courseId, c]) => ({
courseId,
courseTitle: c.title,
learnerCount: c.learnerCount,
counts: c.counts
})),
learners: rows.map((row: OrgComplianceLearnerRow) => ({
groupMemberId: row.groupMemberId,
profileId: row.profileId,
fullname: row.profile?.fullname ?? null,
email: row.email,
avatarUrl: row.profile?.avatarUrl ?? null,
courseId: row.courseId,
courseTitle: row.courseTitle,
status: row.status as OrgComplianceStatus,
cycleNumber: row.cycleNumber,
dueDate: row.dueDate,
completedAt: row.completedAt,
validUntil: row.validUntil
}))
};
}
+41 -45
View File
@@ -29,11 +29,13 @@ import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
import { generateSlug } from '@cio/utils/functions';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { db } from '@cio/db/drizzle';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
@@ -350,17 +352,18 @@ async function sendStudentJoinEmails(input: {
}) {
try {
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
await sendEmail('studentCourseWelcome', {
await enqueueTransactionalEmail('studentCourseWelcome', {
to: input.studentEmail,
fields: {
orgName: input.orgName,
courseName: input.courseName,
loginUrl
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`
});
} catch (error) {
console.error('Failed to send student welcome email:', error);
console.error('Failed to enqueue student welcome email:', error);
}
try {
@@ -372,26 +375,22 @@ async function sendStudentJoinEmails(input: {
const studentProfile = await getProfileById(input.studentId);
const studentName = studentProfile?.fullname || input.studentEmail;
await Promise.all(
teachers
.map((teacher) => teacher.email)
.filter((email): email is string => !!email)
.map((teacherEmail) =>
sendEmail('teacherStudentJoined', {
to: teacherEmail,
fields: {
courseName: input.courseName,
studentName,
studentEmail: input.studentEmail
},
from: buildEmailFromName('ClassroomIO')
}).catch((error) => {
console.error(`Failed to send teacher notification email to ${teacherEmail}:`, error);
})
)
);
const teacherEmails = teachers.map((teacher) => teacher.email).filter((email): email is string => !!email);
if (teacherEmails.length === 0) return;
await enqueueTransactionalEmail('teacherStudentJoined', {
to: teacherEmails,
fields: {
courseName: input.courseName,
studentName,
studentEmail: input.studentEmail
},
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`
});
} catch (error) {
console.error('Failed to send teacher join notification emails:', error);
console.error('Failed to enqueue teacher join notification emails:', error);
}
}
@@ -434,7 +433,7 @@ async function createEmailInviteAndSend(input: {
}
try {
const responses = await sendEmail('studentCourseInvite', {
await enqueueTransactionalEmail('studentCourseInvite', {
to: input.recipientEmail,
fields: {
orgName: input.orgName,
@@ -442,29 +441,11 @@ async function createEmailInviteAndSend(input: {
inviteLink: createdInvite.inviteLink,
expiresAt: getExpiryLabel(createdInvite.expiresAt)
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-invite-email:${createdInvite.id}`
});
const allSuccessful = responses.every((response) => response.success);
if (!allSuccessful) {
const errorMessage = responses
.filter((response) => !response.success)
.map((response) => response.error || 'Unknown email error')
.join('; ');
await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_FAILED', {
actorProfileId: input.createdByProfileId,
targetEmail: input.recipientEmail,
metadata: { error: errorMessage }
});
return {
invite: createdInvite,
sent: false,
error: errorMessage
};
}
// Optimistic EMAIL_SENT — see comment in services/organization/invite.ts.
await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_SENT', {
actorProfileId: input.createdByProfileId,
targetEmail: input.recipientEmail
@@ -683,6 +664,14 @@ export async function enrollInCourse(
studentEmail: normalizedEmail
});
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED,
orgId: org.id,
userId: user.id,
courseId,
props: { path: 'free-enrollment' }
});
return {
success: true,
alreadyJoined: false,
@@ -1036,6 +1025,13 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
studentId: user.id,
studentEmail: normalizedEmail
});
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED,
userId: user.id,
courseId: result.courseId,
props: { path: 'invite' }
});
}
return {
+17 -11
View File
@@ -2,7 +2,9 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
export interface PaymentRequestData {
courseId: string;
@@ -40,25 +42,23 @@ export async function createPaymentRequest(data: PaymentRequestData) {
const teacherEmail = teacherResult[0].email;
// Send email to teacher about student buy request
try {
await sendEmail('teacherStudentBuyRequest', {
await enqueueTransactionalEmail('teacherStudentBuyRequest', {
to: teacherEmail,
fields: {
courseName,
studentEmail: data.studentEmail,
studentFullname: data.studentFullname
},
from: buildEmailFromName('ClassroomIO')
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}`
});
} catch (emailError) {
console.error('Failed to send teacher buy request email:', emailError);
// Don't fail the request if email fails
console.error('Failed to enqueue teacher buy request email:', emailError);
}
// Send email to student with payment instructions
try {
await sendEmail('studentProvePayment', {
await enqueueTransactionalEmail('studentProvePayment', {
to: data.studentEmail,
fields: {
courseName,
@@ -67,13 +67,19 @@ export async function createPaymentRequest(data: PaymentRequestData) {
orgName
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: teacherEmail
replyTo: teacherEmail,
idempotencyKey: `payment-request:student:${data.courseId}:${data.studentEmail}`
});
} catch (emailError) {
console.error('Failed to send student payment proof email:', emailError);
// Don't fail the request if email fails
console.error('Failed to enqueue student payment proof email:', emailError);
}
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_STARTED,
courseId: data.courseId,
props: { path: 'payment-request', orgName }
});
return {
success: true,
courseName,
+22 -24
View File
@@ -14,7 +14,8 @@ import type { TGroupmember } from '@cio/db/types';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
/**
@@ -85,45 +86,44 @@ export async function addMember(
studentName = data.name || null;
}
// Send welcome email to student
if (studentEmail) {
try {
await sendEmail('studentCourseWelcome', {
await enqueueTransactionalEmail('studentCourseWelcome', {
to: studentEmail,
fields: {
orgName,
courseName,
loginUrl
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`
});
} catch (emailError) {
console.error('Failed to send student welcome email:', emailError);
console.error('Failed to enqueue student welcome email:', emailError);
}
}
// Get all teachers (ADMIN or TUTOR) in the course
const teachersResult = await getCourseTeachers({ courseId });
// Send notification to all teachers
if (teachersResult.length > 0 && studentEmail && studentName) {
const teacherEmails = teachersResult.map((t) => t.email).filter((email): email is string => email !== null);
await Promise.all(
teacherEmails.map((teacherEmail) =>
sendEmail('teacherStudentJoined', {
to: teacherEmail,
if (teacherEmails.length > 0) {
try {
await enqueueTransactionalEmail('teacherStudentJoined', {
to: teacherEmails,
fields: {
courseName,
studentName,
studentEmail
},
from: buildEmailFromName('ClassroomIO')
}).catch((emailError) => {
console.error(`Failed to send teacher notification email to ${teacherEmail}:`, emailError);
})
)
);
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`
});
} catch (emailError) {
console.error('Failed to enqueue teacher notification email:', emailError);
}
}
}
}
} catch (emailError) {
@@ -193,19 +193,17 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
const baseUrl = getDashboardBaseUrl(orgSiteName);
const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`;
// Send welcome emails to teachers
const teacherEmailPromises: Promise<unknown>[] = [];
addedMembers.forEach((member, index) => {
const memberData = members[index];
// Only send emails to teachers (TUTOR or ADMIN role) who have email
const roleId = memberData.roleId;
const email = memberData.email;
const name = memberData.name;
if ((roleId === ROLE.ADMIN || roleId === ROLE.TUTOR) && email && name) {
teacherEmailPromises.push(
sendEmail('teacherCourseWelcome', {
enqueueTransactionalEmail('teacherCourseWelcome', {
to: email,
fields: {
name,
@@ -213,9 +211,10 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
courseName,
inviteLink
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `teacher-course-welcome:${courseId}:${email}`
}).catch((emailError) => {
console.error(`Failed to send welcome email to ${email}:`, emailError);
console.error(`Failed to enqueue welcome email to ${email}:`, emailError);
return [];
})
);
@@ -226,8 +225,7 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
try {
await Promise.all(teacherEmailPromises);
} catch (emailError) {
// Log but don't fail the request if email fails
console.error('Failed to send welcome emails:', emailError);
console.error('Failed to enqueue welcome emails:', emailError);
}
}
+72 -3
View File
@@ -1,4 +1,12 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
generateTranscriptVttPresignedUrl,
generateVideoDownloadPresignedUrls,
TRANSCRIPT_VTT_PRESIGN_SECONDS
} from '@api/utils/s3';
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { getAssetsByIds } from '@cio/db/queries/assets';
import {
getCourseRowBySlug,
getPublicCourseItem as getPublicCourseItemQuery,
@@ -8,7 +16,6 @@ import {
} from '@cio/db/queries/course';
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section';
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
/**
* Anonymous-safe: fetch the full tree for a published public course by slug.
@@ -24,7 +31,7 @@ export async function getPublicCourseTreeService(courseSlug: string): Promise<Pu
}
export type PublicItemResponse =
| (Extract<PublicCourseItemContent, { kind: 'lesson' }> & { kind: 'lesson' })
| (Omit<Extract<PublicCourseItemContent, { kind: 'lesson' }>, 'courseOrganizationId'> & { kind: 'lesson' })
| (Extract<PublicCourseItemContent, { kind: 'exercise' }> & {
kind: 'exercise';
questions: PublicExercisePayload;
@@ -74,7 +81,69 @@ export async function getPublicCourseItemService(
if (!baseItem) return null;
if (baseItem.kind === 'lesson') {
return baseItem;
const { courseOrganizationId, ...lesson } = baseItem;
let video = lesson.video;
if (video?.type === 'upload') {
let storageKey: string | undefined;
if (video.assetId) {
const assets = await getAssetsByIds([video.assetId], courseOrganizationId ?? undefined);
storageKey = assets[0]?.storageKey ?? undefined;
}
if (!storageKey && video.key) {
storageKey = video.key;
}
if (storageKey) {
const urls = await generateVideoDownloadPresignedUrls([storageKey]);
const signedLink = urls[storageKey];
if (signedLink) {
const { key: _omitKey, ...rest } = video;
video = { ...rest, link: signedLink };
}
}
}
if (video?.assetId && courseOrganizationId) {
const transcriptRow = await getMediaTranscriptByAsset(video.assetId, courseOrganizationId);
let transcript: {
vttUrl: string;
vttUrlExpiresAt: string;
language: string;
} | null = null;
if (transcriptRow) {
const vttUrl = await generateTranscriptVttPresignedUrl(transcriptRow.vttStorageKey, transcriptRow.vttBucket);
transcript = {
vttUrl,
vttUrlExpiresAt: new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString(),
language: transcriptRow.language
};
}
video = { ...video, transcript };
}
if (video?.key !== undefined) {
const { key: _omitS3Key, ...withoutKey } = video;
video = withoutKey;
}
return { ...lesson, video };
}
if (!baseItem.isUnlocked) {
return {
...baseItem,
questions: {
unsectioned: [],
sections: []
}
};
}
const { exercise, questions } = await getExerciseWithRelationsOptimized(baseItem.id);
+126
View File
@@ -0,0 +1,126 @@
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';
type Recipient = string | string[];
interface CommonOptions {
/**
* Stable key used as BullMQ `jobId` for idempotent enqueue (e.g.
* `welcome:<userId>`). When omitted the job gets an auto-generated id, so
* duplicate calls produce duplicate emails.
*/
idempotencyKey?: string;
}
export interface EnqueueTemplateEmailInput<TId extends EmailId> extends CommonOptions {
to: Recipient;
fields: z.infer<EmailSchemaFor<TId>>;
from?: string;
replyTo?: string;
}
export interface EnqueueRawEmailInput extends CommonOptions {
to: Recipient;
subject: string;
content: string;
from?: string;
replyTo?: string;
}
export interface EnqueueResult {
/** BullMQ job ids — one per recipient. */
jobIds: string[];
}
function toRecipientArray(to: Recipient): string[] {
return Array.isArray(to) ? to : [to];
}
function recipientKey(base: string | undefined, recipient: string, total: number): string | undefined {
if (!base) return undefined;
return total === 1 ? base : `${base}:${recipient}`;
}
/**
* Fire-and-forget enqueue of a registered template email. Validates `fields`
* against the template's Zod schema up front so bad payloads fail in the
* domain handler instead of silently inside the worker.
*
* BullMQ tracks send state, retries, and failure history — no DB ledger is
* written. Final failures are recorded in `dead_letter_job` by the worker
* for operator triage.
*/
export async function enqueueTransactionalEmail<TId extends EmailId>(
template: TId,
input: EnqueueTemplateEmailInput<TId>
): Promise<EnqueueResult> {
const definition = EmailRegistry.get(template);
if (!definition) {
throw new Error(`Email template "${template}" is not registered`);
}
const validatedFields = definition.schema.parse(input.fields) as Record<string, unknown>;
if (!isRedisConfigured()) {
logRedisUnavailableOnce('Redis not configured: emails not enqueued. Set REDIS_URL and run apps/jobs to send them.');
return { jobIds: [] };
}
const recipients = toRecipientArray(input.to);
const jobIds: string[] = [];
for (const recipient of recipients) {
const jobId = await enqueueEmailSend(
{
kind: 'template',
template,
to: recipient,
fields: validatedFields,
from: input.from,
replyTo: input.replyTo
},
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
);
if (jobId) jobIds.push(jobId);
}
return { jobIds };
}
/**
* Fire-and-forget enqueue of a free-form subject/content email — used by the
* public mail route and submission notifications that don't have a registered
* template.
*/
export async function enqueueRawEmail(input: EnqueueRawEmailInput): Promise<EnqueueResult> {
if (!isRedisConfigured()) {
logRedisUnavailableOnce('Redis not configured: emails not enqueued. Set REDIS_URL and run apps/jobs to send them.');
return { jobIds: [] };
}
const recipients = toRecipientArray(input.to);
const jobIds: string[] = [];
for (const recipient of recipients) {
const jobId = await enqueueEmailSend(
{
kind: 'raw',
to: recipient,
subject: input.subject,
content: input.content,
from: input.from,
replyTo: input.replyTo
},
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
);
if (jobId) jobIds.push(jobId);
}
return { jobIds };
}
+2
View File
@@ -0,0 +1,2 @@
export * from './media-jobs';
export * from './email-jobs';
+248
View File
@@ -0,0 +1,248 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { JOB_STATUS, type JobEnvelope, type JobStatus, suggestNextPollMs } from '@cio/jobs/status';
import {
createMediaJob,
getMediaJobById,
listJobStepsByRun,
listLatestMediaJobsByAsset,
listMediaJobsByIds,
requestMediaJobCancel,
updateMediaJob,
hasActiveMediaJobForAsset
} from '@cio/db/queries';
import { getAssetById } from '@cio/db/queries/assets';
import { enqueueLessonVideoPipeline, enqueueTranscriptionOnly, isRedisConfigured } from '@cio/jobs';
import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
import type { TMediaJob } from '@db/types';
export interface StartTranscriptionOnlyMediaJobInput {
organizationId: string;
assetId: string;
triggeredByProfileId: string | null;
}
/**
* Manual transcription: new `media_job` + extract-audio → transcribe-audio only.
* Requires OpenAI key, upload video asset, and no other active media_job for the asset.
*/
export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOnlyMediaJobInput): Promise<TMediaJob> {
if (!process.env.OPENAI_API_KEY) {
throw new AppError('Transcription not configured', ErrorCodes.OPENAI_KEY_MISSING, 503);
}
const asset = await getAssetById(input.assetId);
if (!asset || asset.organizationId !== input.organizationId) {
throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404);
}
if (asset.kind !== 'video' || asset.provider !== 'upload' || !asset.storageKey) {
throw new AppError('Asset cannot be transcribed', ErrorCodes.ASSET_NOT_TRANSCRIBABLE, 400);
}
const hasActive = await hasActiveMediaJobForAsset(input.assetId, input.organizationId);
if (hasActive) {
throw new AppError('A media job is already running for this asset', ErrorCodes.CONFLICT, 409);
}
const job = await createMediaJob({
organizationId: input.organizationId,
assetId: input.assetId,
triggeredByProfileId: input.triggeredByProfileId,
status: 'queued',
stage: 'transcribing',
progressPercent: 0
});
if (!isRedisConfigured()) {
logRedisUnavailableOnce(
'Redis not configured: transcription media job created but no BullMQ flow enqueued. ' +
'Set REDIS_URL and run apps/jobs to process this job.'
);
return job;
}
try {
const enqueueResult = await enqueueTranscriptionOnly({
mediaJobId: job.id,
assetId: input.assetId,
storageKey: asset.storageKey,
actorContext: {
userId: input.triggeredByProfileId,
organizationId: input.organizationId
}
});
const updated = await updateMediaJob(job.id, {
rootJobId: enqueueResult.rootJobId,
jobIds: enqueueResult.jobIds
});
return updated ?? job;
} catch (error) {
console.error('startTranscriptionOnlyMediaJob enqueue failed:', error);
await updateMediaJob(job.id, {
status: 'failed',
stage: 'failed',
error: {
code: 'ENQUEUE_FAILED',
message: error instanceof Error ? error.message : 'Failed to enqueue transcription job'
}
});
throw new AppError(
error instanceof Error ? error : 'Failed to enqueue transcription job',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export interface StartMediaJobInput {
organizationId: string;
assetId: string;
storageKey: string;
triggeredByProfileId: string | null;
/** Set to false for assets without an OpenAI key (skip transcribe leg). */
withTranscription?: boolean;
}
/**
* Create a `media_job` row and enqueue the BullMQ flow. Falls back to a
* persisted `queued` row with no BullMQ job id when Redis is unavailable so
* the API returns a stable status envelope and a follow-up `start` worker
* can pick it up later. The enqueue itself fails loudly (the DB row stays
* around so an operator can replay it).
*/
export async function startMediaJob(input: StartMediaJobInput): Promise<TMediaJob> {
const job = await createMediaJob({
organizationId: input.organizationId,
assetId: input.assetId,
triggeredByProfileId: input.triggeredByProfileId,
status: 'queued',
stage: 'queued',
progressPercent: 0
});
if (!isRedisConfigured()) {
logRedisUnavailableOnce(
'Redis not configured: media job created but no BullMQ flow enqueued. ' +
'Set REDIS_URL and run apps/jobs to process this job.'
);
return job;
}
try {
const enqueueResult = await enqueueLessonVideoPipeline({
mediaJobId: job.id,
assetId: input.assetId,
storageKey: input.storageKey,
actorContext: {
userId: input.triggeredByProfileId,
organizationId: input.organizationId
},
withTranscription: input.withTranscription
});
const updated = await updateMediaJob(job.id, {
rootJobId: enqueueResult.rootJobId,
jobIds: enqueueResult.jobIds
});
return updated ?? job;
} catch (error) {
console.error('startMediaJob enqueue failed:', error);
await updateMediaJob(job.id, {
status: 'failed',
stage: 'failed',
error: {
code: 'ENQUEUE_FAILED',
message: error instanceof Error ? error.message : 'Failed to enqueue media job'
}
});
throw new AppError(error instanceof Error ? error : 'Failed to enqueue media job', ErrorCodes.INTERNAL_ERROR, 500);
}
}
export interface GetMediaJobEnvelopeOptions {
pollCount?: number;
}
function toEnvelope(job: TMediaJob, options: GetMediaJobEnvelopeOptions = {}): JobEnvelope {
const status = job.status as JobStatus;
const pollCount = options.pollCount ?? 0;
return {
job: {
id: job.id,
domain: 'media',
status,
stage: job.stage,
progressPercent: job.progressPercent,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
result: job.result ?? null,
error: job.error ?? null,
warnings: job.warnings ?? [],
cancelRequestedAt: job.cancelRequestedAt,
attempt: job.attempt,
maxAttempts: job.maxAttempts
},
events: [],
nextPollMs: suggestNextPollMs(status, pollCount)
};
}
export async function getMediaJobEnvelope(
jobId: string,
orgId: string,
options: GetMediaJobEnvelopeOptions = {}
): Promise<JobEnvelope> {
const job = await getMediaJobById(jobId, orgId);
if (!job) {
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
}
return toEnvelope(job, options);
}
export async function listMediaJobEnvelopes(jobIds: string[], orgId: string): Promise<Record<string, JobEnvelope>> {
if (jobIds.length === 0) return {};
const rows = await listMediaJobsByIds(jobIds, orgId);
const map: Record<string, JobEnvelope> = {};
for (const row of rows) {
map[row.id] = toEnvelope(row);
}
return map;
}
export async function listMediaJobsForAssetEnvelopes(assetId: string, orgId: string): Promise<JobEnvelope[]> {
const rows = await listLatestMediaJobsByAsset(assetId, orgId);
return rows.map((row) => toEnvelope(row));
}
export async function cancelMediaJob(jobId: string, orgId: string): Promise<JobEnvelope> {
const job = await getMediaJobById(jobId, orgId);
if (!job) {
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
}
if (job.status === JOB_STATUS.completed || job.status === JOB_STATUS.failed || job.status === JOB_STATUS.canceled) {
return toEnvelope(job);
}
const updated = await requestMediaJobCancel(jobId, orgId);
return toEnvelope(updated ?? job);
}
export async function getMediaJobSteps(jobId: string, orgId: string) {
const job = await getMediaJobById(jobId, orgId);
if (!job) {
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
}
return listJobStepsByRun('media', jobId);
}
+20 -19
View File
@@ -18,7 +18,8 @@ import {
} from '@cio/db/queries/newsfeed';
import { env } from '@api/config/env';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
/**
* Lists newsfeed items for a course
@@ -359,25 +360,25 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
const orgSiteName = feedData.organization?.siteName || 'app';
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
// Send email to all course members
const emailPromises = feedData.courseMembers.map((member) => {
if (!member.email) return Promise.resolve();
const recipients = feedData.courseMembers
.map((member) => member.email)
.filter((email): email is string => Boolean(email));
return sendEmail('newsfeedPost', {
to: member.email,
fields: {
courseTitle: feedData.courseTitle!,
teacherName: feedData.author?.fullname || 'A teacher',
content: feedData.content || '',
postLink,
orgName
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: feedData.author?.email || '[email protected]'
});
if (recipients.length === 0) return;
await enqueueTransactionalEmail('newsfeedPost', {
to: recipients,
fields: {
courseTitle: feedData.courseTitle!,
teacherName: feedData.author?.fullname || 'A teacher',
content: feedData.content || '',
postLink,
orgName
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: feedData.author?.email || '[email protected]',
idempotencyKey: `newsfeed:post:${feedId}`
});
await Promise.all(emailPromises);
} catch (error) {
console.error('Error sending newsfeed post email:', error);
// Don't throw - email failures shouldn't break the API
@@ -403,7 +404,7 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
const orgSiteName = feedData.organization?.siteName || 'app';
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
await sendEmail('newsfeedComment', {
await enqueueTransactionalEmail('newsfeedComment', {
to: feedData.author.email,
fields: {
courseTitle: feedData.courseTitle,
+4 -3
View File
@@ -14,7 +14,7 @@ import { env } from '@api/config/env';
import { ROLE } from '@cio/utils/constants';
import { PLAN } from '@cio/utils/plans';
import { db } from '@cio/db/drizzle';
import { sendEmail } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
export async function createOrganizationWithOwner(
profileId: string,
@@ -125,11 +125,12 @@ export async function completeOnboarding(user: Partial<TUser> & { id: string; em
throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
}
await sendEmail('welcome', {
await enqueueTransactionalEmail('welcome', {
to: user.email,
fields: {
name: profile.fullname
}
},
idempotencyKey: `onboarding:welcome:${user.id}`
});
} catch (error) {
if (error instanceof AppError) {
+7 -1
View File
@@ -32,6 +32,7 @@ import {
getPublishedCoursesBySiteName
} from '@cio/db/queries/course';
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
import { getAccountPrimary } from '@cio/db/queries/account';
import { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics';
import type { OrganizationWithPlans } from '@cio/db/queries/organization/types';
@@ -524,8 +525,13 @@ export async function getOrgSetupData(siteName: string) {
*/
export async function createOrgPlan(data: TNewOrganizationPlan) {
try {
// Subscriptions always attach to the primary workspace, even if a
// secondary org id was supplied at checkout.
const primary = data.orgId ? await getAccountPrimary(data.orgId) : null;
const targetOrgId = primary?.id ?? data.orgId;
const plan = await createOrganizationPlan({
orgId: data.orgId,
orgId: targetOrgId,
planName: data.planName,
subscriptionId: data.subscriptionId,
triggeredBy: data.triggeredBy,
+25 -36
View File
@@ -5,7 +5,8 @@ import type {
TImportAudienceMembers
} from '@cio/utils/validation/organization';
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
import {
createOrganizationInvite,
@@ -202,18 +203,19 @@ async function enrollAudienceStudentProfilesInCourses(
.map(async (p) => {
const email = profileEmailMap.get(p.profileId)!;
try {
await sendEmail('studentCourseWelcome', {
await enqueueTransactionalEmail('studentCourseWelcome', {
to: email,
fields: {
orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
loginUrl
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}`
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInCourses email error for ${email}:`, emailError);
console.error(`enrollAudienceStudentProfilesInCourses enqueue error for ${email}:`, emailError);
}
});
@@ -283,18 +285,19 @@ async function enrollAudienceStudentProfilesInPrograms(
const email = profileEmailMap.get(pair.profileId)!;
try {
await sendEmail('studentProgramWelcome', {
await enqueueTransactionalEmail('studentProgramWelcome', {
to: email,
fields: {
orgName: organization.name,
programName: programNameById.get(pair.programId) || 'Program',
loginUrl
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}`
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInPrograms email error for ${email}:`, emailError);
console.error(`enrollAudienceStudentProfilesInPrograms enqueue error for ${email}:`, emailError);
}
})
);
@@ -385,7 +388,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
const token = tokenByEmail.get(email)!;
try {
const inviteLink = buildInviteLink(token);
const responses = await sendEmail('studentOrgInvite', {
await enqueueTransactionalEmail('studentOrgInvite', {
to: email,
fields: {
email,
@@ -394,26 +397,16 @@ async function createStudentOrgInvitesAndSendEmails(input: {
expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `student-org-invite:${invite.id}`
});
const allSuccessful = responses.every((r) => r.success);
if (allSuccessful) {
return {
inviteId: invite.id,
email,
success: true as const,
error: undefined as string | undefined
};
}
// Optimistic — see comment in services/organization/invite.ts.
return {
inviteId: invite.id,
email,
success: false as const,
error: responses
.filter((r) => !r.success)
.map((r) => r.error || 'Unknown')
.join('; ')
success: true as const,
error: undefined as string | undefined
};
} catch (emailError) {
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
@@ -685,7 +678,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
let emailSent = false;
try {
const inviteLink = buildInviteLink(token);
const responses = await sendEmail('studentOrgInvite', {
await enqueueTransactionalEmail('studentOrgInvite', {
to: emailToUse,
fields: {
email: emailToUse,
@@ -694,27 +687,23 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `student-org-invite:${invite.id}`
});
const allSuccessful = responses.every((r) => r.success);
emailSent = allSuccessful;
emailSent = true;
// Optimistic EMAIL_SENT — worker handles retries; final failure flips
// email_delivery to `failed` for operator follow-up.
await createOrganizationInviteAudits([
{
inviteId: invite.id,
organizationId: orgId,
eventType: allSuccessful ? 'EMAIL_SENT' : 'EMAIL_FAILED',
eventType: 'EMAIL_SENT',
actorProfileId: invitedByProfileId,
targetEmail: emailToUse,
ipAddress: null,
userAgent: null,
metadata: allSuccessful
? {}
: {
error: responses
.filter((r) => !r.success)
.map((r) => r.error || 'Unknown')
.join('; ')
}
metadata: {}
}
]);
} catch (emailError) {
+8 -19
View File
@@ -27,7 +27,7 @@ import { db, type DbOrTxClient } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { sendEmail } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED';
@@ -244,7 +244,7 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
const inviteLink = buildInviteLink(token);
try {
const responses = await sendEmail('inviteTeacher', {
await enqueueTransactionalEmail('inviteTeacher', {
to: email,
fields: {
email,
@@ -253,25 +253,14 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
roleName,
expiresAt: getExpiryLabel(expiresAt),
inviteLink
}
},
idempotencyKey: `org-invite-teacher:${invite.id}`
});
const allSuccessful = responses.every((response) => response.success);
if (!allSuccessful) {
const message = responses
.filter((response) => !response.success)
.map((response) => response.error || 'Unknown email error')
.join('; ');
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_FAILED', {
actorProfileId: invitedByProfileId,
targetEmail: email,
metadata: { error: message }
});
continue;
}
// Record EMAIL_SENT optimistically — the worker retries up to 5 times,
// and a final failure flips the email_delivery row to `failed` for
// operator follow-up. EMAIL_FAILED here only means the enqueue itself
// failed (DB outbox write or transient validation error).
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_SENT', {
actorProfileId: invitedByProfileId,
targetEmail: email
+485
View File
@@ -0,0 +1,485 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import {
countAssignmentsByStatus,
createProgramGoal,
deleteProgramGoal,
getAssignmentsForProfile,
getLatestCompletionRecordsForProfilesAndCourses,
getMaxScoresForProfilesAndCourses,
getNonComplianceCourseCompletions,
getProgramById,
getProgramGoalAssignments,
getProgramGoalById,
getProgramGoals,
getProgramGoalsByOrg,
getProgramMembers,
listAllActiveProgramGoals,
listAssignmentsForReminderScan,
type TNewProgramGoalAssignment,
type TProgramGoal,
type TProgramGoalAssignment,
updateProgramGoal as updateProgramGoalQuery,
upsertProgramGoalAssignments
} from '@cio/db/queries/program';
import { ROLE } from '@cio/utils/constants';
import type { TCreateProgramGoal, TUpdateProgramGoal } from '@cio/utils/validation/program';
const AT_RISK_DAYS = 3;
const DAY_MS = 24 * 60 * 60 * 1000;
type GoalType = TProgramGoal['type'];
// ─── CRUD ────────────────────────────────────────────────────────────────────
export async function createGoal(
programId: string,
profileId: string,
data: TCreateProgramGoal
): Promise<TProgramGoal> {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
try {
const goal = await createProgramGoal({
programId,
title: data.title,
description: data.description ?? null,
type: data.type,
courseIds: data.courseIds,
requiredCount: data.requiredCount ?? null,
scoreThreshold: data.scoreThreshold ?? null,
teamPassRateThreshold: data.teamPassRateThreshold ?? null,
deadlineKind: data.deadlineKind,
deadlineDate: data.deadlineDate ?? null,
relativeDays: data.relativeDays ?? null,
recurringMonths: data.recurringMonths ?? null,
reminderDaysBefore: data.reminderDaysBefore,
createdByProfileId: profileId
});
await evaluateGoal(goal.id).catch((error) => {
console.error('createGoal: initial evaluation failed', error);
});
return goal;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create goal',
ErrorCodes.PROGRAM_GOAL_CREATE_FAILED,
500
);
}
}
export async function listGoals(
programId: string
): Promise<Array<TProgramGoal & { statusCounts: Record<string, number> }>> {
const goals = await getProgramGoals(programId);
if (goals.length === 0) return [];
const counts = await countAssignmentsByStatus(goals.map((goal) => goal.id));
return goals.map((goal) => ({
...goal,
statusCounts: counts.get(goal.id) ?? {}
}));
}
export async function getGoal(goalId: string): Promise<TProgramGoal> {
const goal = await getProgramGoalById(goalId);
if (!goal) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
return goal;
}
export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Promise<TProgramGoal> {
const existing = await getProgramGoalById(goalId);
if (!existing) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
try {
const updated = await updateProgramGoalQuery(goalId, {
title: data.title,
description: data.description ?? undefined,
type: data.type,
courseIds: data.courseIds,
requiredCount: data.requiredCount ?? null,
scoreThreshold: data.scoreThreshold ?? null,
teamPassRateThreshold: data.teamPassRateThreshold ?? null,
reminderDaysBefore: data.reminderDaysBefore,
deadlineKind: data.deadlineKind,
deadlineDate: data.deadlineDate ?? null,
relativeDays: data.relativeDays ?? null,
recurringMonths: data.recurringMonths ?? null,
status: data.status
});
if (!updated) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
await evaluateGoal(goalId).catch((error) => {
console.error('updateGoal: re-evaluation failed', error);
});
return updated;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update goal',
ErrorCodes.PROGRAM_GOAL_UPDATE_FAILED,
500
);
}
}
export async function archiveGoal(goalId: string): Promise<TProgramGoal> {
return updateGoal(goalId, { status: 'archived' });
}
export async function removeGoal(goalId: string): Promise<TProgramGoal> {
const deleted = await deleteProgramGoal(goalId);
if (!deleted) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
return deleted;
}
// ─── Evaluator ───────────────────────────────────────────────────────────────
/**
* Compute due date for one learner against a goal.
*
* - none / readiness → null
* - absolute → goal.deadlineDate
* - relative_to_join → joinedAt + relativeDays
* - recurring → goal.deadlineDate (current cycle); cycle advance handled elsewhere
*/
function computeDueDate(goal: TProgramGoal, joinedAt: Date): Date | null {
if (goal.deadlineKind === 'none') return null;
if (goal.deadlineKind === 'absolute') {
return goal.deadlineDate ? new Date(goal.deadlineDate) : null;
}
if (goal.deadlineKind === 'relative_to_join') {
if (!goal.relativeDays) return null;
return new Date(joinedAt.getTime() + goal.relativeDays * DAY_MS);
}
if (goal.deadlineKind === 'recurring') {
return goal.deadlineDate ? new Date(goal.deadlineDate) : null;
}
return null;
}
/**
* Decide a per-learner status from the cached counts and dates.
*
* Stays completed once it reaches `completed` (cron resets handle recurring).
*/
function decideStatus(args: {
completed: number;
required: number;
dueDate: Date | null;
now: Date;
previousStatus: TProgramGoalAssignment['status'] | null;
}): { status: TProgramGoalAssignment['status']; completedAt: Date | null } {
const { completed, required, dueDate, now, previousStatus } = args;
if (previousStatus === 'waived') {
return { status: 'waived', completedAt: null };
}
if (required > 0 && completed >= required) {
return { status: 'completed', completedAt: now };
}
if (dueDate && now.getTime() > dueDate.getTime()) {
return { status: 'overdue', completedAt: null };
}
if (dueDate && dueDate.getTime() - now.getTime() < AT_RISK_DAYS * DAY_MS) {
return { status: 'at_risk', completedAt: null };
}
if (completed > 0) {
return { status: 'in_progress', completedAt: null };
}
return { status: 'not_started', completedAt: null };
}
/**
* Re-evaluate every assignment for one goal.
*
* Pulls all student program-members, computes a per-learner status, and upserts
* `program_goal_assignment`. Designed to be safe to run repeatedly (idempotent)
* and cheap enough for daily cron over hundreds of programs.
*/
export async function evaluateGoal(goalId: string): Promise<{ evaluated: number }> {
const goal = await getProgramGoalById(goalId);
if (!goal) return { evaluated: 0 };
const members = await getProgramMembers(goal.programId);
const studentMembers = members.filter((member) => member.roleId === ROLE.STUDENT && member.profileId);
if (studentMembers.length === 0) return { evaluated: 0 };
const profileIds = studentMembers.map((member) => member.profileId!);
const courseIds = goal.courseIds ?? [];
const [completionRecords, nonComplianceCompletions, scoreMap, existingAssignments] = await Promise.all([
getLatestCompletionRecordsForProfilesAndCourses(profileIds, courseIds),
getNonComplianceCourseCompletions(profileIds, courseIds),
goal.type === 'score' || goal.type === 'pass_rate'
? getMaxScoresForProfilesAndCourses(profileIds, courseIds)
: Promise.resolve(new Map<string, number>()),
getProgramGoalAssignments(goalId)
]);
const completionByKey = new Map<string, { status: string; score: number | null }>();
for (const record of completionRecords) {
completionByKey.set(`${record.profileId}:${record.courseId}`, {
status: record.status,
score: record.score
});
}
const previousByMember = new Map(existingAssignments.map((assignment) => [assignment.programMemberId, assignment]));
const now = new Date();
const requiredFor = (type: GoalType, total: number) =>
type === 'n_of_m' && goal.requiredCount ? Math.min(goal.requiredCount, total) : total;
const rows: TNewProgramGoalAssignment[] = [];
for (const member of studentMembers) {
if (!member.profileId) continue;
const joinedAt = member.createdAt ? new Date(member.createdAt) : now;
const dueDate = computeDueDate(goal, joinedAt);
const completedCourseIds = courseIds.filter((courseId) => {
const key = `${member.profileId}:${courseId}`;
const record = completionByKey.get(key);
if (record) {
if (goal.type === 'score' || goal.type === 'pass_rate') {
const score = scoreMap.get(key);
return typeof score === 'number' && score >= (goal.scoreThreshold ?? 100);
}
if (record.status === 'compliant') return true;
}
// Fallback: non-compliance course where every lesson is completed.
if (goal.type !== 'score' && goal.type !== 'pass_rate') {
return nonComplianceCompletions.has(key);
}
return false;
});
const completed = completedCourseIds.length;
const required = requiredFor(goal.type, courseIds.length);
const previous = previousByMember.get(member.id);
const decision = decideStatus({
completed,
required,
dueDate,
now,
previousStatus: previous?.status ?? null
});
rows.push({
goalId,
programMemberId: member.id,
dueDate: dueDate ? dueDate.toISOString() : null,
status: decision.status,
completedCount: completed,
requiredCount: required,
completedAt:
decision.status === 'completed' ? (previous?.completedAt ?? decision.completedAt?.toISOString() ?? null) : null,
lastEvaluatedAt: now.toISOString()
});
}
await upsertProgramGoalAssignments(rows);
return { evaluated: rows.length };
}
/**
* Re-evaluate every active goal in a program. Used after material program
* changes (member added, course added) or by cron sweeps.
*/
export async function evaluateProgramGoals(programId: string): Promise<{ evaluated: number }> {
const goals = await getProgramGoals(programId);
let evaluated = 0;
for (const goal of goals) {
const result = await evaluateGoal(goal.id);
evaluated += result.evaluated;
}
return { evaluated };
}
/**
* Re-evaluate all goals in an organization. Used by the daily cron.
*/
export async function evaluateOrgGoals(organizationId: string): Promise<{ evaluated: number; goals: number }> {
const goals = await getProgramGoalsByOrg(organizationId);
let evaluated = 0;
for (const goal of goals) {
const result = await evaluateGoal(goal.id);
evaluated += result.evaluated;
}
return { evaluated, goals: goals.length };
}
/**
* Re-evaluate every active goal across every org. Designed for the daily cron
* sweep — same shape as `runComplianceExpiryCheck` for the compliance feature.
*/
export async function runProgramGoalEvaluationSweep(): Promise<{
goalsEvaluated: number;
assignmentsEvaluated: number;
}> {
const goals = await listAllActiveProgramGoals();
let assignmentsEvaluated = 0;
for (const goal of goals) {
try {
const result = await evaluateGoal(goal.id);
assignmentsEvaluated += result.evaluated;
} catch (error) {
console.error(`runProgramGoalEvaluationSweep: failed to evaluate goal ${goal.id}`, error);
}
}
return { goalsEvaluated: goals.length, assignmentsEvaluated };
}
// ─── Reminder scan ───────────────────────────────────────────────────────────
function getWholeDaysUntil(now: Date, target: Date): number {
const ms = target.getTime() - now.getTime();
return Math.ceil(ms / DAY_MS);
}
/**
* Scan all active goal assignments and send a reminder email when
* `daysUntilDue` matches an entry in the goal's `reminderDaysBefore` cadence
* (mirrors `runComplianceReminderScan`).
*
* Idempotency: the email worker idempotency key is keyed on assignment id +
* the day bucket so a second run on the same day does not re-send.
*/
export async function runProgramGoalReminderScan(): Promise<{
scanned: number;
remindersEnqueued: number;
}> {
const now = new Date();
const rows = await listAssignmentsForReminderScan();
let remindersEnqueued = 0;
for (const row of rows) {
if (!row.assignment.dueDate) continue;
if (!row.email) continue;
const dueDate = new Date(row.assignment.dueDate);
if (Number.isNaN(dueDate.getTime())) continue;
const daysUntilDue = getWholeDaysUntil(now, dueDate);
const reminderDays = row.goal.reminderDaysBefore ?? [];
const isOverdueDay = daysUntilDue === 0;
const matchesReminder = (daysUntilDue > 0 && reminderDays.includes(daysUntilDue)) || isOverdueDay;
if (!matchesReminder) continue;
const loginUrl = getDashboardBaseUrl(row.organizationSiteName ?? undefined);
const dayKey = now.toISOString().slice(0, 10);
try {
await enqueueTransactionalEmail('programGoalReminder', {
to: row.email,
fields: {
orgName: row.organizationName,
programName: row.programName,
goalTitle: row.goal.title,
daysUntilDue,
completedCount: row.assignment.completedCount ?? 0,
requiredCount: row.assignment.requiredCount ?? 0,
loginUrl: `${loginUrl}/lms/programs/${row.programId}`
},
idempotencyKey: `program-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
});
remindersEnqueued += 1;
} catch (error) {
console.error(`runProgramGoalReminderScan: enqueue failed for assignment ${row.assignment.id}`, error);
}
}
return { scanned: rows.length, remindersEnqueued };
}
// ─── LMS read ────────────────────────────────────────────────────────────────
export async function getMyGoals(profileId: string) {
return getAssignmentsForProfile(profileId);
}
// ─── Org-wide overview ───────────────────────────────────────────────────────
/**
* Cross-program goal roll-up for the org owner. Lists every active goal in the
* org with its per-status counts so the dashboard can group by program.
*/
export async function getOrgGoalsOverview(organizationId: string) {
const goals = await getProgramGoalsByOrg(organizationId);
if (goals.length === 0) {
return { goals: [] };
}
const counts = await countAssignmentsByStatus(goals.map((goal) => goal.id));
return {
goals: goals.map((goal) => {
const statusCounts = counts.get(goal.id) ?? {};
const total = Object.values(statusCounts).reduce((sum, value) => sum + (value ?? 0), 0);
const completed = statusCounts.completed ?? 0;
const overdue = statusCounts.overdue ?? 0;
const atRisk = statusCounts.at_risk ?? 0;
const onTrack = total - overdue - atRisk;
const onTrackPct = total === 0 ? 0 : Math.round((onTrack / total) * 100);
return {
goalId: goal.id,
programId: goal.programId,
programName: goal.programName,
title: goal.title,
type: goal.type,
deadlineKind: goal.deadlineKind,
totalLearners: total,
completedCount: completed,
inProgressCount: statusCounts.in_progress ?? 0,
atRiskCount: atRisk,
overdueCount: overdue,
notStartedCount: statusCounts.not_started ?? 0,
waivedCount: statusCounts.waived ?? 0,
onTrackPct
};
})
};
}
+52
View File
@@ -0,0 +1,52 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getProgramById } from '@cio/db/queries/program';
import type { TInviteStudentsToProgram, TAssignExistingStudentsToProgram } from '@cio/utils/validation/program';
import { assignAudienceToCourses, importAudienceMembers } from '@api/services/organization/audience';
/**
* Invite students to a single program by email CSV.
*
* Thin wrapper that hardcodes `programIds = [programId]` and delegates to
* `importAudienceMembers`. Lets a Program ADMIN/TUTOR (or org admin) issue org
* invites scoped to *their own* program, without granting org-wide invite rights.
*/
export async function inviteStudentsToProgram(
programId: string,
data: TInviteStudentsToProgram,
invitedByProfileId: string
) {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return importAudienceMembers(
program.organizationId,
{
recipientCsv: data.recipientCsv,
programIds: [programId],
sendEmail: data.sendEmail,
allCourses: false,
allPrograms: false
},
invitedByProfileId
);
}
/**
* Assign existing student profiles (already members of the org audience) to a
* single program. Same scoping logic as `inviteStudentsToProgram` — restricted
* to one program at a time so the caller's program-admin permission is enough.
*/
export async function assignExistingStudentsToProgram(programId: string, data: TAssignExistingStudentsToProgram) {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return assignAudienceToCourses(program.organizationId, {
profileIds: data.profileIds,
programIds: [programId],
sendEmail: data.sendEmail
});
}
+18 -22
View File
@@ -12,7 +12,8 @@ import type {
TSubmissionGradesUpdate,
TSubmissionUpdate
} from '@cio/utils/validation/submission';
import { buildEmailFromName, deliverEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueRawEmail } from '@api/services/jobs';
import {
createSubmission,
deleteSubmission,
@@ -977,14 +978,13 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
`;
}
await deliverEmail([
{
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: fullSubmission.groupmember.profile.email,
subject: 'Submission Update',
content
}
]);
await enqueueRawEmail({
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: fullSubmission.groupmember.profile.email,
subject: 'Submission Update',
content,
idempotencyKey: `submission-update:${submissionId}:${newStatusId}`
});
}
/**
@@ -1035,18 +1035,14 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s
</div>
`;
// Send email to all tutors
const emailPromises = tutorsResult.map((tutor) => {
if (!tutor.email) return Promise.resolve();
return deliverEmail([
{
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: tutor.email,
subject: `[Submitted]: ${exercise.title}`,
content
}
]);
});
const tutorEmails = tutorsResult.map((tutor) => tutor.email).filter((email): email is string => Boolean(email));
if (tutorEmails.length === 0) return;
await Promise.all(emailPromises);
await enqueueRawEmail({
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: tutorEmails,
subject: `[Submitted]: ${exercise.title}`,
content,
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`
});
}
+55 -215
View File
@@ -4,14 +4,11 @@ import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@
import { listWidgetCourses } from '@cio/db/queries/widget';
import { PLAN } from '@cio/utils/plans';
import {
ZWidgetPayload,
getLayoutCourseLimit,
buildWidgetPayload as buildWidgetPayloadShared,
normalizeWidgetConfig,
resolveWidgetPlanGatedFields,
type TWidgetLayoutType,
type TWidgetPayload,
type TWidgetPayloadCategory,
type TWidgetPayloadCourseTag
type BuildWidgetPayloadCourse,
type TWidgetPayload
} from '@cio/utils/validation/widget';
import type { TWidget } from '@db/types';
import * as csstree from 'css-tree';
@@ -107,7 +104,7 @@ export function generateWidgetPublicKey(): string {
return `wgt_${encodeBase58(bytes).slice(0, 16)}`;
}
function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
export function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
if (!customCss.trim() || !planName || planName === PLAN.BASIC) {
return '';
}
@@ -146,7 +143,7 @@ function sanitizeCustomCssValue(customCss: string, planName?: string | null): st
return csstree.generate(ast);
}
function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
export function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
if (customDomain) {
return `https://${customDomain}`;
}
@@ -174,6 +171,28 @@ export function getWidgetEmbedCode(publicKey: string): string {
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}" data-api-base-url="${apiBaseUrl}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
}
type OrgCourse = Awaited<ReturnType<typeof getOrgCourses>>['items'][number];
export function formatCourseForWidget(
course: OrgCourse,
tags: BuildWidgetPayloadCourse['tags']
): BuildWidgetPayloadCourse {
return {
id: course.id,
slug: course.slug ?? course.id,
title: course.title,
description: course.description,
imageUrl: course.bannerImage || course.logo || null,
isPublished: course.isPublished,
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
lessonCount: course.lessonCount,
exerciseCount: course.exerciseCount ?? 0,
courseType: course.type,
createdAt: course.createdAt ?? new Date().toISOString(),
tags
};
}
export async function buildWidgetPayload(
widget: TWidget,
overrideSelectedCourseIds?: string[]
@@ -184,216 +203,47 @@ export async function buildWidgetPayload(
}
const activePlan = await getActiveOrganizationPlan(widget.organizationId);
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, activePlan?.planName);
const planName = activePlan?.planName ?? null;
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, planName);
const selectedCourseIds =
overrideSelectedCourseIds ?? (await listWidgetCourses(widget.id)).map((course) => course.courseId);
const allCourses = await getOrgCourses({ orgId: widget.organizationId, limit: 200 });
const allPublishedCourses = allCourses.items.filter((course) => course.isPublished);
const manualOrderLookup = new Map(selectedCourseIds.map((courseId, index) => [courseId, index]));
let resolvedCourses =
widget.selectionMode === 'manual'
? allCourses.items.filter((course) => manualOrderLookup.has(course.id))
: allPublishedCourses;
const allCoursesResult = await getOrgCourses({ orgId: widget.organizationId, limit: 200 });
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
widget.organizationId,
resolvedCourses.map((course) => course.id)
allCoursesResult.items.map((course) => course.id)
);
if (normalizedConfig.filters.includeTagIds.length > 0 || normalizedConfig.filters.excludeTagIds.length > 0) {
resolvedCourses = resolvedCourses.filter((course) => {
const courseTags = tagsByCourseId[course.id] ?? [];
const courseTagIds = new Set(courseTags.map((tag) => tag.id));
const includesRequired =
normalizedConfig.filters.includeTagIds.length === 0 ||
normalizedConfig.filters.includeTagIds.some((tagId) => courseTagIds.has(tagId));
const excludesBlocked = normalizedConfig.filters.excludeTagIds.every((tagId) => !courseTagIds.has(tagId));
const allCourses = allCoursesResult.items.map((course) =>
formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
);
return includesRequired && excludesBlocked;
});
}
if (widget.selectionMode === 'manual' || normalizedConfig.sortBy === 'manual') {
resolvedCourses = [...resolvedCourses].sort(
(courseA, courseB) =>
(manualOrderLookup.get(courseA.id) ?? Number.MAX_SAFE_INTEGER) -
(manualOrderLookup.get(courseB.id) ?? Number.MAX_SAFE_INTEGER)
);
} else if (normalizedConfig.sortBy === 'title') {
resolvedCourses = [...resolvedCourses].sort((courseA, courseB) => courseA.title.localeCompare(courseB.title));
} else {
resolvedCourses = [...resolvedCourses].sort(
(courseA, courseB) => new Date(courseB.createdAt ?? '').getTime() - new Date(courseA.createdAt ?? '').getTime()
);
}
const layoutType = widget.layoutType as TWidgetLayoutType;
const layoutOptions = normalizedConfig.layoutOptions;
// Hoist the configured "main" course to the front for layouts that have a hero slot,
// so the rest of the slice/limit logic can treat slot 0 as the featured course.
if (layoutType === 'primary_course' && layoutOptions.primaryCourse.featuredCourseId) {
const featuredId = layoutOptions.primaryCourse.featuredCourseId;
const featuredIndex = resolvedCourses.findIndex((course) => course.id === featuredId);
if (featuredIndex > 0) {
const [featuredCourse] = resolvedCourses.splice(featuredIndex, 1);
resolvedCourses = [featuredCourse, ...resolvedCourses];
}
} else if (layoutType === 'editorial_spotlight' && layoutOptions.editorialSpotlight.mainCourseId) {
const mainId = layoutOptions.editorialSpotlight.mainCourseId;
const mainIndex = resolvedCourses.findIndex((course) => course.id === mainId);
if (mainIndex > 0) {
const [mainCourse] = resolvedCourses.splice(mainIndex, 1);
resolvedCourses = [mainCourse, ...resolvedCourses];
}
}
// Cap the course list according to the active layout's limit. Category shelf
// builds its own grouped slices below from the already-filtered universe.
if (layoutType !== 'category_shelf') {
const layoutLimit = getLayoutCourseLimit(layoutType, layoutOptions);
resolvedCourses = resolvedCourses.slice(0, Math.max(layoutLimit, 1));
} else {
resolvedCourses = resolvedCourses.slice(0, getLayoutCourseLimit(layoutType, layoutOptions));
}
// Build payload-specific aggregates.
const heroCourseId =
layoutType === 'primary_course'
? (layoutOptions.primaryCourse.featuredCourseId ?? resolvedCourses[0]?.id ?? null)
: layoutType === 'editorial_spotlight'
? (layoutOptions.editorialSpotlight.mainCourseId ?? resolvedCourses[0]?.id ?? null)
: null;
const tagPool = buildTagPool(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId);
const categories = buildCategoriesFromShelf(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId);
const planGatedFields = resolveWidgetPlanGatedFields(activePlan?.planName, normalizedConfig.themePreset);
const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, activePlan?.planName);
const planGatedFields = resolveWidgetPlanGatedFields(planName, normalizedConfig.themePreset);
const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, planName);
const orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain);
return ZWidgetPayload.parse({
version: 'v1',
widgetId: widget.id,
publicKey: widget.publicKey,
return buildWidgetPayloadShared({
widget: {
id: widget.id,
publicKey: widget.publicKey,
layoutType: widget.layoutType,
selectionMode: widget.selectionMode,
config: normalizedConfig
},
organization: {
id: organization.id,
name: organization.name,
siteName: organization.siteName,
siteName: organization.siteName ?? '',
customDomain: organization.customDomain
},
layoutType,
selectionMode: widget.selectionMode,
design: {
...normalizedConfig,
advanced: {
...normalizedConfig.advanced,
customCss: sanitizedCustomCss
}
},
orgBaseUrl,
allCourses,
selectedCourseIds,
planGatedFields,
labels: {
loadMoreLabel: normalizedConfig.content.loadMoreLabel,
poweredByLabel: 'Powered by Classroomio'
},
courses: resolvedCourses.map((course) => ({
id: course.id,
slug: course.slug ?? course.id,
title: course.title,
description: course.description,
imageUrl: course.bannerImage || course.logo || null,
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
lessonCount: course.lessonCount,
exerciseCount: course.exerciseCount ?? 0,
rating: null,
ratingCount: 0,
courseType: course.type,
createdAt: course.createdAt ?? new Date().toISOString(),
tags: tagsByCourseId[course.id] ?? [],
featured: heroCourseId ? course.id === heroCourseId : false,
url: `${orgBaseUrl}/course/${course.slug ?? course.id}`
})),
tagPool,
categories,
timestamp: Date.now()
planName,
sanitizedCustomCss
});
}
function buildTagPool(
layoutType: TWidgetLayoutType,
config: ReturnType<typeof normalizeWidgetConfig>,
courses: Array<{ id: string }>,
tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]>
): TWidgetPayloadCourseTag[] {
if (layoutType !== 'tag_filter') {
return [];
}
const allTagsFromCourses = new Map<string, TWidgetPayloadCourseTag>();
for (const course of courses) {
for (const tag of tagsByCourseId[course.id] ?? []) {
if (!allTagsFromCourses.has(tag.id)) {
allTagsFromCourses.set(tag.id, tag);
}
}
}
if (config.layoutOptions.tagFilter.tagSource === 'manual') {
const allowedIds = new Set(config.layoutOptions.tagFilter.manualTagIds);
return [...allTagsFromCourses.values()].filter((tag) => allowedIds.has(tag.id));
}
return [...allTagsFromCourses.values()];
}
function buildCategoriesFromShelf(
layoutType: TWidgetLayoutType,
config: ReturnType<typeof normalizeWidgetConfig>,
courses: Array<{ id: string }>,
tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]>
): TWidgetPayloadCategory[] {
if (layoutType !== 'category_shelf') {
return [];
}
const orderedCategoryIds = config.layoutOptions.categoryShelf.categoryTagIds;
if (orderedCategoryIds.length === 0) {
return [];
}
// Reverse-lookup tag info from the tags-by-course map.
const tagsById = new Map<string, TWidgetPayloadCourseTag>();
for (const tags of Object.values(tagsByCourseId)) {
for (const tag of tags) {
tagsById.set(tag.id, tag);
}
}
const maxPerCategory = config.layoutOptions.categoryShelf.maxPerCategory;
return orderedCategoryIds
.map((tagId) => {
const tagInfo = tagsById.get(tagId);
if (!tagInfo) {
return null;
}
const matchingCourses = courses.filter((course) =>
(tagsByCourseId[course.id] ?? []).some((tag) => tag.id === tagId)
);
return {
tagId,
name: tagInfo.name,
slug: tagInfo.slug,
color: tagInfo.color,
courseIds: matchingCourses.slice(0, maxPerCategory).map((course) => course.id)
} satisfies TWidgetPayloadCategory;
})
.filter((entry): entry is TWidgetPayloadCategory => entry !== null);
}
export async function listWidgetAvailableEditorData(orgId: string) {
const [coursesResult, tagGroups, activePlan] = await Promise.all([
getOrgCourses({ orgId, limit: 200 }),
@@ -406,20 +256,9 @@ export async function listWidgetAvailableEditorData(orgId: string) {
);
return {
availableCourses: coursesResult.items.map((course) => ({
id: course.id,
slug: course.slug ?? course.id,
title: course.title,
description: course.description,
imageUrl: course.bannerImage || course.logo || null,
isPublished: course.isPublished,
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
lessonCount: course.lessonCount,
exerciseCount: course.exerciseCount ?? 0,
courseType: course.type,
createdAt: course.createdAt ?? new Date().toISOString(),
tags: tagsByCourseId[course.id] ?? []
})),
availableCourses: coursesResult.items.map((course) =>
formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
),
availableTags: tagGroups.flatMap((group) =>
group.tags.map((tag) => ({
id: tag.id,
@@ -429,6 +268,7 @@ export async function listWidgetAvailableEditorData(orgId: string) {
groupId: group.id
}))
),
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio')
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio'),
planName: activePlan?.planName ?? null
};
}
+19 -35
View File
@@ -19,14 +19,15 @@ import {
getDefaultWidgetConfig,
normalizeWidgetConfig,
type TCreateWidget,
type TPreviewWidget,
type TRollbackWidget,
type TUpdateWidget
} from '@cio/utils/validation/widget';
import { getOrganizationById } from '@cio/db/queries/organization';
import {
buildWidgetPayload,
generateWidgetPublicKey,
getCourseBaseUrl,
getCourseWidgetScriptUrl,
getWidgetEmbedCode,
listWidgetAvailableEditorData
@@ -82,26 +83,37 @@ export async function getOrganizationWidgetDetail(orgId: string, widgetId: strin
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const [selectedCourses, versions, editorData, previewPayload] = await Promise.all([
const organization = await getOrganizationById(orgId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const [selectedCourses, versions, editorData] = await Promise.all([
listWidgetCourses(widget.id),
listWidgetVersions(orgId, widget.id),
listWidgetAvailableEditorData(orgId),
buildWidgetPayload(widget)
listWidgetAvailableEditorData(orgId)
]);
return {
widget: {
...widget,
config: normalizeWidgetConfig(widget.config as Record<string, unknown>),
config: normalizeWidgetConfig(widget.config as Record<string, unknown>, editorData.planName),
selectedCourseIds: selectedCourses.map((course) => course.courseId),
embedCode: getWidgetEmbedCode(widget.publicKey),
publicScriptUrl: getCourseWidgetScriptUrl()
},
organization: {
id: organization.id,
name: organization.name,
siteName: organization.siteName ?? '',
customDomain: organization.customDomain
},
orgBaseUrl: getCourseBaseUrl(organization.siteName ?? '', organization.customDomain),
availableCourses: editorData.availableCourses,
availableTags: editorData.availableTags,
previewPayload,
versions,
planGatedFields: editorData.planGatedFields
planGatedFields: editorData.planGatedFields,
planName: editorData.planName
};
} catch (error) {
if (error instanceof AppError) {
@@ -285,34 +297,6 @@ export async function rollbackOrganizationWidget(
}
}
export async function previewOrganizationWidget(orgId: string, widgetId: string, data: TPreviewWidget) {
try {
const widget = await getWidgetById(orgId, widgetId);
if (!widget) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const overriddenWidget = {
...widget,
layoutType: data.layoutType ?? widget.layoutType,
selectionMode: data.selectionMode ?? widget.selectionMode,
config: data.config ?? widget.config
};
return buildWidgetPayload(overriddenWidget, data.selectedCourseIds);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to build widget preview',
ErrorCodes.WIDGET_FETCH_FAILED,
500
);
}
}
export async function getPublishedWidgetPayload(publicKey: string) {
try {
const payload = await getPublishedWidgetPayloadByPublicKey(publicKey);