feat: widgets and ai chat
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
listChatConversations,
|
||||
getChatConversation,
|
||||
createChatConversation,
|
||||
saveChatMessages,
|
||||
deleteChatConversation,
|
||||
updateConversationTitle,
|
||||
getChatHistory,
|
||||
saveChatHistory,
|
||||
deleteChatHistory
|
||||
} from '@cio/db/queries/agent';
|
||||
|
||||
export {
|
||||
listChatConversations,
|
||||
getChatConversation,
|
||||
createChatConversation,
|
||||
saveChatMessages,
|
||||
deleteChatConversation,
|
||||
updateConversationTitle,
|
||||
// Legacy compat
|
||||
getChatHistory as fetchChatHistory,
|
||||
saveChatHistory as persistChatHistory,
|
||||
deleteChatHistory as clearChatHistory
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Trims conversation message history to stay within token budget.
|
||||
*
|
||||
* Strategy:
|
||||
* - Always keep the first message (often contains the initial request/document context)
|
||||
* - Always keep the last N messages (recent context is most relevant)
|
||||
* - Drop middle messages when the array is too long
|
||||
*
|
||||
* This is a message-count heuristic, not a token counter. Each message averages
|
||||
* ~200-500 tokens, so capping at ~40 messages keeps us under ~20K tokens of
|
||||
* history, leaving room for the system prompt + document text + LLM response.
|
||||
*/
|
||||
|
||||
const MAX_MESSAGES = 40;
|
||||
const KEEP_RECENT = 30;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function trimMessageHistory(messages: any[]): any[] {
|
||||
if (messages.length <= MAX_MESSAGES) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const firstMessage = messages[0];
|
||||
const recentMessages = messages.slice(-KEEP_RECENT);
|
||||
|
||||
return [firstMessage, ...recentMessages];
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import {
|
||||
MAX_DOCUMENT_TEXT_LENGTH,
|
||||
MAX_AGENT_DOCUMENT_SIZE,
|
||||
DOCUMENT_REDIS_TTL,
|
||||
SUPPORTED_DOCUMENT_TYPES
|
||||
} from '@cio/ai-assistant';
|
||||
import type { DocumentUploadResult } from '@cio/ai-assistant';
|
||||
import { agentDocumentKey } from '@api/utils/redis/key-generators';
|
||||
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
|
||||
import type { RedisClient } from '@api/utils/redis/redis';
|
||||
import { createChatDocument, getChatDocument } from '@cio/db/queries/agent';
|
||||
import { generateFileKey } from '@api/utils/upload';
|
||||
import { uploadToS3 } from '@api/utils/s3';
|
||||
import { getStorageConfig } from '@api/config/storage';
|
||||
import { createAssetFromUploadService } from '@api/services/assets/assets';
|
||||
|
||||
/**
|
||||
* Parse an uploaded document, store extracted text in Redis (hot cache) and Postgres
|
||||
* (durable, scoped to a conversation). Supports PDF, DOCX, and PPTX files.
|
||||
*/
|
||||
export async function parseAndStoreDocument(
|
||||
file: File,
|
||||
orgId: string,
|
||||
userId: string,
|
||||
courseId: string,
|
||||
conversationId: string,
|
||||
redis: RedisClient
|
||||
): Promise<DocumentUploadResult> {
|
||||
const mimeType = file.type;
|
||||
|
||||
if (!SUPPORTED_DOCUMENT_TYPES.includes(mimeType as (typeof SUPPORTED_DOCUMENT_TYPES)[number])) {
|
||||
throw new AppError('Unsupported file type. Allowed: PDF, DOCX, PPTX', 'UNSUPPORTED_FILE_TYPE', 415);
|
||||
}
|
||||
|
||||
if (file.size > MAX_AGENT_DOCUMENT_SIZE) {
|
||||
throw new AppError(
|
||||
`File too large. Maximum size is ${MAX_AGENT_DOCUMENT_SIZE / (1024 * 1024)}MB`,
|
||||
'FILE_TOO_LARGE',
|
||||
413
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
let extractedText: string;
|
||||
let pageCount: number | null = null;
|
||||
|
||||
switch (mimeType) {
|
||||
case 'application/pdf':
|
||||
({ text: extractedText, pageCount } = await extractPdfText(buffer));
|
||||
break;
|
||||
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
||||
extractedText = await extractDocxText(buffer);
|
||||
break;
|
||||
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
||||
({ text: extractedText, pageCount } = await extractPptxText(buffer));
|
||||
break;
|
||||
default:
|
||||
throw new AppError('Unsupported file type', 'UNSUPPORTED_FILE_TYPE', 415);
|
||||
}
|
||||
|
||||
const truncated = extractedText.length > MAX_DOCUMENT_TEXT_LENGTH;
|
||||
|
||||
if (truncated) {
|
||||
extractedText = extractedText.slice(0, MAX_DOCUMENT_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
const wordCount = extractedText.split(/\s+/).filter(Boolean).length;
|
||||
const textPreview = extractedText.slice(0, 200);
|
||||
const documentId = nanoid();
|
||||
|
||||
// Persist the original file to S3 and register it as an asset so it shows
|
||||
// up in the org's asset manager — same pipeline lessons use.
|
||||
const storageKey = generateFileKey(file.name);
|
||||
const storageConfig = getStorageConfig();
|
||||
const uploadResult = await uploadToS3({
|
||||
Bucket: storageConfig.bucketDocuments,
|
||||
Key: storageKey,
|
||||
Body: buffer,
|
||||
ContentType: mimeType
|
||||
});
|
||||
|
||||
if (!uploadResult.success) {
|
||||
throw new AppError(
|
||||
`Failed to upload document to storage: ${uploadResult.error ?? 'unknown error'}`,
|
||||
'DOCUMENT_STORAGE_FAILED',
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
const asset = await createAssetFromUploadService(orgId, userId, {
|
||||
kind: 'document',
|
||||
provider: 'upload',
|
||||
storageProvider: 's3',
|
||||
storageKey,
|
||||
byteSize: file.size,
|
||||
mimeType,
|
||||
title: file.name,
|
||||
isExternal: false,
|
||||
metadata: { source: 'ai_chat', conversationId }
|
||||
});
|
||||
|
||||
await redis.set(
|
||||
agentDocumentKey(documentId),
|
||||
JSON.stringify({
|
||||
text: extractedText,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
uploadedAt: new Date().toISOString()
|
||||
}),
|
||||
{ EX: DOCUMENT_REDIS_TTL }
|
||||
);
|
||||
|
||||
await createChatDocument({
|
||||
id: documentId,
|
||||
conversationId,
|
||||
courseId,
|
||||
userId,
|
||||
assetId: asset.id,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
text: extractedText,
|
||||
wordCount,
|
||||
pageCount
|
||||
});
|
||||
|
||||
trackAgentEvent(AgentEvent.DOCUMENT_UPLOADED, {
|
||||
orgId,
|
||||
userId,
|
||||
courseId,
|
||||
mimeType,
|
||||
fileSize: file.size,
|
||||
wordCount,
|
||||
truncated
|
||||
});
|
||||
|
||||
return {
|
||||
documentId,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
pageCount,
|
||||
wordCount,
|
||||
textPreview,
|
||||
truncated
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve stored document text. Tries the Redis hot cache first; on miss
|
||||
* falls back to Postgres and rehydrates Redis for next time.
|
||||
*/
|
||||
export async function getDocumentText(documentId: string, redis: RedisClient): Promise<string | null> {
|
||||
const raw = await redis.get(agentDocumentKey(documentId));
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { text: string };
|
||||
|
||||
return parsed.text;
|
||||
}
|
||||
|
||||
const record = await getChatDocument(documentId);
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
await redis.set(
|
||||
agentDocumentKey(documentId),
|
||||
JSON.stringify({
|
||||
text: record.text,
|
||||
fileName: record.fileName,
|
||||
mimeType: record.mimeType,
|
||||
uploadedAt: record.createdAt
|
||||
}),
|
||||
{ EX: DOCUMENT_REDIS_TTL }
|
||||
);
|
||||
|
||||
return record.text;
|
||||
}
|
||||
|
||||
// ─── Extraction Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function extractPdfText(buffer: Buffer): Promise<{ text: string; pageCount: number }> {
|
||||
const pdfParse = (await import('pdf-parse')).default;
|
||||
const result = await pdfParse(buffer);
|
||||
|
||||
return { text: result.text, pageCount: result.numpages };
|
||||
}
|
||||
|
||||
async function extractDocxText(buffer: Buffer): Promise<string> {
|
||||
const mammoth = await import('mammoth');
|
||||
const result = await mammoth.extractRawText({ buffer });
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async function extractPptxText(buffer: Buffer): Promise<{ text: string; pageCount: number }> {
|
||||
// pptx-parser may not be available yet — use a simpler approach
|
||||
// For now, try to use officegen or a basic XML extraction
|
||||
try {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const slideTexts: string[] = [];
|
||||
const slideFiles = Object.keys(zip.files)
|
||||
.filter((name) => name.match(/^ppt\/slides\/slide\d+\.xml$/))
|
||||
.sort();
|
||||
|
||||
for (const slideFile of slideFiles) {
|
||||
const content = await zip.files[slideFile].async('text');
|
||||
// Extract text from XML by stripping tags
|
||||
const textContent = content
|
||||
.replace(/<a:t[^>]*>/g, '')
|
||||
.replace(/<\/a:t>/g, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
if (textContent) {
|
||||
slideTexts.push(textContent);
|
||||
}
|
||||
}
|
||||
|
||||
return { text: slideTexts.join('\n\n---\n\n'), pageCount: slideFiles.length };
|
||||
} catch {
|
||||
throw new AppError('Failed to parse PPTX file', 'PPTX_PARSE_ERROR', 422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function stripLeadingLessonTitle(content: string, lessonTitle: string): string {
|
||||
const normalizedTitle = lessonTitle.trim();
|
||||
|
||||
if (!normalizedTitle) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const escapedTitle = escapeRegExp(normalizedTitle);
|
||||
const patterns = [
|
||||
new RegExp(`^\\s*<h[1-6][^>]*>\\s*${escapedTitle}\\s*</h[1-6]>\\s*`, 'i'),
|
||||
new RegExp(
|
||||
`^\\s*<p[^>]*>\\s*(?:<strong[^>]*>|<b[^>]*>)?\\s*${escapedTitle}\\s*(?:</strong>|</b>)?\\s*</p>\\s*`,
|
||||
'i'
|
||||
)
|
||||
];
|
||||
|
||||
let nextContent = content;
|
||||
|
||||
for (const pattern of patterns) {
|
||||
nextContent = nextContent.replace(pattern, '');
|
||||
}
|
||||
|
||||
return nextContent;
|
||||
}
|
||||
|
||||
function normalizeHeadingLevels(content: string): string {
|
||||
return content
|
||||
.replace(/<h1(\s|>)/gi, '<h3$1')
|
||||
.replace(/<\/h1>/gi, '</h3>')
|
||||
.replace(/<h2(\s|>)/gi, '<h3$1')
|
||||
.replace(/<\/h2>/gi, '</h3>');
|
||||
}
|
||||
|
||||
export function normalizeAgentLessonContent(content: string, lessonTitle: string): string {
|
||||
let normalizedContent = content.trim();
|
||||
|
||||
normalizedContent = stripLeadingLessonTitle(normalizedContent, lessonTitle);
|
||||
normalizedContent = normalizeHeadingLevels(normalizedContent);
|
||||
|
||||
return normalizedContent.trim();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { AppError } from '@api/utils/errors';
|
||||
|
||||
export type CurrentQuestionForPatch = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
questionTypeId: number;
|
||||
points: number;
|
||||
order: number;
|
||||
settings?: Record<string, unknown>;
|
||||
options: Array<{
|
||||
id: number | string;
|
||||
label: string | null;
|
||||
isCorrect: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type QuestionPatch = {
|
||||
id: number;
|
||||
question?: string;
|
||||
questionTypeId?: number;
|
||||
points?: number;
|
||||
order?: number;
|
||||
settings?: Record<string, unknown>;
|
||||
options?: Array<{
|
||||
id?: number;
|
||||
label?: string;
|
||||
isCorrect?: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type MergedOption = {
|
||||
id?: number;
|
||||
label: string;
|
||||
isCorrect: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MergedQuestion = {
|
||||
id: number;
|
||||
question: string;
|
||||
questionTypeId: number;
|
||||
points: number;
|
||||
order: number;
|
||||
settings?: Record<string, unknown>;
|
||||
options: MergedOption[];
|
||||
};
|
||||
|
||||
export function buildUpdatedQuestions(
|
||||
currentQuestions: CurrentQuestionForPatch[],
|
||||
patches: QuestionPatch[],
|
||||
exerciseId: string
|
||||
): MergedQuestion[] {
|
||||
const byId = new Map(currentQuestions.map((q) => [Number(q.id), q] as const));
|
||||
|
||||
return patches.map((patch) => {
|
||||
const current = byId.get(patch.id);
|
||||
if (!current) {
|
||||
throw new AppError(
|
||||
`Question ${patch.id} does not belong to exercise ${exerciseId}`,
|
||||
'RESOURCE_NOT_IN_COURSE',
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const currentOptions: MergedOption[] = (current.options || []).map((o) => ({
|
||||
id: Number(o.id),
|
||||
label: o.label || '',
|
||||
isCorrect: o.isCorrect,
|
||||
settings: o.settings ?? undefined
|
||||
}));
|
||||
|
||||
const mergedSettings =
|
||||
patch.settings === undefined ? current.settings : { ...(current.settings ?? {}), ...patch.settings };
|
||||
|
||||
let mergedOptions: MergedOption[];
|
||||
if (patch.options === undefined) {
|
||||
mergedOptions = currentOptions;
|
||||
} else {
|
||||
mergedOptions = patch.options.map((opt) => {
|
||||
if (opt.id != null) {
|
||||
const existingOpt = currentOptions.find((co) => co.id === opt.id);
|
||||
return {
|
||||
id: opt.id,
|
||||
label: opt.label ?? existingOpt?.label ?? '',
|
||||
isCorrect: opt.isCorrect ?? existingOpt?.isCorrect ?? false,
|
||||
settings: opt.settings ?? existingOpt?.settings
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: opt.label ?? '',
|
||||
isCorrect: opt.isCorrect ?? false,
|
||||
settings: opt.settings
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: patch.id,
|
||||
question: patch.question ?? current.title ?? '',
|
||||
questionTypeId: patch.questionTypeId ?? current.questionTypeId,
|
||||
points: patch.points ?? current.points,
|
||||
order: patch.order ?? current.order,
|
||||
settings: mergedSettings,
|
||||
options: mergedOptions
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Cheap models used specifically for title generation to minimize token cost.
|
||||
*/
|
||||
const TITLE_MODELS: Record<AIProvider, string> = {
|
||||
[AIProvider.OPENAI]: 'gpt-4o-mini',
|
||||
[AIProvider.ANTHROPIC]: 'claude-haiku-4-20250414',
|
||||
[AIProvider.GOOGLE]: 'gemini-2.5-flash'
|
||||
};
|
||||
|
||||
function createTitleModel(config: AIProviderConfig) {
|
||||
const modelName = TITLE_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);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported AI provider: ${config.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a short conversational title from the user's first message.
|
||||
* Uses the cheapest available model to minimize token cost.
|
||||
*/
|
||||
export async function generateConversationTitle(
|
||||
firstMessageText: string,
|
||||
providerConfig: AIProviderConfig
|
||||
): Promise<string> {
|
||||
const model = createTitleModel(providerConfig);
|
||||
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system:
|
||||
"Generate a short title (max 6 words) for a chat conversation based on the user's first message. Return ONLY the title, no quotes, no punctuation at the end. Be concise and descriptive.",
|
||||
prompt: firstMessageText,
|
||||
maxOutputTokens: 30
|
||||
});
|
||||
|
||||
return text.trim().slice(0, 80);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { db } from '@cio/db';
|
||||
import * as schema from '@cio/db/schema';
|
||||
import { eq, and, gte, sql } from 'drizzle-orm';
|
||||
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TokenBalance, TokenUsage } from '@cio/ai-assistant';
|
||||
|
||||
// ─── Plan-Based Token Allowances ─────────────────────────────────────────────
|
||||
|
||||
const PLAN_TOKEN_ALLOWANCES: Record<string, number> = {
|
||||
BASIC: 50_000,
|
||||
EARLY_ADOPTER: 500_000,
|
||||
ENTERPRISE: 2_000_000
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the monthly token allowance for an org based on its active plan.
|
||||
* Also checks if the plan payload has a custom `aiTokenAllowance` override.
|
||||
*/
|
||||
async function getPlanAllowance(orgId: string): Promise<{ planName: string; allowance: number }> {
|
||||
const activePlan = await getActiveOrganizationPlan(orgId);
|
||||
|
||||
if (!activePlan || !activePlan.planName) {
|
||||
return { planName: 'BASIC', allowance: PLAN_TOKEN_ALLOWANCES.BASIC };
|
||||
}
|
||||
|
||||
const planName = activePlan.planName;
|
||||
const payload = activePlan.payload as Record<string, unknown> | null;
|
||||
const customAllowance = payload?.aiTokenAllowance as number | undefined;
|
||||
const allowance = customAllowance ?? PLAN_TOKEN_ALLOWANCES[planName] ?? 0;
|
||||
|
||||
return { planName, allowance };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total tokens used by an org in the current month.
|
||||
*/
|
||||
async function getMonthlyUsage(orgId: string): Promise<number> {
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const [result] = await db
|
||||
.select({
|
||||
totalTokens: sql<number>`COALESCE(SUM(${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}), 0)`
|
||||
})
|
||||
.from(schema.aiTokenUsage)
|
||||
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, startOfMonth.toISOString())));
|
||||
|
||||
return Number(result?.totalTokens ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the credit balance for an org.
|
||||
*/
|
||||
async function getCreditBalance(orgId: string): Promise<number> {
|
||||
const [result] = await db
|
||||
.select({ balance: schema.aiCreditBalance.balance })
|
||||
.from(schema.aiCreditBalance)
|
||||
.where(eq(schema.aiCreditBalance.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
return result?.balance ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full token balance for an org: used, allowance, credits, remaining.
|
||||
*/
|
||||
export async function getTokenBalance(orgId: string): Promise<TokenBalance> {
|
||||
const [{ planName, allowance }, monthlyUsage, creditBalance] = await Promise.all([
|
||||
getPlanAllowance(orgId),
|
||||
getMonthlyUsage(orgId),
|
||||
getCreditBalance(orgId)
|
||||
]);
|
||||
|
||||
const remainingAllowance = Math.max(0, allowance - monthlyUsage);
|
||||
const remaining = remainingAllowance + creditBalance;
|
||||
|
||||
return {
|
||||
used: monthlyUsage,
|
||||
allowance,
|
||||
creditBalance,
|
||||
remaining
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the org has sufficient tokens. Throws 402 if exhausted.
|
||||
*/
|
||||
export async function enforceTokenBalance(orgId: string): Promise<TokenBalance> {
|
||||
const balance = await getTokenBalance(orgId);
|
||||
|
||||
if (balance.remaining <= 0) {
|
||||
throw new AppError('Token limit reached', 'TOKEN_LIMIT_REACHED', 402);
|
||||
}
|
||||
|
||||
return balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record token usage after an LLM call.
|
||||
*/
|
||||
export async function recordTokenUsage(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
courseId: string,
|
||||
usage: TokenUsage,
|
||||
model: string
|
||||
): Promise<void> {
|
||||
await db.insert(schema.aiTokenUsage).values({
|
||||
orgId,
|
||||
userId,
|
||||
courseId,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
model
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an org's plan supports a specific feature (e.g., document upload).
|
||||
* Returns the plan name so callers can decide what to gate.
|
||||
*/
|
||||
export async function getOrgPlanName(orgId: string): Promise<string> {
|
||||
const { planName } = await getPlanAllowance(orgId);
|
||||
|
||||
return planName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an org is on a paid plan (EARLY_ADOPTER or above).
|
||||
*/
|
||||
export async function isOrgOnPaidPlan(orgId: string): Promise<boolean> {
|
||||
const planName = await getOrgPlanName(orgId);
|
||||
|
||||
return planName !== 'BASIC';
|
||||
}
|
||||
|
||||
// ─── Usage History ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DailyUsage {
|
||||
date: string;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily token usage for the current month, grouped by day.
|
||||
*/
|
||||
export async function getUsageHistory(orgId: string): Promise<DailyUsage[]> {
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
date: sql<string>`DATE(${schema.aiTokenUsage.createdAt})`,
|
||||
tokens: sql<number>`SUM(${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})`
|
||||
})
|
||||
.from(schema.aiTokenUsage)
|
||||
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, startOfMonth.toISOString())))
|
||||
.groupBy(sql`DATE(${schema.aiTokenUsage.createdAt})`)
|
||||
.orderBy(sql`DATE(${schema.aiTokenUsage.createdAt})`);
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: String(row.date),
|
||||
tokens: Number(row.tokens)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full detailed usage stats for the usage endpoint.
|
||||
*/
|
||||
export async function getDetailedUsage(orgId: string) {
|
||||
const [balance, history] = await Promise.all([getTokenBalance(orgId), getUsageHistory(orgId)]);
|
||||
|
||||
return { ...balance, history };
|
||||
}
|
||||
|
||||
// ─── Credit Purchases ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add credits to an org's balance.
|
||||
* Creates the balance row if it doesn't exist (upsert).
|
||||
*/
|
||||
export async function addCredits(orgId: string, amount: number): Promise<number> {
|
||||
if (amount <= 0) {
|
||||
throw new AppError('Credit amount must be positive', 'INVALID_CREDIT_AMOUNT', 400);
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ id: schema.aiCreditBalance.id, balance: schema.aiCreditBalance.balance })
|
||||
.from(schema.aiCreditBalance)
|
||||
.where(eq(schema.aiCreditBalance.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const newBalance = existing[0].balance + amount;
|
||||
await db
|
||||
.update(schema.aiCreditBalance)
|
||||
.set({ balance: newBalance, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.aiCreditBalance.orgId, orgId));
|
||||
|
||||
return newBalance;
|
||||
}
|
||||
|
||||
await db.insert(schema.aiCreditBalance).values({ orgId, balance: amount });
|
||||
|
||||
return amount;
|
||||
}
|
||||
@@ -499,7 +499,8 @@ async function buildCourseStructureSnapshot(orgId: string, courseId: string): Pr
|
||||
description: course.description,
|
||||
type: course.type,
|
||||
locale: fallbackLocale,
|
||||
metadata: course.metadata
|
||||
metadata: course.metadata,
|
||||
compliance: course.compliance ?? undefined
|
||||
},
|
||||
tags: tags.map((tag) => tag.name),
|
||||
sections: normalizedSections,
|
||||
@@ -721,6 +722,7 @@ export async function publishCourseImportDraftService(
|
||||
const courseDescription = overrides.description ?? draft.course.description;
|
||||
const courseType = overrides.type ?? draft.course.type;
|
||||
const courseMetadata = overrides.metadata ?? draft.course.metadata;
|
||||
const courseCompliance = overrides.compliance ?? draft.course.compliance;
|
||||
const courseUpdateFields: Partial<TCourse> = {
|
||||
title: courseTitle,
|
||||
description: courseDescription,
|
||||
@@ -729,6 +731,9 @@ export async function publishCourseImportDraftService(
|
||||
if (courseMetadata) {
|
||||
courseUpdateFields.metadata = courseMetadata;
|
||||
}
|
||||
if (courseCompliance) {
|
||||
courseUpdateFields.compliance = courseCompliance;
|
||||
}
|
||||
|
||||
const sectionExternalIds = new Set(draft.sections.map((section) => section.externalId));
|
||||
draft.lessons.forEach((lesson) => {
|
||||
@@ -754,7 +759,8 @@ export async function publishCourseImportDraftService(
|
||||
title: courseTitle,
|
||||
description: courseDescription,
|
||||
type: courseType,
|
||||
organizationId: orgId
|
||||
organizationId: orgId,
|
||||
compliance: courseCompliance
|
||||
});
|
||||
|
||||
courseId = courseResult.course.id;
|
||||
@@ -876,6 +882,7 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
const courseDescription = overrides.description ?? draft.course.description;
|
||||
const courseType = overrides.type ?? draft.course.type;
|
||||
const courseMetadata = overrides.metadata ?? draft.course.metadata;
|
||||
const courseCompliance = overrides.compliance ?? draft.course.compliance;
|
||||
const preferredLocale = draft.course.locale as TLocale;
|
||||
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
|
||||
|
||||
@@ -883,7 +890,8 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
title: courseTitle,
|
||||
description: courseDescription,
|
||||
type: courseType,
|
||||
metadata: courseMetadata
|
||||
metadata: courseMetadata,
|
||||
compliance: courseCompliance
|
||||
});
|
||||
|
||||
await maybeApplyCourseBannerImage(course.id, bannerImageUrl);
|
||||
|
||||
@@ -152,6 +152,7 @@ async function buildCertificationEvaluation(
|
||||
): Promise<CourseCompletionEvaluation> {
|
||||
const progress = progressArg ?? (await getCourseProgressQuery(courseId, profileId));
|
||||
const certificate = courseRow.certificate ?? {};
|
||||
const complianceMinScore = courseRow.type === 'COMPLIANCE' ? (courseRow.compliance?.passingScore ?? null) : null;
|
||||
|
||||
const threshold = certificate.threshold ?? 100;
|
||||
const progressPercent = calcCourseProgressPercent({
|
||||
@@ -184,7 +185,7 @@ async function buildCertificationEvaluation(
|
||||
const finalEval = await evaluateFinalExerciseRule({
|
||||
courseId,
|
||||
requiredExerciseId: certificate.requiredExerciseId,
|
||||
minScorePercent: certificate.exerciseMinScorePercent,
|
||||
minScorePercent: certificate.exerciseMinScorePercent ?? complianceMinScore,
|
||||
groupMemberId: progress.groupMemberId
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
createCourseCertificateIssue,
|
||||
createCourseCompletionNotificationEvent,
|
||||
createCourseCompletionRecord,
|
||||
getCourseComplianceHistoryRows,
|
||||
getCourseCurrentComplianceRows,
|
||||
getLatestComplianceRecordsByProfiles,
|
||||
getStudentCourseMembersForCompliance,
|
||||
listComplianceRecordsReadyForExpiry,
|
||||
listLatestComplianceRecordsForReminderScan,
|
||||
updateCourseCertificateIssueStatusByRecordId,
|
||||
updateCourseCompletionRecord
|
||||
} from '@cio/db/queries/course/compliance';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type {
|
||||
TCourseComplianceExtend,
|
||||
TCourseComplianceReset,
|
||||
TCourseComplianceWaive
|
||||
} from '@cio/utils/validation/course/compliance';
|
||||
import type { TCourseCompletionRecord } from '@cio/db/types';
|
||||
import {
|
||||
getExerciseTitleAndMaxPoints,
|
||||
getStudentSubmissionsForExercise
|
||||
} from '@cio/db/queries/course/certification-exercise';
|
||||
import { getProfileByGroupMemberId } from '@cio/db/queries/course/people';
|
||||
import { getUserCourseRole } from '@cio/db/queries/group/group';
|
||||
import { evaluateCourseCertification } from './completion';
|
||||
|
||||
type ComplianceStatus =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'compliant'
|
||||
| 'expiring_soon'
|
||||
| 'in_grace_period'
|
||||
| 'non_compliant'
|
||||
| 'waived';
|
||||
|
||||
type ComplianceSettings = NonNullable<Awaited<ReturnType<typeof getComplianceCourseOrThrow>>['compliance']>;
|
||||
|
||||
function ensureFutureOrPresentIsoDate(dateIso: string, field: string) {
|
||||
const date = new Date(dateIso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new AppError('Invalid date provided', ErrorCodes.VALIDATION_ERROR, 400, field);
|
||||
}
|
||||
}
|
||||
|
||||
function getReminderEventType(daysUntilDue: number) {
|
||||
return `reminder_${daysUntilDue}d`;
|
||||
}
|
||||
|
||||
function addMonths(isoDate: string, months: number) {
|
||||
const date = new Date(isoDate);
|
||||
date.setUTCMonth(date.getUTCMonth() + months);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function getWholeDaysUntil(startDate: Date, endDate: Date) {
|
||||
const millisecondsPerDay = 24 * 60 * 60 * 1000;
|
||||
return Math.ceil((endDate.getTime() - startDate.getTime()) / millisecondsPerDay);
|
||||
}
|
||||
|
||||
function getStatusCounts(records: Array<{ record: TCourseCompletionRecord | null }>) {
|
||||
const counts: Record<ComplianceStatus | 'no_record', number> = {
|
||||
no_record: 0,
|
||||
not_started: 0,
|
||||
in_progress: 0,
|
||||
compliant: 0,
|
||||
expiring_soon: 0,
|
||||
in_grace_period: 0,
|
||||
non_compliant: 0,
|
||||
waived: 0
|
||||
};
|
||||
|
||||
for (const row of records) {
|
||||
if (!row.record) {
|
||||
counts.no_record += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = row.record.status as ComplianceStatus;
|
||||
counts[status] = (counts[status] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function getComplianceCourseOrThrow(courseId: string) {
|
||||
const [course] = await getCourseById(courseId);
|
||||
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (course.type !== 'COMPLIANCE' || !course.compliance) {
|
||||
throw new AppError('Course is not a compliance course', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
return course;
|
||||
}
|
||||
|
||||
async function assertCourseComplianceHistoryAccess(
|
||||
courseId: string,
|
||||
currentProfileId: string,
|
||||
targetProfileId: string
|
||||
) {
|
||||
if (currentProfileId === targetProfileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roleId = await getUserCourseRole(courseId, currentProfileId);
|
||||
|
||||
if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) {
|
||||
throw new AppError('You do not have permission to view this learner', ErrorCodes.VALIDATION_ERROR, 403);
|
||||
}
|
||||
}
|
||||
|
||||
async function getTargetStudentMembers(courseId: string, profileIds?: string[]) {
|
||||
const students = await getStudentCourseMembersForCompliance(courseId, profileIds);
|
||||
|
||||
if (profileIds && students.length !== profileIds.length) {
|
||||
throw new AppError(
|
||||
'One or more learners were not found in this course',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'profileIds'
|
||||
);
|
||||
}
|
||||
|
||||
return students;
|
||||
}
|
||||
|
||||
function resolveComplianceInitialDueDate(course: Awaited<ReturnType<typeof getComplianceCourseOrThrow>>) {
|
||||
const dueDate = course.certificate?.deadline ?? null;
|
||||
|
||||
if (!dueDate) {
|
||||
throw new AppError(
|
||||
'Compliance courses require a certificate deadline before learners can be enrolled',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'certificate.deadline'
|
||||
);
|
||||
}
|
||||
|
||||
const parsedDueDate = new Date(dueDate);
|
||||
if (Number.isNaN(parsedDueDate.getTime())) {
|
||||
throw new AppError(
|
||||
'Compliance course deadline is invalid',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'certificate.deadline'
|
||||
);
|
||||
}
|
||||
|
||||
return dueDate;
|
||||
}
|
||||
|
||||
function getCurrentIncompleteRecord(record: TCourseCompletionRecord | undefined | null) {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (record.completedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async function getComplianceCompletionSnapshot(params: {
|
||||
groupMemberId: string;
|
||||
requiredExerciseId: string | null | undefined;
|
||||
}) {
|
||||
const { groupMemberId, requiredExerciseId } = params;
|
||||
|
||||
if (!requiredExerciseId) {
|
||||
return {
|
||||
score: null,
|
||||
attempts: null
|
||||
};
|
||||
}
|
||||
|
||||
const { maxPoints } = await getExerciseTitleAndMaxPoints(requiredExerciseId);
|
||||
const submissions = await getStudentSubmissionsForExercise(groupMemberId, requiredExerciseId);
|
||||
const completedSubmissions = submissions.filter((submission) => submission.gradingState === 'completed');
|
||||
|
||||
if (completedSubmissions.length === 0 || maxPoints <= 0) {
|
||||
return {
|
||||
score: null,
|
||||
attempts: submissions.length
|
||||
};
|
||||
}
|
||||
|
||||
const bestScorePercent = completedSubmissions.reduce((highestPercent, submission) => {
|
||||
const totalPoints = Number(submission.total ?? 0);
|
||||
const nextPercent = Math.round((totalPoints / maxPoints) * 100);
|
||||
|
||||
return Math.max(highestPercent, nextPercent);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
score: bestScorePercent,
|
||||
attempts: submissions.length
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureComplianceEnrollmentRecordForLearner(courseId: string, groupMemberId: string, profileId: string) {
|
||||
const course = await getComplianceCourseOrThrow(courseId);
|
||||
const dueDate = resolveComplianceInitialDueDate(course);
|
||||
const [existingRecord] = await getLatestComplianceRecordsByProfiles(courseId, [profileId]);
|
||||
|
||||
if (existingRecord) {
|
||||
return existingRecord;
|
||||
}
|
||||
|
||||
return createCourseCompletionRecord({
|
||||
courseId,
|
||||
groupMemberId,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status: 'not_started',
|
||||
dueDate,
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 0
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureComplianceEnrollmentRecordsForProfiles(courseIds: string[], profileIds: string[]) {
|
||||
if (courseIds.length === 0 || profileIds.length === 0) {
|
||||
return {
|
||||
createdCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
let createdCount = 0;
|
||||
|
||||
for (const courseId of courseIds) {
|
||||
const [course] = await getCourseById(courseId);
|
||||
|
||||
if (!course || course.type !== 'COMPLIANCE') {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolveComplianceInitialDueDate(await getComplianceCourseOrThrow(courseId));
|
||||
|
||||
const learners = await getStudentCourseMembersForCompliance(courseId, profileIds);
|
||||
for (const learner of learners) {
|
||||
const profileId = learner.member.profileId;
|
||||
if (!profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [existingRecord] = await getLatestComplianceRecordsByProfiles(courseId, [profileId]);
|
||||
if (existingRecord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await ensureComplianceEnrollmentRecordForLearner(courseId, learner.member.id, profileId);
|
||||
createdCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createdCount
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncComplianceProgressFromSubmission(courseId: string, groupMemberId: string) {
|
||||
const [course] = await getCourseById(courseId);
|
||||
|
||||
if (!course || course.type !== 'COMPLIANCE') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await getProfileByGroupMemberId(groupMemberId);
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeRecord = await ensureComplianceEnrollmentRecordForLearner(courseId, groupMemberId, profile.id);
|
||||
if (!activeRecord.completedAt && activeRecord.status === 'not_started') {
|
||||
await updateCourseCompletionRecord(activeRecord.id, {
|
||||
status: 'in_progress',
|
||||
startedAt: activeRecord.startedAt ?? new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
const evaluation = await evaluateCourseCertification(courseId, profile.id);
|
||||
if (!evaluation.eligibleForCertificate) {
|
||||
return {
|
||||
status: 'in_progress',
|
||||
completed: false
|
||||
};
|
||||
}
|
||||
|
||||
const latestRecord = await ensureComplianceEnrollmentRecordForLearner(courseId, groupMemberId, profile.id);
|
||||
if (latestRecord.completedAt) {
|
||||
return {
|
||||
status: latestRecord.status,
|
||||
completed: true
|
||||
};
|
||||
}
|
||||
|
||||
const completedAt = new Date().toISOString();
|
||||
const validUntil = addMonths(completedAt, course.compliance?.retakeIntervalMonths ?? 0);
|
||||
const completionSnapshot = await getComplianceCompletionSnapshot({
|
||||
groupMemberId,
|
||||
requiredExerciseId: course.certificate?.requiredExerciseId ?? null
|
||||
});
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await updateCourseCompletionRecord(
|
||||
latestRecord.id,
|
||||
{
|
||||
status: 'compliant',
|
||||
startedAt: latestRecord.startedAt ?? completedAt,
|
||||
completedAt,
|
||||
validUntil,
|
||||
score: completionSnapshot.score ?? latestRecord.score,
|
||||
attempts: completionSnapshot.attempts ?? latestRecord.attempts
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (course.certificate?.isDownloadable) {
|
||||
await createCourseCertificateIssue(
|
||||
{
|
||||
courseId,
|
||||
profileId: profile.id,
|
||||
courseCompletionRecordId: latestRecord.id,
|
||||
cycleNumber: latestRecord.cycleNumber,
|
||||
expiresAt: validUntil,
|
||||
status: 'valid'
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'compliant',
|
||||
completed: true,
|
||||
validUntil
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseComplianceOverview(courseId: string) {
|
||||
const course = await getComplianceCourseOrThrow(courseId);
|
||||
const rows = await getCourseCurrentComplianceRows(courseId);
|
||||
const counts = getStatusCounts(rows);
|
||||
|
||||
return {
|
||||
courseId: course.id,
|
||||
compliance: course.compliance,
|
||||
summary: {
|
||||
totalLearners: rows.length,
|
||||
noRecord: counts.no_record,
|
||||
notStarted: counts.not_started,
|
||||
inProgress: counts.in_progress,
|
||||
compliant: counts.compliant,
|
||||
expiringSoon: counts.expiring_soon,
|
||||
inGracePeriod: counts.in_grace_period,
|
||||
nonCompliant: counts.non_compliant,
|
||||
waived: counts.waived
|
||||
},
|
||||
learners: rows.map((row) => ({
|
||||
groupMemberId: row.member.id,
|
||||
profileId: row.member.profileId,
|
||||
fullname: row.profile?.fullname ?? null,
|
||||
username: row.profile?.username ?? null,
|
||||
email: row.profile?.email ?? row.member.email ?? null,
|
||||
status: row.record?.status ?? 'not_started',
|
||||
cycleNumber: row.record?.cycleNumber ?? null,
|
||||
dueDate: row.record?.dueDate ?? null,
|
||||
completedAt: row.record?.completedAt ?? null,
|
||||
validUntil: row.record?.validUntil ?? null,
|
||||
score: row.record?.score ?? null,
|
||||
attempts: row.record?.attempts ?? 0,
|
||||
waiverExpiresAt: row.record?.waiverExpiresAt ?? null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLearnerComplianceHistory(courseId: string, currentProfileId: string, targetProfileId: string) {
|
||||
await getComplianceCourseOrThrow(courseId);
|
||||
await assertCourseComplianceHistoryAccess(courseId, currentProfileId, targetProfileId);
|
||||
|
||||
const history = await getCourseComplianceHistoryRows(courseId, targetProfileId);
|
||||
|
||||
if (!history.member) {
|
||||
throw new AppError('Learner not found in this course', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return {
|
||||
learner: {
|
||||
groupMemberId: history.member.id,
|
||||
profileId: history.member.profileId,
|
||||
fullname: history.profile?.fullname ?? null,
|
||||
username: history.profile?.username ?? null,
|
||||
avatarUrl: history.profile?.avatarUrl ?? null,
|
||||
email: history.profile?.email ?? history.member.email ?? null
|
||||
},
|
||||
currentRecord: history.records[0] ?? null,
|
||||
history: history.records
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetCourseCompliance(courseId: string, payload: TCourseComplianceReset) {
|
||||
await getComplianceCourseOrThrow(courseId);
|
||||
ensureFutureOrPresentIsoDate(payload.dueDate, 'dueDate');
|
||||
|
||||
const students = await getTargetStudentMembers(courseId, payload.profileIds);
|
||||
const profileIds = students.flatMap((student) => (student.member.profileId ? [student.member.profileId] : []));
|
||||
const existingRecords = await getLatestComplianceRecordsByProfiles(courseId, profileIds);
|
||||
const recordByProfileId = new Map(existingRecords.map((record) => [record.profileId, record]));
|
||||
|
||||
let createdCount = 0;
|
||||
let updatedCount = 0;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const student of students) {
|
||||
const profileId = student.member.profileId;
|
||||
if (!profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingRecord = recordByProfileId.get(profileId);
|
||||
if (!existingRecord) {
|
||||
await createCourseCompletionRecord(
|
||||
{
|
||||
courseId,
|
||||
groupMemberId: student.member.id,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status: 'not_started',
|
||||
dueDate: payload.dueDate,
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 0
|
||||
},
|
||||
tx
|
||||
);
|
||||
createdCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateCourseCompletionRecord(
|
||||
existingRecord.id,
|
||||
{
|
||||
status: 'not_started',
|
||||
dueDate: payload.dueDate,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
validUntil: null,
|
||||
expiredAt: null,
|
||||
score: null,
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 0,
|
||||
waivedBy: null,
|
||||
waiverReason: null,
|
||||
waiverExpiresAt: null
|
||||
},
|
||||
tx
|
||||
);
|
||||
updatedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
createdCount,
|
||||
updatedCount,
|
||||
learnerCount: students.length,
|
||||
dueDate: payload.dueDate
|
||||
};
|
||||
}
|
||||
|
||||
export async function extendCourseCompliance(courseId: string, payload: TCourseComplianceExtend) {
|
||||
await getComplianceCourseOrThrow(courseId);
|
||||
ensureFutureOrPresentIsoDate(payload.dueDate, 'dueDate');
|
||||
|
||||
const students = await getTargetStudentMembers(courseId, payload.profileIds);
|
||||
const profileIds = students.flatMap((student) => (student.member.profileId ? [student.member.profileId] : []));
|
||||
const existingRecords = await getLatestComplianceRecordsByProfiles(courseId, profileIds);
|
||||
const recordByProfileId = new Map(existingRecords.map((record) => [record.profileId, record]));
|
||||
|
||||
let createdCount = 0;
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const student of students) {
|
||||
const profileId = student.member.profileId;
|
||||
if (!profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingRecord = getCurrentIncompleteRecord(recordByProfileId.get(profileId));
|
||||
if (!existingRecord) {
|
||||
const latestRecord = recordByProfileId.get(profileId);
|
||||
if (latestRecord?.completedAt) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await createCourseCompletionRecord(
|
||||
{
|
||||
courseId,
|
||||
groupMemberId: student.member.id,
|
||||
profileId,
|
||||
cycleNumber: latestRecord ? latestRecord.cycleNumber + 1 : 1,
|
||||
status: 'not_started',
|
||||
dueDate: payload.dueDate,
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 0
|
||||
},
|
||||
tx
|
||||
);
|
||||
createdCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateCourseCompletionRecord(
|
||||
existingRecord.id,
|
||||
{
|
||||
dueDate: payload.dueDate
|
||||
},
|
||||
tx
|
||||
);
|
||||
updatedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
createdCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
learnerCount: students.length,
|
||||
dueDate: payload.dueDate
|
||||
};
|
||||
}
|
||||
|
||||
export async function waiveCourseCompliance(courseId: string, actorProfileId: string, payload: TCourseComplianceWaive) {
|
||||
await getComplianceCourseOrThrow(courseId);
|
||||
|
||||
if (payload.waiverExpiresAt) {
|
||||
ensureFutureOrPresentIsoDate(payload.waiverExpiresAt, 'waiverExpiresAt');
|
||||
}
|
||||
|
||||
const students = await getTargetStudentMembers(courseId, payload.profileIds);
|
||||
const profileIds = students.flatMap((student) => (student.member.profileId ? [student.member.profileId] : []));
|
||||
const existingRecords = await getLatestComplianceRecordsByProfiles(courseId, profileIds);
|
||||
const recordByProfileId = new Map(existingRecords.map((record) => [record.profileId, record]));
|
||||
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const student of students) {
|
||||
const profileId = student.member.profileId;
|
||||
if (!profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingRecord = getCurrentIncompleteRecord(recordByProfileId.get(profileId));
|
||||
if (!existingRecord) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateCourseCompletionRecord(
|
||||
existingRecord.id,
|
||||
{
|
||||
status: 'waived',
|
||||
waivedBy: actorProfileId,
|
||||
waiverReason: payload.waiverReason ?? null,
|
||||
waiverExpiresAt: payload.waiverExpiresAt ?? null
|
||||
},
|
||||
tx
|
||||
);
|
||||
updatedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
learnerCount: students.length,
|
||||
waiverExpiresAt: payload.waiverExpiresAt ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export async function runComplianceExpiryCheck() {
|
||||
const nowIso = new Date().toISOString();
|
||||
const records = await listComplianceRecordsReadyForExpiry(nowIso);
|
||||
|
||||
let processedCount = 0;
|
||||
let createdCycleCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const record of records) {
|
||||
if (!record.validUntil) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDueDate = record.validUntil;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await updateCourseCompletionRecord(
|
||||
record.id,
|
||||
{
|
||||
expiredAt: nowIso
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await updateCourseCertificateIssueStatusByRecordId(record.id, 'expired', tx);
|
||||
|
||||
const created = await createCourseCompletionRecord(
|
||||
{
|
||||
courseId: record.courseId,
|
||||
groupMemberId: record.groupMemberId,
|
||||
profileId: record.profileId,
|
||||
cycleNumber: record.cycleNumber + 1,
|
||||
status: 'not_started',
|
||||
dueDate: nextDueDate,
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 0
|
||||
},
|
||||
tx
|
||||
).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes('duplicate key value')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
processedCount += 1;
|
||||
if (created) {
|
||||
createdCycleCount += 1;
|
||||
} else {
|
||||
skippedCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
processedCount,
|
||||
createdCycleCount,
|
||||
skippedCount
|
||||
};
|
||||
}
|
||||
|
||||
export async function runComplianceReminderScan() {
|
||||
const now = new Date();
|
||||
const rows = await listLatestComplianceRecordsForReminderScan();
|
||||
|
||||
let scannedCount = 0;
|
||||
let statusUpdatedCount = 0;
|
||||
let reminderEventCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
scannedCount += 1;
|
||||
|
||||
const compliance = row.course.compliance as ComplianceSettings | null;
|
||||
if (!compliance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.record.status === 'waived') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dueDate = new Date(row.record.dueDate);
|
||||
if (Number.isNaN(dueDate.getTime())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const daysUntilDue = getWholeDaysUntil(now, dueDate);
|
||||
const gracePeriodDays = compliance.gracePeriodDays ?? 0;
|
||||
let nextStatus: ComplianceStatus | null = null;
|
||||
|
||||
if (daysUntilDue < 0) {
|
||||
const gracePeriodEndsAt = new Date(dueDate.getTime() + gracePeriodDays * 24 * 60 * 60 * 1000);
|
||||
nextStatus = gracePeriodDays > 0 && now <= gracePeriodEndsAt ? 'in_grace_period' : 'non_compliant';
|
||||
} else if ((compliance.reminderDaysBefore ?? []).includes(daysUntilDue)) {
|
||||
nextStatus = 'expiring_soon';
|
||||
|
||||
const createdEvent = await createCourseCompletionNotificationEvent({
|
||||
courseCompletionRecordId: row.record.id,
|
||||
channel: 'email',
|
||||
eventType: getReminderEventType(daysUntilDue)
|
||||
});
|
||||
|
||||
if (createdEvent) {
|
||||
reminderEventCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStatus && row.record.status !== nextStatus) {
|
||||
await updateCourseCompletionRecord(row.record.id, { status: nextStatus });
|
||||
statusUpdatedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scannedCount,
|
||||
statusUpdatedCount,
|
||||
reminderEventCount
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,26 @@ function buildContentKey(item: { id: string; type: string }) {
|
||||
return `${item.type}-${item.id}`;
|
||||
}
|
||||
|
||||
function normalizeDeleteItems<TItem extends { id: string; type: string }>(items: TItem[]): TItem[] {
|
||||
const seenKeys = new Set<string>();
|
||||
const uniqueItems: TItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const itemKey = buildContentKey(item);
|
||||
if (seenKeys.has(itemKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenKeys.add(itemKey);
|
||||
uniqueItems.push(item);
|
||||
}
|
||||
|
||||
const exercises = uniqueItems.filter((item) => item.type === ContentType.Exercise);
|
||||
const lessons = uniqueItems.filter((item) => item.type === ContentType.Lesson);
|
||||
|
||||
return [...exercises, ...lessons];
|
||||
}
|
||||
|
||||
async function getCourseContentMap(courseId: string) {
|
||||
const contentItems = await getCourseContentItems(courseId);
|
||||
const contentMap = new Map<string, CourseContentItemRow>();
|
||||
@@ -307,11 +327,14 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
|
||||
}
|
||||
|
||||
const contentItems = await getCourseContentItems(courseId);
|
||||
const itemsToDelete = contentItems.filter(
|
||||
(item) =>
|
||||
(item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === sectionId
|
||||
const itemsToDelete = normalizeDeleteItems(
|
||||
contentItems.filter(
|
||||
(item) =>
|
||||
(item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === sectionId
|
||||
)
|
||||
);
|
||||
|
||||
console.log('Items to delete:', itemsToDelete);
|
||||
await db.transaction(async (tx) => {
|
||||
for (const item of itemsToDelete) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
@@ -324,6 +347,7 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
|
||||
|
||||
const deleted = await deleteExercise(item.id, tx);
|
||||
if (!deleted) {
|
||||
console.log('delete exercise failed for id:', item.id);
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
@@ -343,9 +367,10 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
|
||||
|
||||
const items = payload.items;
|
||||
await assertCourseContentItems(courseId, items);
|
||||
const normalizedItems = normalizeDeleteItems(items);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
for (const item of normalizedItems) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
const deleted = await deleteLesson(item.id, tx);
|
||||
if (!deleted) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||
import type { TCourse } from '@cio/db/types';
|
||||
import type { TCourseCreate } from '@cio/utils/validation/course';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import * as schema from '@db/schema';
|
||||
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
||||
@@ -168,7 +169,7 @@ export async function getCourse(courseId?: string, slug?: string, profileId?: st
|
||||
*/
|
||||
export async function createCourse(
|
||||
profileId: string,
|
||||
data: { title: string; description: string; type: 'LIVE_CLASS' | 'SELF_PACED'; organizationId: string }
|
||||
data: TCourseCreate
|
||||
): Promise<{ course: TCourse; groupId: string; memberId: string }> {
|
||||
try {
|
||||
const description = sanitizeHtml(data.description);
|
||||
@@ -192,7 +193,8 @@ export async function createCourse(
|
||||
title: data.title,
|
||||
description,
|
||||
type: data.type,
|
||||
groupId: newGroup.id
|
||||
groupId: newGroup.id,
|
||||
compliance: data.compliance ? sanitizeUnknownStrings(data.compliance) : undefined
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import type { TCourse, TOrganization } from '@cio/db/types';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { updateCourse } from './course';
|
||||
import { buildCourseBaseUrl, ensureCourseSlug, generateUniqueCourseSlug } from './landing-page';
|
||||
|
||||
export type CourseGoLiveIssue = {
|
||||
code: string;
|
||||
message: string;
|
||||
target?: string;
|
||||
};
|
||||
|
||||
export type CourseGoLiveReadiness = {
|
||||
ready: boolean;
|
||||
blockers: CourseGoLiveIssue[];
|
||||
warnings: CourseGoLiveIssue[];
|
||||
suggestedFixes: {
|
||||
slug?: string;
|
||||
landingPage?: TCourseLandingPageUpdate;
|
||||
};
|
||||
courseUrl?: string;
|
||||
};
|
||||
|
||||
type CourseReadinessInput = {
|
||||
course: Pick<
|
||||
TCourse,
|
||||
'id' | 'title' | 'description' | 'overview' | 'slug' | 'logo' | 'bannerImage' | 'metadata' | 'type' | 'cost'
|
||||
>;
|
||||
contentItems: CourseContentItemRow[];
|
||||
suggestedSlug?: string;
|
||||
organization?: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'> | null;
|
||||
};
|
||||
|
||||
const PLACEHOLDER_PATTERNS = [
|
||||
/\[edit me\]/i,
|
||||
/^untitled\b/i,
|
||||
/^new course$/i,
|
||||
/^course title$/i,
|
||||
/^course description$/i,
|
||||
/welcome to this amazing course/i
|
||||
];
|
||||
|
||||
function normalizeReadableText(value: string | null | undefined): string {
|
||||
return (value ?? '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ | /gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasMeaningfulText(value: string | null | undefined): boolean {
|
||||
const normalized = normalizeReadableText(value);
|
||||
|
||||
if (normalized.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function buildIssue(code: string, message: string, target?: string): CourseGoLiveIssue {
|
||||
return target ? { code, message, target } : { code, message };
|
||||
}
|
||||
|
||||
function hasLessonContent(item: CourseContentItemRow): boolean {
|
||||
return Boolean(
|
||||
item.hasNoteContent || item.hasSlideContent || (item.videosCount ?? 0) > 0 || (item.documentsCount ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function buildCourseUrl(
|
||||
organization: CourseReadinessInput['organization'],
|
||||
slug: string | null | undefined
|
||||
): string | undefined {
|
||||
if (!organization || !slug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
export function evaluateCourseGoLiveReadiness(input: CourseReadinessInput): CourseGoLiveReadiness {
|
||||
const { course, contentItems, organization, suggestedSlug } = input;
|
||||
const blockers: CourseGoLiveIssue[] = [];
|
||||
const warnings: CourseGoLiveIssue[] = [];
|
||||
const landingPageFixes: TCourseLandingPageUpdate = {};
|
||||
const metadataFixes: NonNullable<TCourseLandingPageUpdate['metadata']> = {};
|
||||
|
||||
if (!hasMeaningfulText(course.title)) {
|
||||
blockers.push(buildIssue('COURSE_TITLE_MISSING', 'Add a clear course title.', 'course.title'));
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.description)) {
|
||||
blockers.push(buildIssue('COURSE_DESCRIPTION_MISSING', 'Add a clear course description.', 'course.description'));
|
||||
}
|
||||
|
||||
if (!course.slug && !suggestedSlug) {
|
||||
blockers.push(buildIssue('COURSE_SLUG_MISSING', 'Generate a public course slug.', 'course.slug'));
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.overview)) {
|
||||
blockers.push(buildIssue('LANDING_OVERVIEW_MISSING', 'Add a landing-page overview.', 'course.overview'));
|
||||
landingPageFixes.overview = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.description)) {
|
||||
blockers.push(
|
||||
buildIssue('LANDING_DESCRIPTION_MISSING', 'Add a landing-page description.', 'course.metadata.description')
|
||||
);
|
||||
metadataFixes.description = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.goals)) {
|
||||
blockers.push(
|
||||
buildIssue('LANDING_GOALS_MISSING', 'Add course goals for the landing page.', 'course.metadata.goals')
|
||||
);
|
||||
metadataFixes.goals = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.requirements)) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'LANDING_REQUIREMENTS_MISSING',
|
||||
'Add course requirements for the landing page.',
|
||||
'course.metadata.requirements'
|
||||
)
|
||||
);
|
||||
metadataFixes.requirements = '';
|
||||
}
|
||||
|
||||
if (!course.logo && !course.bannerImage) {
|
||||
blockers.push(buildIssue('LANDING_IMAGE_MISSING', 'Add a landing-page banner or course image.', 'course.logo'));
|
||||
landingPageFixes.generateImage = true;
|
||||
}
|
||||
|
||||
const learningItems = contentItems.filter(
|
||||
(item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise
|
||||
);
|
||||
|
||||
if (learningItems.length === 0) {
|
||||
blockers.push(buildIssue('COURSE_CONTENT_MISSING', 'Add at least one lesson or exercise.', 'course.content'));
|
||||
}
|
||||
|
||||
for (const item of contentItems) {
|
||||
if (item.type === ContentType.Lesson && !hasLessonContent(item)) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'LESSON_CONTENT_EMPTY',
|
||||
`Add content to lesson "${item.title ?? 'Untitled lesson'}".`,
|
||||
`lesson:${item.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === ContentType.Exercise && (item.questionCount ?? 0) === 0) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'EXERCISE_QUESTIONS_MISSING',
|
||||
`Add at least one question to exercise "${item.title ?? 'Untitled exercise'}".`,
|
||||
`exercise:${item.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.instructor?.name)) {
|
||||
warnings.push(
|
||||
buildIssue('INSTRUCTOR_MISSING', 'Add instructor information for more trust.', 'course.metadata.instructor')
|
||||
);
|
||||
}
|
||||
|
||||
if (!course.metadata?.reviews?.some((review) => !review.hide && hasMeaningfulText(review.description))) {
|
||||
warnings.push(
|
||||
buildIssue('REVIEWS_MISSING', 'Add testimonials or reviews when available.', 'course.metadata.reviews')
|
||||
);
|
||||
}
|
||||
|
||||
if ((course.cost ?? 0) > 0 && !hasMeaningfulText(course.metadata?.paymentLink)) {
|
||||
warnings.push(
|
||||
buildIssue('PAYMENT_LINK_MISSING', 'Add a payment link for this paid course.', 'course.metadata.paymentLink')
|
||||
);
|
||||
}
|
||||
|
||||
if (course.type === 'COMPLIANCE') {
|
||||
warnings.push(
|
||||
buildIssue('COMPLIANCE_REVIEW_RECOMMENDED', 'Review compliance settings before publishing.', 'course.compliance')
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(metadataFixes).length > 0) {
|
||||
landingPageFixes.metadata = metadataFixes;
|
||||
}
|
||||
|
||||
const suggestedFixes: CourseGoLiveReadiness['suggestedFixes'] = {};
|
||||
if (!course.slug && suggestedSlug) {
|
||||
suggestedFixes.slug = suggestedSlug;
|
||||
}
|
||||
|
||||
if (Object.keys(landingPageFixes).length > 0) {
|
||||
suggestedFixes.landingPage = landingPageFixes;
|
||||
}
|
||||
|
||||
return {
|
||||
ready: blockers.length === 0,
|
||||
blockers,
|
||||
warnings,
|
||||
suggestedFixes,
|
||||
courseUrl: buildCourseUrl(organization, course.slug ?? suggestedSlug)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseGoLiveReadiness(courseId: string): Promise<CourseGoLiveReadiness> {
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const organizationId = await getCourseOrganizationId(courseId);
|
||||
const [contentItems, organization] = await Promise.all([
|
||||
getCourseContentItems(courseId),
|
||||
organizationId ? getOrganizationById(organizationId) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
const suggestedSlug = course.slug
|
||||
? undefined
|
||||
: await generateUniqueCourseSlug(generateSlug(course.title, { fallback: 'course' }));
|
||||
|
||||
return evaluateCourseGoLiveReadiness({
|
||||
course,
|
||||
contentItems,
|
||||
suggestedSlug,
|
||||
organization
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishCourseWhenReady(courseId: string) {
|
||||
const readiness = await getCourseGoLiveReadiness(courseId);
|
||||
if (!readiness.ready) {
|
||||
const blockerList = readiness.blockers.map((blocker) => blocker.message).join(' ');
|
||||
throw new AppError(`Course is not ready to go live. ${blockerList}`, 'COURSE_GO_LIVE_BLOCKED', 400);
|
||||
}
|
||||
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const slug = await ensureCourseSlug(courseId, course.title);
|
||||
const publishedCourse = await updateCourse(courseId, { slug, isPublished: true });
|
||||
const publishedReadiness = await getCourseGoLiveReadiness(courseId);
|
||||
|
||||
return {
|
||||
course: publishedCourse,
|
||||
readiness: publishedReadiness
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||
|
||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
||||
@@ -647,6 +648,8 @@ export async function enrollInCourse(
|
||||
.limit(1);
|
||||
|
||||
if (existingMember) {
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alreadyJoined: true,
|
||||
@@ -677,6 +680,8 @@ export async function enrollInCourse(
|
||||
email: normalizedEmail
|
||||
});
|
||||
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]);
|
||||
|
||||
await sendStudentJoinEmails({
|
||||
courseId,
|
||||
courseName: title,
|
||||
@@ -1071,6 +1076,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
};
|
||||
});
|
||||
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([result.courseId], [user.id]);
|
||||
|
||||
if (!result.alreadyJoined) {
|
||||
await sendStudentJoinEmails({
|
||||
courseId: result.courseId,
|
||||
|
||||
@@ -20,7 +20,7 @@ type CourseLandingPageUpdateResult = {
|
||||
bannerImageUrl: string | null;
|
||||
};
|
||||
|
||||
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
export async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let suffix = 0;
|
||||
let candidate = baseSlug;
|
||||
|
||||
@@ -40,7 +40,7 @@ async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
|
||||
export async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (course?.slug) {
|
||||
return course.slug;
|
||||
@@ -52,7 +52,9 @@ async function ensureCourseSlug(courseId: string, title: string | null | undefin
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
||||
function buildCourseBaseUrl(organization: TOrganization) {
|
||||
export function buildCourseBaseUrl(
|
||||
organization: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'>
|
||||
) {
|
||||
if (organization.customDomain && organization.isCustomDomainVerified) {
|
||||
return `https://${organization.customDomain}`;
|
||||
}
|
||||
@@ -60,7 +62,7 @@ function buildCourseBaseUrl(organization: TOrganization) {
|
||||
return getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
}
|
||||
|
||||
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
|
||||
export async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
|
||||
const organization = await getOrganizationById(organizationId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||
|
||||
/**
|
||||
* Gets all course members (people) for a course
|
||||
@@ -53,6 +54,10 @@ export async function addMember(
|
||||
|
||||
const addedMember = await addCourseMember(courseId, data);
|
||||
|
||||
if (data.roleId === ROLE.STUDENT && data.profileId) {
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [data.profileId]);
|
||||
}
|
||||
|
||||
// Send emails when a student joins
|
||||
if (data.roleId === ROLE.STUDENT) {
|
||||
try {
|
||||
@@ -176,6 +181,13 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
||||
|
||||
// Add all members
|
||||
const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member)));
|
||||
const studentProfileIds = members
|
||||
.filter((member) => member.roleId === ROLE.STUDENT && member.profileId)
|
||||
.map((member) => member.profileId!);
|
||||
|
||||
if (studentProfileIds.length > 0) {
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], studentProfileIds);
|
||||
}
|
||||
|
||||
// Send welcome emails for teachers (TUTOR role = 2, ADMIN role = 1)
|
||||
const baseUrl = getDashboardBaseUrl(orgSiteName);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { env } from '@api/config/env';
|
||||
import {
|
||||
getCourseStats,
|
||||
getDashOrgStats,
|
||||
getOrgStudentLoginsByDayOfWeek,
|
||||
getUserLoginStreak,
|
||||
getRecentCertifications,
|
||||
getTotalCertificatesIssued
|
||||
} from '@cio/db/queries/dash';
|
||||
import { dashLoginActivityKey } from '@api/utils/redis/key-generators';
|
||||
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
|
||||
|
||||
import { OrganisationAnalytics } from '@api/types';
|
||||
import { getOrgIdBySiteName } from '@cio/db/queries';
|
||||
@@ -65,3 +70,101 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
|
||||
throw new AppError('Failed to load organisation analytics', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
|
||||
|
||||
type StudentLoginActivityRow = { day: string; count: number };
|
||||
|
||||
/** Redis TTL for login-activity chart payload (1 day). */
|
||||
const LOGIN_ACTIVITY_CACHE_TTL_SECONDS = 86_400;
|
||||
|
||||
function parseLoginActivityCache(raw: string): StudentLoginActivityRow[] | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || parsed.length !== 7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const out: StudentLoginActivityRow[] = [];
|
||||
for (const item of parsed) {
|
||||
if (
|
||||
typeof item !== 'object' ||
|
||||
item === null ||
|
||||
!('day' in item) ||
|
||||
!('count' in item) ||
|
||||
typeof (item as { day: unknown }).day !== 'string' ||
|
||||
typeof (item as { count: unknown }).count !== 'number' ||
|
||||
!Number.isInteger((item as { count: number }).count) ||
|
||||
(item as { count: number }).count < 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
out.push({ day: (item as { day: string }).day, count: (item as { count: number }).count });
|
||||
}
|
||||
|
||||
for (const label of DAY_LABELS) {
|
||||
if (!out.some((r) => r.day === label)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Day-of-week login chart; results cached in Redis (24h TTL) when `REDIS_URL` is set. */
|
||||
export async function getStudentLoginActivity(orgId: string, days: number): Promise<StudentLoginActivityRow[]> {
|
||||
const cacheKey = dashLoginActivityKey(orgId, days);
|
||||
|
||||
if (env.REDIS_URL) {
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
const rows = parseLoginActivityCache(cached);
|
||||
if (rows) {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('Redis get failed for login activity cache, using database', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await getOrgStudentLoginsByDayOfWeek(orgId, days);
|
||||
|
||||
const countByDow = new Map(rows.map((r) => [r.dayOfWeek, r.count]));
|
||||
|
||||
const result = DAY_LABELS.map((label, index) => ({
|
||||
day: label,
|
||||
count: countByDow.get(index) ?? 0
|
||||
}));
|
||||
|
||||
if (env.REDIS_URL) {
|
||||
try {
|
||||
await redis.setEx(cacheKey, LOGIN_ACTIVITY_CACHE_TTL_SECONDS, JSON.stringify(result));
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('Redis set failed for login activity cache, continuing', error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('getStudentLoginActivity error:', error);
|
||||
throw new AppError('Failed to load login activity', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentUserLoginStreak(userId: string) {
|
||||
try {
|
||||
return await getUserLoginStreak(userId);
|
||||
} catch (error) {
|
||||
console.error('getCurrentUserLoginStreak error:', error);
|
||||
throw new AppError('Failed to load login streak', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
|
||||
import type { TExercise, TExerciseTemplate, TNewOption, TNewQuestion } from '@cio/db/types';
|
||||
import type { TExerciseCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
|
||||
import { getLessonById } from '@cio/db/queries/lesson';
|
||||
import {
|
||||
createExercises,
|
||||
syncOptionIdSequence,
|
||||
@@ -65,6 +66,20 @@ function sanitizeExercisePayload(data: TExerciseCreate | TExerciseUpdate): TExer
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveExerciseCourseId(exercise: TExercise): Promise<string | null> {
|
||||
if (exercise.courseId) {
|
||||
return exercise.courseId;
|
||||
}
|
||||
|
||||
if (!exercise.lessonId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lesson = await getLessonById(exercise.lessonId);
|
||||
|
||||
return lesson?.courseId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new exercise with optional questions and options
|
||||
* @param data Exercise creation data
|
||||
@@ -373,6 +388,39 @@ export async function deleteExerciseService(exerciseId: string): Promise<TExerci
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteExerciseForCourseService(courseId: string, exerciseId: string): Promise<TExercise> {
|
||||
try {
|
||||
const exercise = await getExerciseById(exerciseId);
|
||||
if (!exercise) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const exerciseCourseId = await resolveExerciseCourseId(exercise);
|
||||
if (exerciseCourseId !== courseId) {
|
||||
throw new AppError('Exercise does not belong to this course', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const deleted = await deleteExercise(exerciseId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete exercise',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists exercises for a course
|
||||
* @param courseId Course ID
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ROLE } from '@cio/utils/constants';
|
||||
import crypto from 'node:crypto';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getProfilesByEmails } from '@cio/db/queries/auth';
|
||||
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -188,6 +189,10 @@ async function enrollAudienceStudentProfilesInCourses(
|
||||
);
|
||||
}
|
||||
|
||||
if (validProfiles.length > 0) {
|
||||
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, validProfiles);
|
||||
}
|
||||
|
||||
let emailsSent = 0;
|
||||
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
|
||||
@@ -534,6 +539,10 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
if (profiles.length > 0) {
|
||||
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
|
||||
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
|
||||
await ensureComplianceEnrollmentRecordsForProfiles(
|
||||
courseIds,
|
||||
profiles.map((profile) => profile.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
|
||||
|
||||
type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED';
|
||||
|
||||
@@ -440,6 +441,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
[{ profileId: user.id, email: normalizedEmail }],
|
||||
invite.invite.roleId
|
||||
);
|
||||
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]);
|
||||
} catch (error) {
|
||||
console.error('acceptOrganizationInvite course enrollment error:', error);
|
||||
}
|
||||
@@ -652,6 +654,7 @@ export async function acceptOrganizationInviteById(
|
||||
[{ profileId: user.id, email: normalizedEmail }],
|
||||
result.invite.roleId
|
||||
);
|
||||
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]);
|
||||
} catch (error) {
|
||||
console.error('acceptOrganizationInviteById course enrollment error:', error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
searchLmsCourses,
|
||||
searchLmsPrograms,
|
||||
searchOrgAudience,
|
||||
searchOrgCourses,
|
||||
searchOrgPrograms,
|
||||
searchOrgTagsAndGroups,
|
||||
searchOrgWidgets
|
||||
} from '@cio/db/queries';
|
||||
|
||||
export async function searchOrganization(orgId: string, search: string, limit: number) {
|
||||
const [courses, programs, widgets, tags, audience] = await Promise.all([
|
||||
searchOrgCourses(orgId, search, limit),
|
||||
searchOrgPrograms(orgId, search, limit),
|
||||
searchOrgWidgets(orgId, search, limit),
|
||||
searchOrgTagsAndGroups(orgId, search, limit),
|
||||
searchOrgAudience(orgId, search, limit)
|
||||
]);
|
||||
|
||||
return {
|
||||
courses,
|
||||
programs,
|
||||
widgets,
|
||||
tags,
|
||||
audience
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchLmsOrganization(orgId: string, profileId: string, search: string, limit: number) {
|
||||
const [courses, programs] = await Promise.all([
|
||||
searchLmsCourses(orgId, profileId, search, limit),
|
||||
searchLmsPrograms(orgId, profileId, search, limit)
|
||||
]);
|
||||
|
||||
return {
|
||||
courses,
|
||||
programs
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types';
|
||||
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { generateDocumentDownloadPresignedUrls } from '@api/utils/s3';
|
||||
import { syncComplianceProgressFromSubmission } from '@api/services/course/compliance';
|
||||
|
||||
type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed';
|
||||
type SubmissionOverallStatus = 'auto_graded' | 'manual_required' | 'hybrid';
|
||||
@@ -573,7 +574,9 @@ export async function createSubmissionService(
|
||||
}
|
||||
|
||||
const shouldAutoGrade =
|
||||
course?.type === 'SELF_PACED' && overallStatus === 'auto_graded' && exerciseWithRelations.questions.length > 0;
|
||||
(course?.type === 'SELF_PACED' || course?.type === 'COMPLIANCE') &&
|
||||
overallStatus === 'auto_graded' &&
|
||||
exerciseWithRelations.questions.length > 0;
|
||||
|
||||
if (shouldAutoGrade) {
|
||||
const answeredIds = new Set(answerByQuestionId.keys());
|
||||
@@ -631,6 +634,8 @@ export async function createSubmissionService(
|
||||
console.error('Failed to send exercise submission update email:', emailError);
|
||||
});
|
||||
|
||||
await syncComplianceProgressFromSubmission(courseId, submittedBy);
|
||||
|
||||
const enrichedAnswers =
|
||||
answersWithPoints.length > 0 ? await enrichFileUploadAnswersArray(answersWithPoints) : answersWithPoints;
|
||||
return { ...submission, answers: enrichedAnswers };
|
||||
@@ -641,6 +646,8 @@ export async function createSubmissionService(
|
||||
console.error('Failed to send exercise submission update email:', emailError);
|
||||
});
|
||||
|
||||
await syncComplianceProgressFromSubmission(courseId, submittedBy);
|
||||
|
||||
return submission;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -695,6 +702,10 @@ export async function updateSubmissionService(submissionId: string, data: TSubmi
|
||||
});
|
||||
}
|
||||
|
||||
if (updated.gradingState === 'completed' && updated.courseId && updated.submittedBy) {
|
||||
await syncComplianceProgressFromSubmission(updated.courseId, updated.submittedBy);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -793,6 +804,10 @@ export async function updateSubmissionGradesBatch(
|
||||
});
|
||||
}
|
||||
|
||||
if (updated.courseId && updated.submittedBy) {
|
||||
await syncComplianceProgressFromSubmission(updated.courseId, updated.submittedBy);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import { getOrgCourses } from '@cio/db/queries/course';
|
||||
import { getActiveOrganizationPlan, getOrganizationById } from '@cio/db/queries/organization';
|
||||
import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@cio/db/queries/tag';
|
||||
import { listWidgetCourses } from '@cio/db/queries/widget';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
import {
|
||||
ZWidgetPayload,
|
||||
getLayoutCourseLimit,
|
||||
normalizeWidgetConfig,
|
||||
resolveWidgetPlanGatedFields,
|
||||
type TWidgetLayoutType,
|
||||
type TWidgetPayload,
|
||||
type TWidgetPayloadCategory,
|
||||
type TWidgetPayloadCourseTag
|
||||
} from '@cio/utils/validation/widget';
|
||||
import type { TWidget } from '@db/types';
|
||||
import * as csstree from 'css-tree';
|
||||
import { env } from '@api/config/env';
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
const DEFAULT_EMBED_CDN_BASE_URL = 'https://assets.cdn.clsrio.com';
|
||||
const DEFAULT_LOCAL_EMBED_BASE_URL = 'http://localhost:5180';
|
||||
const DEFAULT_WIDGET_API_BASE_URL = 'https://api.classroomio.com';
|
||||
const DEFAULT_WIDGET_APP_BASE_URL = 'https://classroomio.com';
|
||||
|
||||
const ALLOWED_CUSTOM_CSS_PROPERTIES = new Set([
|
||||
'align-items',
|
||||
'background',
|
||||
'background-color',
|
||||
'border',
|
||||
'border-color',
|
||||
'border-radius',
|
||||
'border-style',
|
||||
'border-width',
|
||||
'box-shadow',
|
||||
'color',
|
||||
'display',
|
||||
'fill',
|
||||
'flex',
|
||||
'flex-basis',
|
||||
'flex-direction',
|
||||
'flex-grow',
|
||||
'flex-shrink',
|
||||
'font',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-style',
|
||||
'font-weight',
|
||||
'gap',
|
||||
'grid',
|
||||
'grid-template-columns',
|
||||
'grid-template-rows',
|
||||
'height',
|
||||
'justify-content',
|
||||
'letter-spacing',
|
||||
'line-height',
|
||||
'margin',
|
||||
'margin-top',
|
||||
'margin-right',
|
||||
'margin-bottom',
|
||||
'margin-left',
|
||||
'max-height',
|
||||
'max-width',
|
||||
'min-height',
|
||||
'min-width',
|
||||
'object-fit',
|
||||
'opacity',
|
||||
'overflow',
|
||||
'padding',
|
||||
'padding-top',
|
||||
'padding-right',
|
||||
'padding-bottom',
|
||||
'padding-left',
|
||||
'place-items',
|
||||
'stroke',
|
||||
'text-align',
|
||||
'text-decoration',
|
||||
'text-transform',
|
||||
'transform',
|
||||
'transition',
|
||||
'width',
|
||||
'white-space'
|
||||
]);
|
||||
|
||||
function encodeBase58(bytes: Uint8Array): string {
|
||||
let value = 0n;
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8n) + BigInt(byte);
|
||||
}
|
||||
|
||||
if (value === 0n) {
|
||||
return BASE58_ALPHABET[0];
|
||||
}
|
||||
|
||||
let encoded = '';
|
||||
while (value > 0n) {
|
||||
const remainder = Number(value % 58n);
|
||||
encoded = BASE58_ALPHABET[remainder] + encoded;
|
||||
value /= 58n;
|
||||
}
|
||||
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function generateWidgetPublicKey(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return `wgt_${encodeBase58(bytes).slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
|
||||
if (!customCss.trim() || !planName || planName === PLAN.BASIC) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (customCss.length > 5_000) {
|
||||
throw new Error('Custom CSS exceeds 5KB limit');
|
||||
}
|
||||
|
||||
const normalizedCss = customCss.toLowerCase();
|
||||
const bannedSnippets = ['@import', 'javascript:', 'expression(', 'behavior:', 'binding:', '-moz-binding'];
|
||||
if (bannedSnippets.some((snippet) => normalizedCss.includes(snippet))) {
|
||||
throw new Error('Custom CSS contains unsupported rules');
|
||||
}
|
||||
|
||||
const ast = csstree.parse(customCss, { context: 'stylesheet' });
|
||||
|
||||
csstree.walk(ast, (node: any) => {
|
||||
if (node.type === 'Atrule') {
|
||||
throw new Error('At-rules are not allowed in custom CSS');
|
||||
}
|
||||
|
||||
if (node.type === 'Declaration') {
|
||||
const propertyName = node.property.toLowerCase();
|
||||
const propertyValue = csstree.generate(node.value).toLowerCase();
|
||||
|
||||
if (!ALLOWED_CUSTOM_CSS_PROPERTIES.has(propertyName)) {
|
||||
throw new Error(`Custom CSS property "${propertyName}" is not allowed`);
|
||||
}
|
||||
|
||||
if (propertyName === 'position' && propertyValue.includes('fixed')) {
|
||||
throw new Error('position: fixed is not allowed in custom CSS');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return csstree.generate(ast);
|
||||
}
|
||||
|
||||
function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
|
||||
if (customDomain) {
|
||||
return `https://${customDomain}`;
|
||||
}
|
||||
|
||||
return siteName ? `https://${siteName}.classroomio.com` : DEFAULT_WIDGET_APP_BASE_URL;
|
||||
}
|
||||
|
||||
export function getCourseWidgetScriptUrl(): string {
|
||||
const embedBaseUrl = (
|
||||
env.PUBLIC_EMBED_BASE_URL ||
|
||||
(env.NODE_ENV === 'development' ? DEFAULT_LOCAL_EMBED_BASE_URL : DEFAULT_EMBED_CDN_BASE_URL)
|
||||
).replace(/\/$/, '');
|
||||
|
||||
return `${embedBaseUrl}/embeds/course-widget/course-widget.js`;
|
||||
}
|
||||
|
||||
export function getWidgetApiBaseUrl(): string {
|
||||
return (env.PUBLIC_SERVER_URL || DEFAULT_WIDGET_API_BASE_URL).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function getWidgetEmbedCode(publicKey: string): string {
|
||||
const scriptUrl = getCourseWidgetScriptUrl();
|
||||
const apiBaseUrl = getWidgetApiBaseUrl();
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
export async function buildWidgetPayload(
|
||||
widget: TWidget,
|
||||
overrideSelectedCourseIds?: string[]
|
||||
): Promise<TWidgetPayload> {
|
||||
const organization = await getOrganizationById(widget.organizationId);
|
||||
if (!organization) {
|
||||
throw new Error('Organization not found for widget');
|
||||
}
|
||||
|
||||
const activePlan = await getActiveOrganizationPlan(widget.organizationId);
|
||||
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, activePlan?.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 tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
|
||||
widget.organizationId,
|
||||
resolvedCourses.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));
|
||||
|
||||
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 orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain);
|
||||
|
||||
return ZWidgetPayload.parse({
|
||||
version: 'v1',
|
||||
widgetId: widget.id,
|
||||
publicKey: widget.publicKey,
|
||||
organization: {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
siteName: organization.siteName,
|
||||
customDomain: organization.customDomain
|
||||
},
|
||||
layoutType,
|
||||
selectionMode: widget.selectionMode,
|
||||
design: {
|
||||
...normalizedConfig,
|
||||
advanced: {
|
||||
...normalizedConfig.advanced,
|
||||
customCss: sanitizedCustomCss
|
||||
}
|
||||
},
|
||||
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()
|
||||
});
|
||||
}
|
||||
|
||||
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 }),
|
||||
getTagGroupsWithTags(orgId),
|
||||
getActiveOrganizationPlan(orgId)
|
||||
]);
|
||||
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
|
||||
orgId,
|
||||
coursesResult.items.map((course) => course.id)
|
||||
);
|
||||
|
||||
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] ?? []
|
||||
})),
|
||||
availableTags: tagGroups.flatMap((group) =>
|
||||
group.tags.map((tag) => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
color: tag.color,
|
||||
groupId: group.id
|
||||
}))
|
||||
),
|
||||
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio')
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
archiveWidget,
|
||||
createWidget,
|
||||
createWidgetVersion,
|
||||
getWidgetListItemById,
|
||||
getNextWidgetVersion,
|
||||
getPublishedWidgetPayloadByPublicKey,
|
||||
getWidgetById,
|
||||
getWidgetVersionById,
|
||||
listWidgetCourses,
|
||||
listWidgetVersions,
|
||||
listWidgetsByOrganization,
|
||||
replaceWidgetCourses,
|
||||
updateWidget
|
||||
} from '@cio/db/queries/widget';
|
||||
import {
|
||||
ZWidgetPayload,
|
||||
getDefaultWidgetConfig,
|
||||
normalizeWidgetConfig,
|
||||
type TCreateWidget,
|
||||
type TPreviewWidget,
|
||||
type TRollbackWidget,
|
||||
type TUpdateWidget
|
||||
} from '@cio/utils/validation/widget';
|
||||
|
||||
import {
|
||||
buildWidgetPayload,
|
||||
generateWidgetPublicKey,
|
||||
getCourseWidgetScriptUrl,
|
||||
getWidgetEmbedCode,
|
||||
listWidgetAvailableEditorData
|
||||
} from './widget-payload';
|
||||
|
||||
export async function listOrganizationWidgets(orgId: string) {
|
||||
try {
|
||||
return await listWidgetsByOrganization(orgId);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list widgets',
|
||||
ErrorCodes.WIDGET_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrganizationWidget(orgId: string, userId: string, data: TCreateWidget) {
|
||||
try {
|
||||
const widget = await createWidget({
|
||||
organizationId: orgId,
|
||||
name: data.name,
|
||||
layoutType: data.layoutType,
|
||||
selectionMode: data.selectionMode,
|
||||
publicKey: generateWidgetPublicKey(),
|
||||
config: normalizeWidgetConfig(data.config ?? getDefaultWidgetConfig()),
|
||||
createdByUserId: userId,
|
||||
updatedByUserId: userId
|
||||
});
|
||||
|
||||
await replaceWidgetCourses(widget.id, data.selectedCourseIds);
|
||||
|
||||
const listItem = await getWidgetListItemById(orgId, widget.id);
|
||||
|
||||
if (!listItem) {
|
||||
throw new AppError('Widget not found after creation', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return listItem;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create widget',
|
||||
ErrorCodes.WIDGET_CREATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrganizationWidgetDetail(orgId: string, widgetId: string) {
|
||||
try {
|
||||
const widget = await getWidgetById(orgId, widgetId);
|
||||
if (!widget) {
|
||||
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const [selectedCourses, versions, editorData, previewPayload] = await Promise.all([
|
||||
listWidgetCourses(widget.id),
|
||||
listWidgetVersions(orgId, widget.id),
|
||||
listWidgetAvailableEditorData(orgId),
|
||||
buildWidgetPayload(widget)
|
||||
]);
|
||||
|
||||
return {
|
||||
widget: {
|
||||
...widget,
|
||||
config: normalizeWidgetConfig(widget.config as Record<string, unknown>),
|
||||
selectedCourseIds: selectedCourses.map((course) => course.courseId),
|
||||
embedCode: getWidgetEmbedCode(widget.publicKey),
|
||||
publicScriptUrl: getCourseWidgetScriptUrl()
|
||||
},
|
||||
availableCourses: editorData.availableCourses,
|
||||
availableTags: editorData.availableTags,
|
||||
previewPayload,
|
||||
versions,
|
||||
planGatedFields: editorData.planGatedFields
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get widget detail',
|
||||
ErrorCodes.WIDGET_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOrganizationWidget(orgId: string, widgetId: string, userId: string, data: TUpdateWidget) {
|
||||
try {
|
||||
const existingWidget = await getWidgetById(orgId, widgetId);
|
||||
if (!existingWidget) {
|
||||
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const updatedWidget = await updateWidget(orgId, widgetId, {
|
||||
name: data.name ?? existingWidget.name,
|
||||
layoutType: data.layoutType ?? existingWidget.layoutType,
|
||||
selectionMode: data.selectionMode ?? existingWidget.selectionMode,
|
||||
config: normalizeWidgetConfig((data.config ?? existingWidget.config) as Record<string, unknown>),
|
||||
hasUnpublishedChanges: true,
|
||||
updatedByUserId: userId
|
||||
});
|
||||
|
||||
if (!updatedWidget) {
|
||||
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (data.selectedCourseIds) {
|
||||
await replaceWidgetCourses(widgetId, data.selectedCourseIds);
|
||||
}
|
||||
|
||||
const listItem = await getWidgetListItemById(orgId, widgetId);
|
||||
|
||||
if (!listItem) {
|
||||
throw new AppError('Widget not found after update', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return listItem;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update widget',
|
||||
ErrorCodes.WIDGET_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteOrganizationWidget(orgId: string, widgetId: string, userId: string) {
|
||||
try {
|
||||
const archivedWidget = await archiveWidget(orgId, widgetId, userId);
|
||||
if (!archivedWidget) {
|
||||
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return archivedWidget;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete widget',
|
||||
ErrorCodes.WIDGET_DELETE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishOrganizationWidget(orgId: string, widgetId: string, userId: string) {
|
||||
try {
|
||||
const widget = await getWidgetById(orgId, widgetId);
|
||||
if (!widget) {
|
||||
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const payload = await buildWidgetPayload(widget);
|
||||
const nextVersion = await getNextWidgetVersion(widget.id);
|
||||
const version = await createWidgetVersion({
|
||||
widgetId: widget.id,
|
||||
version: nextVersion,
|
||||
configSnapshot: normalizeWidgetConfig(widget.config as Record<string, unknown>),
|
||||
payloadSnapshot: payload,
|
||||
runtimeManifest: {
|
||||
version: 'v1',
|
||||
release: 'current',
|
||||
entryUrl: getCourseWidgetScriptUrl()
|
||||
},
|
||||
publishedByUserId: userId
|
||||
});
|
||||
|
||||
const updatedWidget = await updateWidget(orgId, widget.id, {
|
||||
status: 'PUBLISHED',
|
||||
hasUnpublishedChanges: false,
|
||||
latestPublishedVersionId: version.id,
|
||||
updatedByUserId: userId
|
||||
});
|
||||
|
||||
return {
|
||||
widget: updatedWidget,
|
||||
version,
|
||||
payload,
|
||||
embedCode: getWidgetEmbedCode(widget.publicKey),
|
||||
publicScriptUrl: getCourseWidgetScriptUrl()
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to publish widget',
|
||||
ErrorCodes.WIDGET_PUBLISH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rollbackOrganizationWidget(
|
||||
orgId: string,
|
||||
widgetId: string,
|
||||
userId: string,
|
||||
data: TRollbackWidget
|
||||
) {
|
||||
try {
|
||||
const [widget, version] = await Promise.all([
|
||||
getWidgetById(orgId, widgetId),
|
||||
getWidgetVersionById(orgId, widgetId, data.versionId)
|
||||
]);
|
||||
|
||||
if (!widget) {
|
||||
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
throw new AppError('Widget version not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const nextVersion = await getNextWidgetVersion(widgetId);
|
||||
const rollbackVersion = await createWidgetVersion({
|
||||
widgetId: widget.id,
|
||||
version: nextVersion,
|
||||
configSnapshot: version.configSnapshot,
|
||||
payloadSnapshot: version.payloadSnapshot,
|
||||
runtimeManifest: version.runtimeManifest,
|
||||
rolledBackFromVersionId: version.id,
|
||||
publishedByUserId: userId
|
||||
});
|
||||
|
||||
const updatedWidget = await updateWidget(orgId, widgetId, {
|
||||
status: 'PUBLISHED',
|
||||
hasUnpublishedChanges: false,
|
||||
latestPublishedVersionId: rollbackVersion.id,
|
||||
updatedByUserId: userId
|
||||
});
|
||||
|
||||
return {
|
||||
widget: updatedWidget,
|
||||
version: rollbackVersion
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to rollback widget',
|
||||
ErrorCodes.WIDGET_ROLLBACK_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (!payload) {
|
||||
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return ZWidgetPayload.parse(payload);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch widget payload',
|
||||
ErrorCodes.WIDGET_PAYLOAD_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user