feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
+73 -1
View File
@@ -1,7 +1,12 @@
import { ZCourseInviteTokenParam } from '@cio/utils/validation/course/invite';
import { previewStudentInvite } from '@api/services/course/invite';
import { ZOrganizationInviteTokenParam } from '@cio/utils/validation/organization/invite';
import { acceptOrganizationInvite, previewOrganizationInvite } from '@api/services/organization/invite';
import {
acceptOrganizationInvite,
acceptOrganizationInviteById,
getPendingOrgInviteForUser,
previewOrganizationInvite
} from '@api/services/organization/invite';
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
@@ -10,6 +15,7 @@ import { createRateLimiter } from '@api/middlewares/rate-limiter';
import { handleError } from '@api/utils/errors';
import { extractClientIp } from '@api/utils/redis/key-generators';
import { zValidator } from '@hono/zod-validator';
import * as z from 'zod';
const previewInviteRateLimit = createRateLimiter({
windowMs: 60 * 1000,
@@ -36,7 +42,73 @@ const acceptOrganizationInviteRateLimit = createRateLimiter({
}
});
const pendingInviteRateLimit = createRateLimiter({
windowMs: 60 * 1000,
maxRequests: 30,
message: 'Too many pending invite requests. Please try again later.',
keyGenerator: (c) => {
const user = c.get('user');
return `invite_pending:${user?.id ?? extractClientIp(c)}`;
}
});
const acceptByIdRateLimit = createRateLimiter({
windowMs: 60 * 60 * 1000,
maxRequests: 20,
message: 'Too many invite accept attempts. Please try again later.',
keyGenerator: (c) => {
const user = c.get('user');
const actor = user?.id ? `user:${user.id}` : `ip:${extractClientIp(c)}`;
return `org_invite_accept_by_id:${actor}:${c.req.param('inviteId')}`;
}
});
export const inviteRouter = new Hono()
/**
* GET /invite/organization/pending
* Returns the active pending org invite for the currently logged-in user.
*/
.get('/organization/pending', authMiddleware, pendingInviteRateLimit, async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id');
if (!orgId || !user.email) {
return c.json({ success: true, data: null }, 200);
}
const pendingInvite = await getPendingOrgInviteForUser(orgId, user.email);
return c.json({ success: true, data: pendingInvite }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load pending invite');
}
})
/**
* POST /invite/organization/:inviteId/accept-by-id
* Accepts an org invite by invite ID (for logged-in users with a pending invite surfaced by /pending).
*/
.post(
'/organization/:inviteId/accept-by-id',
authMiddleware,
acceptByIdRateLimit,
zValidator('param', z.object({ inviteId: z.string().uuid() })),
async (c) => {
try {
const { inviteId } = c.req.valid('param');
const user = c.get('user')!;
const result = await acceptOrganizationInviteById(
inviteId,
{ id: user.id, email: user.email },
{ ipAddress: extractClientIp(c), userAgent: c.req.header('user-agent') || null }
);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to accept organization invite');
}
}
)
/**
* GET /invite/student/:token
* Server-only preview for secure invite links (API key). Returns course, org, invite for enroll page.
@@ -386,7 +386,7 @@ export const organizationRouter = new Hono()
const orgId = c.get('orgId');
const userRole = c.get('userRole');
const automationKey = c.get('automationKey');
const { tags } = c.req.valid('query');
const query = c.req.valid('query');
if (!orgId) {
return c.json(
@@ -403,25 +403,28 @@ export const organizationRouter = new Hono()
await assertMcpAutomationUsageAllowed(automationKey, 'list_org_courses');
}
const tagSlugs = tags
?.split(',')
.map((value) => value.trim())
.filter(Boolean);
const result = await getOrganizationCourses(
orgId,
user?.id ?? automationKey?.createdByProfileId ?? '',
userRole ?? ROLE.ADMIN,
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined
query
);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'list_org_courses', { tags: tagSlugs });
await recordMcpAutomationUsage(automationKey, 'list_org_courses', {
tags: query.tags,
search: query.search,
page: query.page,
limit: query.limit
});
}
return c.json(
{
success: true,
data: result
data: result.items,
pagination: result.pagination,
query: result.query
},
200
);
+1
View File
@@ -0,0 +1 @@
export { programRouter } from './program';
+487
View File
@@ -0,0 +1,487 @@
import * as z from 'zod';
import {
ZAddCourseToProgram,
ZAddProgramMembers,
ZCreateProgram,
ZCreateProgramNewsfeed,
ZCreateProgramNewsfeedComment,
ZUpdateProgram,
ZUpdateProgramMember,
ZUpdateProgramNewsfeed,
ZUpdateProgramReaction
} from '@cio/utils/validation/program';
import {
addCourseToProgramService,
addProgramMembers,
createProgram,
createProgramNewsfeedCommentService,
createProgramNewsfeedService,
deleteProgram,
removeProgramMemberService,
deleteProgramNewsfeedCommentService,
deleteProgramNewsfeedService,
getEnrolledPrograms,
getProgram,
listOrgPrograms,
listProgramCourses,
listProgramMembers,
listProgramNewsfeed,
listProgramNewsfeedComments,
removeCourseFromProgramService,
updateProgram,
updateProgramMemberService,
updateProgramNewsfeedReactionService,
updateProgramNewsfeedService
} from '@api/services/program/program';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
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';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
const ZProgramParam = z.object({ programId: z.string().uuid() });
const ZMemberParam = z.object({ programId: z.string().uuid(), memberId: z.string().uuid() });
const ZCourseParam = z.object({ programId: z.string().uuid(), courseId: z.string().uuid() });
const ZFeedParam = z.object({ programId: z.string().uuid(), feedId: z.string().uuid() });
const ZCommentParam = z.object({
programId: z.string().uuid(),
feedId: z.string().uuid(),
commentId: z.coerce.number().int()
});
const ZListQuery = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(50).default(10)
});
const ZOrgQuery = z.object({ organizationId: z.string().uuid() });
export const programRouter = new Hono()
// ── Enrolled Programs (LMS) ─── must be before /:programId ───────────────
/**
* GET /program/enrolled
* Get programs the current user is enrolled in
*/
.get('/enrolled', authMiddleware, async (c) => {
try {
const user = c.get('user')!;
const programs = await getEnrolledPrograms(user.id);
return c.json({ success: true, data: programs }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get enrolled programs');
}
})
// ── Programs ──────────────────────────────────────────────────────────────
/**
* GET /program?organizationId=...
* List all programs for an org
*/
.get('/', authMiddleware, zValidator('query', ZOrgQuery), async (c) => {
try {
const { organizationId } = c.req.valid('query');
const programs = await listOrgPrograms(organizationId);
return c.json({ success: true, data: programs }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list programs');
}
})
/**
* POST /program
* Create a new program
*/
.post(
'/',
authMiddleware,
zValidator('json', ZCreateProgram.extend({ organizationId: z.string().uuid() })),
async (c) => {
try {
const user = c.get('user')!;
const { organizationId, ...data } = c.req.valid('json');
const program = await createProgram(organizationId, user.id, data);
return c.json({ success: true, data: program }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program');
}
}
)
/**
* GET /program/:programId
* Get a program by ID
*/
.get('/:programId', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
try {
const { programId } = c.req.valid('param');
const program = await getProgram(programId);
return c.json({ success: true, data: program }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get program');
}
})
/**
* PUT /program/:programId
* Update a program
*/
.put(
'/:programId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZUpdateProgram),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const program = await updateProgram(programId, data);
return c.json({ success: true, data: program }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program');
}
}
)
/**
* DELETE /program/:programId
* Delete a program
*/
.delete('/:programId', authMiddleware, programTeamMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
try {
const { programId } = c.req.valid('param');
const program = await deleteProgram(programId);
return c.json({ success: true, data: program }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program');
}
})
// ── Members ───────────────────────────────────────────────────────────────
/**
* GET /program/:programId/members
* List program members
*/
.get(
'/:programId/members',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZProgramParam),
async (c) => {
try {
const { programId } = c.req.valid('param');
const members = await listProgramMembers(programId);
return c.json({ success: true, data: members }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program members');
}
}
)
/**
* POST /program/:programId/members
* Add members to a program
*/
.post(
'/:programId/members',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZAddProgramMembers),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await addProgramMembers(programId, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to add program members');
}
}
)
/**
* PUT /program/:programId/members/:memberId
* Update a program member's role
*/
.put(
'/:programId/members/:memberId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZMemberParam),
zValidator('json', ZUpdateProgramMember),
async (c) => {
try {
const { programId, memberId } = c.req.valid('param');
const data = c.req.valid('json');
const member = await updateProgramMemberService(programId, memberId, data);
return c.json({ success: true, data: member }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program member');
}
}
)
/**
* DELETE /program/:programId/members/:memberId
* Remove a member from a program
*/
.delete(
'/:programId/members/:memberId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZMemberParam),
async (c) => {
try {
const { programId, memberId } = c.req.valid('param');
const member = await removeProgramMemberService(programId, memberId);
return c.json({ success: true, data: member }, 200);
} catch (error) {
return handleError(c, error, 'Failed to remove program member');
}
}
)
// ── Courses ───────────────────────────────────────────────────────────────
/**
* GET /program/:programId/courses
* List courses in a program
*/
.get(
'/:programId/courses',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZProgramParam),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const courses = await listProgramCourses(programId, user.id);
return c.json({ success: true, data: courses }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program courses');
}
}
)
/**
* POST /program/:programId/courses
* Add a course to a program
*/
.post(
'/:programId/courses',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZAddCourseToProgram),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await addCourseToProgramService(programId, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to add course to program');
}
}
)
/**
* DELETE /program/:programId/courses/:courseId
* Remove a course from a program
*/
.delete(
'/:programId/courses/:courseId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZCourseParam),
async (c) => {
try {
const { programId, courseId } = c.req.valid('param');
const result = await removeCourseFromProgramService(programId, courseId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to remove course from program');
}
}
)
// ── Newsfeed ──────────────────────────────────────────────────────────────
/**
* GET /program/:programId/newsfeed
* List newsfeed for a program (paginated)
*/
.get(
'/:programId/newsfeed',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('query', ZListQuery),
async (c) => {
try {
const { programId } = c.req.valid('param');
const { cursor, limit } = c.req.valid('query');
const result = await listProgramNewsfeed(programId, { cursor, limit });
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program newsfeed');
}
}
)
/**
* POST /program/:programId/newsfeed
* Create a newsfeed post
*/
.post(
'/:programId/newsfeed',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZCreateProgramNewsfeed),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await createProgramNewsfeedService(programId, user.id, data);
return c.json({ success: true, data: feed }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program newsfeed post');
}
}
)
/**
* PUT /program/:programId/newsfeed/:feedId
* Update a newsfeed post
*/
.put(
'/:programId/newsfeed/:feedId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZUpdateProgramNewsfeed),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await updateProgramNewsfeedService(feedId, data);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program newsfeed post');
}
}
)
/**
* PUT /program/:programId/newsfeed/:feedId/react
* Update reaction on a newsfeed post
*/
.put(
'/:programId/newsfeed/:feedId/react',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZUpdateProgramReaction),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await updateProgramNewsfeedReactionService(feedId, data);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program newsfeed reaction');
}
}
)
/**
* DELETE /program/:programId/newsfeed/:feedId
* Delete a newsfeed post
*/
.delete(
'/:programId/newsfeed/:feedId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZFeedParam),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const feed = await deleteProgramNewsfeedService(feedId);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program newsfeed post');
}
}
)
/**
* GET /program/:programId/newsfeed/:feedId/comments
* List comments on a newsfeed post
*/
.get(
'/:programId/newsfeed/:feedId/comments',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZFeedParam),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const comments = await listProgramNewsfeedComments(feedId);
return c.json({ success: true, data: comments }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program newsfeed comments');
}
}
)
/**
* POST /program/:programId/newsfeed/:feedId/comment
* Create a comment on a newsfeed post
*/
.post(
'/:programId/newsfeed/:feedId/comment',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZCreateProgramNewsfeedComment),
async (c) => {
try {
const user = c.get('user')!;
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const comment = await createProgramNewsfeedCommentService(feedId, user.id, data);
return c.json({ success: true, data: comment }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program newsfeed comment');
}
}
)
/**
* DELETE /program/:programId/newsfeed/:feedId/comment/:commentId
* Delete a comment on a newsfeed post
*/
.delete(
'/:programId/newsfeed/:feedId/comment/:commentId',
authMiddleware,
programNewsfeedCommentAuthorOrTeamMiddleware,
zValidator('param', ZCommentParam),
async (c) => {
try {
const { commentId } = c.req.valid('param');
const comment = await deleteProgramNewsfeedCommentService(commentId);
return c.json({ success: true, data: comment }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program newsfeed comment');
}
}
);
+299
View File
@@ -0,0 +1,299 @@
import {
ZPublicApiAssignAudienceCourses,
ZPublicApiAudienceMemberParam,
ZPublicApiAudienceQuery,
ZPublicApiCreateAudience,
ZPublicApiUpdateAudience
} from '@cio/utils/validation/public-api';
import {
assignAudienceCoursesService,
createAudienceService,
getAudienceMemberService,
listAudienceService,
removeAudienceService,
updateAudienceService
} from '@api/services/v1/audience';
import { Hono } from '@api/utils/hono';
import { automationKeyMiddleware } from '@api/middlewares/automation-key';
import { automationKeyScopesMiddleware } from '@api/middlewares/automation-key-scopes';
import { handleError } from '@api/utils/errors';
import { describeRoute, validator } from 'hono-openapi';
const AudienceListResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
data: { type: 'array' as const, items: { type: 'object' as const } },
pagination: { type: 'object' as const },
query: { type: 'object' as const }
},
required: ['success', 'data', 'pagination', 'query']
};
const AudienceMutationResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
data: { type: 'object' as const }
},
required: ['success', 'data']
};
const AudienceDetailResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
data: { type: 'object' as const }
},
required: ['success', 'data']
};
export const v1AudienceRouter = new Hono()
.get(
'/',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'List audience members for the authenticated organization',
tags: ['Public API Audience'],
responses: {
200: {
description: 'Audience members returned successfully',
content: {
'application/json': {
schema: AudienceListResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' }
}
}),
validator('query', ZPublicApiAudienceQuery),
async (c) => {
try {
const orgId = c.get('orgId')!;
const query = c.req.valid('query');
const audience = await listAudienceService(orgId, query);
return c.json(
{
success: true,
data: audience.items,
pagination: audience.pagination,
query: audience.query
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to list audience members');
}
}
)
.post(
'/',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Create an audience member invitation for the authenticated organization',
tags: ['Public API Audience'],
responses: {
201: {
description: 'Audience member created successfully',
content: {
'application/json': {
schema: AudienceMutationResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' }
}
}),
validator('json', ZPublicApiCreateAudience),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId');
const payload = c.req.valid('json');
const result = await createAudienceService(orgId, actorId, payload);
return c.json(
{
success: true,
data: result
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to create audience member');
}
}
)
.post(
'/assign-courses',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Assign one or more audience members to one or more courses',
tags: ['Public API Audience'],
responses: {
200: {
description: 'Audience members assigned to courses successfully',
content: {
'application/json': {
schema: AudienceMutationResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' }
}
}),
validator('json', ZPublicApiAssignAudienceCourses),
async (c) => {
try {
const orgId = c.get('orgId')!;
const payload = c.req.valid('json');
const result = await assignAudienceCoursesService(orgId, payload);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to assign audience to courses');
}
}
)
.get(
'/:memberId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Get a single audience member by id',
tags: ['Public API Audience'],
responses: {
200: {
description: 'Audience member returned successfully',
content: {
'application/json': {
schema: AudienceDetailResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Audience member not found' }
}
}),
validator('param', ZPublicApiAudienceMemberParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const audienceMember = await getAudienceMemberService(orgId, params);
return c.json(
{
success: true,
data: audienceMember
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch audience member');
}
}
)
.delete(
'/:memberId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Remove an audience member by id',
tags: ['Public API Audience'],
responses: {
200: {
description: 'Audience member removed successfully',
content: {
'application/json': {
schema: AudienceMutationResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Audience member not found' }
}
}),
validator('param', ZPublicApiAudienceMemberParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId');
const { memberId } = c.req.valid('param');
const result = await removeAudienceService(orgId, memberId, actorId);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to remove audience member');
}
}
)
.put(
'/:memberId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Update a pending audience member email address',
tags: ['Public API Audience'],
responses: {
200: {
description: 'Audience member updated successfully',
content: {
'application/json': {
schema: AudienceDetailResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Audience member not found' }
}
}),
validator('param', ZPublicApiAudienceMemberParam),
validator('json', ZPublicApiUpdateAudience),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId');
const { memberId } = c.req.valid('param');
const payload = c.req.valid('json');
const result = await updateAudienceService(orgId, memberId, actorId, payload);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update audience member');
}
}
);
+410
View File
@@ -0,0 +1,410 @@
import {
ZPublicApiCourseParam,
ZPublicApiCoursesQuery,
ZPublicApiCreateCourse,
ZPublicApiUpdateCourse,
ZPublicApiUpdateCourseStructure
} from '@cio/utils/validation/public-api';
import {
createPublicApiCourseService,
deletePublicApiCourseService,
exportCourseService,
getCourseService,
listCoursesService,
listCourseStudentsService,
updatePublicApiCourseService,
updatePublicApiCourseStructureService
} from '@api/services/v1/course';
import { Hono } from '@api/utils/hono';
import { automationKeyMiddleware } from '@api/middlewares/automation-key';
import { automationKeyScopesMiddleware } from '@api/middlewares/automation-key-scopes';
import { handleError } from '@api/utils/errors';
import { describeRoute, validator } from 'hono-openapi';
const CoursesListResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
data: { type: 'array' as const, items: { type: 'object' as const } }
},
required: ['success', 'data']
};
const CourseDetailResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
data: { type: 'object' as const }
},
required: ['success', 'data']
};
export const v1CoursesRouter = new Hono()
.get(
'/',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'List courses for the authenticated organization',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Courses returned successfully',
content: {
'application/json': {
schema: CoursesListResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' }
}
}),
validator('query', ZPublicApiCoursesQuery),
async (c) => {
try {
const orgId = c.get('orgId')!;
const query = c.req.valid('query');
const courses = await listCoursesService(orgId, query);
return c.json(
{
success: true,
data: courses
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to list courses');
}
}
)
.post(
'/',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Create a course for the authenticated organization',
tags: ['Public API Courses'],
responses: {
201: {
description: 'Course created successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' }
}
}),
validator('json', ZPublicApiCreateCourse),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId');
const payload = c.req.valid('json');
const course = await createPublicApiCourseService(orgId, actorId, payload);
return c.json(
{
success: true,
data: course
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to create course');
}
}
)
.get(
'/:courseId/students',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'List enrolled students for a course',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course students returned successfully',
content: {
'application/json': {
schema: CoursesListResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const students = await listCourseStudentsService(orgId, params);
return c.json(
{
success: true,
data: students
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to list course students');
}
}
)
.get(
'/:courseId/export',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Export a course structure snapshot',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course export returned successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const courseExport = await exportCourseService(orgId, params);
return c.json(
{
success: true,
data: courseExport
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to export course');
}
}
)
.get(
'/:courseId/structure',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Get a course structure snapshot',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course structure returned successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const courseStructure = await exportCourseService(orgId, params);
return c.json(
{
success: true,
data: courseStructure
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch course structure');
}
}
)
.put(
'/:courseId/structure',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Synchronize a course structure using the draft payload shape',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course structure updated successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
validator('json', ZPublicApiUpdateCourseStructure),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId');
const params = c.req.valid('param');
const payload = c.req.valid('json');
const result = await updatePublicApiCourseStructureService(orgId, actorId, params, payload);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update course structure');
}
}
)
.get(
'/:courseId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Get a single course by id',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course returned successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const course = await getCourseService(orgId, params);
return c.json(
{
success: true,
data: course
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch course');
}
}
)
.put(
'/:courseId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Update a course by id',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course updated successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
400: { description: 'Invalid request body' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
validator('json', ZPublicApiUpdateCourse),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const payload = c.req.valid('json');
const course = await updatePublicApiCourseService(orgId, params, payload);
return c.json(
{
success: true,
data: course
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update course');
}
}
)
.delete(
'/:courseId',
automationKeyMiddleware,
automationKeyScopesMiddleware(['public_api:*']),
describeRoute({
description: 'Delete a course by id',
tags: ['Public API Courses'],
responses: {
200: {
description: 'Course deleted successfully',
content: {
'application/json': {
schema: CourseDetailResponse
}
}
},
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
404: { description: 'Course not found' }
}
}),
validator('param', ZPublicApiCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const params = c.req.valid('param');
const course = await deletePublicApiCourseService(orgId, params);
return c.json(
{
success: true,
data: course
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to delete course');
}
}
);
+5
View File
@@ -0,0 +1,5 @@
import { Hono } from '@api/utils/hono';
import { v1AudienceRouter } from './audience';
import { v1CoursesRouter } from './courses';
export const v1Router = new Hono().route('/audience', v1AudienceRouter).route('/courses', v1CoursesRouter);