chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
+462
View File
@@ -0,0 +1,462 @@
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 { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { handleError, AppError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { streamText, stepCountIs, convertToModelMessages } from 'ai';
import {
ZAgentChatBody,
ZAgentCreditPurchase,
ZAgentCreditsBody,
ZAgentGenerateCourseTitleBody,
ZAgentStatusQuery
} from '@cio/utils/validation/agent';
import {
addCredits,
enforceTokenBalance,
getDetailedUsage,
getPurchasedSummary,
getTeamLeaderboard,
getTokenBalance,
isOrgOnPaidPlan,
recordTokenUsage
} from '@api/services/agent/usage';
import { parseAndStoreDocument } from '@api/services/agent/document';
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
import { generateCourseMeta } from '@api/services/agent/title-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 { 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 { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import { redis } from '@api/utils/redis/redis';
import { db } from '@cio/db';
import * as schema from '@cio/db/schema';
import { eq } from 'drizzle-orm';
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 {
collectDocumentIds,
getLatestImplementationPlan,
loadDocumentsText,
verifyExerciseBelongsToCourse,
verifyLessonBelongsToCourse
} from '@api/services/agent/chat-context';
import { buildAgentTools } from '@api/services/agent/chat-tools';
import { agentHistoryRouter } from './history';
const agentCoreRouter = new Hono()
.get('/status', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentStatusQuery), async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const { courseId } = c.req.valid('query');
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
const status: AgentStatus = {
enabled: false,
role: AgentRole.STUDENT,
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 }
};
return c.json({ success: true, data: status });
}
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
const usage = await getTokenBalance(orgId);
const status: AgentStatus = {
enabled: true,
role,
usage
};
return c.json({ success: true, data: status });
} catch (error) {
return handleError(c, error, 'Failed to get agent status');
}
})
.post('/upload', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const courseId = c.req.query('courseId');
if (!courseId) {
throw new AppError('Course ID is required', 'COURSE_ID_REQUIRED', 400);
}
const conversationId = c.req.query('conversationId');
if (!conversationId) {
throw new AppError('Conversation ID is required', 'CONVERSATION_ID_REQUIRED', 400);
}
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
if (!isTeamMember) {
throw new AppError('You must be a course team member to upload documents', 'NOT_COURSE_TEAM_MEMBER', 403);
}
const conversation = await getChatConversation(conversationId, user.id);
if (!conversation || conversation.courseId !== courseId) {
throw new AppError('Conversation not found', 'CONVERSATION_NOT_FOUND', 404);
}
const isPaid = await isOrgOnPaidPlan(orgId);
if (!isPaid) {
return c.json(
{
success: false,
error: 'document_upload_requires_upgrade',
upgradeRequired: true
},
403
);
}
const body = await c.req.parseBody();
const file = body.file;
if (!(file instanceof File)) {
throw new AppError('File is required', 'FILE_REQUIRED', 400);
}
const result = await parseAndStoreDocument(file, orgId, user.id, courseId, conversationId, redis);
return c.json({ success: true, data: result });
} catch (error) {
if (error instanceof AppError) {
if (error.statusCode === 413) {
return c.json({ success: false, error: 'file_too_large', maxSize: 5242880 }, 413);
}
if (error.statusCode === 415) {
return c.json(
{
success: false,
error: 'unsupported_file_type',
allowed: [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
]
},
415
);
}
}
return handleError(c, error, 'Failed to upload document');
}
})
.get('/usage/purchased', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const data = await getPurchasedSummary(orgId);
return c.json({ success: true, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch purchased summary');
}
})
.get('/usage/leaderboard', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const data = await getTeamLeaderboard(orgId);
return c.json({ success: true, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch leaderboard');
}
})
.get('/usage', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const usage = await getDetailedUsage(orgId);
return c.json({ success: true, data: usage });
} catch (error) {
return handleError(c, error, 'Failed to fetch usage stats');
}
})
.post('/credits', authMiddleware, orgAdminMiddleware, zValidator('json', ZAgentCreditsBody), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { amount } = c.req.valid('json');
await addCredits(orgId, amount);
const balance = await getTokenBalance(orgId);
return c.json({ success: true, data: balance });
} catch (error) {
return handleError(c, error, 'Failed to purchase credits');
}
})
.post('/credits/purchase', authOrApiKeyMiddleware, zValidator('json', ZAgentCreditPurchase), async (c) => {
try {
const body = c.req.valid('json');
const purchase = await recordCreditPurchase(body);
return c.json({ success: true, data: purchase });
} catch (error) {
return handleError(c, error, 'Failed to record credit purchase');
}
})
.post(
'/generate-course-title',
authMiddleware,
orgMemberMiddleware,
zValidator('json', ZAgentGenerateCourseTitleBody),
async (c) => {
try {
const { prompt } = c.req.valid('json');
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
const meta = await generateCourseMeta(prompt, providerConfig);
return c.json({ success: true as const, data: meta });
} catch (error) {
return handleError(c, error, 'Failed to generate course title');
}
}
)
.post('/chat', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentChatBody), async (c) => {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
try {
const { courseId, messages, context, model: requestedModel } = c.req.valid('json');
const modelId = requestedModel ?? DEFAULT_PICKER_MODEL_ID;
const modelDescriptor = AGENT_MODELS[modelId];
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
if (!baseProviderConfig) {
throw new AppError(`AI assistant is not configured for model "${modelId}"`, 'AI_NOT_CONFIGURED', 503);
}
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,
description: schema.course.description,
organizationId: schema.group.organizationId
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(eq(schema.course.id, courseId))
.limit(1);
if (!courseRow) {
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
}
if (courseRow.organizationId !== orgId) {
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
}
if (!modelDescriptor.isFree) {
const isPaid = await isOrgOnPaidPlan(orgId);
if (!isPaid) {
throw new AppError('Upgrade required to use this AI model', 'UPGRADE_REQUIRED', 403);
}
}
await enforceTokenBalance(orgId);
const documentIds = collectDocumentIds(messages, context?.documentId);
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined;
const existingSections = await listCourseSections(courseId);
let lessonTitle: string | undefined;
let lessonContent: string | undefined;
if (context?.lessonId) {
try {
await verifyLessonBelongsToCourse(context.lessonId, courseId);
const lesson = await getLesson(context.lessonId);
lessonTitle = lesson.title;
const lessonWithLangs = lesson as {
lessonLanguages?: Array<{ locale: string; content: string | null }>;
};
const langContent = lessonWithLangs.lessonLanguages?.find((ll) => ll.locale === 'en');
lessonContent = langContent?.content || undefined;
} catch {
// Lesson not found or doesn't belong — continue without lesson context
}
}
let exerciseTitle: string | undefined;
if (context?.exerciseId) {
try {
await verifyExerciseBelongsToCourse(context.exerciseId, courseId);
const exercise = await getExercise(context.exerciseId);
exerciseTitle = exercise.title;
} catch {
// Exercise not found or doesn't belong — continue without exercise context
}
}
const agentContext: AgentContext = {
orgId,
courseId,
courseTitle: courseRow.title,
courseDescription: courseRow.description || undefined,
userId: user.id,
role,
locale: 'en',
lessonId: context?.lessonId,
lessonTitle,
lessonContent,
exerciseId: context?.exerciseId,
exerciseTitle,
documentId: context?.documentId,
documentText,
existingSectionCount: existingSections.length
};
trackAgentEvent(AgentEvent.CHAT_STARTED, {
orgId,
userId: user.id,
courseId,
role,
hasDocument: !!documentText,
messageCount: messages.length
});
const startTime = Date.now();
const model = createModel(providerConfig);
const approvedPlan = getLatestImplementationPlan(messages);
const systemPrompt = [
buildSystemPrompt(agentContext),
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 trimmedMessages = trimMessageHistory(messages);
const modelMessages = await convertToModelMessages(trimmedMessages);
let completedStepCount = 0;
let finishReason: string | undefined;
const isAnthropic = providerConfig.provider === AIProvider.ANTHROPIC;
const systemContent = isAnthropic
? {
role: 'system' as const,
content: systemPrompt,
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
}
: systemPrompt;
const result = streamText({
model,
maxRetries: 0,
system: systemContent,
messages: modelMessages,
tools: agentTools,
stopWhen: stepCountIs(MAX_STEPS_PER_ROUND),
onStepFinish: () => {
completedStepCount += 1;
},
onFinish: async ({ totalUsage, finishReason: resultFinishReason, steps }) => {
completedStepCount = steps.length;
finishReason = resultFinishReason;
const durationMs = Date.now() - startTime;
const inputTokens = totalUsage?.inputTokens ?? 0;
const outputTokens = totalUsage?.outputTokens ?? 0;
if (totalUsage) {
await recordTokenUsage(
orgId,
user.id,
courseId,
{
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens
},
providerConfig.model || providerConfig.provider
);
}
trackAgentEvent(AgentEvent.CHAT_COMPLETED, {
orgId,
userId: user.id,
courseId,
inputTokens,
outputTokens,
model: providerConfig.model || providerConfig.provider,
durationMs
});
}
});
return result.toUIMessageStreamResponse({
messageMetadata: ({ part }) => {
if (part.type !== 'finish') {
return undefined;
}
return {
tokenUsage: {
promptTokens: part.totalUsage.inputTokens,
completionTokens: part.totalUsage.outputTokens,
totalTokens: part.totalUsage.totalTokens
},
continuation:
completedStepCount >= MAX_STEPS_PER_ROUND
? {
reason: 'step_limit' as const,
maxSteps: MAX_STEPS_PER_ROUND,
finishReason
}
: undefined
};
}
});
} catch (error) {
trackAgentEvent(AgentEvent.CHAT_ERROR, {
orgId,
userId: user?.id,
errorMessage: error instanceof Error ? error.message : 'Unknown error'
});
if (error instanceof AppError) {
return c.json({ success: false, error: error.message, code: error.code }, error.statusCode);
}
console.error('Agent chat error:', error);
return c.json({ success: false, error: 'Failed to process chat message', code: 'INTERNAL_ERROR' }, 500);
}
});
export const agentRouter = new Hono().route('/', agentCoreRouter).route('/history', agentHistoryRouter);
File diff suppressed because it is too large Load Diff
-35
View File
@@ -1,35 +0,0 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { ZAgentCreditsBody } from '@cio/utils/validation/agent';
import { addCredits, getTokenBalance } from '@api/services/agent/usage';
/**
* POST /agent/credits
* Purchase additional token credits. Org admins only.
* In v1, this directly adds credits (no payment integration).
* Payment integration will be added when billing provider is wired up.
*/
export const agentCreditsRouter = new Hono().post(
'/credits',
authMiddleware,
orgAdminMiddleware,
zValidator('json', ZAgentCreditsBody),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { amount } = c.req.valid('json');
// TODO: Integrate with payment provider before adding credits.
// For now, this endpoint adds credits directly for testing purposes.
await addCredits(orgId, amount);
const balance = await getTokenBalance(orgId);
return c.json({ success: true, data: balance });
} catch (error) {
return handleError(c, error, 'Failed to purchase credits');
}
}
);
+20 -21
View File
@@ -28,7 +28,7 @@ export const agentHistoryRouter = new Hono()
* GET /agent/history?courseId=...
* List all conversations for the current user in a course.
*/
.get('/history', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentHistoryQuery), async (c) => {
.get('/', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentHistoryQuery), async (c) => {
try {
const user = c.get('user')!;
const { courseId } = c.req.valid('query');
@@ -46,7 +46,7 @@ export const agentHistoryRouter = new Hono()
* Get a single conversation with its messages.
*/
.get(
'/history/:conversationId',
'/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
@@ -72,31 +72,25 @@ export const agentHistoryRouter = new Hono()
* POST /agent/history
* Create a new conversation.
*/
.post(
'/history',
authMiddleware,
orgMemberMiddleware,
zValidator('json', ZAgentConversationCreateBody),
async (c) => {
try {
const user = c.get('user')!;
const { courseId, title } = c.req.valid('json');
.post('/', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentConversationCreateBody), async (c) => {
try {
const user = c.get('user')!;
const { courseId, title } = c.req.valid('json');
const conversation = await createChatConversation(courseId, user.id, title);
const conversation = await createChatConversation(courseId, user.id, title);
return c.json({ success: true as const, data: conversation }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create conversation');
}
return c.json({ success: true as const, data: conversation }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create conversation');
}
)
})
/**
* PUT /agent/history/:conversationId
* Save messages to a conversation.
*/
.put(
'/history/:conversationId',
'/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
@@ -121,7 +115,7 @@ export const agentHistoryRouter = new Hono()
* Delete a conversation.
*/
.delete(
'/history/:conversationId',
'/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentHistoryDeleteParam),
@@ -144,7 +138,7 @@ export const agentHistoryRouter = new Hono()
* Generate a short title from the first user message using a cheap model.
*/
.post(
'/history/:conversationId/generate-title',
'/:conversationId/generate-title',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentGenerateTitleParam),
@@ -160,7 +154,12 @@ export const agentHistoryRouter = new Hono()
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
const title = await generateConversationTitle(firstMessageText, providerConfig);
let title: string;
try {
title = await generateConversationTitle(firstMessageText, providerConfig);
} catch {
title = firstMessageText.slice(0, 60).trim();
}
await updateConversationTitle(conversationId, user.id, title);
+1 -6
View File
@@ -1,6 +1 @@
export { agentStatusRouter } from './status';
export { agentChatRouter } from './chat';
export { agentUploadRouter } from './upload';
export { agentUsageRouter } from './usage';
export { agentCreditsRouter } from './credits';
export { agentHistoryRouter } from './history';
export { agentRouter } from './agent';
-58
View File
@@ -1,58 +0,0 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { ZAgentStatusQuery } from '@cio/utils/validation/agent';
import { getTokenBalance } from '@api/services/agent/usage';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { AgentRole, type AgentStatus } from '@cio/ai-assistant';
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
/**
* GET /agent/status
* Returns whether AI assistant is enabled, the user's role, and usage info.
* Used by the dashboard to show/hide the AI button and configure the UI.
*/
export const agentStatusRouter = new Hono().get(
'/status',
authMiddleware,
orgMemberMiddleware,
zValidator('query', ZAgentStatusQuery),
async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const { courseId } = c.req.valid('query');
// Check if AI is configured (self-hosted check) — enabled when any provider key is set
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
const status: AgentStatus = {
enabled: false,
role: AgentRole.STUDENT,
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 }
};
return c.json({ success: true, data: status });
}
// Detect role
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
// Get usage
const usage = await getTokenBalance(orgId);
const status: AgentStatus = {
enabled: true,
role,
usage
};
return c.json({ success: true, data: status });
} catch (error) {
return handleError(c, error, 'Failed to get agent status');
}
}
);
-91
View File
@@ -1,91 +0,0 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { handleError, AppError } from '@api/utils/errors';
import { parseAndStoreDocument } from '@api/services/agent/document';
import { isOrgOnPaidPlan } from '@api/services/agent/usage';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { getChatConversation } from '@cio/db/queries/agent';
import { redis } from '@api/utils/redis/redis';
/**
* POST /agent/upload
* Upload a PDF, DOCX, or PPTX file for AI-powered course plan generation.
* EARLY_ADOPTER+ plan required. Max 5MB.
*/
export const agentUploadRouter = new Hono().post('/upload', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const courseId = c.req.query('courseId');
if (!courseId) {
throw new AppError('Course ID is required', 'COURSE_ID_REQUIRED', 400);
}
const conversationId = c.req.query('conversationId');
if (!conversationId) {
throw new AppError('Conversation ID is required', 'CONVERSATION_ID_REQUIRED', 400);
}
// Verify course team membership
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
if (!isTeamMember) {
throw new AppError('You must be a course team member to upload documents', 'NOT_COURSE_TEAM_MEMBER', 403);
}
// Verify the conversation exists and belongs to this user + course
const conversation = await getChatConversation(conversationId, user.id);
if (!conversation || conversation.courseId !== courseId) {
throw new AppError('Conversation not found', 'CONVERSATION_NOT_FOUND', 404);
}
// Plan gate — EARLY_ADOPTER+ required
const isPaid = await isOrgOnPaidPlan(orgId);
if (!isPaid) {
return c.json(
{
success: false,
error: 'document_upload_requires_upgrade',
upgradeRequired: true
},
403
);
}
// Parse multipart body
const body = await c.req.parseBody();
const file = body.file;
if (!(file instanceof File)) {
throw new AppError('File is required', 'FILE_REQUIRED', 400);
}
const result = await parseAndStoreDocument(file, orgId, user.id, courseId, conversationId, redis);
return c.json({ success: true, data: result });
} catch (error) {
if (error instanceof AppError) {
if (error.statusCode === 413) {
return c.json({ success: false, error: 'file_too_large', maxSize: 5242880 }, 413);
}
if (error.statusCode === 415) {
return c.json(
{
success: false,
error: 'unsupported_file_type',
allowed: [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
]
},
415
);
}
}
return handleError(c, error, 'Failed to upload document');
}
});
-21
View File
@@ -1,21 +0,0 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { handleError } from '@api/utils/errors';
import { getDetailedUsage } from '@api/services/agent/usage';
/**
* GET /agent/usage
* Returns detailed usage stats for the current org:
* used, allowance, creditBalance, remaining, and daily history for the current month.
*/
export const agentUsageRouter = new Hono().get('/usage', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const usage = await getDetailedUsage(orgId);
return c.json({ success: true, data: usage });
} catch (error) {
return handleError(c, error, 'Failed to fetch usage stats');
}
});
+71 -1
View File
@@ -5,7 +5,11 @@ import {
ZExerciseGetParam,
ZExerciseListQuery,
ZExerciseSubmissionCreate,
ZExerciseUpdate
ZExerciseUpdate,
ZExerciseVideoRecordingParam,
ZExerciseVideoRecordingPlaybackParam,
ZExerciseVideoRecordingUploadComplete,
ZExerciseVideoRecordingUploadInit
} from '@cio/utils/validation/exercise';
import { ZTemplateById, ZTemplateByTag } from '@cio/utils/validation/mocks';
import {
@@ -19,6 +23,11 @@ import {
} from '@api/services/exercise/exercise';
import { fetchAllTemplatesMetadata, fetchTemplateById, fetchTemplatesByTag } from '@api/services/exercise/template';
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import {
completeVideoRecordingUpload,
getVideoRecordingPlaybackUrl,
initVideoRecordingUpload
} from '@api/services/exercise/video-recording';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
@@ -259,6 +268,67 @@ export const exerciseRouter = new Hono()
}
}
)
.post(
'/:exerciseId/question/:questionId/video-recording/upload/init',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZExerciseVideoRecordingParam),
zValidator('json', ZExerciseVideoRecordingUploadInit),
async (c) => {
try {
const user = c.get('user')!;
const courseId = c.req.param('courseId')!;
const { exerciseId, questionId } = c.req.valid('param');
const data = c.req.valid('json');
const upload = await initVideoRecordingUpload(courseId, exerciseId, questionId, user.id, data);
return c.json({ success: true, data: upload }, 200);
} catch (error) {
return handleError(c, error, 'Failed to initialize video recording upload');
}
}
)
.post(
'/:exerciseId/question/:questionId/video-recording/upload/complete',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZExerciseVideoRecordingParam),
zValidator('json', ZExerciseVideoRecordingUploadComplete),
async (c) => {
try {
const user = c.get('user')!;
const courseId = c.req.param('courseId')!;
const { exerciseId, questionId } = c.req.valid('param');
const data = c.req.valid('json');
const answer = await completeVideoRecordingUpload(courseId, exerciseId, questionId, user.id, data);
return c.json({ success: true, data: answer }, 200);
} catch (error) {
return handleError(c, error, 'Failed to complete video recording upload');
}
}
)
.get(
'/:exerciseId/submission/:submissionId/question/:questionId/video-recording/playback',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZExerciseVideoRecordingPlaybackParam),
async (c) => {
try {
const user = c.get('user')!;
const courseId = c.req.param('courseId')!;
const { exerciseId, submissionId, questionId } = c.req.valid('param');
const playback = await getVideoRecordingPlaybackUrl(courseId, exerciseId, submissionId, questionId, user.id);
return c.json({ success: true, data: playback }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get video recording playback URL');
}
}
)
// Template routes
.get('/template', authMiddleware, courseMemberMiddleware, async (c) => {
try {
+1
View File
@@ -0,0 +1 @@
export * from './public-course';
@@ -0,0 +1,55 @@
import { ZPublicCourseBySlugParam, ZPublicCourseItemParam } from '@cio/utils/validation/course';
import { getPublicCourseItemService, getPublicCourseTreeService } from '@api/services/course/public-course';
import { Hono } from '@api/utils/hono';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
/**
* Anonymous-safe (no auth middleware) routes that back the `(org-site)/course/[slug]`
* surface in the dashboard. Every response returns a single typed shape per the
* repo's single-response-shape rule; the item endpoint uses a `kind` discriminator.
*/
export const publicCourseRouter = new Hono()
.get('/:courseSlug', zValidator('param', ZPublicCourseBySlugParam), async (c) => {
try {
const { courseSlug } = c.req.valid('param');
const tree = await getPublicCourseTreeService(courseSlug);
if (!tree) {
return c.json(
{
success: false as const,
error: 'Public course not found',
code: 'COURSE_NOT_FOUND'
},
404
);
}
return c.json({ success: true as const, data: tree });
} catch (error) {
return handleError(c, error, 'Failed to load public course');
}
})
.get('/:courseSlug/item/:itemSlug', zValidator('param', ZPublicCourseItemParam), async (c) => {
try {
const { courseSlug, itemSlug } = c.req.valid('param');
const item = await getPublicCourseItemService(courseSlug, itemSlug);
if (!item) {
return c.json(
{
success: false as const,
error: 'Public course item not found',
code: 'NOT_FOUND'
},
404
);
}
return c.json({ success: true as const, data: item });
} catch (error) {
return handleError(c, error, 'Failed to load public course item');
}
});