feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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')!;
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './jobs';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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')!;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user