feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
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');
}
}
);
+172
View File
@@ -0,0 +1,172 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
import {
ZAgentHistoryQuery,
ZAgentConversationParam,
ZAgentConversationCreateBody,
ZAgentHistorySaveBody,
ZAgentHistoryDeleteParam,
ZAgentGenerateTitleParam,
ZAgentGenerateTitleBody
} from '@cio/utils/validation/agent';
import { handleError, AppError } from '@api/utils/errors';
import {
listChatConversations,
getChatConversation,
createChatConversation,
saveChatMessages,
deleteChatConversation,
updateConversationTitle
} from '@api/services/agent/chat-history';
import { generateConversationTitle } from '@api/services/agent/title-generation';
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
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) => {
try {
const user = c.get('user')!;
const { courseId } = c.req.valid('query');
const conversations = await listChatConversations(courseId, user.id);
return c.json({ success: true as const, data: conversations });
} catch (error) {
return handleError(c, error, 'Failed to list conversations');
}
})
/**
* GET /agent/history/:conversationId
* Get a single conversation with its messages.
*/
.get(
'/history/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const conversation = await getChatConversation(conversationId, user.id);
if (!conversation) {
return c.json({ success: false, error: 'Conversation not found' }, 404);
}
return c.json({ success: true as const, data: conversation });
} catch (error) {
return handleError(c, error, 'Failed to fetch conversation');
}
}
)
/**
* 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');
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');
}
}
)
/**
* PUT /agent/history/:conversationId
* Save messages to a conversation.
*/
.put(
'/history/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
zValidator('json', ZAgentHistorySaveBody),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const { messages, title } = c.req.valid('json');
await saveChatMessages(conversationId, user.id, messages, title);
return c.json({ success: true as const });
} catch (error) {
return handleError(c, error, 'Failed to save messages');
}
}
)
/**
* DELETE /agent/history/:conversationId
* Delete a conversation.
*/
.delete(
'/history/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentHistoryDeleteParam),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
await deleteChatConversation(conversationId, user.id);
return c.json({ success: true as const });
} catch (error) {
return handleError(c, error, 'Failed to delete conversation');
}
}
)
/**
* POST /agent/history/:conversationId/generate-title
* Generate a short title from the first user message using a cheap model.
*/
.post(
'/history/:conversationId/generate-title',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentGenerateTitleParam),
zValidator('json', ZAgentGenerateTitleBody),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const { firstMessageText } = c.req.valid('json');
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
const title = await generateConversationTitle(firstMessageText, providerConfig);
await updateConversationTitle(conversationId, user.id, title);
return c.json({ success: true as const, data: { title } });
} catch (error) {
return handleError(c, error, 'Failed to generate title');
}
}
);
+6
View File
@@ -0,0 +1,6 @@
export { agentStatusRouter } from './status';
export { agentChatRouter } from './chat';
export { agentUploadRouter } from './upload';
export { agentUsageRouter } from './usage';
export { agentCreditsRouter } from './credits';
export { agentHistoryRouter } from './history';
+58
View File
@@ -0,0 +1,58 @@
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
@@ -0,0 +1,91 @@
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
@@ -0,0 +1,21 @@
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');
}
});
+135
View File
@@ -0,0 +1,135 @@
import {
ZCourseComplianceExtend,
ZCourseComplianceLearnerParam,
ZCourseComplianceParam,
ZCourseComplianceReset,
ZCourseComplianceWaive
} from '@cio/utils/validation/course/compliance';
import {
extendCourseCompliance,
getCourseComplianceOverview,
getLearnerComplianceHistory,
resetCourseCompliance,
waiveCourseCompliance
} from '@api/services/course/compliance';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const complianceRouter = new Hono()
.get('/', authMiddleware, courseTeamMemberMiddleware, zValidator('param', ZCourseComplianceParam), async (c) => {
try {
const { courseId } = c.req.valid('param');
const data = await getCourseComplianceOverview(courseId);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch course compliance overview');
}
})
.get(
'/learners/:profileId',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseComplianceLearnerParam),
async (c) => {
try {
const { courseId, profileId } = c.req.valid('param');
const user = c.get('user')!;
const data = await getLearnerComplianceHistory(courseId, user.id, profileId);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch learner compliance history');
}
}
)
.post(
'/reset',
authMiddleware,
courseTeamMemberMiddleware,
zValidator('param', ZCourseComplianceParam),
zValidator('json', ZCourseComplianceReset),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const payload = c.req.valid('json');
const data = await resetCourseCompliance(courseId, payload);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to reset course compliance');
}
}
)
.post(
'/extend',
authMiddleware,
courseTeamMemberMiddleware,
zValidator('param', ZCourseComplianceParam),
zValidator('json', ZCourseComplianceExtend),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const payload = c.req.valid('json');
const data = await extendCourseCompliance(courseId, payload);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to extend course compliance');
}
}
)
.post(
'/waive',
authMiddleware,
courseTeamMemberMiddleware,
zValidator('param', ZCourseComplianceParam),
zValidator('json', ZCourseComplianceWaive),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const payload = c.req.valid('json');
const user = c.get('user')!;
const data = await waiveCourseCompliance(courseId, user.id, payload);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to waive course compliance');
}
}
);
+2
View File
@@ -34,6 +34,7 @@ import { attendanceRouter } from '@api/routes/course/attendance';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { cloneCourse } from '@api/services/course/clone';
import { complianceRouter } from '@api/routes/course/compliance';
import { contentRouter } from '@api/routes/course/content';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseTeamMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-team-member-or-automation-key';
@@ -531,6 +532,7 @@ export const courseRouter = new Hono()
.route('/katex', katexRouter)
.route('/:courseId/payment-request', paymentRequestRouter)
.route('/:courseId/content', contentRouter)
.route('/:courseId/compliance', complianceRouter)
.route('/:courseId/section', sectionRouter)
.route('/:courseId/lesson', lessonRouter)
.route('/:courseId/exercise', exerciseRouter)
+3 -1
View File
@@ -11,6 +11,7 @@ import { ZTemplateById, ZTemplateByTag } from '@cio/utils/validation/mocks';
import {
createExercise,
createExerciseFromTemplate,
deleteExerciseForCourseService,
deleteExerciseService,
getExercise,
listExercises,
@@ -222,8 +223,9 @@ export const exerciseRouter = new Hono()
)
.delete('/:exerciseId', authMiddleware, courseMemberMiddleware, zValidator('param', ZExerciseGetParam), async (c) => {
try {
const courseId = c.req.param('courseId')!;
const { exerciseId } = c.req.valid('param');
const exercise = await deleteExerciseService(exerciseId);
const exercise = await deleteExerciseForCourseService(courseId, exerciseId);
return c.json({ success: true, data: exercise }, 200);
} catch (error) {
+27 -10
View File
@@ -1,17 +1,14 @@
import { Hono } from '@api/utils/hono';
import { ZDashStats } from '@cio/utils/validation/dash';
import { ZDashStats, ZLoginActivity } from '@cio/utils/validation/dash';
import { authMiddleware } from '@api/middlewares/auth';
import { getOrganisationAnalytics } from '@api/services/dash';
import { getCurrentUserLoginStreak, getOrganisationAnalytics, getStudentLoginActivity } from '@api/services/dash';
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';
export const dashAnalyticsRouter = new Hono().get(
'/stats',
authMiddleware,
orgMemberMiddleware,
zValidator('query', ZDashStats),
async (c) => {
export const dashAnalyticsRouter = new Hono()
.get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => {
try {
const { orgId, siteName } = c.req.valid('query');
@@ -21,5 +18,25 @@ export const dashAnalyticsRouter = new Hono().get(
} catch (error) {
return handleError(c, error, 'Failed to load organisation analytics');
}
}
);
})
.get('/login-activity', authMiddleware, orgAdminMiddleware, zValidator('query', ZLoginActivity), async (c) => {
try {
const { orgId, days } = c.req.valid('query');
const result = await getStudentLoginActivity(orgId!, days);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load login activity');
}
})
.get('/login-streak', authMiddleware, async (c) => {
try {
const user = c.get('user')!;
const result = await getCurrentUserLoginStreak(user.id);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load login streak');
}
});
@@ -0,0 +1,37 @@
import { runComplianceExpiryCheck, runComplianceReminderScan } from '@api/services/course/compliance';
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { handleError } from '@api/utils/errors';
export const internalComplianceRouter = new Hono()
.post('/check-expiry', apiKeyMiddleware, async (c) => {
try {
const data = await runComplianceExpiryCheck();
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to run compliance expiry check');
}
})
.post('/send-reminders', apiKeyMiddleware, async (c) => {
try {
const data = await runComplianceReminderScan();
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to run compliance reminder scan');
}
});
@@ -4,3 +4,4 @@ export * from './tags';
export * from './token-auth';
export * from './course-import';
export * from './automation';
export * from './widgets';
@@ -61,7 +61,9 @@ import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { quizRouter } from '@api/routes/organization/quiz';
import { searchRouter } from '@api/routes/organization/search';
import { tagsRouter } from '@api/routes/organization/tags';
import { widgetsRouter } from '@api/routes/organization/widgets';
import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono()
@@ -664,6 +666,8 @@ export const organizationRouter = new Hono()
)
.route('/automation', automationRouter)
.route('/course-import', courseImportRouter)
.route('/search', searchRouter)
.route('/tags', tagsRouter)
.route('/widgets', widgetsRouter)
.route('/assets', assetsRouter)
.route('/:orgId/quiz', quizRouter);
@@ -0,0 +1,34 @@
import { ZSearchOrganization } from '@cio/utils/validation/organization';
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 { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { searchLmsOrganization, searchOrganization } from '@api/services/organization/search';
import { zValidator } from '@hono/zod-validator';
export const searchRouter = new Hono()
.get('/', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZSearchOrganization), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { q, limit } = c.req.valid('query');
const results = await searchOrganization(orgId, q, limit);
return c.json({ success: true, data: results });
} catch (error) {
return handleError(c, error, 'Failed to search organization');
}
})
.get('/lms', authMiddleware, orgMemberMiddleware, zValidator('query', ZSearchOrganization), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { q, limit } = c.req.valid('query');
const results = await searchLmsOrganization(orgId, user.id, q, limit);
return c.json({ success: true, data: results });
} catch (error) {
return handleError(c, error, 'Failed to search LMS');
}
});
+140
View File
@@ -0,0 +1,140 @@
import { Hono } from '@api/utils/hono';
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 {
createOrganizationWidget,
deleteOrganizationWidget,
getOrganizationWidgetDetail,
listOrganizationWidgets,
previewOrganizationWidget,
publishOrganizationWidget,
rollbackOrganizationWidget,
updateOrganizationWidget
} from '@api/services/widget';
import { zValidator } from '@hono/zod-validator';
export const widgetsRouter = new Hono()
.get('/', authMiddleware, orgTeamMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const widgets = await listOrganizationWidgets(orgId);
return c.json({ success: true, data: widgets });
} catch (error) {
return handleError(c, error, 'Failed to fetch widgets');
}
})
.post('/', authMiddleware, orgTeamMemberMiddleware, zValidator('json', ZCreateWidget), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const data = c.req.valid('json');
const widget = await createOrganizationWidget(orgId, user.id, data);
return c.json({ success: true, data: widget }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create widget');
}
})
.get('/:widgetId', authMiddleware, orgTeamMemberMiddleware, zValidator('param', ZWidgetIdParams), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { widgetId } = c.req.valid('param');
const widgetDetail = await getOrganizationWidgetDetail(orgId, widgetId);
return c.json({ success: true, data: widgetDetail });
} catch (error) {
return handleError(c, error, 'Failed to fetch widget');
}
})
.put(
'/:widgetId',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('param', ZWidgetIdParams),
zValidator('json', ZUpdateWidget),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { widgetId } = c.req.valid('param');
const data = c.req.valid('json');
const widget = await updateOrganizationWidget(orgId, widgetId, user.id, data);
return c.json({ success: true, data: widget });
} catch (error) {
return handleError(c, error, 'Failed to update widget');
}
}
)
.delete('/:widgetId', authMiddleware, orgAdminMiddleware, zValidator('param', ZWidgetIdParams), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { widgetId } = c.req.valid('param');
const widget = await deleteOrganizationWidget(orgId, widgetId, user.id);
return c.json({ success: true, data: widget });
} catch (error) {
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')!;
const user = c.get('user')!;
const { widgetId } = c.req.valid('param');
const result = await publishOrganizationWidget(orgId, widgetId, user.id);
return c.json({ success: true, data: result });
} catch (error) {
return handleError(c, error, 'Failed to publish widget');
}
})
.post(
'/:widgetId/rollback',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZWidgetIdParams),
zValidator('json', ZRollbackWidget),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { widgetId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await rollbackOrganizationWidget(orgId, widgetId, user.id, data);
return c.json({ success: true, data: result });
} catch (error) {
return handleError(c, error, 'Failed to rollback widget');
}
}
);
+1
View File
@@ -0,0 +1 @@
export * from './widgets';
+39
View File
@@ -0,0 +1,39 @@
import { Hono } from '@api/utils/hono';
import { handleError } from '@api/utils/errors';
import { getPublishedWidgetPayload } from '@api/services/widget';
import { ZWidgetPublicKeyParams } from '@cio/utils/validation/widget';
import { zValidator } from '@hono/zod-validator';
import { createRateLimiter } from '@api/middlewares/rate-limiter';
import { DEFAULT_WINDOW_MS } from '@api/constants/rate-limiter';
const widgetPayloadIpLimiter = createRateLimiter({
maxRequests: 100,
windowMs: DEFAULT_WINDOW_MS,
keyGenerator: (c) =>
`widget_payload_ip:${c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown'}`
});
const widgetPayloadKeyLimiter = createRateLimiter({
maxRequests: 1000,
windowMs: DEFAULT_WINDOW_MS,
keyGenerator: (c) => `widget_payload_key:${c.req.param('publicKey')}`
});
export const publicWidgetsRouter = new Hono().get(
'/:publicKey/payload',
zValidator('param', ZWidgetPublicKeyParams),
widgetPayloadIpLimiter,
widgetPayloadKeyLimiter,
async (c) => {
try {
const { publicKey } = c.req.valid('param');
const payload = await getPublishedWidgetPayload(publicKey);
c.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
return c.json({ success: true, data: payload });
} catch (error) {
return handleError(c, error, 'Failed to fetch widget payload');
}
}
);