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

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+2
View File
@@ -2,12 +2,14 @@ import { getAccountData, updateUser } from '@api/services/account';
import { Hono } from '@api/utils/hono';
import { ZUpdateProfile } from '@cio/utils/validation/account';
import { accountWorkspacesRouter } from '@api/routes/account/workspaces';
import { authMiddleware } from '@api/middlewares/auth';
import { getProfileById } from '@cio/db/queries/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const accountRouter = new Hono()
.route('/', accountWorkspacesRouter)
.get('/', authMiddleware, async (c) => {
const user = c.get('user')!;
+67
View File
@@ -0,0 +1,67 @@
import {
createWorkspaceForAccount,
deleteWorkspaceFromAccount,
getAccountUsage,
getWorkspaceLimits,
listAccountWorkspaces
} from '@api/services/account';
import { Hono } from '@api/utils/hono';
import { ZCreateWorkspace, ZWorkspaceIdParam } from '@cio/utils/validation/account';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { zValidator } from '@hono/zod-validator';
export const accountWorkspacesRouter = new Hono()
.get('/workspaces', authMiddleware, orgAdminMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const [workspaces, limits] = await Promise.all([listAccountWorkspaces(orgId), getWorkspaceLimits(orgId)]);
return c.json({ success: true, data: { workspaces, limits } }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch account workspaces');
}
})
.post('/workspaces', authMiddleware, orgAdminMiddleware, zValidator('json', ZCreateWorkspace), async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const data = c.req.valid('json');
const workspace = await createWorkspaceForAccount(orgId, user.id, data);
return c.json({ success: true, data: workspace }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create workspace');
}
})
.delete(
'/workspaces/:workspaceId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZWorkspaceIdParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { workspaceId } = c.req.valid('param');
await deleteWorkspaceFromAccount(orgId, workspaceId);
return c.json({ success: true, data: { id: workspaceId } }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete workspace');
}
}
)
.get('/usage', authMiddleware, orgAdminMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const usage = await getAccountUsage(orgId);
return c.json({ success: true, data: usage }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch account usage');
}
});
+105
View File
@@ -0,0 +1,105 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { serveStatic } from '@hono/node-server/serve-static';
import type { Context, Hono, Next } from 'hono';
import { QUEUE_NAMES, getQueue, isRedisConfigured } from '@cio/jobs';
import { env } from '@api/config/env';
/**
* Mounts the bull-board UI at `/admin/queues` for inspecting BullMQ state
* (waiting/active/completed/failed jobs, payloads, retries, etc.).
*
* Access control:
* - Development: open (matches existing dev ergonomics).
* - Production: requires either
* - a Better Auth session whose `user.email` is in
* `QUEUE_DASHBOARD_ADMIN_EMAILS` (comma-separated), or
* - the `QUEUE_DASHBOARD_TOKEN` bearer escape hatch via header or `?token=`.
* Failures return `404` so the route doesn't reveal its existence.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mountQueueDashboard(app: Hono<any, any, any>): void {
if (!isRedisConfigured()) {
console.warn('Queue dashboard not mounted: REDIS_URL is not set.');
return;
}
const isProduction = env.NODE_ENV === 'production';
if (isProduction && !canEnforceProdAuth()) {
console.warn(
'Queue dashboard not mounted: NODE_ENV=production requires QUEUE_DASHBOARD_ADMIN_EMAILS or QUEUE_DASHBOARD_TOKEN.'
);
return;
}
const serverAdapter = new HonoAdapter(serveStatic);
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: Object.values(QUEUE_NAMES).map((name) => new BullMQAdapter(getQueue(name))),
serverAdapter
});
if (isProduction) {
app.use('/admin/queues/*', queueDashboardAuth);
app.use('/admin/queues', queueDashboardAuth);
}
app.route('/admin/queues', serverAdapter.registerPlugin());
}
function canEnforceProdAuth(): boolean {
return Boolean(adminEmailSet().size > 0 || env.QUEUE_DASHBOARD_TOKEN);
}
function adminEmailSet(): Set<string> {
const raw = env.QUEUE_DASHBOARD_ADMIN_EMAILS ?? '';
return new Set(
raw
.split(',')
.map((email) => email.trim().toLowerCase())
.filter(Boolean)
);
}
async function queueDashboardAuth(c: Context, next: Next): Promise<Response | void> {
if (matchesEscapeHatch(c)) {
return next();
}
const user = c.get('user') as { email?: string | null } | null;
const email = user?.email?.toLowerCase();
if (email && adminEmailSet().has(email)) {
return next();
}
// Hide existence rather than 401/403 — fewer hints for crawlers/scanners.
return c.notFound();
}
function matchesEscapeHatch(c: Context): boolean {
const expected = env.QUEUE_DASHBOARD_TOKEN;
if (!expected) return false;
const headerToken = c.req
.header('Authorization')
?.replace(/^Bearer\s+/i, '')
.trim();
const queryToken = c.req.query('token');
const provided = headerToken || queryToken;
return Boolean(provided && timingSafeEqual(provided, expected));
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i += 1) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return mismatch === 0;
}
+263 -31
View File
@@ -11,7 +11,12 @@ import {
ZAgentCreditPurchase,
ZAgentCreditsBody,
ZAgentGenerateCourseTitleBody,
ZAgentStatusQuery
ZAgentGenerateTextBody,
ZAgentStatusQuery,
ZAgentSummarizeBody,
ZTutorUsageQuery,
ZTutorUsagePeriod,
ZTutorUsageUserParam
} from '@cio/utils/validation/agent';
import {
addCredits,
@@ -23,15 +28,32 @@ import {
isOrgOnPaidPlan,
recordTokenUsage
} from '@api/services/agent/usage';
import {
enforceStudentTutorPolicy,
getStudentTutorStatus,
getTutorCapStatusSummary,
getTutorLearnerDetail,
getTutorLearnerLeaderboard,
incrementStudentTutorCount
} from '@api/services/agent/tutor-usage';
import { buildStudentAgentTools } from '@api/services/agent/student-tools';
import { parseAndStoreDocument } from '@api/services/agent/document';
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
import { generateCourseMeta } from '@api/services/agent/title-generation';
import { generateFieldText } from '@api/services/agent/text-generation';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { getChatConversation } from '@cio/db/queries/agent';
import { AgentRole, AIProvider, MAX_STEPS_PER_ROUND, type AgentContext, type AgentStatus } from '@cio/ai-assistant';
import {
AgentRole,
AIProvider,
MAX_STEPS_PER_ROUND,
getCourseTemplate,
type AgentContext,
type AgentStatus
} from '@cio/ai-assistant';
import { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models';
import { buildSystemPrompt } from '@cio/ai-assistant/prompt';
import { buildSystemPrompt, buildContextMessage } from '@cio/ai-assistant/prompt';
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import { redis } from '@api/utils/redis/redis';
import { db } from '@cio/db';
@@ -41,14 +63,17 @@ import { listCourseSections } from '@api/services/course/section';
import { getLesson } from '@api/services/lesson/lesson';
import { getExercise } from '@api/services/exercise/exercise';
import { trimMessageHistory } from '@api/services/agent/context-window';
import { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
import {
collectDocumentIds,
getActiveCourseTemplateId,
getLatestImplementationPlan,
loadDocumentsText,
verifyExerciseBelongsToCourse,
verifyLessonBelongsToCourse
} from '@api/services/agent/chat-context';
import { buildAgentTools } from '@api/services/agent/chat-tools';
import { summarizeConversation } from '@api/services/agent/summarize';
import { agentHistoryRouter } from './history';
const agentCoreRouter = new Hono()
@@ -63,7 +88,8 @@ const agentCoreRouter = new Hono()
const status: AgentStatus = {
enabled: false,
role: AgentRole.STUDENT,
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 }
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 },
tutor: { enabled: false, capRemaining: null, cap: null, enforced: false }
};
return c.json({ success: true, data: status });
@@ -74,10 +100,16 @@ const agentCoreRouter = new Hono()
const usage = await getTokenBalance(orgId);
const tutor =
role === AgentRole.STUDENT
? await getStudentTutorStatus(orgId, courseId, user.id)
: { enabled: true, cap: null, capRemaining: null, enforced: false };
const status: AgentStatus = {
enabled: true,
role,
usage
usage,
tutor
};
return c.json({ success: true, data: status });
@@ -187,6 +219,67 @@ const agentCoreRouter = new Hono()
return handleError(c, error, 'Failed to fetch usage stats');
}
})
.get(
'/tutor-usage/leaderboard',
authMiddleware,
orgMemberMiddleware,
zValidator('query', ZTutorUsageQuery),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { period, search, sort, page, limit } = c.req.valid('query');
const data = await getTutorLearnerLeaderboard(orgId, { period, search, sort, page, limit });
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch learner leaderboard');
}
}
)
.get(
'/tutor-usage/summary',
authMiddleware,
orgMemberMiddleware,
zValidator(
'query',
ZTutorUsagePeriod.optional()
.transform((v) => v ?? 'current')
.pipe(ZTutorUsagePeriod)
.or(ZTutorUsagePeriod)
.transform((v) => ({ period: v }))
),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const period = c.req.query('period');
const parsed = ZTutorUsagePeriod.safeParse(period);
const data = await getTutorCapStatusSummary(orgId, parsed.success ? parsed.data : 'current');
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch tutor cap summary');
}
}
)
.get(
'/tutor-usage/:userId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZTutorUsageUserParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { userId } = c.req.valid('param');
const period = c.req.query('period');
const parsed = ZTutorUsagePeriod.safeParse(period);
const data = await getTutorLearnerDetail(orgId, userId, parsed.success ? parsed.data : 'current');
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch learner detail');
}
}
)
.post('/credits', authMiddleware, orgAdminMiddleware, zValidator('json', ZAgentCreditsBody), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
@@ -233,6 +326,59 @@ const agentCoreRouter = new Hono()
}
}
)
.post(
'/generate-text',
authMiddleware,
orgMemberMiddleware,
zValidator('json', ZAgentGenerateTextBody),
async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const { prompt, tone, format, context, courseId } = c.req.valid('json');
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
await enforceTokenBalance(orgId);
const { text, usage, modelName } = await generateFieldText(prompt, tone, format, context, providerConfig);
if (courseId) {
await recordTokenUsage(orgId, user.id, courseId, usage, modelName);
}
return c.json({ success: true as const, data: { text } });
} catch (error) {
return handleError(c, error, 'Failed to generate text');
}
}
)
.post('/summarize', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentSummarizeBody), async (c) => {
try {
const { messages, courseId } = c.req.valid('json');
const user = c.get('user')!;
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
if (!isTeamMember) {
throw new AppError(
'You must be a course team member to summarize conversations',
'NOT_COURSE_TEAM_MEMBER',
403
);
}
const summary = await summarizeConversation({ messages });
return c.json({ success: true as const, data: { summary } });
} catch (error) {
return handleError(c, error, 'Failed to summarize conversation');
}
})
.post('/chat', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentChatBody), async (c) => {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
@@ -240,7 +386,14 @@ const agentCoreRouter = new Hono()
try {
const { courseId, messages, context, model: requestedModel } = c.req.valid('json');
const modelId = requestedModel ?? DEFAULT_PICKER_MODEL_ID;
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
// Students always use the platform default; admins can pick. Forcing the
// default avoids exposing premium models to learners and keeps per-message
// cost predictable for fair-use accounting.
const modelId =
role === AgentRole.STUDENT ? DEFAULT_PICKER_MODEL_ID : (requestedModel ?? DEFAULT_PICKER_MODEL_ID);
const modelDescriptor = AGENT_MODELS[modelId];
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
@@ -250,13 +403,6 @@ const agentCoreRouter = new Hono()
const providerConfig = { ...baseProviderConfig, model: modelDescriptor.backendModelId };
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
if (role === AgentRole.STUDENT) {
throw new AppError('AI assistant is not available for students yet', 'STUDENT_NOT_SUPPORTED', 403);
}
const [courseRow] = await db
.select({
title: schema.course.title,
@@ -276,7 +422,7 @@ const agentCoreRouter = new Hono()
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
}
if (!modelDescriptor.isFree) {
if (role === AgentRole.TEACHER && !modelDescriptor.isFree) {
const isPaid = await isOrgOnPaidPlan(orgId);
if (!isPaid) {
@@ -284,7 +430,14 @@ const agentCoreRouter = new Hono()
}
}
await enforceTokenBalance(orgId);
// Students go through tutor policy (workspace toggle, pool, per-learner cap).
// Teachers continue to use the existing pool-only check.
const studentPolicy =
role === AgentRole.STUDENT ? await enforceStudentTutorPolicy(orgId, courseId, user.id) : null;
if (role === AgentRole.TEACHER) {
await enforceTokenBalance(orgId);
}
const documentIds = collectDocumentIds(messages, context?.documentId);
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined;
@@ -348,38 +501,98 @@ const agentCoreRouter = new Hono()
const startTime = Date.now();
const model = createModel(providerConfig);
const approvedPlan = getLatestImplementationPlan(messages);
const systemPrompt = [
buildSystemPrompt(agentContext),
const approvedPlan = role === AgentRole.TEACHER ? getLatestImplementationPlan(messages) : undefined;
const activeTemplateId = role === AgentRole.TEACHER ? getActiveCourseTemplateId(messages) : undefined;
const activeTemplate = activeTemplateId ? getCourseTemplate(activeTemplateId) : undefined;
// Stable across requests — safe to cache as a long-lived Anthropic prefix.
// Volatile per-request context (lesson/exercise/document/section count/
// approved plan/active template) is sent as a user-turn message instead so
// it doesn't invalidate the tools+system cache.
const systemPrompt = buildSystemPrompt(agentContext, {
tutorSettings: studentPolicy?.settings
});
const contextMessageText = buildContextMessage(agentContext, {
template: activeTemplate,
approvedPlan
? [
'',
'The latest user message approved a final course plan for immediate execution.',
'Implement that exact plan directly without asking the user to restate it.',
'Treat this approved plan as the canonical source if it differs from any earlier draft.',
`Approved plan JSON:\n${JSON.stringify(approvedPlan, null, 2)}`
].join('\n')
: ''
].join('\n');
const agentTools = buildAgentTools(orgId, user.id, courseId);
});
const agentTools =
role === AgentRole.STUDENT
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
: buildAgentTools(orgId, user.id, courseId, messages);
const trimmedMessages = trimMessageHistory(messages);
const modelMessages = await convertToModelMessages(trimmedMessages);
const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(trimmedMessages));
let completedStepCount = 0;
let finishReason: string | undefined;
const isAnthropic = providerConfig.provider === AIProvider.ANTHROPIC;
// 1h TTL keeps the prefix warm across tool-execution gaps in long agent
// runs. Break-even is 3 requests within the hour; well under most plan-
// execution loops.
const systemContent = isAnthropic
? {
role: 'system' as const,
content: systemPrompt,
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
providerOptions: {
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } }
}
}
: systemPrompt;
// Prepend volatile context as a user-turn message so the stable system +
// tools prefix stays cacheable even when the teacher navigates to a
// different lesson or the agent creates sections mid-run.
const modelMessages =
contextMessageText.length > 0
? [
{ role: 'user' as const, content: [{ type: 'text' as const, text: contextMessageText }] },
...convertedMessages
]
: convertedMessages;
console.log(
'[agent.chat] modelMessages →',
JSON.stringify(
modelMessages.map((m, i) => ({
i,
role: m.role,
parts: Array.isArray(m.content)
? m.content.map((p: any) => ({
type: p.type,
toolCallId: p.toolCallId,
toolName: p.toolName,
textPreview: typeof p.text === 'string' ? p.text.slice(0, 60) : undefined
}))
: typeof m.content === 'string'
? m.content.slice(0, 80)
: m.content
})),
null,
2
)
);
// Cache the growing conversation prefix: each turn reads the prior
// transcript at ~0.1x cost instead of reprocessing it at full price.
if (isAnthropic && modelMessages.length > 0) {
const lastMessage = modelMessages[modelMessages.length - 1];
const existingAnthropic = (lastMessage.providerOptions?.anthropic as Record<string, unknown> | undefined) ?? {};
lastMessage.providerOptions = {
...(lastMessage.providerOptions ?? {}),
anthropic: {
...existingAnthropic,
cacheControl: { type: 'ephemeral', ttl: '1h' }
}
};
}
const result = streamText({
model,
maxRetries: 0,
maxRetries: 2,
system: systemContent,
messages: modelMessages,
tools: agentTools,
@@ -394,6 +607,21 @@ const agentCoreRouter = new Hono()
const inputTokens = totalUsage?.inputTokens ?? 0;
const outputTokens = totalUsage?.outputTokens ?? 0;
// Cache hit/miss visibility. If cacheRead stays 0 across repeated
// turns of the same conversation, a silent invalidator is leaking
// into the cached prefix — audit system prompt and tool definitions.
if (isAnthropic) {
const details = totalUsage?.inputTokenDetails;
const cacheRead = details?.cacheReadTokens ?? 0;
const cacheWrite = details?.cacheWriteTokens ?? 0;
const uncached = details?.noCacheTokens ?? inputTokens;
const total = uncached + cacheRead + cacheWrite;
const hitRate = total > 0 ? Math.round((cacheRead / total) * 100) : 0;
console.log(
`[agent.chat] cache hit=${hitRate}% read=${cacheRead} write=${cacheWrite} uncached=${uncached} output=${outputTokens}`
);
}
if (totalUsage) {
await recordTokenUsage(
orgId,
@@ -408,6 +636,10 @@ const agentCoreRouter = new Hono()
);
}
if (role === AgentRole.STUDENT) {
await incrementStudentTutorCount(orgId, user.id, courseId);
}
trackAgentEvent(AgentEvent.CHAT_COMPLETED, {
orgId,
userId: user.id,
+55
View File
@@ -7,6 +7,7 @@ import {
ZAgentConversationParam,
ZAgentConversationCreateBody,
ZAgentHistorySaveBody,
ZAgentHistoryRenameBody,
ZAgentHistoryDeleteParam,
ZAgentGenerateTitleParam,
ZAgentGenerateTitleBody
@@ -20,6 +21,7 @@ import {
deleteChatConversation,
updateConversationTitle
} from '@api/services/agent/chat-history';
import { compactConversation } from '@api/services/agent/compact';
import { generateConversationTitle } from '@api/services/agent/title-generation';
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
@@ -110,6 +112,37 @@ export const agentHistoryRouter = new Hono()
}
)
/**
* PATCH /agent/history/:conversationId
* Rename a conversation without re-uploading messages.
*/
.patch(
'/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
zValidator('json', ZAgentHistoryRenameBody),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const { title } = c.req.valid('json');
const conversation = await getChatConversation(conversationId, user.id);
if (!conversation) {
return c.json({ success: false as const, error: 'Conversation not found' }, 404);
}
await updateConversationTitle(conversationId, user.id, title);
return c.json({ success: true as const, data: { id: conversationId, title } });
} catch (error) {
return handleError(c, error, 'Failed to rename conversation');
}
}
)
/**
* DELETE /agent/history/:conversationId
* Delete a conversation.
@@ -133,6 +166,28 @@ export const agentHistoryRouter = new Hono()
}
)
/**
* POST /agent/history/:conversationId/compact
* Summarize and replace persisted messages with a single seed message.
*/
.post(
'/:conversationId/compact',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const data = await compactConversation(conversationId, user.id);
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to compact conversation');
}
}
)
/**
* POST /agent/history/:conversationId/generate-title
* Generate a short title from the first user message using a cheap model.
+39
View File
@@ -0,0 +1,39 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { ZCourseAiTutorOverride } from '@cio/utils/validation/agent';
import { getCourseAiTutorSettingsView, updateCourseAiTutorOverrideService } from '@api/services/agent/tutor-config';
/**
* Per-course AI tutor override.
*
* GET /course/:courseId/ai-tutor → { org, override, effective }
* PUT /course/:courseId/ai-tutor → updates the per-course override slot.
* Mounted at /course/:courseId/ai-tutor.
*/
export const courseAiTutorRouter = new Hono()
.get('/', authMiddleware, courseTeamMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const courseId = c.req.param('courseId') as string;
const view = await getCourseAiTutorSettingsView(orgId, courseId);
return c.json({ success: true as const, data: view });
} catch (error) {
return handleError(c, error, 'Failed to fetch course AI tutor settings');
}
})
.put('/', authMiddleware, courseTeamMemberMiddleware, zValidator('json', ZCourseAiTutorOverride), async (c) => {
try {
const courseId = c.req.param('courseId') as string;
const patch = c.req.valid('json');
const updated = await updateCourseAiTutorOverrideService(courseId, patch);
return c.json({ success: true as const, data: updated });
} catch (error) {
return handleError(c, error, 'Failed to update course AI tutor override');
}
});
+93 -19
View File
@@ -1,5 +1,5 @@
import {
ZCertificateDownload,
ZCertificateDownloadRequest,
ZCourseClone,
ZCourseCloneParam,
ZCourseCreate,
@@ -31,6 +31,7 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag';
import { Hono } from '@api/utils/hono';
import { attendanceRouter } from '@api/routes/course/attendance';
import { courseAiTutorRouter } from '@api/routes/course/ai-tutor';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { cloneCourse } from '@api/services/course/clone';
@@ -43,9 +44,11 @@ import { createRateLimiter } from '@api/middlewares/rate-limiter';
import { enrollInCourse } from '@api/services/course/invite';
import { exerciseRouter } from '@api/routes/course/exercise';
import { extractClientIp } from '@api/utils/redis/key-generators';
import { generateCertificate } from '@api/utils/certificate';
import { generateCertificatePdf, generateCertificatePng } from '@api/utils/certificate';
import { assembleCertificateRender, assembleOwnerPreviewRender } from '@api/services/course/certificate';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { generateCoursePdf } from '@api/utils/course';
import { handleError } from '@api/utils/errors';
import { AppError, ErrorCodes, handleError } from '@api/utils/errors';
import { invitesRouter } from '@api/routes/course/invite';
import { katexRouter } from '@api/routes/course/katex';
import { lessonRouter } from '@api/routes/course/lesson';
@@ -62,6 +65,36 @@ import { submissionRouter } from '@api/routes/course/submission';
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
import { zValidator } from '@hono/zod-validator';
function slugifyForFilename(value: string): string {
return (
value
.normalize('NFKD')
.replace(/[^a-zA-Z0-9 ]/g, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 60) || 'certificate'
);
}
async function loadCertificateInput(
courseId: string,
userId: string,
body: import('@cio/utils/validation/course').TCertificateDownloadRequest
) {
if (body.previewMode) {
const isTeam = await isCourseTeamMemberOrOrgAdmin(courseId, userId);
if (!isTeam) {
throw new AppError('Only course team members can preview certificate designs', ErrorCodes.UNAUTHORIZED, 403);
}
return assembleOwnerPreviewRender(courseId, userId, body);
}
await assertCertificateDownloadAllowed(courseId, userId);
return assembleCertificateRender(courseId, body);
}
const enrollRateLimit = createRateLimiter({
windowMs: 60 * 60 * 1000,
maxRequests: 20,
@@ -454,27 +487,67 @@ export const courseRouter = new Hono()
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownload),
zValidator('json', ZCertificateDownloadRequest),
async (c) => {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
await assertCertificateDownloadAllowed(courseId, user.id);
try {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
const body = c.req.valid('json');
const validatedData = c.req.valid('json');
const input = await loadCertificateInput(courseId, user.id, body);
const buffer = await generateCertificatePdf(input);
const result = await generateCertificate(validatedData);
c.header('Content-Type', 'application/pdf');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${slugifyForFilename(input.data.courseName)}.pdf"`
);
c.header('Content-Type', 'application/pdf');
c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`);
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(buffer);
controller.close();
}
})
);
} catch (error) {
return handleError(c, error, 'Failed to download certificate');
}
}
)
.post(
'/:courseId/download/certificate/png',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownloadRequest),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
const body = c.req.valid('json');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(result);
controller.close();
}
})
);
const input = await loadCertificateInput(courseId, user.id, body);
const buffer = await generateCertificatePng(input);
c.header('Content-Type', 'image/png');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${slugifyForFilename(input.data.courseName)}.png"`
);
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(buffer);
controller.close();
}
})
);
} catch (error) {
return handleError(c, error, 'Failed to download certificate image');
}
}
)
.post(
@@ -542,4 +615,5 @@ export const courseRouter = new Hono()
.route('/:courseId/newsfeed', newsfeedRouter)
.route('/:courseId/members', membersRouter)
.route('/:courseId/invites', invitesRouter)
.route('/:courseId/ai-tutor', courseAiTutorRouter)
.route('/presign', presignRouter);
+99 -2
View File
@@ -1,13 +1,44 @@
import { Hono } from '@api/utils/hono';
import { ZDashStats, ZLoginActivity } from '@cio/utils/validation/dash';
import {
ZDashAnalyticsRange,
ZDashComplianceOverview,
ZDashCourseFunnel,
ZDashStats,
ZIngestBatch,
ZLoginActivity
} from '@cio/utils/validation/dash';
import { authMiddleware } from '@api/middlewares/auth';
import { getCurrentUserLoginStreak, getOrganisationAnalytics, getStudentLoginActivity } from '@api/services/dash';
import { getOrgComplianceOverview } from '@api/services/course/compliance';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
import {
getCountryBreakdown,
getCourseFunnel,
getLandingStats,
getPopularTypes,
ingestEventBatch
} from '@api/services/analytics';
export const dashAnalyticsRouter = new Hono()
.post('/track', zValidator('json', ZIngestBatch), async (c) => {
try {
const batch = c.req.valid('json');
const user = c.get('user');
const result = await ingestEventBatch(batch, {
country: c.req.header('cf-ipcountry') ?? c.req.header('CF-IPCountry') ?? null,
userAgent: c.req.header('user-agent') ?? null,
userId: user?.id ?? null
});
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to ingest analytics events');
}
})
.get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => {
try {
const { orgId, siteName } = c.req.valid('query');
@@ -39,4 +70,70 @@ export const dashAnalyticsRouter = new Hono()
} catch (error) {
return handleError(c, error, 'Failed to load login streak');
}
});
})
.get('/landing-stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashAnalyticsRange), async (c) => {
try {
const { orgId, days } = c.req.valid('query');
const bust = c.req.query('bust') === '1';
const result = await getLandingStats(orgId, days, bust);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load landing stats');
}
})
.get(
'/country-breakdown',
authMiddleware,
orgMemberMiddleware,
zValidator('query', ZDashAnalyticsRange),
async (c) => {
try {
const { orgId, days } = c.req.valid('query');
const bust = c.req.query('bust') === '1';
const result = await getCountryBreakdown(orgId, days, bust);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load country breakdown');
}
}
)
.get('/course-funnel', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashCourseFunnel), async (c) => {
try {
const { orgId, days, courseId } = c.req.valid('query');
const bust = c.req.query('bust') === '1';
const result = await getCourseFunnel(orgId, days, courseId, bust);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load course funnel');
}
})
.get('/popular-types', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashAnalyticsRange), async (c) => {
try {
const { orgId, days } = c.req.valid('query');
const bust = c.req.query('bust') === '1';
const result = await getPopularTypes(orgId, days, bust);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load popular course types');
}
})
.get(
'/compliance-overview',
authMiddleware,
orgAdminMiddleware,
zValidator('query', ZDashComplianceOverview),
async (c) => {
try {
const { orgId } = c.req.valid('query');
const result = await getOrgComplianceOverview(orgId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load compliance overview');
}
}
);
+13
View File
@@ -0,0 +1,13 @@
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { handleError } from '@api/utils/errors';
import { runWorkspaceGraceCheck } from '@api/services/account';
export const internalAccountRouter = new Hono().post('/workspace-grace', apiKeyMiddleware, async (c) => {
try {
const data = await runWorkspaceGraceCheck();
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to run workspace grace check');
}
});
+18
View File
@@ -0,0 +1,18 @@
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { handleError } from '@api/utils/errors';
import { runAnalyticsRollupDaily } from '@cio/analytics';
export const internalAnalyticsRouter = new Hono().post('/rollup-daily', apiKeyMiddleware, async (c) => {
try {
const daysAgoHeader = c.req.query('daysAgo');
const daysAgo = daysAgoHeader ? Number(daysAgoHeader) : 1;
const result = await runAnalyticsRollupDaily({
daysAgo: Number.isFinite(daysAgo) && daysAgo > 0 ? daysAgo : 1
});
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to run analytics rollup');
}
});
@@ -1,4 +1,5 @@
import { runComplianceExpiryCheck, runComplianceReminderScan } from '@api/services/course/compliance';
import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/services/program/goal';
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
@@ -34,4 +35,32 @@ export const internalComplianceRouter = new Hono()
} catch (error) {
return handleError(c, error, 'Failed to run compliance reminder scan');
}
})
/**
* POST /internal/compliance/evaluate-program-goals
* Daily sweep: re-evaluate every active program goal and refresh per-learner
* `program_goal_assignment` rows.
*/
.post('/evaluate-program-goals', apiKeyMiddleware, async (c) => {
try {
const data = await runProgramGoalEvaluationSweep();
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to run program goal evaluation sweep');
}
})
/**
* POST /internal/compliance/send-program-goal-reminders
* Sends learner reminder emails for upcoming/overdue program goals using
* each goal's `reminderDaysBefore` cadence.
*/
.post('/send-program-goal-reminders', apiKeyMiddleware, async (c) => {
try {
const data = await runProgramGoalReminderScan();
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to run program goal reminder scan');
}
});
+9
View File
@@ -0,0 +1,9 @@
import { Hono } from '@api/utils/hono';
import { internalAccountRouter } from './account';
import { internalAnalyticsRouter } from './analytics';
import { internalComplianceRouter } from './compliance';
export const internalRouter = new Hono()
.route('/compliance', internalComplianceRouter)
.route('/analytics', internalAnalyticsRouter)
.route('/account', internalAccountRouter);
+1
View File
@@ -0,0 +1 @@
export * from './jobs';
+142
View File
@@ -0,0 +1,142 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
import { ZAssetJobsParam, ZJobBatchQuery, ZJobIdParam } from '@cio/utils/validation/jobs';
import {
cancelMediaJob,
getMediaJobEnvelope,
getMediaJobSteps,
listMediaJobEnvelopes,
listMediaJobsForAssetEnvelopes,
startTranscriptionOnlyMediaJob
} from '@api/services/jobs';
import type { JobEnvelope } from '@cio/jobs/status';
/**
* Generic + media-flavoured job status routes.
*
* GET /jobs/media/:jobId -> single envelope (ETag/304 supported)
* GET /jobs/media?ids=a,b,c -> map keyed by job id
* GET /jobs/media/asset/:assetId -> all runs for an asset (lesson editor badges)
* POST /jobs/media/:jobId/cancel -> request cancellation
* GET /jobs/media/:jobId/steps -> ledger entries (admin / debug)
*
* POST /jobs/media/asset/:assetId/transcribe -> enqueue extract + whisper only (202)
*
* Domain-specific surfaces (e.g. /media/jobs) can re-export the same handlers
* once additional domains adopt the envelope. Returning a single shape per
* route keeps the RPC types unambiguous.
*/
function envelopeETag(envelope: JobEnvelope): string {
return `W/"${envelope.job.id}-${envelope.job.updatedAt}"`;
}
export const jobsRouter = new Hono()
.get(
'/media/:jobId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZJobIdParam.extend({ jobId: ZJobIdParam.shape.jobId }).pick({ jobId: true })),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { jobId } = c.req.valid('param');
const envelope = await getMediaJobEnvelope(jobId, orgId);
const etag = envelopeETag(envelope);
const ifNoneMatch = c.req.header('if-none-match');
if (ifNoneMatch === etag) {
c.header('ETag', etag);
return c.body(null, 304);
}
c.header('ETag', etag);
c.header('Last-Modified', new Date(envelope.job.updatedAt).toUTCString());
return c.json({ success: true, data: envelope }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch media job');
}
}
)
.get('/media', authMiddleware, orgMemberMiddleware, zValidator('query', ZJobBatchQuery), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { ids } = c.req.valid('query');
const envelopes = await listMediaJobEnvelopes(ids, orgId);
return c.json({ success: true, data: envelopes }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch media jobs');
}
})
.get(
'/media/asset/:assetId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetJobsParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const envelopes = await listMediaJobsForAssetEnvelopes(assetId, orgId);
return c.json({ success: true, data: envelopes }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch media jobs for asset');
}
}
)
.post(
'/media/asset/:assetId/transcribe',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetJobsParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { assetId } = c.req.valid('param');
const job = await startTranscriptionOnlyMediaJob({
organizationId: orgId,
assetId,
triggeredByProfileId: user?.id ?? null
});
return c.json({ success: true, data: job }, 202);
} catch (error) {
return handleError(c, error, 'Failed to start transcription');
}
}
)
.post(
'/media/:jobId/cancel',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZJobIdParam.pick({ jobId: true })),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { jobId } = c.req.valid('param');
const envelope = await cancelMediaJob(jobId, orgId);
return c.json({ success: true, data: envelope }, 200);
} catch (error) {
return handleError(c, error, 'Failed to cancel media job');
}
}
)
.get(
'/media/:jobId/steps',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZJobIdParam.pick({ jobId: true })),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { jobId } = c.req.valid('param');
const steps = await getMediaJobSteps(jobId, orgId);
return c.json({ success: true, data: steps }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch media job steps');
}
}
);
+30 -16
View File
@@ -1,31 +1,45 @@
import { Hono } from '@api/utils/hono';
import { ZDeliverEmail } from '@cio/utils/validation/mail';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { deliverEmail } from '@cio/email';
import { enqueueRawEmail } from '@api/services/jobs';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
/**
* Public mail endpoint — accepts a list of raw subject/content emails and
* enqueues them on the BullMQ `emails` queue via the transactional outbox.
* Returns 202 with the outbox + delivery ids so callers can poll status if
* needed.
*/
export const mailRouter = new Hono().post('/', apiKeyMiddleware, zValidator('json', ZDeliverEmail), async (c) => {
const validatedData = c.req.valid('json');
try {
const results = await deliverEmail(validatedData);
const enqueued = await Promise.all(
validatedData.map((emailItem) =>
enqueueRawEmail({
from: emailItem.from,
to: emailItem.to,
subject: emailItem.subject,
content: emailItem.content,
replyTo: emailItem.replyTo
})
)
);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
code: 'EMAIL_SEND_PARTIAL_FAILURE',
details: results
},
500
);
}
const jobIds = enqueued.flatMap((entry) => entry.jobIds);
return c.json({ success: true, data: results }, 200);
return c.json(
{
success: true,
data: {
accepted: jobIds.length,
jobIds
}
},
202
);
} catch (error) {
return handleError(c, error, 'Failed to process email send request', 'EMAIL_SEND_FAILED');
return handleError(c, error, 'Failed to enqueue email send request', 'EMAIL_SEND_FAILED');
}
});
@@ -0,0 +1,38 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { ZAiTutorSettingsUpdate } from '@cio/utils/validation/agent';
import { getResolvedOrgAiTutorSettings, updateOrgAiTutorSettingsService } from '@api/services/agent/tutor-config';
/**
* Org-level AI tutor configuration.
*
* GET /organization/ai-tutor — any org member can read the resolved settings.
* PUT /organization/ai-tutor — org admin only; merges the patch into stored settings.
*/
export const organizationAiTutorRouter = new Hono()
.get('/', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const settings = await getResolvedOrgAiTutorSettings(orgId);
return c.json({ success: true as const, data: settings });
} catch (error) {
return handleError(c, error, 'Failed to fetch AI tutor settings');
}
})
.put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZAiTutorSettingsUpdate), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const patch = c.req.valid('json');
const updated = await updateOrgAiTutorSettingsService(orgId, patch);
return c.json({ success: true as const, data: updated });
} catch (error) {
return handleError(c, error, 'Failed to update AI tutor settings');
}
});
@@ -19,6 +19,7 @@ import {
getAssetService,
getAssetUsageGraphService,
getOrganizationAssetStorageService,
getTranscriptForOrganizationAssetService,
getYouTubeMetadataService,
listOrganizationAssetsService,
updateAssetService
@@ -150,6 +151,27 @@ export const assetsRouter = new Hono()
}
}
)
/**
* GET /organization/assets/:assetId/transcript
* Whisper transcript + presigned VTT URL for captions (or null if none).
*/
.get('/:assetId/transcript', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const data = await getTranscriptForOrganizationAssetService(orgId, assetId);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch transcript');
}
})
/**
* GET /organization/assets/:assetId
* Get a single asset by id
@@ -48,6 +48,7 @@ import { Hono } from '@api/utils/hono';
import { ROLE } from '@cio/utils/constants';
import { TOrganization } from '@db/types';
import { assetsRouter } from '@api/routes/organization/assets';
import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
@@ -670,4 +671,5 @@ export const organizationRouter = new Hono()
.route('/tags', tagsRouter)
.route('/widgets', widgetsRouter)
.route('/assets', assetsRouter)
.route('/ai-tutor', organizationAiTutorRouter)
.route('/:orgId/quiz', quizRouter);
+1 -27
View File
@@ -3,19 +3,12 @@ import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import {
ZCreateWidget,
ZPreviewWidget,
ZRollbackWidget,
ZUpdateWidget,
ZWidgetIdParams
} from '@cio/utils/validation/widget';
import { ZCreateWidget, ZRollbackWidget, ZUpdateWidget, ZWidgetIdParams } from '@cio/utils/validation/widget';
import {
createOrganizationWidget,
deleteOrganizationWidget,
getOrganizationWidgetDetail,
listOrganizationWidgets,
previewOrganizationWidget,
publishOrganizationWidget,
rollbackOrganizationWidget,
updateOrganizationWidget
@@ -87,25 +80,6 @@ export const widgetsRouter = new Hono()
return handleError(c, error, 'Failed to delete widget');
}
})
.post(
'/:widgetId/preview',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('param', ZWidgetIdParams),
zValidator('json', ZPreviewWidget),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { widgetId } = c.req.valid('param');
const data = c.req.valid('json');
const payload = await previewOrganizationWidget(orgId, widgetId, data);
return c.json({ success: true, data: payload });
} catch (error) {
return handleError(c, error, 'Failed to build widget preview');
}
}
)
.post('/:widgetId/publish', authMiddleware, orgAdminMiddleware, zValidator('param', ZWidgetIdParams), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
+255
View File
@@ -3,10 +3,14 @@ import * as z from 'zod';
import {
ZAddCourseToProgram,
ZAddProgramMembers,
ZAssignExistingStudentsToProgram,
ZCreateProgram,
ZCreateProgramGoal,
ZCreateProgramNewsfeed,
ZCreateProgramNewsfeedComment,
ZInviteStudentsToProgram,
ZUpdateProgram,
ZUpdateProgramGoal,
ZUpdateProgramMember,
ZUpdateProgramNewsfeed,
ZUpdateProgramReaction
@@ -34,9 +38,23 @@ import {
updateProgramNewsfeedReactionService,
updateProgramNewsfeedService
} from '@api/services/program/program';
import { assignExistingStudentsToProgram, inviteStudentsToProgram } from '@api/services/program/invite';
import {
archiveGoal,
createGoal,
evaluateGoal,
evaluateProgramGoals,
getGoal,
getMyGoals,
getOrgGoalsOverview,
listGoals,
removeGoal,
updateGoal
} from '@api/services/program/goal';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { programMemberMiddleware } from '@api/middlewares/program-member';
import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member';
import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team';
@@ -52,6 +70,7 @@ const ZCommentParam = z.object({
feedId: z.string().uuid(),
commentId: z.coerce.number().int()
});
const ZGoalParam = z.object({ programId: z.string().uuid(), goalId: z.string().uuid() });
const ZListQuery = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(50).default(10)
@@ -247,6 +266,56 @@ export const programRouter = new Hono()
}
)
// ── Invite ────────────────────────────────────────────────────────────────
/**
* POST /program/:programId/invite
* Invite new students to a program by CSV emails. Issues org invites
* pre-tagged with this program so accept auto-enrolls them.
* Gated by program-team-member middleware — Program ADMINs/TUTORs can
* invite into their own program without org-admin rights.
*/
.post(
'/:programId/invite',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZInviteStudentsToProgram),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await inviteStudentsToProgram(programId, data, user.id);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to invite students to program');
}
}
)
/**
* POST /program/:programId/invite/assign
* Assign existing org-audience student profiles to a program.
*/
.post(
'/:programId/invite/assign',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZAssignExistingStudentsToProgram),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await assignExistingStudentsToProgram(programId, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to assign students to program');
}
}
)
// ── Courses ───────────────────────────────────────────────────────────────
/**
@@ -484,4 +553,190 @@ export const programRouter = new Hono()
return handleError(c, error, 'Failed to delete program newsfeed comment');
}
}
)
// ── Goals ─────────────────────────────────────────────────────────────────
/**
* GET /program/my/goals
* Goal assignments for the current authenticated user across all their programs.
* Used by the LMS "Your Goals" widget.
*/
.get('/my/goals', authMiddleware, async (c) => {
try {
const user = c.get('user')!;
const goals = await getMyGoals(user.id);
return c.json({ success: true, data: goals }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load goals');
}
})
/**
* GET /program/goals/overview?organizationId=...
* Cross-program goal roll-up for the org owner. One entry per active goal
* with per-status learner counts, scoped to the org via header + query.
*/
.get('/goals/overview', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZOrgQuery), async (c) => {
try {
const { organizationId } = c.req.valid('query');
const overview = await getOrgGoalsOverview(organizationId);
return c.json({ success: true, data: overview }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load goals overview');
}
})
/**
* GET /program/:programId/goals
*/
.get('/:programId/goals', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
try {
const { programId } = c.req.valid('param');
const goals = await listGoals(programId);
return c.json({ success: true, data: goals }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program goals');
}
})
/**
* POST /program/:programId/goals
*/
.post(
'/:programId/goals',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZCreateProgramGoal),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const goal = await createGoal(programId, user.id, data);
return c.json({ success: true, data: goal }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program goal');
}
}
)
/**
* GET /program/:programId/goals/:goalId
*/
.get(
'/:programId/goals/:goalId',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await getGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get program goal');
}
}
)
/**
* PUT /program/:programId/goals/:goalId
*/
.put(
'/:programId/goals/:goalId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
zValidator('json', ZUpdateProgramGoal),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const data = c.req.valid('json');
const goal = await updateGoal(goalId, data);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program goal');
}
}
)
/**
* DELETE /program/:programId/goals/:goalId
* Hard delete (vs. archive). Use PUT with status=archived to preserve history.
*/
.delete(
'/:programId/goals/:goalId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await removeGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program goal');
}
}
)
/**
* POST /program/:programId/goals/:goalId/archive
*/
.post(
'/:programId/goals/:goalId/archive',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await archiveGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to archive program goal');
}
}
)
/**
* POST /program/:programId/goals/:goalId/evaluate
* Force a re-evaluation of one goal's per-learner statuses.
*/
.post(
'/:programId/goals/:goalId/evaluate',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const result = await evaluateGoal(goalId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to evaluate program goal');
}
}
)
/**
* POST /program/:programId/goals/evaluate-all
* Force a re-evaluation of every active goal in the program.
*/
.post(
'/:programId/goals/evaluate-all',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
async (c) => {
try {
const { programId } = c.req.valid('param');
const result = await evaluateProgramGoals(programId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to evaluate program goals');
}
}
);