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:
@@ -1,6 +1,6 @@
|
||||
# OpenAPI Spec Generator Script
|
||||
|
||||
This script generates the OpenAPI specification from your Hono application and optionally uploads it to a Cloudflare R2 bucket.
|
||||
This script generates the OpenAPI specification for the public API routes only and optionally uploads it to a Cloudflare R2 bucket.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -38,10 +38,11 @@ CLOUDFLARE_ACCOUNT_ID=your_account_id
|
||||
The script will:
|
||||
|
||||
1. **Generate** the OpenAPI specification from your Hono app routes
|
||||
2. **Save locally** to `dist/openapi/openapi.json`
|
||||
3. **Upload to R2** (if credentials provided):
|
||||
- `openapi/openapi-YYYY-MM-DD.json` (dated version)
|
||||
- `openapi/openapi-latest.json` (latest version)
|
||||
2. **Filter** the output to `/public-api/v1/*`
|
||||
3. **Save locally** to `dist/openapi/public-api/openapi.json`
|
||||
4. **Upload to R2** (if credentials provided):
|
||||
- `openapi/public-api/openapi-YYYY-MM-DD.json` (dated version)
|
||||
- `openapi/public-api/openapi-latest.json` (latest version)
|
||||
|
||||
## GitHub Action Integration
|
||||
|
||||
|
||||
@@ -16,6 +16,35 @@ interface UploadOptions {
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
function filterPublicApiSpec(spec: Record<string, unknown>) {
|
||||
const paths = (spec.paths ?? {}) as Record<string, unknown>;
|
||||
const publicApiPaths = Object.fromEntries(
|
||||
Object.entries(paths).filter(([path]) => path.startsWith('/public-api/v1/'))
|
||||
);
|
||||
|
||||
const tags = Array.isArray(spec.tags)
|
||||
? spec.tags.filter((tag) => {
|
||||
if (!tag || typeof tag !== 'object' || !('name' in tag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof tag.name === 'string' && tag.name.startsWith('Public API');
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...spec,
|
||||
openapi: spec.openapi ?? '3.1.0',
|
||||
info: {
|
||||
title: 'ClassroomIO Public API',
|
||||
version: '1.0.0',
|
||||
description: 'Public API for managing organizations, audience members, and courses in ClassroomIO.'
|
||||
},
|
||||
paths: publicApiPaths,
|
||||
tags
|
||||
};
|
||||
}
|
||||
|
||||
class OpenAPISpecGenerator {
|
||||
private s3Client: S3Client | null = null;
|
||||
|
||||
@@ -68,7 +97,8 @@ class OpenAPISpecGenerator {
|
||||
}
|
||||
});
|
||||
|
||||
const specString = JSON.stringify(spec, null, 2);
|
||||
const publicApiSpec = filterPublicApiSpec(spec as Record<string, unknown>);
|
||||
const specString = JSON.stringify(publicApiSpec, null, 2);
|
||||
console.log('✅ OpenAPI specification generated successfully');
|
||||
|
||||
return specString;
|
||||
@@ -80,7 +110,7 @@ class OpenAPISpecGenerator {
|
||||
|
||||
async saveLocalSpec(spec: string): Promise<string> {
|
||||
try {
|
||||
const outputDir = join(process.cwd(), 'dist', 'openapi');
|
||||
const outputDir = join(process.cwd(), 'dist', 'openapi', 'public-api');
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const filePath = join(outputDir, 'openapi.json');
|
||||
@@ -122,10 +152,10 @@ class OpenAPISpecGenerator {
|
||||
try {
|
||||
const spec = await this.generateSpec();
|
||||
|
||||
// await this.saveLocalSpec(spec);
|
||||
await this.saveLocalSpec(spec);
|
||||
|
||||
if (this.s3Client) {
|
||||
const uploadKey = `openapi/openapi-${new Date().toISOString().split('T')[0]}.json`;
|
||||
const uploadKey = `openapi/public-api/openapi-${new Date().toISOString().split('T')[0]}.json`;
|
||||
await this.uploadToR2({
|
||||
key: uploadKey,
|
||||
content: spec,
|
||||
@@ -134,7 +164,7 @@ class OpenAPISpecGenerator {
|
||||
|
||||
// Also upload as latest version
|
||||
await this.uploadToR2({
|
||||
key: 'openapi/openapi-latest.json',
|
||||
key: 'openapi/public-api/openapi-latest.json',
|
||||
content: spec,
|
||||
contentType: 'application/json'
|
||||
});
|
||||
|
||||
@@ -20,11 +20,13 @@ import { organizationRouter } from '@api/routes/organization';
|
||||
import { licenseRouter } from '@api/routes/license';
|
||||
import { organizationSsoRouter } from '@api/routes/organization/sso';
|
||||
import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth';
|
||||
import { v1Router } from '@api/routes/v1';
|
||||
import { ssoDiscoveryRouter } from '@api/routes/sso/discovery';
|
||||
import { prettyJSON } from 'hono/pretty-json';
|
||||
import rateLimiter from '@api/middlewares/rate-limiter';
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
import { signupGuard } from '@api/middlewares/signup-guard';
|
||||
import { programRouter } from '@api/routes/program';
|
||||
import { unsplashRouter } from '@api/routes/unsplash/unsplash';
|
||||
|
||||
// Create Hono app with chaining for RPC support
|
||||
@@ -94,6 +96,8 @@ export const app = new Hono()
|
||||
.route('/dash', dashAnalyticsRouter)
|
||||
.route('/community', communityRouter)
|
||||
.route('/invite', inviteRouter)
|
||||
.route('/public-api/v1', v1Router)
|
||||
.route('/program', programRouter)
|
||||
.route('/unsplash', unsplashRouter)
|
||||
|
||||
// Error handling
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
|
||||
|
||||
export const automationKeyScopesMiddleware =
|
||||
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
|
||||
async (c: Context, next: Next) => {
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
if (!automationKey) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: ErrorCodes.UNAUTHORIZED
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Automation key is missing required scopes',
|
||||
code: ErrorCodes.FORBIDDEN
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { Context, Next } from 'hono';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { isUserCourseMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||
import { ensureProgramCourseAccess } from '@api/services/course/course';
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is a member of a course's group
|
||||
@@ -39,6 +40,11 @@ export const courseMemberMiddleware = async (c: Context, next: Next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
const backfilledFromProgram = await ensureProgramCourseAccess(courseId, user.id);
|
||||
if (backfilledFromProgram) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { isOrgAdminByProgramId, isProgramMember } from '@cio/db/queries/program';
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is a member of the program
|
||||
* or an org admin for the program's organization.
|
||||
*/
|
||||
export const programMemberMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const programId = c.req.param('programId');
|
||||
if (!programId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Program ID is required',
|
||||
code: 'PROGRAM_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const [member, isOrgAdmin] = await Promise.all([
|
||||
isProgramMember(programId, user.id),
|
||||
isOrgAdminByProgramId(programId, user.id)
|
||||
]);
|
||||
|
||||
if (member || isOrgAdmin) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'You must be a member of this program to perform this action',
|
||||
code: ErrorCodes.PROGRAM_FORBIDDEN
|
||||
},
|
||||
403
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in programMemberMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify program membership',
|
||||
code: ErrorCodes.PROGRAM_MEMBER_CHECK_FAILED
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
getProgramMemberByProfileId,
|
||||
getProgramMemberRole,
|
||||
getProgramNewsfeedById,
|
||||
getProgramNewsfeedCommentById,
|
||||
isOrgAdminByProgramId
|
||||
} from '@cio/db/queries/program';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const commentId = c.req.param('commentId');
|
||||
if (!commentId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Comment ID is required',
|
||||
code: 'COMMENT_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const comment = await getProgramNewsfeedCommentById(Number(commentId));
|
||||
if (!comment?.programNewsfeedId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Comment not found',
|
||||
code: ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const feed = await getProgramNewsfeedById(comment.programNewsfeedId);
|
||||
if (!feed?.programId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Program newsfeed item not found',
|
||||
code: ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const member = await getProgramMemberByProfileId(feed.programId, user.id);
|
||||
const isAuthor = comment.authorId === member?.id;
|
||||
if (isAuthor) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const isOrgAdmin = await isOrgAdminByProgramId(feed.programId, user.id);
|
||||
if (isOrgAdmin) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const roleId = await getProgramMemberRole(feed.programId, user.id);
|
||||
const isTeamMember = roleId === ROLE.ADMIN || roleId === ROLE.TUTOR;
|
||||
if (isTeamMember) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Only the author or program team members can perform this action',
|
||||
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
|
||||
},
|
||||
403
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in programNewsfeedCommentAuthorOrTeamMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify comment authorization',
|
||||
code: 'COMMENT_AUTH_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { getProgramMemberRole, isOrgAdminByProgramId } from '@cio/db/queries/program';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is a program teacher or org admin.
|
||||
* Mirrors the course-team-member pattern for program routes.
|
||||
*/
|
||||
export const programTeamMemberMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const programId = c.req.param('programId');
|
||||
if (!programId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Program ID is required',
|
||||
code: 'PROGRAM_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const [roleId, isOrgAdmin] = await Promise.all([
|
||||
getProgramMemberRole(programId, user.id),
|
||||
isOrgAdminByProgramId(programId, user.id)
|
||||
]);
|
||||
|
||||
if (roleId === ROLE.TUTOR || roleId === ROLE.ADMIN || isOrgAdmin) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: ErrorCodes.UNAUTHORIZED
|
||||
},
|
||||
403
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in programTeamMemberMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify program team membership',
|
||||
code: 'PROGRAM_TEAM_MEMBER_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -88,8 +88,5 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
|
||||
|
||||
// Default rate limiter middleware with standard configuration
|
||||
export default createRateLimiter({
|
||||
maxRequests: (c) =>
|
||||
c.req.path === '/api/auth/get-session'
|
||||
? DEFAULT_RATE_LIMITER_OPTIONS.maxRequests * 3
|
||||
: DEFAULT_RATE_LIMITER_OPTIONS.maxRequests
|
||||
maxRequests: (c) => (c.req.path === '/api/auth/get-session' ? 1000 : DEFAULT_RATE_LIMITER_OPTIONS.maxRequests)
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { programRouter } from './program';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
@@ -45,7 +45,7 @@ export async function fetchCommunityQuestions(
|
||||
case ROLE.TUTOR:
|
||||
// Tutors can see questions in courses they have access to
|
||||
const tutorCourses = await getOrgCourses({ orgId, profileId: userId });
|
||||
const tutorCourseIds = tutorCourses.map((course) => course.id);
|
||||
const tutorCourseIds = tutorCourses.items.map((course) => course.id);
|
||||
discussions = await getCommunityQuestions({ courseIds: tutorCourseIds });
|
||||
break;
|
||||
case ROLE.STUDENT:
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
updateCourseSectionService
|
||||
} from '@api/services/course/section';
|
||||
import { createLesson, listLessons, updateLessonService, deleteLessonService } from '@api/services/lesson';
|
||||
import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language';
|
||||
import {
|
||||
deleteLessonLanguageService,
|
||||
upsertLessonLanguageService,
|
||||
updateLessonLanguageService
|
||||
} from '@api/services/lesson-language';
|
||||
import {
|
||||
createCourseImportDraft,
|
||||
getCourseImportDraftById,
|
||||
@@ -76,6 +80,10 @@ type PublishDraftToExistingCourseResult = PublishDraftResult & {
|
||||
updatedExercises: number;
|
||||
createdLessonLanguages: number;
|
||||
updatedLessonLanguages: number;
|
||||
deletedSections: number;
|
||||
deletedLessons: number;
|
||||
deletedExercises: number;
|
||||
deletedLessonLanguages: number;
|
||||
untouchedSections: number;
|
||||
untouchedLessons: number;
|
||||
untouchedExercises: number;
|
||||
@@ -579,7 +587,7 @@ export async function createCourseImportDraftFromCourseService(
|
||||
|
||||
export async function getCourseImportStructureService(orgId: string, courseId: string) {
|
||||
try {
|
||||
return await buildCourseStructureSnapshot(orgId, courseId);
|
||||
return buildCourseStructureSnapshot(orgId, courseId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
@@ -863,6 +871,7 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
const existingDraft = await getCourseImportDraftService(orgId, draftId);
|
||||
const draft = parseStoredDraft(existingDraft);
|
||||
const course = await getCourseRecord(orgId, overrides.courseId);
|
||||
const syncMode = overrides.syncMode ?? 'merge';
|
||||
const courseTitle = overrides.title ?? draft.course.title;
|
||||
const courseDescription = overrides.description ?? draft.course.description;
|
||||
const courseType = overrides.type ?? draft.course.type;
|
||||
@@ -1003,10 +1012,10 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
}
|
||||
|
||||
const appliedTagNames = normalizeDraftTagNames(draft.tags);
|
||||
if (appliedTagNames.length > 0) {
|
||||
if (appliedTagNames.length > 0 || syncMode === 'replace') {
|
||||
await assignCourseTagsByName(orgId, course.id, {
|
||||
tagNames: appliedTagNames,
|
||||
mode: 'merge'
|
||||
mode: syncMode
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1015,6 +1024,72 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
const referencedExerciseIds = new Set(
|
||||
(draft.exercises ?? []).map((exercise) => exercise.externalId).filter((id) => existingExerciseIds.has(id))
|
||||
);
|
||||
let deletedLessonLanguages = 0;
|
||||
let deletedExercises = 0;
|
||||
let deletedLessons = 0;
|
||||
let deletedSections = 0;
|
||||
|
||||
if (syncMode === 'replace') {
|
||||
const referencedLanguageKeys = new Set(
|
||||
draft.lessonLanguages
|
||||
.map((lessonLanguage) => {
|
||||
const lessonId = lessonIdMap.get(lessonLanguage.lessonExternalId);
|
||||
if (!lessonId || !referencedLessonIds.has(lessonId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${lessonId}:${lessonLanguage.locale}`;
|
||||
})
|
||||
.filter((languageKey): languageKey is string => Boolean(languageKey))
|
||||
);
|
||||
|
||||
for (const lessonLanguage of existingLessonLanguages) {
|
||||
const lessonId = lessonLanguage.lessonId;
|
||||
if (!lessonId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!referencedLessonIds.has(lessonId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const languageKey = `${lessonId}:${lessonLanguage.locale}`;
|
||||
if (referencedLanguageKeys.has(languageKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await deleteLessonLanguageService(lessonId, lessonLanguage.locale as TLocale);
|
||||
deletedLessonLanguages += 1;
|
||||
}
|
||||
|
||||
for (const existingExercise of existingExercises) {
|
||||
if (referencedExerciseIds.has(existingExercise.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await deleteExerciseService(existingExercise.id);
|
||||
deletedExercises += 1;
|
||||
}
|
||||
|
||||
for (const existingLesson of existingLessons) {
|
||||
if (referencedLessonIds.has(existingLesson.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await deleteLessonService(existingLesson.id);
|
||||
deletedLessons += 1;
|
||||
}
|
||||
|
||||
for (const existingSection of existingSections) {
|
||||
if (referencedSectionIds.has(existingSection.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await deleteCourseSectionService(existingSection.id);
|
||||
deletedSections += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const courseUrl = await resolveCourseUrl(orgId, course.id, courseTitle);
|
||||
|
||||
await updateCourseImportDraft(orgId, draftId, {
|
||||
@@ -1026,8 +1101,14 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
publishedCourseId: course.id,
|
||||
updatedSections,
|
||||
updatedLessons,
|
||||
updatedExercises,
|
||||
createdLessonLanguages,
|
||||
updatedLessonLanguages
|
||||
updatedLessonLanguages,
|
||||
deletedSections,
|
||||
deletedLessons,
|
||||
deletedExercises,
|
||||
deletedLessonLanguages,
|
||||
syncMode
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1044,9 +1125,13 @@ export async function publishCourseImportDraftToExistingCourseService(
|
||||
updatedExercises,
|
||||
createdLessonLanguages,
|
||||
updatedLessonLanguages,
|
||||
untouchedSections: existingSections.length - referencedSectionIds.size,
|
||||
untouchedLessons: existingLessons.length - referencedLessonIds.size,
|
||||
untouchedExercises: existingExercises.length - referencedExerciseIds.size,
|
||||
deletedSections,
|
||||
deletedLessons,
|
||||
deletedExercises,
|
||||
deletedLessonLanguages,
|
||||
untouchedSections: Math.max(existingSections.length - referencedSectionIds.size - deletedSections, 0),
|
||||
untouchedLessons: Math.max(existingLessons.length - referencedLessonIds.size - deletedLessons, 0),
|
||||
untouchedExercises: Math.max(existingExercises.length - referencedExerciseIds.size - deletedExercises, 0),
|
||||
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
|
||||
import { addGroupMember, createGroup } from '@cio/db/queries/group';
|
||||
import { addGroupMember, createGroup, getCourseProgramAccess } from '@cio/db/queries/group';
|
||||
import {
|
||||
createCourseNewsfeed,
|
||||
createCourse as createCourseQuery,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||
import type { TCourse } from '@cio/db/types';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import * as schema from '@db/schema';
|
||||
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
||||
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
@@ -29,6 +30,45 @@ import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type Co
|
||||
const DEFAULT_CONTENT_GROUPING = true;
|
||||
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
|
||||
|
||||
export async function ensureProgramCourseAccess(courseId: string, profileId: string) {
|
||||
const access = await getCourseProgramAccess(courseId, profileId);
|
||||
if (!access) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const organizationId = access.organizationId;
|
||||
if (!organizationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedEmail = access.profileEmail?.trim() || undefined;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(schema.organizationmember)
|
||||
.values({
|
||||
organizationId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
await tx
|
||||
.insert(schema.groupmember)
|
||||
.values({
|
||||
groupId: access.courseGroupId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function sanitizeCourseMetadata(metadata: TCourse['metadata'] | undefined) {
|
||||
if (!metadata) return metadata;
|
||||
|
||||
@@ -133,49 +173,57 @@ export async function createCourse(
|
||||
try {
|
||||
const description = sanitizeHtml(data.description);
|
||||
|
||||
const result = await db.transaction(async () => {
|
||||
// 1. Create group
|
||||
const [newGroup] = await createGroup({
|
||||
name: data.title,
|
||||
description,
|
||||
organizationId: data.organizationId
|
||||
});
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [newGroup] = await createGroup(
|
||||
{
|
||||
name: data.title,
|
||||
description,
|
||||
organizationId: data.organizationId
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newGroup) {
|
||||
throw new AppError('Failed to create group', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
// 2. Create course with group_id
|
||||
const [newCourse] = await createCourseQuery({
|
||||
title: data.title,
|
||||
description,
|
||||
type: data.type,
|
||||
groupId: newGroup.id
|
||||
});
|
||||
const [newCourse] = await createCourseQuery(
|
||||
{
|
||||
title: data.title,
|
||||
description,
|
||||
type: data.type,
|
||||
groupId: newGroup.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newCourse) {
|
||||
throw new AppError('Failed to create course', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
// 3. Add group member (creator as TUTOR)
|
||||
const [newMember] = await addGroupMember({
|
||||
profileId,
|
||||
groupId: newGroup.id,
|
||||
roleId: ROLE.TUTOR
|
||||
});
|
||||
const [newMember] = await addGroupMember(
|
||||
{
|
||||
profileId,
|
||||
groupId: newGroup.id,
|
||||
roleId: ROLE.TUTOR
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newMember) {
|
||||
throw new AppError('Failed to add group member', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
// 4. Add default news feed
|
||||
await createCourseNewsfeed({
|
||||
content: sanitizeHtml(`<h2>Welcome to this course 🎉 </h2>
|
||||
await createCourseNewsfeed(
|
||||
{
|
||||
content: sanitizeHtml(`<h2>Welcome to this course 🎉 </h2>
|
||||
<p>Thank you for joining this course and I hope you get the best out of it.</p>`),
|
||||
courseId: newCourse.id,
|
||||
isPinned: true,
|
||||
authorId: newMember.id
|
||||
});
|
||||
courseId: newCourse.id,
|
||||
isPinned: true,
|
||||
authorId: newMember.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return {
|
||||
course: newCourse,
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash';
|
||||
import {
|
||||
getCourseStats,
|
||||
getDashOrgStats,
|
||||
getRecentCertifications,
|
||||
getTotalCertificatesIssued
|
||||
} from '@cio/db/queries/dash';
|
||||
|
||||
import { OrganisationAnalytics } from '@api/types';
|
||||
import { getOrgIdBySiteName } from '@cio/db/queries';
|
||||
|
||||
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
|
||||
const analytics: OrganisationAnalytics = {
|
||||
revenue: 0,
|
||||
totalCertificates: 0,
|
||||
numberOfCourses: 0,
|
||||
totalStudents: 0,
|
||||
topCourses: [],
|
||||
enrollments: []
|
||||
recentCertifications: []
|
||||
};
|
||||
|
||||
let resolvedOrgId = orgId;
|
||||
@@ -26,13 +31,14 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
|
||||
}
|
||||
|
||||
try {
|
||||
const [stats, topCourses, enrollments] = await Promise.all([
|
||||
const [stats, topCourses, recentCertificationRows, certificateCountRows] = await Promise.all([
|
||||
getDashOrgStats(resolvedOrgId!),
|
||||
getCourseStats(resolvedOrgId!),
|
||||
getRecentEnrollments(resolvedOrgId!)
|
||||
getRecentCertifications(resolvedOrgId!),
|
||||
getTotalCertificatesIssued(resolvedOrgId!)
|
||||
]);
|
||||
|
||||
analytics.revenue = 0;
|
||||
analytics.totalCertificates = certificateCountRows[0]?.count ?? 0;
|
||||
analytics.numberOfCourses = stats?.[0]?.noOfCourses ?? 0;
|
||||
analytics.totalStudents = stats?.[0]?.enrolledStudents ?? 0;
|
||||
|
||||
@@ -40,16 +46,17 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
|
||||
id: c.courseId,
|
||||
title: c.courseTitle,
|
||||
enrollments: c.totalStudents,
|
||||
completion: c.completionPercentage
|
||||
completion: c.completionPercentage,
|
||||
certification: c.certificationPercentage
|
||||
}));
|
||||
|
||||
analytics.enrollments = enrollments.map((e) => ({
|
||||
id: e.profileId,
|
||||
avatarUrl: e.avatarUrl,
|
||||
name: e.fullname,
|
||||
courseId: e.courseId,
|
||||
course: e.courseTitle,
|
||||
date: e.enrolledAt ?? ''
|
||||
analytics.recentCertifications = recentCertificationRows.map((row) => ({
|
||||
id: row.profileId,
|
||||
avatarUrl: row.avatarUrl,
|
||||
name: row.fullname,
|
||||
courseId: row.courseId,
|
||||
course: row.courseTitle,
|
||||
date: row.earnedAt ?? ''
|
||||
}));
|
||||
|
||||
return analytics;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
updateOptions,
|
||||
updateQuestions
|
||||
} from '@cio/db/queries/exercise';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import type { DbOrTxClient } from '@cio/db/drizzle';
|
||||
@@ -123,7 +124,13 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
|
||||
}
|
||||
|
||||
// Fetch the complete exercise with questions and options
|
||||
return await getExercise(exercise.id);
|
||||
const result = await getExercise(exercise.id);
|
||||
|
||||
if (sanitizedData.courseId) {
|
||||
await touchCourseUpdatedAt(sanitizedData.courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('createExercise error:', error);
|
||||
if (error instanceof AppError) {
|
||||
@@ -193,7 +200,9 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
|
||||
try {
|
||||
const sanitizedData = sanitizeExercisePayload(data);
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
|
||||
// Update exercise basic fields only if they changed
|
||||
@@ -252,6 +261,13 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
|
||||
// Return the updated exercise
|
||||
return await getExercise(exerciseId, txClient);
|
||||
});
|
||||
|
||||
const courseId = existing?.courseId;
|
||||
if (courseId) {
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('updateExerciseService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
@@ -270,7 +286,9 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
|
||||
try {
|
||||
const sanitizedData = sanitizeExercisePayload(data);
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
|
||||
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
|
||||
@@ -303,6 +321,13 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
|
||||
|
||||
return await getExercise(exerciseId, txClient);
|
||||
});
|
||||
|
||||
const courseId = existing?.courseId;
|
||||
if (courseId) {
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('replaceExerciseService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
@@ -330,6 +355,10 @@ export async function deleteExerciseService(exerciseId: string): Promise<TExerci
|
||||
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
if (exercise.courseId) {
|
||||
await touchCourseUpdatedAt(exercise.courseId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeOptionalHtml } from '@api/utils/sanitize-html';
|
||||
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@db/types';
|
||||
import {
|
||||
deleteLessonLanguage,
|
||||
getLessonLanguageByLessonIdAndLocale,
|
||||
getLessonLanguagesByLessonId,
|
||||
updateLessonLanguage,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from '@cio/db/queries/lesson/language';
|
||||
|
||||
import { getLessonById } from '@cio/db/queries/lesson/lesson';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
|
||||
/**
|
||||
* Gets all language translations for a lesson
|
||||
@@ -70,6 +72,10 @@ export async function upsertLessonLanguageService(
|
||||
lessonId
|
||||
});
|
||||
|
||||
if (lesson.courseId) {
|
||||
await touchCourseUpdatedAt(lesson.courseId);
|
||||
}
|
||||
|
||||
return language;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -111,6 +117,11 @@ export async function updateLessonLanguageService(
|
||||
...data,
|
||||
content: sanitizeOptionalHtml(data.content)
|
||||
});
|
||||
|
||||
if (lesson.courseId) {
|
||||
await touchCourseUpdatedAt(lesson.courseId);
|
||||
}
|
||||
|
||||
return language;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -123,3 +134,38 @@ export async function updateLessonLanguageService(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLessonLanguageService(lessonId: string, locale: TLocale): Promise<TLessonLanguage> {
|
||||
try {
|
||||
const lesson = await getLessonById(lessonId);
|
||||
if (!lesson) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const existing = await getLessonLanguageByLessonIdAndLocale(lessonId, locale);
|
||||
if (!existing) {
|
||||
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const deleted = await deleteLessonLanguage(lessonId, locale);
|
||||
if (!deleted) {
|
||||
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (lesson.courseId) {
|
||||
await touchCourseUpdatedAt(lesson.courseId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete lesson language',
|
||||
ErrorCodes.LESSON_LANGUAGE_DELETE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
upsertLessonCompletion,
|
||||
type LessonById
|
||||
} from '@cio/db/queries/lesson';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
import { enrichLessonWithPresignedUrls } from '@api/utils/lesson-media';
|
||||
|
||||
/**
|
||||
@@ -38,6 +39,8 @@ export async function createLesson(courseId: string, data: TLessonCreate): Promi
|
||||
throw new AppError('Failed to create lesson', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
|
||||
return lesson;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -91,6 +94,10 @@ export async function updateLessonService(lessonId: string, data: TLessonUpdate)
|
||||
throw new AppError('Failed to update lesson', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
if (updated.courseId) {
|
||||
await touchCourseUpdatedAt(updated.courseId);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -117,6 +124,10 @@ export async function deleteLessonService(lessonId: string): Promise<TLesson> {
|
||||
throw new AppError('Failed to delete lesson', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
if (deleted.courseId) {
|
||||
await touchCourseUpdatedAt(deleted.courseId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -168,7 +179,14 @@ export async function reorderLessons(lessons: TLessonReorder['lessons']): Promis
|
||||
);
|
||||
|
||||
const updated = await Promise.all(updatePromises);
|
||||
return updated.filter((l): l is TLesson => l !== null);
|
||||
const validLessons = updated.filter((l): l is TLesson => l !== null);
|
||||
|
||||
const courseId = validLessons[0]?.courseId;
|
||||
if (courseId) {
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
}
|
||||
|
||||
return validLessons;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to reorder lessons',
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { OrgAudienceMember, OrgAudiencePagination, OrgAudienceQuery } from '@api/types/org';
|
||||
import type { TGetAudienceQuery, TGetOrganizationCoursesQuery } from '@cio/utils/validation/organization';
|
||||
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types';
|
||||
import {
|
||||
deleteOrganizationAudienceMember,
|
||||
cancelOrganizationPlan,
|
||||
createOrganizationPlan,
|
||||
deleteOrganizationAudienceMember,
|
||||
deleteOrganizationMember,
|
||||
getOrgIdBySiteName,
|
||||
getActiveOrganizationPlan,
|
||||
getFirstOrganizationWithPlans,
|
||||
getLatestOrgInvitesByEmails,
|
||||
getOrgIdBySiteName,
|
||||
getOrganizationAudience,
|
||||
getOrganizationAudienceMember,
|
||||
getOrganizationBySiteName,
|
||||
getOrganizationTeam,
|
||||
getOrganizations,
|
||||
@@ -16,8 +20,8 @@ import {
|
||||
updateOrganization,
|
||||
updateOrganizationPlan
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
|
||||
import {
|
||||
countPublishedCoursesBySiteName,
|
||||
getCoursesById,
|
||||
getCoursesBySiteNameForSetup,
|
||||
getEnrolledCourses,
|
||||
@@ -27,16 +31,18 @@ import {
|
||||
getOrgCourses,
|
||||
getPublishedCoursesBySiteName
|
||||
} from '@cio/db/queries/course';
|
||||
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
|
||||
import { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics';
|
||||
|
||||
import type { OrganizationWithPlans } from '@cio/db/queries/organization/types';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { createOrganizationWithOwner } from '@api/services/onboarding';
|
||||
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
|
||||
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
|
||||
import type { OrgAudienceMember, OrgAudiencePagination, OrgAudienceQuery } from '@api/types/org';
|
||||
import type { TGetAudienceQuery } from '@cio/utils/validation/organization';
|
||||
|
||||
const PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT = 4;
|
||||
|
||||
/**
|
||||
* Creates a new organization with the current user as owner
|
||||
@@ -169,6 +175,41 @@ export async function getOrgAudience(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrgAudienceMember(orgId: string, memberId: number): Promise<OrgAudienceMember> {
|
||||
try {
|
||||
const member = await getOrganizationAudienceMember(orgId, memberId);
|
||||
|
||||
if (!member) {
|
||||
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (!member.profileId && member.email) {
|
||||
const invites = await getLatestOrgInvitesByEmails(orgId, [member.email.toLowerCase()]);
|
||||
const invite = invites[0];
|
||||
|
||||
return {
|
||||
...member,
|
||||
status: deriveAudienceMemberStatus(member.profileId, invite)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...member,
|
||||
status: deriveAudienceMemberStatus(member.profileId, undefined)
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch audience member',
|
||||
ErrorCodes.ORG_AUDIENCE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a student audience member from an organization
|
||||
* @param orgId - The organization ID
|
||||
@@ -218,20 +259,31 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
|
||||
if (tagSlugs && tagSlugs.length > 0) {
|
||||
filteredCourseIds = await getCourseIdsByTagSlugs(org.id, tagSlugs);
|
||||
if (filteredCourseIds.length === 0) {
|
||||
return [];
|
||||
return {
|
||||
courses: [],
|
||||
hasMoreCourses: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const courses = await getPublishedCoursesBySiteName(siteName, filteredCourseIds);
|
||||
const totalCourses = await countPublishedCoursesBySiteName(siteName, filteredCourseIds);
|
||||
const courses = await getPublishedCoursesBySiteName(
|
||||
siteName,
|
||||
filteredCourseIds,
|
||||
PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT
|
||||
);
|
||||
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
|
||||
org.id,
|
||||
courses.map((course) => course.id)
|
||||
);
|
||||
|
||||
return courses.map((course) => ({
|
||||
...course,
|
||||
tags: tagsByCourseId[course.id] ?? []
|
||||
}));
|
||||
return {
|
||||
courses: courses.map((course) => ({
|
||||
...course,
|
||||
tags: tagsByCourseId[course.id] ?? []
|
||||
})),
|
||||
hasMoreCourses: totalCourses > courses.length
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
@@ -251,44 +303,110 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
|
||||
* - Admins: all courses with totalStudents
|
||||
* - Tutors: assigned courses with totalStudents
|
||||
*/
|
||||
export async function getOrganizationCourses(orgId: string, userId: string, userRole: number, tagSlugs?: string[]) {
|
||||
export async function getOrganizationCourses(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRole: number,
|
||||
query: TGetOrganizationCoursesQuery
|
||||
) {
|
||||
try {
|
||||
if (!userRole) {
|
||||
throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
const { page, limit, search } = query;
|
||||
const tagSlugs = query.tags
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let filteredCourseIds: string[] | undefined = undefined;
|
||||
if (tagSlugs && tagSlugs.length > 0) {
|
||||
filteredCourseIds = await getCourseIdsByTagSlugs(orgId, tagSlugs);
|
||||
if (filteredCourseIds.length === 0) {
|
||||
return [];
|
||||
return {
|
||||
items: [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
},
|
||||
query: {
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
tags: query.tags
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
switch (userRole) {
|
||||
case ROLE.ADMIN: {
|
||||
const courses = await getOrgCourses({ orgId, courseIds: filteredCourseIds });
|
||||
const courses = await getOrgCourses({
|
||||
orgId,
|
||||
courseIds: filteredCourseIds,
|
||||
page,
|
||||
limit,
|
||||
search
|
||||
});
|
||||
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
|
||||
orgId,
|
||||
courses.map((course) => course.id)
|
||||
courses.items.map((course) => course.id)
|
||||
);
|
||||
|
||||
return courses.map((course) => ({
|
||||
...course,
|
||||
tags: tagsByCourseId[course.id] ?? []
|
||||
}));
|
||||
return {
|
||||
items: courses.items.map((course) => ({
|
||||
...course,
|
||||
tags: tagsByCourseId[course.id] ?? []
|
||||
})),
|
||||
pagination: {
|
||||
page: courses.page,
|
||||
limit: courses.limit,
|
||||
total: courses.total,
|
||||
totalPages: courses.totalPages
|
||||
},
|
||||
query: {
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
tags: query.tags
|
||||
}
|
||||
};
|
||||
}
|
||||
case ROLE.TUTOR: {
|
||||
const courses = await getOrgCourses({ orgId, profileId: userId, courseIds: filteredCourseIds });
|
||||
const courses = await getOrgCourses({
|
||||
orgId,
|
||||
profileId: userId,
|
||||
courseIds: filteredCourseIds,
|
||||
page,
|
||||
limit,
|
||||
search
|
||||
});
|
||||
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
|
||||
orgId,
|
||||
courses.map((course) => course.id)
|
||||
courses.items.map((course) => course.id)
|
||||
);
|
||||
|
||||
return courses.map((course) => ({
|
||||
...course,
|
||||
tags: tagsByCourseId[course.id] ?? []
|
||||
}));
|
||||
return {
|
||||
items: courses.items.map((course) => ({
|
||||
...course,
|
||||
tags: tagsByCourseId[course.id] ?? []
|
||||
})),
|
||||
pagination: {
|
||||
page: courses.page,
|
||||
limit: courses.limit,
|
||||
total: courses.total,
|
||||
totalPages: courses.totalPages
|
||||
},
|
||||
query: {
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
tags: query.tags
|
||||
}
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403);
|
||||
@@ -433,6 +551,21 @@ export async function createOrgPlan(data: TNewOrganizationPlan) {
|
||||
*/
|
||||
export async function updateOrg(orgId: string, data: Partial<TOrganization>) {
|
||||
try {
|
||||
// Enforce plan restriction: only paid plans can use non-minimal landing page themes
|
||||
if (data.landingpage && typeof data.landingpage === 'object') {
|
||||
const landingpage = data.landingpage as Record<string, unknown>;
|
||||
const theme = landingpage.theme;
|
||||
|
||||
if (typeof theme === 'string' && theme !== 'minimal') {
|
||||
const activePlan = await getActiveOrganizationPlan(orgId);
|
||||
const planName = activePlan?.planName ?? PLAN.BASIC;
|
||||
|
||||
if (planName === PLAN.BASIC) {
|
||||
throw new AppError('Landing page themes require a paid plan', ErrorCodes.UPGRADE_REQUIRED, 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const organization = await updateOrganization(orgId, data);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
|
||||
@@ -6,11 +6,13 @@ import type {
|
||||
} from '@cio/utils/validation/organization';
|
||||
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
|
||||
import {
|
||||
createOrganizationInvite,
|
||||
createOrganizationInviteAudits,
|
||||
createOrganizationInvites,
|
||||
createOrganizationMembers,
|
||||
getOrganizationAudienceMember,
|
||||
getLatestOrganizationInviteRowByOrgAndEmail,
|
||||
getOrgMembersByProfileIds,
|
||||
getOrganizationById,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
revokeActiveOrganizationInvitesByEmails
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds, getOrgCourseGroups, getOrgCourses } from '@cio/db/queries/course';
|
||||
import { updateOrganizationAudienceMember } from '@cio/db/queries/organization';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import crypto from 'node:crypto';
|
||||
@@ -113,16 +116,33 @@ async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAud
|
||||
let courseNames: string[] = [];
|
||||
if (data.allCourses) {
|
||||
const courses = await getOrgCourses({ orgId });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
courseIds = courses.items.map((c) => c.id);
|
||||
courseNames = courses.items.map((c) => c.title).filter(Boolean);
|
||||
} else if (data.courseIds && data.courseIds.length > 0) {
|
||||
const courses = await getOrgCourses({ orgId, courseIds: data.courseIds });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
courseIds = courses.items.map((c) => c.id);
|
||||
courseNames = courses.items.map((c) => c.title).filter(Boolean);
|
||||
}
|
||||
return { courseIds, courseNames };
|
||||
}
|
||||
|
||||
async function resolveProgramIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
|
||||
let programIds: string[] = [];
|
||||
let programNames: string[] = [];
|
||||
|
||||
if (data.allPrograms) {
|
||||
const programs = await getProgramsByOrg(orgId);
|
||||
programIds = programs.map((program) => program.id);
|
||||
programNames = programs.map((program) => program.name).filter(Boolean);
|
||||
} else if (data.programIds && data.programIds.length > 0) {
|
||||
const programs = await getProgramsByOrg(orgId, data.programIds);
|
||||
programIds = programs.map((program) => program.id);
|
||||
programNames = programs.map((program) => program.name).filter(Boolean);
|
||||
}
|
||||
|
||||
return { programIds, programNames };
|
||||
}
|
||||
|
||||
async function enrollAudienceStudentProfilesInCourses(
|
||||
orgId: string,
|
||||
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
|
||||
@@ -202,16 +222,98 @@ async function enrollAudienceStudentProfilesInCourses(
|
||||
};
|
||||
}
|
||||
|
||||
async function enrollAudienceStudentProfilesInPrograms(
|
||||
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
|
||||
orgId: string,
|
||||
profileIds: string[],
|
||||
programIds: string[],
|
||||
shouldSendEmail: boolean
|
||||
): Promise<{ assigned: number; alreadyEnrolled: number; emailsSent: number }> {
|
||||
if (programIds.length === 0 || profileIds.length === 0) {
|
||||
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
|
||||
}
|
||||
|
||||
const uniqueProfileIds = [...new Set(profileIds)];
|
||||
const orgMembers = await getOrgMembersByProfileIds(orgId, uniqueProfileIds);
|
||||
const studentMembers = orgMembers.filter((member) => member.profileId && member.roleId === ROLE.STUDENT);
|
||||
const validProfileIds = new Set(studentMembers.map((member) => member.profileId!));
|
||||
const profileEmailMap = new Map(
|
||||
studentMembers.filter((member) => member.profileId).map((member) => [member.profileId!, member.email ?? ''])
|
||||
);
|
||||
|
||||
const programs = await getProgramsByOrg(orgId, programIds);
|
||||
if (programs.length === 0) {
|
||||
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
|
||||
}
|
||||
|
||||
const programNameById = new Map(programs.map((program) => [program.id, program.name || 'Program']));
|
||||
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
const validProgramIds = programs.map((program) => program.id);
|
||||
const validProfiles = uniqueProfileIds.filter((profileId) => validProfileIds.has(profileId));
|
||||
const pairs = validProfiles.flatMap((profileId) => validProgramIds.map((programId) => ({ programId, profileId })));
|
||||
const existingSet = await getExistingProgramMembers(pairs);
|
||||
const toInsert = pairs.filter((pair) => !existingSet.has(`${pair.programId}:${pair.profileId}`));
|
||||
const alreadyEnrolled = pairs.length - toInsert.length;
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await Promise.all(
|
||||
toInsert.map((pair) =>
|
||||
addProgramMember({
|
||||
programId: pair.programId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: pair.profileId,
|
||||
email: profileEmailMap.get(pair.profileId) || undefined
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let emailsSent = 0;
|
||||
|
||||
if (shouldSendEmail && toInsert.length > 0) {
|
||||
await Promise.all(
|
||||
toInsert
|
||||
.filter((pair) => profileEmailMap.get(pair.profileId))
|
||||
.map(async (pair) => {
|
||||
const email = profileEmailMap.get(pair.profileId)!;
|
||||
|
||||
try {
|
||||
await sendEmail('studentProgramWelcome', {
|
||||
to: email,
|
||||
fields: {
|
||||
orgName: organization.name,
|
||||
programName: programNameById.get(pair.programId) || 'Program',
|
||||
loginUrl
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
||||
});
|
||||
emailsSent++;
|
||||
} catch (emailError) {
|
||||
console.error(`enrollAudienceStudentProfilesInPrograms email error for ${email}:`, emailError);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
assigned: toInsert.length,
|
||||
alreadyEnrolled,
|
||||
emailsSent
|
||||
};
|
||||
}
|
||||
|
||||
async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
orgId: string;
|
||||
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>;
|
||||
emails: string[];
|
||||
courseIds: string[];
|
||||
courseNamesLabel: string | undefined;
|
||||
programIds: string[];
|
||||
accessNamesLabel: string | undefined;
|
||||
invitedByProfileId: string;
|
||||
shouldSendEmail: boolean;
|
||||
}): Promise<{ created: number; emailsSent: number; emailsFailed: number }> {
|
||||
const { orgId, organization, emails, courseIds, courseNamesLabel, invitedByProfileId, shouldSendEmail } = input;
|
||||
const { orgId, organization, emails, courseIds, programIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } =
|
||||
input;
|
||||
|
||||
if (emails.length === 0) {
|
||||
return { created: 0, emailsSent: 0, emailsFailed: 0 };
|
||||
@@ -236,7 +338,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
isRevoked: false,
|
||||
metadata: {
|
||||
source: 'AUDIENCE_IMPORT',
|
||||
courseIds: courseIds.length > 0 ? courseIds : undefined
|
||||
courseIds: courseIds.length > 0 ? courseIds : undefined,
|
||||
programIds: programIds.length > 0 ? programIds : undefined
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -261,7 +364,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
roleId: ROLE.STUDENT,
|
||||
roleName: 'Student',
|
||||
expiresAt,
|
||||
courseIds
|
||||
courseIds,
|
||||
programIds
|
||||
}
|
||||
};
|
||||
})
|
||||
@@ -283,7 +387,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
orgName: organization.name,
|
||||
inviteLink,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
courseNames: courseNamesLabel
|
||||
courseNames: accessNamesLabel
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
||||
});
|
||||
@@ -388,15 +492,24 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
}
|
||||
|
||||
const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data);
|
||||
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
|
||||
const { programIds, programNames } = await resolveProgramIdsAndNamesForImport(orgId, data);
|
||||
const accessNames = [...courseNames, ...programNames];
|
||||
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
|
||||
|
||||
const assignResult = await enrollAudienceStudentProfilesInCourses(
|
||||
const assignedToCourses = await enrollAudienceStudentProfilesInCourses(
|
||||
orgId,
|
||||
organization,
|
||||
existingStudentProfileIds,
|
||||
courseIds,
|
||||
data.sendEmail
|
||||
);
|
||||
const assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
|
||||
organization,
|
||||
orgId,
|
||||
existingStudentProfileIds,
|
||||
programIds,
|
||||
data.sendEmail
|
||||
);
|
||||
|
||||
let imported = 0;
|
||||
let importEmailsSent = 0;
|
||||
@@ -425,12 +538,26 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
}
|
||||
}
|
||||
|
||||
if (programIds.length > 0) {
|
||||
const profiles = await getProfilesByEmails(newEmails);
|
||||
if (profiles.length > 0) {
|
||||
await enrollAudienceStudentProfilesInPrograms(
|
||||
organization,
|
||||
orgId,
|
||||
profiles.map((profile) => profile.id),
|
||||
programIds,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const inviteOutcome = await createStudentOrgInvitesAndSendEmails({
|
||||
orgId,
|
||||
organization,
|
||||
emails: newEmails,
|
||||
courseIds,
|
||||
courseNamesLabel,
|
||||
programIds,
|
||||
accessNamesLabel,
|
||||
invitedByProfileId,
|
||||
shouldSendEmail: data.sendEmail
|
||||
});
|
||||
@@ -447,7 +574,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
organization,
|
||||
emails: pendingStudentEmails,
|
||||
courseIds,
|
||||
courseNamesLabel,
|
||||
programIds,
|
||||
accessNamesLabel,
|
||||
invitedByProfileId,
|
||||
shouldSendEmail: data.sendEmail
|
||||
});
|
||||
@@ -457,11 +585,12 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
|
||||
return {
|
||||
imported,
|
||||
assigned: assignResult.assigned,
|
||||
alreadyEnrolledInCourses: assignResult.alreadyEnrolled,
|
||||
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
|
||||
alreadyEnrolledInCourses: assignedToCourses.alreadyEnrolled,
|
||||
alreadyEnrolledInPrograms: assignedToPrograms.alreadyEnrolled,
|
||||
pendingInvitesRenewed: pendingStudentEmails.length,
|
||||
duplicates: recipients.duplicates.length,
|
||||
emailsSent: assignResult.emailsSent + importEmailsSent + pendingEmailsSent,
|
||||
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent + importEmailsSent + pendingEmailsSent,
|
||||
emailsFailed: importEmailsFailed + pendingEmailsFailed
|
||||
};
|
||||
}
|
||||
@@ -483,22 +612,32 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
const emailToUse = member.email.toLowerCase().trim();
|
||||
|
||||
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse);
|
||||
const meta = latestInvite?.metadata as { courseIds?: string[] } | undefined;
|
||||
const courseIdsFromMetadata = meta?.courseIds?.filter(Boolean) ?? [];
|
||||
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
|
||||
const courseIdsFromMetadata = meta.courseIds?.filter(Boolean) ?? [];
|
||||
const programIdsFromMetadata = meta.programIds?.filter(Boolean) ?? [];
|
||||
|
||||
let courseIds: string[] = [];
|
||||
let courseNames: string[] = [];
|
||||
if (courseIdsFromMetadata.length > 0) {
|
||||
const courses = await getOrgCourses({ orgId, courseIds: courseIdsFromMetadata });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
courseIds = courses.items.map((c) => c.id);
|
||||
courseNames = courses.items.map((c) => c.title).filter(Boolean);
|
||||
}
|
||||
|
||||
let programIds: string[] = [];
|
||||
let programNames: string[] = [];
|
||||
if (programIdsFromMetadata.length > 0) {
|
||||
const programs = await getProgramsByOrg(orgId, programIdsFromMetadata);
|
||||
programIds = programs.map((program) => program.id);
|
||||
programNames = programs.map((program) => program.name).filter(Boolean);
|
||||
}
|
||||
|
||||
await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId);
|
||||
|
||||
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
|
||||
const token = generateToken();
|
||||
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
|
||||
const accessNames = [...courseNames, ...programNames];
|
||||
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
|
||||
|
||||
const invite = await createOrganizationInvite({
|
||||
organizationId: orgId,
|
||||
@@ -510,7 +649,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
isRevoked: false,
|
||||
metadata: {
|
||||
source: 'AUDIENCE_RESEND',
|
||||
courseIds: courseIds.length > 0 ? courseIds : undefined
|
||||
courseIds: courseIds.length > 0 ? courseIds : undefined,
|
||||
programIds: programIds.length > 0 ? programIds : undefined
|
||||
}
|
||||
});
|
||||
|
||||
@@ -527,7 +667,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
roleId: ROLE.STUDENT,
|
||||
roleName: 'Student',
|
||||
expiresAt,
|
||||
courseIds
|
||||
courseIds,
|
||||
programIds
|
||||
}
|
||||
}
|
||||
]);
|
||||
@@ -542,7 +683,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
orgName: organization.name,
|
||||
inviteLink,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
courseNames: courseNamesLabel
|
||||
courseNames: accessNamesLabel
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
||||
});
|
||||
@@ -633,10 +774,113 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const courseGroups = await getOrgCourseGroups(orgId, data.courseIds);
|
||||
if (courseGroups.length === 0) {
|
||||
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
|
||||
const courseIds = data.courseIds ?? [];
|
||||
const programIds = data.programIds ?? [];
|
||||
|
||||
let assignedToCourses = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
|
||||
let assignedToPrograms = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
|
||||
if (courseGroups.length === 0) {
|
||||
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
|
||||
}
|
||||
|
||||
assignedToCourses = await enrollAudienceStudentProfilesInCourses(
|
||||
orgId,
|
||||
organization,
|
||||
data.profileIds,
|
||||
courseIds,
|
||||
data.sendEmail
|
||||
);
|
||||
}
|
||||
|
||||
return enrollAudienceStudentProfilesInCourses(orgId, organization, data.profileIds, data.courseIds, data.sendEmail);
|
||||
if (programIds.length > 0) {
|
||||
const programs = await getProgramsByOrg(orgId, programIds);
|
||||
if (programs.length === 0) {
|
||||
throw new AppError('No valid programs found', ErrorCodes.VALIDATION_ERROR, 400, 'programIds');
|
||||
}
|
||||
|
||||
assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
|
||||
organization,
|
||||
orgId,
|
||||
data.profileIds,
|
||||
programIds,
|
||||
data.sendEmail
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
|
||||
alreadyEnrolled: assignedToCourses.alreadyEnrolled + assignedToPrograms.alreadyEnrolled,
|
||||
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent
|
||||
};
|
||||
}
|
||||
|
||||
export async function updatePendingAudienceMemberEmail(
|
||||
orgId: string,
|
||||
memberId: number,
|
||||
data: { email: string; sendEmail: boolean },
|
||||
invitedByProfileId: string
|
||||
) {
|
||||
const organization = await getOrganizationById(orgId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const existingMember = await getOrganizationAudienceMember(orgId, memberId);
|
||||
if (!existingMember) {
|
||||
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (existingMember.profileId) {
|
||||
throw new AppError(
|
||||
'Only pending audience members can be updated through this endpoint',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'memberId'
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedEmail = data.email.toLowerCase().trim();
|
||||
const currentEmail = existingMember.email.toLowerCase().trim();
|
||||
|
||||
if (!normalizedEmail) {
|
||||
throw new AppError('Email is required', ErrorCodes.VALIDATION_ERROR, 400, 'email');
|
||||
}
|
||||
|
||||
if (normalizedEmail !== currentEmail) {
|
||||
const matchingMembers = await getOrganizationMembersByNormalizedEmails(orgId, [normalizedEmail]);
|
||||
|
||||
if (matchingMembers.length > 0) {
|
||||
throw new AppError('An audience member with this email already exists', ErrorCodes.CONFLICT, 409, 'email');
|
||||
}
|
||||
}
|
||||
|
||||
await revokeActiveOrganizationInvitesByEmails(orgId, [currentEmail, normalizedEmail], invitedByProfileId);
|
||||
|
||||
const updatedMember = await updateOrganizationAudienceMember(orgId, memberId, {
|
||||
email: normalizedEmail,
|
||||
verified: false
|
||||
});
|
||||
|
||||
if (!updatedMember) {
|
||||
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, currentEmail);
|
||||
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
|
||||
|
||||
await createStudentOrgInvitesAndSendEmails({
|
||||
orgId,
|
||||
organization,
|
||||
emails: [normalizedEmail],
|
||||
courseIds: meta.courseIds?.filter(Boolean) ?? [],
|
||||
programIds: meta.programIds?.filter(Boolean) ?? [],
|
||||
accessNamesLabel: undefined,
|
||||
invitedByProfileId,
|
||||
shouldSendEmail: data.sendEmail
|
||||
});
|
||||
|
||||
return updatedMember;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MCP_TOOL_CREDIT_COST,
|
||||
type TMcpToolName
|
||||
} from '@cio/utils/plans';
|
||||
import { PLAN } from '@cio/utils/plans/constants';
|
||||
|
||||
export type OrganizationAutomationUsageSummary = {
|
||||
type: TOrganizationApiKeyType;
|
||||
@@ -56,11 +57,16 @@ export async function assertOrganizationAutomationKeyCreationAllowed(
|
||||
organizationId: string,
|
||||
type: TOrganizationApiKeyType
|
||||
): Promise<void> {
|
||||
const planName = await getOrganizationPlanName(organizationId);
|
||||
|
||||
if (type === AUTOMATION_TYPE.API && planName === PLAN.BASIC) {
|
||||
throw new AppError('Public API keys require an Early Adopter or Enterprise plan', ErrorCodes.UPGRADE_REQUIRED, 403);
|
||||
}
|
||||
|
||||
if (type !== AUTOMATION_TYPE.MCP) {
|
||||
return;
|
||||
}
|
||||
|
||||
const planName = await getOrganizationPlanName(organizationId);
|
||||
const limits = getMcpAutomationLimits(planName);
|
||||
const activeKeys = await countActiveOrganizationApiKeys(organizationId, type);
|
||||
|
||||
|
||||
@@ -7,18 +7,20 @@ import {
|
||||
createOrganizationInvite,
|
||||
createOrganizationInviteAudit,
|
||||
createOrganizationMembers,
|
||||
getActivePendingOrgInviteForEmail,
|
||||
getOrganizationById,
|
||||
getOrganizationInviteByTokenHash,
|
||||
revokeActiveOrganizationInvitesByEmails
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
|
||||
import { addProgramMember, getExistingProgramMembers } from '@cio/db/queries/program';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { parseCourseIdsFromInviteMetadata } from '@api/utils/org';
|
||||
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
|
||||
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
||||
import { sendEmail } from '@cio/email';
|
||||
|
||||
@@ -426,6 +428,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
if (invite) {
|
||||
const metadata = invite.invite.metadata;
|
||||
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
|
||||
const programIds = parseProgramIdsFromInviteMetadata(metadata);
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
try {
|
||||
@@ -441,6 +444,30 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
console.error('acceptOrganizationInvite course enrollment error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (programIds.length > 0) {
|
||||
try {
|
||||
const existingProgramMemberships = await getExistingProgramMembers(
|
||||
programIds.map((programId) => ({ programId, profileId: user.id }))
|
||||
);
|
||||
const programIdsToInsert = programIds.filter(
|
||||
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
programIdsToInsert.map((programId) =>
|
||||
addProgramMember({
|
||||
programId,
|
||||
roleId: invite.invite.roleId,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('acceptOrganizationInvite program enrollment error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
|
||||
@@ -452,3 +479,210 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
redirectTo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pending org invite for the currently logged-in user, if one exists.
|
||||
* Used by the LMS dashboard to prompt the student to accept on first load.
|
||||
*/
|
||||
export async function getPendingOrgInviteForUser(orgId: string, email: string) {
|
||||
if (!orgId || !email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await getActivePendingOrgInviteForEmail(orgId, email);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.invite.id,
|
||||
email: data.invite.email,
|
||||
roleId: data.invite.roleId,
|
||||
roleLabel: getRoleLabel(data.invite.roleId),
|
||||
expiresAt: data.invite.expiresAt,
|
||||
organization: data.organization
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts an organization invite by invite ID (for logged-in students with a pending invite).
|
||||
* Replicates the acceptance logic of acceptOrganizationInvite but looks up by invite ID instead of token.
|
||||
*/
|
||||
export async function acceptOrganizationInviteById(
|
||||
inviteId: string,
|
||||
user: TAuthUser,
|
||||
context: TInviteRequestContext = {}
|
||||
) {
|
||||
if (!user.id || !user.email) {
|
||||
throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401);
|
||||
}
|
||||
|
||||
const normalizedEmail = user.email.toLowerCase().trim();
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.select({
|
||||
invite: schema.organizationInvite,
|
||||
organization: {
|
||||
id: schema.organization.id,
|
||||
name: schema.organization.name,
|
||||
siteName: schema.organization.siteName
|
||||
}
|
||||
})
|
||||
.from(schema.organizationInvite)
|
||||
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
|
||||
.where(eq(schema.organizationInvite.id, inviteId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
throw new AppError('Invalid invite', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const status = getInviteStatus(row.invite);
|
||||
if (status === 'REVOKED') {
|
||||
throw new AppError('This invite has been revoked', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
if (status === 'EXPIRED') {
|
||||
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
if (row.invite.email.toLowerCase().trim() !== normalizedEmail) {
|
||||
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
if (status === 'ACCEPTED') {
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: true };
|
||||
}
|
||||
|
||||
const [orgMemberByEmail] = await tx
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, row.invite.organizationId),
|
||||
eq(schema.organizationmember.email, normalizedEmail)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (orgMemberByEmail) {
|
||||
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== user.id) {
|
||||
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(schema.organizationmember)
|
||||
.set({ profileId: user.id, roleId: row.invite.roleId, email: normalizedEmail, verified: true })
|
||||
.where(eq(schema.organizationmember.id, orgMemberByEmail.id));
|
||||
} else {
|
||||
const [orgMemberByProfile] = await tx
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, row.invite.organizationId),
|
||||
eq(schema.organizationmember.profileId, user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (orgMemberByProfile) {
|
||||
await tx
|
||||
.update(schema.organizationmember)
|
||||
.set({ roleId: row.invite.roleId, email: normalizedEmail, verified: true })
|
||||
.where(eq(schema.organizationmember.id, orgMemberByProfile.id));
|
||||
} else {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: row.invite.organizationId,
|
||||
roleId: row.invite.roleId,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
const [acceptedInvite] = await tx
|
||||
.update(schema.organizationInvite)
|
||||
.set({
|
||||
acceptedAt: new Date().toISOString(),
|
||||
acceptedByProfileId: user.id,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationInvite.id, row.invite.id),
|
||||
eq(schema.organizationInvite.isRevoked, false),
|
||||
isNull(schema.organizationInvite.acceptedAt)
|
||||
)
|
||||
)
|
||||
.returning({ id: schema.organizationInvite.id });
|
||||
|
||||
if (!acceptedInvite) {
|
||||
throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409);
|
||||
}
|
||||
|
||||
return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: false };
|
||||
});
|
||||
|
||||
await recordOrganizationInviteAudit(result.invite.id, result.invite.organizationId, 'ACCEPTED', {
|
||||
actorProfileId: user.id,
|
||||
targetEmail: normalizedEmail,
|
||||
ipAddress: context.ipAddress,
|
||||
userAgent: context.userAgent,
|
||||
metadata: { alreadyAccepted: result.alreadyAccepted }
|
||||
});
|
||||
|
||||
const metadata = result.invite.metadata;
|
||||
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
|
||||
const programIds = parseProgramIdsFromInviteMetadata(metadata);
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
try {
|
||||
const courseGroupMappings = await getCourseGroupIds(courseIds);
|
||||
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
|
||||
await enrollUsersInCourseGroups(
|
||||
validGroupIds,
|
||||
[{ profileId: user.id, email: normalizedEmail }],
|
||||
result.invite.roleId
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('acceptOrganizationInviteById course enrollment error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (programIds.length > 0) {
|
||||
try {
|
||||
const existingProgramMemberships = await getExistingProgramMembers(
|
||||
programIds.map((programId) => ({ programId, profileId: user.id }))
|
||||
);
|
||||
const programIdsToInsert = programIds.filter(
|
||||
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
programIdsToInsert.map((programId) =>
|
||||
addProgramMember({ programId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('acceptOrganizationInviteById program enrollment error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const siteName = result.organization.siteName || '';
|
||||
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
|
||||
|
||||
return {
|
||||
organizationId: result.organization.id,
|
||||
roleId: result.roleId,
|
||||
alreadyAccepted: result.alreadyAccepted,
|
||||
redirectTo
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
type TCreateProgram,
|
||||
type TUpdateProgram,
|
||||
type TAddCourseToProgram,
|
||||
type TAddProgramMembers,
|
||||
type TUpdateProgramMember,
|
||||
type TCreateProgramNewsfeed,
|
||||
type TUpdateProgramNewsfeed,
|
||||
type TUpdateProgramReaction,
|
||||
type TCreateProgramNewsfeedComment
|
||||
} from '@cio/utils/validation/program';
|
||||
import {
|
||||
addCourseToProgram,
|
||||
createProgramWithCreatorMembership,
|
||||
createProgramNewsfeed as createProgramNewsfeedQuery,
|
||||
createProgramNewsfeedComment as createProgramNewsfeedCommentQuery,
|
||||
deleteProgram as deleteProgramQuery,
|
||||
deleteProgramNewsfeed as deleteProgramNewsfeedQuery,
|
||||
deleteProgramNewsfeedComment as deleteProgramNewsfeedCommentQuery,
|
||||
getEnrolledProgramsByProfile,
|
||||
getProgramById,
|
||||
getProgramMemberByProfileId,
|
||||
getProgramMembers,
|
||||
getProgramNewsfeed,
|
||||
getProgramNewsfeedById,
|
||||
getProgramNewsfeedCommentById,
|
||||
getProgramNewsfeedComments,
|
||||
getProgramsByOrg,
|
||||
getCoursesByProgram,
|
||||
getProgramMemberRole,
|
||||
isProgramCourse,
|
||||
isProgramMember,
|
||||
removeCourseFromProgram,
|
||||
removeProgramMember,
|
||||
updateProgram as updateProgramQuery,
|
||||
updateProgramMember as updateProgramMemberQuery,
|
||||
updateProgramNewsfeed as updateProgramNewsfeedQuery,
|
||||
updateProgramNewsfeedReaction
|
||||
} from '@cio/db/queries/program';
|
||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||
import { getProfileByEmail } from '@cio/db/queries/auth';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
type ProgramMemberEnrollment = {
|
||||
profileId: string;
|
||||
email: string | null;
|
||||
roleId: number;
|
||||
};
|
||||
|
||||
async function enrollProgramStudentsInGroups(
|
||||
organizationId: string,
|
||||
groupIds: string[],
|
||||
members: ProgramMemberEnrollment[]
|
||||
) {
|
||||
const studentMembers = members.filter((member) => member.profileId && member.roleId === ROLE.STUDENT);
|
||||
const uniqueGroupIds = [...new Set(groupIds)];
|
||||
|
||||
if (studentMembers.length === 0 || uniqueGroupIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const organizationMemberRows = studentMembers.map((member) => ({
|
||||
organizationId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: member.profileId,
|
||||
email: member.email?.trim() || undefined,
|
||||
verified: true
|
||||
}));
|
||||
|
||||
const groupMemberRows = uniqueGroupIds.flatMap((groupId) =>
|
||||
studentMembers.map((member) => ({
|
||||
groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: member.profileId,
|
||||
email: member.email?.trim() || undefined
|
||||
}))
|
||||
);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.organizationmember).values(organizationMemberRows).onConflictDoNothing();
|
||||
await tx.insert(schema.groupmember).values(groupMemberRows).onConflictDoNothing();
|
||||
});
|
||||
|
||||
return groupMemberRows.length;
|
||||
}
|
||||
|
||||
// ─── Program CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createProgram(organizationId: string, profileId: string, data: TCreateProgram) {
|
||||
try {
|
||||
const description = data.description?.trim() || `A program for ${data.name}`;
|
||||
|
||||
return createProgramWithCreatorMembership(
|
||||
{
|
||||
organizationId,
|
||||
createdByProfileId: profileId,
|
||||
name: data.name,
|
||||
description,
|
||||
coverImage: data.coverImage
|
||||
},
|
||||
profileId
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create program',
|
||||
ErrorCodes.PROGRAM_CREATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgram(programId: string) {
|
||||
try {
|
||||
const program = await getProgramById(programId);
|
||||
if (!program) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
}
|
||||
return program;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get program',
|
||||
ErrorCodes.PROGRAM_NOT_FOUND,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOrgPrograms(organizationId: string) {
|
||||
try {
|
||||
return getProgramsByOrg(organizationId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list programs',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgram(programId: string, data: TUpdateProgram) {
|
||||
try {
|
||||
const updated = await updateProgramQuery(programId, data);
|
||||
if (!updated) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update program',
|
||||
ErrorCodes.PROGRAM_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgram(programId: string) {
|
||||
try {
|
||||
const deleted = await deleteProgramQuery(programId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
}
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete program',
|
||||
ErrorCodes.PROGRAM_DELETE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Members ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function listProgramMembers(programId: string) {
|
||||
try {
|
||||
return getProgramMembers(programId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list program members',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addProgramMembers(programId: string, data: TAddProgramMembers) {
|
||||
try {
|
||||
const program = await getProgramById(programId);
|
||||
if (!program) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const programCourses = await getCoursesByProgram(programId);
|
||||
const courseGroupIds = (await getCourseGroupIds(programCourses.map((course) => course.course.id))).map(
|
||||
(courseGroup) => courseGroup.groupId
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
data.members.map(async ({ profileId: providedProfileId, email, roleId }) => {
|
||||
const profile = !providedProfileId && email ? await getProfileByEmail(email) : null;
|
||||
const profileId = providedProfileId ?? profile?.id ?? null;
|
||||
const normalizedEmail = email?.toLowerCase().trim() ?? profile?.email ?? null;
|
||||
|
||||
if (profileId) {
|
||||
const existing = await getProgramMemberByProfileId(programId, profileId);
|
||||
if (existing) {
|
||||
throw new AppError(
|
||||
`${email} is already a member of this program`,
|
||||
ErrorCodes.MEMBER_ALREADY_IN_PROGRAM,
|
||||
409
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!profileId && !email) {
|
||||
throw new AppError(
|
||||
'Either profileId or email must be provided for each member',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [member] = await tx
|
||||
.insert(schema.programMember)
|
||||
.values({
|
||||
programId,
|
||||
profileId,
|
||||
roleId,
|
||||
email: normalizedEmail ?? undefined
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (member && member.roleId === ROLE.STUDENT && member.profileId && courseGroupIds.length > 0) {
|
||||
const validCourseGroupIds = courseGroupIds.filter((groupId): groupId is string => Boolean(groupId));
|
||||
|
||||
await tx
|
||||
.insert(schema.organizationmember)
|
||||
.values({
|
||||
organizationId: program.organizationId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: member.profileId,
|
||||
email: normalizedEmail ?? undefined,
|
||||
verified: true
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
await tx
|
||||
.insert(schema.groupmember)
|
||||
.values(
|
||||
validCourseGroupIds.map((groupId) => ({
|
||||
groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: member.profileId,
|
||||
email: normalizedEmail ?? undefined
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
return member;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const added = results
|
||||
.filter((r) => r.status === 'fulfilled')
|
||||
.map((r) => (r as PromiseFulfilledResult<unknown>).value);
|
||||
const errors = results
|
||||
.filter((r) => r.status === 'rejected')
|
||||
.map((r) => (r as PromiseRejectedResult).reason?.message || 'Unknown error');
|
||||
|
||||
return { added, errors };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to add program members',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeProgramMemberService(_programId: string, memberId: string) {
|
||||
try {
|
||||
const deleted = await removeProgramMember(memberId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404);
|
||||
}
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to remove program member',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramMemberService(_programId: string, memberId: string, data: TUpdateProgramMember) {
|
||||
try {
|
||||
const updated = await updateProgramMemberQuery(memberId, { roleId: data.roleId });
|
||||
if (!updated) {
|
||||
throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404);
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update program member',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEnrolledPrograms(profileId: string) {
|
||||
try {
|
||||
return getEnrolledProgramsByProfile(profileId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get enrolled programs',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Courses ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function listProgramCourses(programId: string, profileId: string) {
|
||||
try {
|
||||
const roleId = await getProgramMemberRole(programId, profileId);
|
||||
const onlyPublished = roleId === ROLE.STUDENT;
|
||||
|
||||
return getCoursesByProgram(programId, onlyPublished);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list program courses',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCourseToProgramService(programId: string, data: TAddCourseToProgram) {
|
||||
try {
|
||||
const program = await getProgramById(programId);
|
||||
if (!program) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const alreadyAdded = await isProgramCourse(programId, data.courseId);
|
||||
if (alreadyAdded) {
|
||||
throw new AppError('Course is already in this program', ErrorCodes.COURSE_ALREADY_IN_PROGRAM, 409);
|
||||
}
|
||||
|
||||
const result = await addCourseToProgram(programId, data.courseId);
|
||||
const students = (await getProgramMembers(programId))
|
||||
.filter((member) => member.roleId === ROLE.STUDENT && member.profileId)
|
||||
.map((member) => ({
|
||||
profileId: member.profileId!,
|
||||
email: member.email ?? null,
|
||||
roleId: member.roleId
|
||||
}));
|
||||
|
||||
const courseGroupIds = (await getCourseGroupIds([data.courseId]))
|
||||
.map((courseGroup) => courseGroup.groupId)
|
||||
.filter((groupId): groupId is string => Boolean(groupId));
|
||||
|
||||
if (students.length > 0 && courseGroupIds.length > 0) {
|
||||
await enrollProgramStudentsInGroups(program.organizationId, courseGroupIds, students);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to add course to program',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCourseFromProgramService(programId: string, courseId: string) {
|
||||
try {
|
||||
const deleted = await removeCourseFromProgram(programId, courseId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Course not found in program', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to remove course from program',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Newsfeed ────────────────────────────────────────────────────────
|
||||
|
||||
export async function listProgramNewsfeed(programId: string, options: { cursor?: string; limit: number }) {
|
||||
try {
|
||||
return getProgramNewsfeed(programId, options);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list program newsfeed',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProgramNewsfeedService(programId: string, profileId: string, data: TCreateProgramNewsfeed) {
|
||||
try {
|
||||
const member = await getProgramMemberByProfileId(programId, profileId);
|
||||
if (!member) {
|
||||
throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
return createProgramNewsfeedQuery({
|
||||
programId,
|
||||
authorId: member.id,
|
||||
content: data.content,
|
||||
isPinned: data.isPinned ?? false
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create program newsfeed',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramNewsfeedService(feedId: string, data: TUpdateProgramNewsfeed) {
|
||||
try {
|
||||
const feed = await getProgramNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
return updateProgramNewsfeedQuery(feedId, data);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update program newsfeed',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramNewsfeedReactionService(feedId: string, data: TUpdateProgramReaction) {
|
||||
try {
|
||||
const feed = await getProgramNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
return updateProgramNewsfeedReaction(feedId, data.reaction);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update program newsfeed reaction',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgramNewsfeedService(feedId: string) {
|
||||
try {
|
||||
const feed = await getProgramNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
return deleteProgramNewsfeedQuery(feedId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete program newsfeed',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Newsfeed Comments ────────────────────────────────────────────────
|
||||
|
||||
export async function listProgramNewsfeedComments(feedId: string) {
|
||||
try {
|
||||
const feed = await getProgramNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
return getProgramNewsfeedComments(feedId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list program newsfeed comments',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProgramNewsfeedCommentService(
|
||||
feedId: string,
|
||||
profileId: string,
|
||||
data: TCreateProgramNewsfeedComment
|
||||
) {
|
||||
try {
|
||||
const feed = await getProgramNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
const member = await getProgramMemberByProfileId(feed.programId!, profileId);
|
||||
if (!member) {
|
||||
throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
|
||||
}
|
||||
return createProgramNewsfeedCommentQuery({
|
||||
programNewsfeedId: feedId,
|
||||
authorId: member.id,
|
||||
content: data.content
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create program newsfeed comment',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgramNewsfeedCommentService(commentId: number) {
|
||||
try {
|
||||
const comment = await getProgramNewsfeedCommentById(commentId);
|
||||
if (!comment) {
|
||||
throw new AppError('Comment not found', ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND, 404);
|
||||
}
|
||||
const feed = await getProgramNewsfeedById(comment.programNewsfeedId!);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
return deleteProgramNewsfeedCommentQuery(commentId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete program newsfeed comment',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auth Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function assertProgramMember(programId: string, profileId: string) {
|
||||
const member = await isProgramMember(programId, profileId);
|
||||
if (!member) {
|
||||
throw new AppError('You are not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
TPublicApiAssignAudienceCourses,
|
||||
TPublicApiAudienceMemberParam,
|
||||
TPublicApiAudienceQuery,
|
||||
TPublicApiCreateAudience,
|
||||
TPublicApiUpdateAudience
|
||||
} from '@cio/utils/validation/public-api';
|
||||
|
||||
import {
|
||||
assignAudienceToCourses,
|
||||
importAudienceMembers,
|
||||
updatePendingAudienceMemberEmail
|
||||
} from '@api/services/organization/audience';
|
||||
import { getOrgAudience, getOrgAudienceMember, removeAudienceMember } from '@api/services/organization';
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
|
||||
export async function listAudienceService(orgId: string, query: TPublicApiAudienceQuery) {
|
||||
return getOrgAudience(orgId, query);
|
||||
}
|
||||
|
||||
export async function createAudienceService(orgId: string, actorId: string | null, payload: TPublicApiCreateAudience) {
|
||||
if (!actorId) {
|
||||
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
|
||||
}
|
||||
|
||||
return importAudienceMembers(
|
||||
orgId,
|
||||
{
|
||||
recipientCsv: payload.email,
|
||||
courseIds: payload.courseIds,
|
||||
allCourses: false,
|
||||
allPrograms: false,
|
||||
sendEmail: payload.sendEmail
|
||||
},
|
||||
actorId
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAudienceMemberService(orgId: string, params: TPublicApiAudienceMemberParam) {
|
||||
return getOrgAudienceMember(orgId, params.memberId);
|
||||
}
|
||||
|
||||
export async function assignAudienceCoursesService(orgId: string, payload: TPublicApiAssignAudienceCourses) {
|
||||
return assignAudienceToCourses(orgId, payload);
|
||||
}
|
||||
|
||||
export async function removeAudienceService(orgId: string, memberId: number, actorId?: string | null) {
|
||||
return removeAudienceMember(orgId, memberId, actorId ?? undefined);
|
||||
}
|
||||
|
||||
export async function updateAudienceService(
|
||||
orgId: string,
|
||||
memberId: number,
|
||||
actorId: string | null,
|
||||
payload: TPublicApiUpdateAudience
|
||||
) {
|
||||
if (!actorId) {
|
||||
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
|
||||
}
|
||||
|
||||
await updatePendingAudienceMemberEmail(orgId, memberId, payload, actorId);
|
||||
|
||||
return getOrgAudienceMember(orgId, memberId);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
TPublicApiCourseParam,
|
||||
TPublicApiCoursesQuery,
|
||||
TPublicApiCreateCourse,
|
||||
TPublicApiUpdateCourse,
|
||||
TPublicApiUpdateCourseStructure
|
||||
} from '@cio/utils/validation/public-api';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import {
|
||||
createCourse as createCourseService,
|
||||
deleteCourse as deleteCourseService,
|
||||
getCourse,
|
||||
updateCourse as updateCourseService
|
||||
} from '@api/services/course/course';
|
||||
import {
|
||||
createCourseImportDraftService,
|
||||
getCourseImportStructureService,
|
||||
publishCourseImportDraftToExistingCourseService
|
||||
} from '@api/services/course-import/course-import';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getOrganizationCourses } from '@api/services/organization';
|
||||
import { listCourseMembers } from '@api/services/course/people';
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
|
||||
async function assertCourseBelongsToOrganization(orgId: string, courseId: string): Promise<void> {
|
||||
const courseOrganizationId = await getCourseOrganizationId(courseId);
|
||||
if (!courseOrganizationId || courseOrganizationId !== orgId) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCoursesService(orgId: string, query: TPublicApiCoursesQuery) {
|
||||
const result = await getOrganizationCourses(orgId, '', ROLE.ADMIN, {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
search: undefined,
|
||||
tags: query.tags
|
||||
});
|
||||
|
||||
return result.items;
|
||||
}
|
||||
|
||||
export async function getCourseService(orgId: string, params: TPublicApiCourseParam) {
|
||||
await assertCourseBelongsToOrganization(orgId, params.courseId);
|
||||
|
||||
return getCourse(params.courseId);
|
||||
}
|
||||
|
||||
export async function listCourseStudentsService(orgId: string, params: TPublicApiCourseParam) {
|
||||
await assertCourseBelongsToOrganization(orgId, params.courseId);
|
||||
|
||||
const members = await listCourseMembers(params.courseId);
|
||||
return members.filter((member) => member.roleId === ROLE.STUDENT);
|
||||
}
|
||||
|
||||
export async function exportCourseService(orgId: string, params: TPublicApiCourseParam) {
|
||||
await assertCourseBelongsToOrganization(orgId, params.courseId);
|
||||
|
||||
return getCourseImportStructureService(orgId, params.courseId);
|
||||
}
|
||||
|
||||
export async function createPublicApiCourseService(
|
||||
orgId: string,
|
||||
actorId: string | null,
|
||||
payload: TPublicApiCreateCourse
|
||||
) {
|
||||
if (!actorId) {
|
||||
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
|
||||
}
|
||||
|
||||
return createCourseService(actorId, {
|
||||
...payload,
|
||||
organizationId: orgId
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePublicApiCourseService(
|
||||
orgId: string,
|
||||
params: TPublicApiCourseParam,
|
||||
payload: TPublicApiUpdateCourse
|
||||
) {
|
||||
await assertCourseBelongsToOrganization(orgId, params.courseId);
|
||||
|
||||
return updateCourseService(params.courseId, payload);
|
||||
}
|
||||
|
||||
export async function deletePublicApiCourseService(orgId: string, params: TPublicApiCourseParam) {
|
||||
await assertCourseBelongsToOrganization(orgId, params.courseId);
|
||||
|
||||
return deleteCourseService(params.courseId);
|
||||
}
|
||||
|
||||
export async function updatePublicApiCourseStructureService(
|
||||
orgId: string,
|
||||
actorId: string | null,
|
||||
params: TPublicApiCourseParam,
|
||||
payload: TPublicApiUpdateCourseStructure
|
||||
) {
|
||||
if (!actorId) {
|
||||
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
|
||||
}
|
||||
|
||||
await assertCourseBelongsToOrganization(orgId, params.courseId);
|
||||
|
||||
const draft = await createCourseImportDraftService(orgId, actorId, {
|
||||
sourceType: 'course',
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
summary: {
|
||||
sourceCourseId: params.courseId,
|
||||
syncMode: payload.mode,
|
||||
...(payload.summary ?? {})
|
||||
},
|
||||
sourceArtifacts: [
|
||||
{
|
||||
type: 'course',
|
||||
courseId: params.courseId,
|
||||
label: payload.draft.course.title
|
||||
},
|
||||
...(payload.sourceArtifacts ?? [])
|
||||
],
|
||||
draft: payload.draft
|
||||
});
|
||||
|
||||
const result = await publishCourseImportDraftToExistingCourseService(orgId, draft.id, {
|
||||
courseId: params.courseId,
|
||||
syncMode: payload.mode
|
||||
});
|
||||
|
||||
return {
|
||||
draftId: draft.id,
|
||||
syncMode: payload.mode,
|
||||
...result
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,10 @@ export interface TopCourse {
|
||||
title: string;
|
||||
enrollments: number;
|
||||
completion: number;
|
||||
certification: number;
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
export interface RecentCertification {
|
||||
id: string;
|
||||
avatarUrl: string | null;
|
||||
name: string;
|
||||
@@ -15,9 +16,9 @@ export interface Enrollment {
|
||||
}
|
||||
|
||||
export interface OrganisationAnalytics {
|
||||
revenue: number;
|
||||
totalCertificates: number;
|
||||
numberOfCourses: number;
|
||||
totalStudents: number;
|
||||
topCourses: TopCourse[];
|
||||
enrollments: Enrollment[];
|
||||
recentCertifications: RecentCertification[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import { ErrorCodes, type ErrorCode } from '@cio/utils/constants';
|
||||
|
||||
export { ErrorCodes, type ErrorCode };
|
||||
|
||||
/**
|
||||
* Standard application error with code for easy identification
|
||||
*/
|
||||
@@ -26,146 +30,6 @@ export interface ErrorResponse {
|
||||
field?: string;
|
||||
}
|
||||
|
||||
// Error codes constants for consistency
|
||||
export const ErrorCodes = {
|
||||
// Common errors
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
|
||||
// Dashboard Analytics errors
|
||||
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
|
||||
NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED',
|
||||
|
||||
// Community errors
|
||||
UPDATE_COMMUNITY_QUESTION_FAILED: 'UPDATE_COMMUNITY_QUESTION_FAILED',
|
||||
ADD_COMMUNITY_QUESTION_FAILED: 'ADD_COMMUNITY_QUESTION_FAILED',
|
||||
COMMUNITY_QUESTIONS_FETCH_FAILED: 'COMMUNITY_QUESTIONS_FETCH_FAILED',
|
||||
COMMUNITY_QUESTION_NOT_FOUND: 'COMMUNITY_QUESTION_NOT_FOUND',
|
||||
COMMENT_SUBMISSION_FAILED: 'COMMENT_SUBMISSION_FAILED',
|
||||
UPVOTE_FAILED: 'UPVOTE_FAILED',
|
||||
DELETE_COMMENT_FAILED: 'DELETE_COMMENT_FAILED',
|
||||
DELETE_QUESTION_FAILED: 'DELETE_QUESTION_FAILED',
|
||||
|
||||
// Organization errors
|
||||
SITENAME_EXISTS: 'SITENAME_EXISTS',
|
||||
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
|
||||
ORG_NOT_FOUND: 'ORG_NOT_FOUND',
|
||||
ORGANIZATION_NOT_FOUND: 'ORGANIZATION_NOT_FOUND',
|
||||
ORG_TEAM_FETCH_FAILED: 'ORG_TEAM_FETCH_FAILED',
|
||||
ORG_AUDIENCE_FETCH_FAILED: 'ORG_AUDIENCE_FETCH_FAILED',
|
||||
COURSES_FETCH_FAILED: 'COURSES_FETCH_FAILED',
|
||||
ORG_PLAN_CREATE_FAILED: 'ORG_PLAN_CREATE_FAILED',
|
||||
ORG_PLAN_UPDATE_FAILED: 'ORG_PLAN_UPDATE_FAILED',
|
||||
ORG_PLAN_CANCEL_FAILED: 'ORG_PLAN_CANCEL_FAILED',
|
||||
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
|
||||
ORG_TEAM_INVITE_FAILED: 'ORG_TEAM_INVITE_FAILED',
|
||||
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_REMOVE_FAILED',
|
||||
ORG_AUDIENCE_REMOVE_FAILED: 'ORG_AUDIENCE_REMOVE_FAILED',
|
||||
ORG_TEAM_EMAIL_EXISTS: 'ORG_TEAM_EMAIL_EXISTS',
|
||||
ORG_TEAM_NOT_AUTHORIZED: 'ORG_TEAM_NOT_AUTHORIZED',
|
||||
ORGANIZATION_API_KEY_NOT_FOUND: 'ORGANIZATION_API_KEY_NOT_FOUND',
|
||||
ORGANIZATION_API_KEY_CREATE_FAILED: 'ORGANIZATION_API_KEY_CREATE_FAILED',
|
||||
ORGANIZATION_API_KEY_FETCH_FAILED: 'ORGANIZATION_API_KEY_FETCH_FAILED',
|
||||
ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED',
|
||||
ORGANIZATION_API_KEY_ROTATE_FAILED: 'ORGANIZATION_API_KEY_ROTATE_FAILED',
|
||||
AUTOMATION_KEY_LIMIT_EXCEEDED: 'AUTOMATION_KEY_LIMIT_EXCEEDED',
|
||||
AUTOMATION_CREDIT_LIMIT_EXCEEDED: 'AUTOMATION_CREDIT_LIMIT_EXCEEDED',
|
||||
AUTOMATION_RATE_LIMIT_EXCEEDED: 'AUTOMATION_RATE_LIMIT_EXCEEDED',
|
||||
TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND',
|
||||
TAG_NOT_FOUND: 'TAG_NOT_FOUND',
|
||||
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
|
||||
TAG_CREATE_FAILED: 'TAG_CREATE_FAILED',
|
||||
TAG_UPDATE_FAILED: 'TAG_UPDATE_FAILED',
|
||||
TAG_ASSIGNMENT_FAILED: 'TAG_ASSIGNMENT_FAILED',
|
||||
|
||||
// Asset errors
|
||||
ASSET_NOT_FOUND: 'ASSET_NOT_FOUND',
|
||||
ASSET_IN_USE: 'ASSET_IN_USE',
|
||||
ASSET_USAGE_NOT_FOUND: 'ASSET_USAGE_NOT_FOUND',
|
||||
ASSET_CREATE_FAILED: 'ASSET_CREATE_FAILED',
|
||||
ASSET_FETCH_FAILED: 'ASSET_FETCH_FAILED',
|
||||
ASSET_LIST_FAILED: 'ASSET_LIST_FAILED',
|
||||
ASSET_UPDATE_FAILED: 'ASSET_UPDATE_FAILED',
|
||||
ASSET_DELETE_FAILED: 'ASSET_DELETE_FAILED',
|
||||
ASSET_ATTACH_FAILED: 'ASSET_ATTACH_FAILED',
|
||||
ASSET_DETACH_FAILED: 'ASSET_DETACH_FAILED',
|
||||
ASSET_USAGE_FETCH_FAILED: 'ASSET_USAGE_FETCH_FAILED',
|
||||
ASSET_EXPORT_FAILED: 'ASSET_EXPORT_FAILED',
|
||||
ASSET_STORAGE_FETCH_FAILED: 'ASSET_STORAGE_FETCH_FAILED',
|
||||
|
||||
// Profile errors
|
||||
PROFILE_UPDATE_FAILED: 'PROFILE_UPDATE_FAILED',
|
||||
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
|
||||
|
||||
// Account errors
|
||||
ACCOUNT_NOT_FOUND: 'ACCOUNT_NOT_FOUND',
|
||||
|
||||
// Auth errors
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
|
||||
// Course errors
|
||||
COURSE_NOT_FOUND: 'COURSE_NOT_FOUND',
|
||||
COURSE_FETCH_FAILED: 'COURSE_FETCH_FAILED',
|
||||
COURSE_IMPORT_DRAFT_NOT_FOUND: 'COURSE_IMPORT_DRAFT_NOT_FOUND',
|
||||
COURSE_IMPORT_CREATE_FAILED: 'COURSE_IMPORT_CREATE_FAILED',
|
||||
COURSE_IMPORT_FETCH_FAILED: 'COURSE_IMPORT_FETCH_FAILED',
|
||||
COURSE_IMPORT_UPDATE_FAILED: 'COURSE_IMPORT_UPDATE_FAILED',
|
||||
COURSE_IMPORT_PUBLISH_FAILED: 'COURSE_IMPORT_PUBLISH_FAILED',
|
||||
|
||||
// Lesson errors
|
||||
LESSON_NOT_FOUND: 'LESSON_NOT_FOUND',
|
||||
LESSON_FETCH_FAILED: 'LESSON_FETCH_FAILED',
|
||||
COURSE_SECTION_NOT_FOUND: 'COURSE_SECTION_NOT_FOUND',
|
||||
|
||||
// Exercise errors
|
||||
EXERCISE_NOT_FOUND: 'EXERCISE_NOT_FOUND',
|
||||
EXERCISE_FETCH_FAILED: 'EXERCISE_FETCH_FAILED',
|
||||
|
||||
// Submission errors
|
||||
SUBMISSION_NOT_FOUND: 'SUBMISSION_NOT_FOUND',
|
||||
SUBMISSION_FETCH_FAILED: 'SUBMISSION_FETCH_FAILED',
|
||||
|
||||
// Comment errors
|
||||
COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
|
||||
LESSON_COMMENT_UPDATE_FAILED: 'LESSON_COMMENT_UPDATE_FAILED',
|
||||
NEWSFEED_COMMENT_UPDATE_FAILED: 'NEWSFEED_COMMENT_UPDATE_FAILED',
|
||||
|
||||
// Quiz errors
|
||||
QUIZ_NOT_FOUND: 'QUIZ_NOT_FOUND',
|
||||
QUIZ_FETCH_FAILED: 'QUIZ_FETCH_FAILED',
|
||||
QUIZ_CREATE_FAILED: 'QUIZ_CREATE_FAILED',
|
||||
QUIZ_UPDATE_FAILED: 'QUIZ_UPDATE_FAILED',
|
||||
QUIZ_DELETE_FAILED: 'QUIZ_DELETE_FAILED',
|
||||
|
||||
// Lesson Language errors
|
||||
LESSON_LANGUAGE_NOT_FOUND: 'LESSON_LANGUAGE_NOT_FOUND',
|
||||
LESSON_LANGUAGE_FETCH_FAILED: 'LESSON_LANGUAGE_FETCH_FAILED',
|
||||
LESSON_LANGUAGE_CREATE_FAILED: 'LESSON_LANGUAGE_CREATE_FAILED',
|
||||
LESSON_LANGUAGE_UPDATE_FAILED: 'LESSON_LANGUAGE_UPDATE_FAILED',
|
||||
|
||||
// SSO errors
|
||||
SSO_REGISTRATION_FAILED: 'SSO_REGISTRATION_FAILED',
|
||||
SSO_NOT_FOUND: 'SSO_NOT_FOUND',
|
||||
SSO_NOT_ENABLED: 'SSO_NOT_ENABLED',
|
||||
UPGRADE_REQUIRED: 'UPGRADE_REQUIRED',
|
||||
CONFLICT: 'CONFLICT',
|
||||
|
||||
// Token exchange errors
|
||||
TOKEN_EXCHANGE_INVALID_TOKEN: 'TOKEN_EXCHANGE_INVALID_TOKEN',
|
||||
TOKEN_EXCHANGE_EXPIRED: 'TOKEN_EXCHANGE_EXPIRED',
|
||||
TOKEN_EXCHANGE_ORG_NOT_FOUND: 'TOKEN_EXCHANGE_ORG_NOT_FOUND',
|
||||
TOKEN_EXCHANGE_NOT_ENABLED: 'TOKEN_EXCHANGE_NOT_ENABLED',
|
||||
|
||||
// Token auth admin errors
|
||||
TOKEN_AUTH_ALREADY_EXISTS: 'TOKEN_AUTH_ALREADY_EXISTS',
|
||||
|
||||
// Generic
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR'
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||
|
||||
/**
|
||||
* Handles errors consistently across all routes
|
||||
* @param c - Hono context
|
||||
|
||||
@@ -11,6 +11,7 @@ type LessonDocument = NonNullable<TLesson['documents']>[number];
|
||||
|
||||
function mapProviderToVideoType(provider: string): LessonVideo['type'] {
|
||||
if (provider === 'youtube') return 'youtube';
|
||||
if (provider === 'google_drive') return 'google_drive';
|
||||
if (provider === 'generic' || provider === 'external_url') return 'generic';
|
||||
return 'upload';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */
|
||||
export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
function parseInviteIds(metadata: unknown, keys: string[]): string[] {
|
||||
let obj: Record<string, unknown> | null = null;
|
||||
if (metadata == null) {
|
||||
return [];
|
||||
@@ -17,9 +16,21 @@ export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
if (!obj) {
|
||||
return [];
|
||||
}
|
||||
const raw = obj.courseIds ?? obj.course_ids;
|
||||
|
||||
const raw = keys.map((key) => obj[key]).find((value) => value !== undefined);
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return raw.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
}
|
||||
|
||||
/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */
|
||||
export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
return parseInviteIds(metadata, ['courseIds', 'course_ids']);
|
||||
}
|
||||
|
||||
/** Normalize JSONB organization-invite metadata for program ids (matches importAudienceMembers storage). */
|
||||
export function parseProgramIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
return parseInviteIds(metadata, ['programIds', 'program_ids']);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,17 @@
|
||||
* These functions help create consistent and secure rate limit keys
|
||||
*/
|
||||
|
||||
import { getConnInfo } from '@hono/node-server/conninfo';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
function normalizeIp(value: string | null | undefined): string | null {
|
||||
const ip = value?.trim();
|
||||
|
||||
if (!ip) return null;
|
||||
|
||||
return ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract real client IP address considering various proxy headers
|
||||
*/
|
||||
@@ -15,9 +24,11 @@ export const extractClientIp = (c: Context): string => {
|
||||
// 3. X-Forwarded-For (standard, but can be spoofed)
|
||||
// 4. Connection IP (least reliable)
|
||||
|
||||
const cfConnectingIp = c.req.header('cf-connecting-ip');
|
||||
const realIp = c.req.header('x-real-ip');
|
||||
const cfConnectingIp = normalizeIp(c.req.header('cf-connecting-ip'));
|
||||
const realIp = normalizeIp(c.req.header('x-real-ip'));
|
||||
const forwardedFor = c.req.header('x-forwarded-for');
|
||||
const connInfo = getConnInfo(c);
|
||||
const remoteAddress = normalizeIp(connInfo.remote.address);
|
||||
|
||||
if (cfConnectingIp) {
|
||||
return cfConnectingIp;
|
||||
@@ -30,7 +41,11 @@ export const extractClientIp = (c: Context): string => {
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2"
|
||||
// The first IP is usually the original client
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
return normalizeIp(forwardedFor.split(',')[0]) ?? 'unknown';
|
||||
}
|
||||
|
||||
if (remoteAddress) {
|
||||
return remoteAddress;
|
||||
}
|
||||
|
||||
// Fallback - this might be a proxy IP
|
||||
|
||||
Reference in New Issue
Block a user