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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "new-york",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
|
||||
@@ -19,26 +19,26 @@
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^0.21.16",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"bits-ui": "^2.16.1",
|
||||
"clsx": "^2.1.1",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwind-variants": "^0.3.0",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^2.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@cio/ui": "workspace:*",
|
||||
"@lucide/svelte": "^0.562.0",
|
||||
"lucide-svelte": "^0.468.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"posthog-js": "^1.180.1",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,66 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
@import '@cio/ui/output.css';
|
||||
@import '../../../packages/ui/src/custom/animation/marketing-layers.css';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@source './**/*.{html,js,svelte,ts}';
|
||||
@source '../../../packages/ui/src/custom/animation/**/*.svelte';
|
||||
|
||||
/* Theme tokens (colors, fonts, radii, motion) + 2xl breakpoint for layout (e.g. `max-w-(--breakpoint-2xl)`). */
|
||||
@theme inline {
|
||||
--breakpoint-2xl: 1400px;
|
||||
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-drawn:
|
||||
Virgil, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
|
||||
--color-border: hsl(var(--border) / <alpha-value>);
|
||||
--color-input: hsl(var(--input) / <alpha-value>);
|
||||
--color-ring: hsl(var(--ring) / <alpha-value>);
|
||||
--color-background: hsl(var(--background) / <alpha-value>);
|
||||
--color-foreground: hsl(var(--foreground) / <alpha-value>);
|
||||
--color-primary: hsl(var(--primary) / <alpha-value>);
|
||||
--color-primary-foreground: hsl(var(--primary-foreground) / <alpha-value>);
|
||||
--color-secondary: hsl(var(--secondary) / <alpha-value>);
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground) / <alpha-value>);
|
||||
--color-destructive: hsl(var(--destructive) / <alpha-value>);
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground) / <alpha-value>);
|
||||
--color-muted: hsl(var(--muted) / <alpha-value>);
|
||||
--color-muted-foreground: hsl(var(--muted-foreground) / <alpha-value>);
|
||||
--color-accent: hsl(var(--accent) / <alpha-value>);
|
||||
--color-accent-foreground: hsl(var(--accent-foreground) / <alpha-value>);
|
||||
--color-popover: hsl(var(--popover) / <alpha-value>);
|
||||
--color-popover-foreground: hsl(var(--popover-foreground) / <alpha-value>);
|
||||
--color-card: hsl(var(--card) / <alpha-value>);
|
||||
--color-card-foreground: hsl(var(--card-foreground) / <alpha-value>);
|
||||
|
||||
--animate-border-beam: border-beam calc(var(--duration) * 1s) infinite linear;
|
||||
--animate-meteor: meteor 5s linear infinite;
|
||||
--animate-shimmer: shimmer 8s infinite;
|
||||
--animate-spin-around: spin-around calc(var(--speed) * 2) infinite linear;
|
||||
--animate-magicslide: magicslide var(--speed) ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* Used by ShinnyText (`animate-shimmer`); marketing-layers uses `shimmer-marketing` for other UIs. */
|
||||
@keyframes shimmer {
|
||||
0%,
|
||||
90%,
|
||||
100% {
|
||||
background-position: calc(-100% - var(--shimmer-width)) 0;
|
||||
}
|
||||
30%,
|
||||
60% {
|
||||
background-position: calc(100% + var(--shimmer-width)) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Virgil';
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Motion, AnimatePresence } from 'svelte-motion';
|
||||
import { inview } from 'svelte-inview';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
let {
|
||||
duration = 0.4,
|
||||
delay = 0,
|
||||
yOffset = 6,
|
||||
inViewMargin = '-50px',
|
||||
blur = '6px',
|
||||
once = false,
|
||||
class: className = ''
|
||||
} = $props();
|
||||
let id = $state(crypto.randomUUID().slice(0, 8));
|
||||
let isInView = $state('hidden');
|
||||
|
||||
let defaultVariants = {
|
||||
hidden: { opacity: 0, y: yOffset, filter: `blur(${blur})` },
|
||||
visible: { opacity: 1, y: 0, filter: `blur(0px)` }
|
||||
};
|
||||
</script>
|
||||
|
||||
<AnimatePresence let:item list={[{ key: id }]}>
|
||||
<Motion
|
||||
initial="hidden"
|
||||
animate={isInView}
|
||||
exit="hidden"
|
||||
variants={defaultVariants}
|
||||
transition={{
|
||||
delay: 0.04 + delay,
|
||||
duration,
|
||||
ease: 'easeOut'
|
||||
}}
|
||||
let:motion
|
||||
>
|
||||
<div
|
||||
use:inview={{ rootMargin: inViewMargin, unobserveOnEnter: once }}
|
||||
use:motion
|
||||
oninview_change={({ detail }) => {
|
||||
isInView = detail.inView ? 'visible' : 'hidden';
|
||||
}}
|
||||
class={cn(className)}
|
||||
>
|
||||
<slot>Default</slot>
|
||||
</div>
|
||||
</Motion>
|
||||
</AnimatePresence>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as BlurFade } from './blurfade.svelte';
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
import { Motion } from "svelte-motion";
|
||||
|
||||
export let variant: {
|
||||
hidden: { filter: string; opacity: number };
|
||||
visible: { filter: string; opacity: number };
|
||||
} = {
|
||||
hidden: { filter: "blur(10px)", opacity: 0 },
|
||||
visible: { filter: "blur(0px)", opacity: 1 },
|
||||
};
|
||||
export let duration: number = 1;
|
||||
let className = "font-display text-4xl font-bold tracking-[-0.02em] drop-shadow-sm md:text-7xl md:leading-[5rem]";
|
||||
export { className as class };
|
||||
|
||||
let defaultVariants = {
|
||||
hidden: { filter: "blur(10px)", opacity: 0 },
|
||||
visible: { filter: "blur(0px)", opacity: 1 },
|
||||
};
|
||||
let combinedVariants = variant || defaultVariants;
|
||||
</script>
|
||||
|
||||
<Motion
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ duration }}
|
||||
variants={combinedVariants}
|
||||
let:motion
|
||||
>
|
||||
<h1
|
||||
class={cn(className)}
|
||||
use:motion
|
||||
>
|
||||
<slot />
|
||||
</h1>
|
||||
</Motion>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as BlurIn } from './blurin.svelte';
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
export let size = 200;
|
||||
export let duration = 15;
|
||||
export let anchor = 90;
|
||||
export let borderWidth = 1.5;
|
||||
export let colorFrom = '#ffaa40';
|
||||
export let colorTo = '#9c40ff';
|
||||
export let delay = 0;
|
||||
let delaySec = delay + 's';
|
||||
|
||||
let className: any = '';
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div
|
||||
style:--border-width={borderWidth}
|
||||
style:--size={size}
|
||||
style:--color-from={colorFrom}
|
||||
style:--color-to={colorTo}
|
||||
style:--delay={delaySec}
|
||||
style:--anchor={anchor}
|
||||
style:--duration={duration}
|
||||
class={cn(
|
||||
'pointer-events-none absolute inset-[0] rounded-[inherit] [border:calc(var(--border-width)*1px)_solid_transparent]',
|
||||
|
||||
// mask styles
|
||||
'![mask-clip:padding-box,border-box] ![mask-composite:intersect] [mask:linear-gradient(transparent,transparent),linear-gradient(white,white)]',
|
||||
|
||||
// pseudo styles
|
||||
'after:animate-border-beam after:absolute after:aspect-square after:w-[calc(var(--size)*1px)] after:[animation-delay:var(--delay)] after:[background:linear-gradient(to_left,var(--color-from),var(--color-to),transparent)] after:[offset-anchor:calc(var(--anchor)*1%)_50%] after:[offset-path:rect(0_auto_auto_0_round_calc(var(--size)*1px))]',
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as BorderBeam } from './border-beam.svelte';
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
let { children, className } = $props();
|
||||
|
||||
// let cardContent = {
|
||||
// title: 'Svelte is Vibe',
|
||||
// description: 'Svelte is a fun way to build web applications.'
|
||||
// };
|
||||
</script>
|
||||
|
||||
<div class={cn('p-4 text-left', className)}>
|
||||
<!-- <h3 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{cardContent.title}
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
{cardContent.description}
|
||||
</p> -->
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
import Ellipses from './ellipses.svelte';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
let { className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'relative mx-auto w-full border border-dashed border-zinc-300 px-8 shadow-sm sm:px-6 md:px-8 dark:border-zinc-800 dark:bg-[#09090B]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class="absolute left-0 top-4 -z-0 h-px w-full bg-zinc-400 sm:top-6 md:top-8 dark:bg-zinc-700"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-4 left-0 z-0 h-px w-full bg-zinc-400 sm:bottom-6 md:bottom-8 dark:bg-zinc-700"
|
||||
></div>
|
||||
<div class="relative w-full border-x border-zinc-400 dark:border-zinc-700">
|
||||
<Ellipses />
|
||||
<div class="relative z-20 mx-auto py-8">
|
||||
<slot>Default</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,13 +0,0 @@
|
||||
<script>
|
||||
let sharedClass =
|
||||
'rounded-full outline outline-8 dark:outline-gray-950 sm:my-6 md:my-8 size-1 my-4 outline-gray-50 bg-green-400';
|
||||
</script>
|
||||
|
||||
<div class="absolute z-0 grid h-full w-full items-center gap-8 lg:grid-cols-2">
|
||||
<section class="absolute z-0 grid h-full w-full grid-cols-2 place-content-between">
|
||||
<div class="{sharedClass} -mx-[2.5px]"></div>
|
||||
<div class="{sharedClass} -mx-[2px] place-self-end"></div>
|
||||
<div class="{sharedClass} -mx-[2.5px]"></div>
|
||||
<div class="{sharedClass} -mx-[2px] place-self-end"></div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as CardContainer } from './container.svelte';
|
||||
export { default as CardBody } from './body.svelte';
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
export let width: string | number = 16;
|
||||
export let height: string | number = 16;
|
||||
export let x = 0;
|
||||
export let y = 0;
|
||||
export let cx = 1;
|
||||
export let cy = 1;
|
||||
export let cr = 1;
|
||||
export let fillColor = 'rgb(163 163 163 / 0.8)';
|
||||
|
||||
let className: any = '';
|
||||
export { className as class };
|
||||
let id = crypto.randomUUID().toString().slice(0, 10); // generating a unique ID for Components
|
||||
</script>
|
||||
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class={cn('pointer-events-none absolute inset-0 h-full w-full ', className)}
|
||||
{...$$restProps}
|
||||
fill={fillColor}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
{id}
|
||||
{width}
|
||||
{height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternContentUnits="userSpaceOnUse"
|
||||
{x}
|
||||
{y}
|
||||
>
|
||||
<circle id="pattern-circle" {cx} {cy} r={cr} />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" stroke-width={0} fill={`url(#${id})`} />
|
||||
</svg>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as DotPattern } from './dot-pattern.svelte';
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
import { AnimatePresence, Motion } from 'svelte-motion';
|
||||
|
||||
let className: any = 'Gradual Spacing';
|
||||
export { className as class };
|
||||
|
||||
export let words = 'Gradual Spacing';
|
||||
export let duration = 0.5;
|
||||
export let delayMultiple = 0.04;
|
||||
export let framerProps = {
|
||||
hidden: { opacity: 0, x: -20 },
|
||||
visible: { opacity: 1, x: 0 }
|
||||
};
|
||||
let wordsspilit = words.split('');
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center space-x-1">
|
||||
<AnimatePresence let:item list={[{ key: wordsspilit }]}>
|
||||
{#each wordsspilit as char, i}
|
||||
<Motion
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
variants={framerProps}
|
||||
transition={{
|
||||
duration: duration,
|
||||
delay: i * delayMultiple
|
||||
}}
|
||||
let:motion
|
||||
>
|
||||
<span use:motion class={cn('drop-shadow-sm', className)}>
|
||||
{#if char === ' '}
|
||||
<span> </span>
|
||||
{:else}
|
||||
{char}
|
||||
{/if}
|
||||
</span>
|
||||
</Motion>
|
||||
{/each}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as GradualSpacing } from './gradual-spacing.svelte';
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { writable } from 'svelte/store';
|
||||
import { AnimatePresence, Motion } from 'svelte-motion';
|
||||
import { Play, X } from 'lucide-svelte';
|
||||
|
||||
type AnimationStyle =
|
||||
| 'from-bottom'
|
||||
| 'from-center'
|
||||
| 'from-top'
|
||||
| 'from-left'
|
||||
| 'from-right'
|
||||
| 'fade'
|
||||
| 'top-in-bottom-out'
|
||||
| 'left-in-right-out';
|
||||
|
||||
// interface HeroVideoProps {
|
||||
// animationStyle?: AnimationStyle;
|
||||
// videoSrc: string;
|
||||
// thumbnailSrc: string;
|
||||
// thumbnailAlt?: string;
|
||||
// }
|
||||
|
||||
export let animationStyle: AnimationStyle = 'from-center';
|
||||
export let videoSrc: string;
|
||||
export let thumbnailSrc: string;
|
||||
export let thumbnailAlt: string = 'Video thumbnail';
|
||||
export let iconColor: string = 'white';
|
||||
|
||||
const isVideoOpen = writable(false);
|
||||
const isCloseHovered = writable(false);
|
||||
const isPlayHovered = writable(false);
|
||||
|
||||
function openVideo() {
|
||||
isVideoOpen.set(true);
|
||||
}
|
||||
|
||||
function closeVideo() {
|
||||
isVideoOpen.set(false);
|
||||
}
|
||||
|
||||
let animationVariants = {
|
||||
'from-bottom': {
|
||||
initial: { y: 100, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: 100, opacity: 0 }
|
||||
},
|
||||
'from-center': {
|
||||
initial: { scale: 0.5, opacity: 0 },
|
||||
animate: { scale: 1, opacity: 1 },
|
||||
exit: { scale: 0.5, opacity: 0 }
|
||||
},
|
||||
'from-top': {
|
||||
initial: { y: -100, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -100, opacity: 0 }
|
||||
},
|
||||
'from-left': {
|
||||
initial: { x: -100, opacity: 0 },
|
||||
animate: { x: 0, opacity: 1 },
|
||||
exit: { x: -100, opacity: 0 }
|
||||
},
|
||||
'from-right': {
|
||||
initial: { x: 100, opacity: 0 },
|
||||
animate: { x: 0, opacity: 1 },
|
||||
exit: { x: 100, opacity: 0 }
|
||||
},
|
||||
fade: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 }
|
||||
},
|
||||
'top-in-bottom-out': {
|
||||
initial: { y: -100, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: 200, opacity: 0 }
|
||||
},
|
||||
'left-in-right-out': {
|
||||
initial: { x: -100, opacity: 0 },
|
||||
animate: { x: 0, opacity: 1 },
|
||||
exit: { x: 200, opacity: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
$: selectedAnimation = animationVariants[animationStyle];
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Thumbnail -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="relative cursor-pointer" on:click={openVideo}>
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={thumbnailAlt}
|
||||
width={1920}
|
||||
height={1080}
|
||||
class="w-full rounded-2xl transition-all duration-200"
|
||||
/>
|
||||
<!-- {$isPlayHovered===true ? 'blur-[2px]' :'' } to make background blur put this in above img class -->
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="flex size-24 items-center justify-center rounded-full border border-neutral-400 bg-black backdrop-blur-md transition-transform duration-300 ease-out"
|
||||
on:mouseenter={() => isPlayHovered.set(true)}
|
||||
on:mouseleave={() => isPlayHovered.set(false)}
|
||||
>
|
||||
<div
|
||||
class="relative flex size-20 items-center justify-center rounded-full border border-neutral-400 bg-black backdrop-blur-2xl transition-all duration-300 ease-out"
|
||||
class:isPlayHovered={$isPlayHovered}
|
||||
>
|
||||
<Play
|
||||
strokeWidth={1.7}
|
||||
class="size-8"
|
||||
style="transform: scale({$isPlayHovered ? 1.1 : 1}); transition: transform 0.3s ease;
|
||||
color: {iconColor};
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence let:item list={[{ key: $isVideoOpen }]}>
|
||||
{#if item.key}
|
||||
<Motion initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} let:motion>
|
||||
<div
|
||||
use:motion
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-md"
|
||||
>
|
||||
<!-- Modal Content -->
|
||||
<Motion
|
||||
initial={selectedAnimation.initial}
|
||||
animate={selectedAnimation.animate}
|
||||
exit={selectedAnimation.exit}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
let:motion
|
||||
>
|
||||
<div use:motion class="relative mx-4 aspect-video w-full max-w-4xl md:mx-0">
|
||||
<Motion let:motion whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<button
|
||||
use:motion
|
||||
class="absolute -top-16 right-0 rounded-full bg-neutral-900/50 p-2 text-xl text-white ring-1 backdrop-blur-md"
|
||||
on:click={closeVideo}
|
||||
on:mouseenter={() => isCloseHovered.set(true)}
|
||||
on:mouseleave={() => isCloseHovered.set(false)}
|
||||
>
|
||||
<X class="size-5" />
|
||||
</button>
|
||||
</Motion>
|
||||
<Motion
|
||||
animate={{ scale: $isCloseHovered ? 0.98 : 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
let:motion
|
||||
>
|
||||
<div
|
||||
use:motion
|
||||
class="relative isolate z-[1] size-full overflow-hidden rounded-2xl border-2 border-white"
|
||||
>
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<iframe
|
||||
src={videoSrc}
|
||||
class="size-full rounded-2xl"
|
||||
allowfullscreen
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
></iframe>
|
||||
</div>
|
||||
</Motion>
|
||||
</div>
|
||||
</Motion>
|
||||
</div>
|
||||
</Motion>
|
||||
{/if}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.size-24 {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
}
|
||||
.size-20 {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
}
|
||||
.size-8 {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
.size-full {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as HeroVideoDialog } from './hero-video-dialog.svelte';
|
||||
@@ -1 +0,0 @@
|
||||
export { default as MagicCard } from './magic-card.svelte';
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Motion, useMotionValue, useMotionTemplate } from 'svelte-motion';
|
||||
import { onMount } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
export let gradientSize: number = 200;
|
||||
export let gradientColor: string = '#262626';
|
||||
export let gradientOpacity: number = 0.8;
|
||||
let className: string = '';
|
||||
export { className as class };
|
||||
|
||||
let gradSize = useMotionValue(gradientSize);
|
||||
let gradColor = useMotionValue(gradientColor);
|
||||
let mouseX = useMotionValue(-gradientSize);
|
||||
let mouseY = useMotionValue(-gradientSize);
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
mouseX.set(e.clientX - rect.left);
|
||||
mouseY.set(e.clientY - rect.top);
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
mouseX.set(-gradientSize);
|
||||
mouseY.set(-gradientSize);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mouseX.set(-gradientSize);
|
||||
mouseY.set(-gradientSize);
|
||||
});
|
||||
let bg = useMotionTemplate`radial-gradient(${gradSize}px circle at ${mouseX}px ${mouseY}px, ${gradColor}, transparent 100%)`;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- I have added py-4 in below code, you can customize the component as per needs -->
|
||||
<div
|
||||
on:mousemove={handleMouseMove}
|
||||
on:mouseleave={handleMouseLeave}
|
||||
class={cn(
|
||||
'group relative flex size-full justify-center overflow-hidden rounded-xl border bg-neutral-100 py-4 text-black dark:bg-neutral-900 dark:text-white',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div class="relative z-10">
|
||||
<!-- Default -->
|
||||
<slot>
|
||||
<div class="flex h-full items-center justify-center text-center">
|
||||
<p class="text-2xl">Magic Card</p>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<Motion
|
||||
style={{
|
||||
background: bg,
|
||||
opacity: gradientOpacity
|
||||
}}
|
||||
let:motion
|
||||
>
|
||||
<div
|
||||
use:motion
|
||||
class="pointer-events-none absolute -inset-px rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
/>
|
||||
</Motion>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.size-full {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Meteors } from './meteors.svelte';
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let number = 10;
|
||||
let meteorStyles: any = [];
|
||||
let changeMeteors = (num: number) => {
|
||||
meteorStyles = [];
|
||||
const styles = [...new Array(num)].map(() => ({
|
||||
top: -20,
|
||||
left: Math.floor(Math.random() * 700) + 'px',
|
||||
animationDelay: Math.random() * 1 + 0.2 + 's',
|
||||
animationDuration: Math.floor(Math.random() * 8 + 2.9) + 's'
|
||||
}));
|
||||
meteorStyles = styles;
|
||||
};
|
||||
onMount(() => {
|
||||
changeMeteors(number);
|
||||
});
|
||||
// $: changeMeteors(number);
|
||||
</script>
|
||||
|
||||
{#each meteorStyles as style, idx}
|
||||
<span
|
||||
id="meteor-{idx + 1}"
|
||||
class={cn(
|
||||
'animate-meteor pointer-events-none absolute left-1/2 top-1/2 size-[2.4px] rotate-[215deg] rounded-full bg-slate-500 shadow-[0_0_0_1px_#ffffff10]'
|
||||
)}
|
||||
style="top: {style.top}px; left: {style.left}; animation-delay: {style.animationDelay}; animation-duration: {style.animationDuration};"
|
||||
>
|
||||
<!-- Meteor Tail -->
|
||||
<div
|
||||
class="pointer-events-none absolute top-1/2 -z-10 h-px w-[50px] -translate-y-1/2 bg-gradient-to-r from-slate-500 via-blue-600/30 to-transparent"
|
||||
/>
|
||||
</span>
|
||||
{/each}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ShimmerButton } from './shimmerbutton.svelte';
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
export let href: string;
|
||||
export let target: string = '';
|
||||
export let rel: string = '';
|
||||
export let shimmerColor = '#ffffff';
|
||||
export let shimmerSize = '0.05em';
|
||||
export let shimmerDuration = '3s';
|
||||
export let borderRadius = '100px';
|
||||
export let background = 'rgba(0, 0, 0, 1)';
|
||||
let className: any = '';
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<a
|
||||
style:--spread="90deg"
|
||||
style:--shimmer-color={shimmerColor}
|
||||
style:--radius={borderRadius}
|
||||
style:--speed={shimmerDuration}
|
||||
style:--cut={shimmerSize}
|
||||
style:--bg={background}
|
||||
class={cn(
|
||||
'group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden whitespace-nowrap border border-white/10 px-6 py-3 text-white [background:var(--bg)] [border-radius:var(--radius)] dark:text-white',
|
||||
'transform-gpu transition-transform duration-300 ease-in-out active:translate-y-[1px]',
|
||||
className
|
||||
)}
|
||||
{href}
|
||||
{target}
|
||||
{rel}
|
||||
data-rewardful
|
||||
>
|
||||
<div class={cn('-z-30 blur-[2px]', 'absolute inset-0 overflow-visible [container-type:size]')}>
|
||||
<div
|
||||
class="animate-magicslide absolute inset-0 h-[100cqh] [aspect-ratio:1] [border-radius:0] [mask:none]"
|
||||
>
|
||||
<!-- spark before -->
|
||||
<div
|
||||
class="animate-spin-around absolute inset-[-100%] w-auto rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))] [translate:0_0]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<slot>Shimmer Button</slot>
|
||||
<!-- Highlight -->
|
||||
<div
|
||||
class={cn(
|
||||
'insert-0 absolute h-full w-full',
|
||||
|
||||
'rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]',
|
||||
|
||||
// transition
|
||||
'transform-gpu transition-all duration-300 ease-in-out',
|
||||
|
||||
// on hover
|
||||
'group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]',
|
||||
|
||||
// on click
|
||||
'group-active:shadow-[inset_0_-10px_10px_#ffffff3f]'
|
||||
)}
|
||||
></div>
|
||||
<!-- backdrop -->
|
||||
<div
|
||||
class={cn(
|
||||
'absolute -z-20 [background:var(--bg)] [border-radius:var(--radius)] [inset:var(--cut)]'
|
||||
)}
|
||||
></div>
|
||||
</a>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ShinnyText } from './shinny-text.svelte';
|
||||
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
export let shimmerWidth = 100;
|
||||
let className: any = '';
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<p
|
||||
style:--shimmer-width="{shimmerWidth}px"
|
||||
class={cn(
|
||||
'mx-auto max-w-md text-neutral-600/50 dark:text-neutral-400/50 ',
|
||||
|
||||
// Shimmer effect
|
||||
'animate-shimmer bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shimmer-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]',
|
||||
|
||||
// Shimmer gradient
|
||||
'bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80',
|
||||
|
||||
className
|
||||
)}
|
||||
>
|
||||
<slot>Shimmer Animation</slot>
|
||||
</p>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Sparkle } from './sparkle-text.svelte';
|
||||
@@ -1,73 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface TSparkle {
|
||||
id: string;
|
||||
x: string;
|
||||
y: string;
|
||||
color: string;
|
||||
delay: number;
|
||||
scale: number;
|
||||
lifespan: number;
|
||||
}
|
||||
|
||||
export let text: string = 'Hello World';
|
||||
export let colors = {
|
||||
first: '#9E7AFF',
|
||||
second: '#FE8BBB'
|
||||
};
|
||||
export let sparklesCount = 5;
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
let sparkles: TSparkle[] = [];
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Sparkle from './sparkle.svelte';
|
||||
|
||||
let generateStar = () => {
|
||||
let starX = `${Math.random() * 100}%`;
|
||||
let starY = `${Math.random() * 100}%`;
|
||||
let color = Math.random() > 0.5 ? colors.first : colors.second;
|
||||
let delay = Math.random() * 4;
|
||||
let scale = Math.random() * 1 + 0.3;
|
||||
let lifespan = Math.random() * 10 + 5;
|
||||
let id = `${starX}-${starY}-${Date.now()}`;
|
||||
return { id, x: starX, y: starY, color, delay, scale, lifespan };
|
||||
};
|
||||
|
||||
let initializeStars = async () => {
|
||||
let newSparkles = Array.from({ length: sparklesCount }, generateStar);
|
||||
sparkles = newSparkles;
|
||||
};
|
||||
|
||||
const updateStars = () => {
|
||||
let temp = sparkles.map((star) => {
|
||||
if (star.lifespan <= 0) {
|
||||
return generateStar();
|
||||
} else {
|
||||
return { ...star, lifespan: star.lifespan - 0.1 };
|
||||
}
|
||||
});
|
||||
sparkles = temp;
|
||||
};
|
||||
|
||||
let interval: NodeJS.Timer;
|
||||
onMount(async () => {
|
||||
initializeStars();
|
||||
interval = setInterval(updateStars, 100);
|
||||
});
|
||||
onDestroy(() => clearInterval(interval));
|
||||
</script>
|
||||
|
||||
<div
|
||||
style:--sparkles-first-color="{colors.first};"
|
||||
style:--sparkles-second-color="{colors.second};"
|
||||
class={className}
|
||||
{...$$restProps}
|
||||
>
|
||||
<span class="relative inline-block">
|
||||
{#each sparkles as item, _}
|
||||
<Sparkle {...item} />
|
||||
{/each}
|
||||
<strong>{text}</strong>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Motion } from "svelte-motion";
|
||||
|
||||
export let id;
|
||||
export let x;
|
||||
export let y;
|
||||
export let color;
|
||||
export let delay;
|
||||
export let scale;
|
||||
</script>
|
||||
|
||||
<Motion
|
||||
initial={{ opacity: 0, left: x, top: y }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0],
|
||||
scale: [0, scale, 0],
|
||||
rotate: [75, 120, 150],
|
||||
}}
|
||||
transition={{ duration: 0.8, repeat: Infinity, delay }}
|
||||
let:motion
|
||||
>
|
||||
<svg
|
||||
{id}
|
||||
use:motion
|
||||
class="pointer-events-none absolute z-20"
|
||||
width="21"
|
||||
height="21"
|
||||
viewBox="0 0 21 21"
|
||||
>
|
||||
<path
|
||||
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
</Motion>
|
||||
@@ -1,9 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
let { children, className } = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('text-left', className)}>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
let className: any = '';
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={24}
|
||||
height={24}
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke="currentColor"
|
||||
class={cn('absolute text-black dark:text-white', className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m6-6H6" />
|
||||
</svg>
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as SquareCardBody } from './body.svelte';
|
||||
export { default as SquareCardIcon } from './icon.svelte';
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import { ShimmerButton } from '$lib/components/animation/shimmerbutton';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { buttonVariants } from '@cio/ui/base/button';
|
||||
import { ShimmerButton } from '@cio/ui/custom/animation';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { cn } from '$lib/utils';
|
||||
import BadgeCheck from 'lucide-svelte/icons/badge-check';
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root>
|
||||
<!-- <span
|
||||
class="absolute inset-0 z-30 flex items-center justify-center bg-black/50 opacity-0 transition-all duration-500 hover:opacity-80"
|
||||
>
|
||||
</span> -->
|
||||
<Dialog.Trigger class={cn(buttonVariants({ variant: 'default' }), `z-20 ${ctaClass}`)}>
|
||||
{ctaLabel}
|
||||
</Dialog.Trigger>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { ShinnyText } from '$lib/components/animation/shinny-text';
|
||||
import { ShinnyText } from '@cio/ui/custom/animation';
|
||||
import Copy from 'lucide-svelte/icons/copy';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
)}
|
||||
>
|
||||
<ShinnyText
|
||||
class="animate-gradient inline-flex items-center justify-center bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text px-4 py-1 text-transparent text-white transition ease-out hover:text-neutral-400 hover:duration-300"
|
||||
class="animate-gradient inline-flex items-center justify-center bg-linear-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-size-[var(--bg-size)_100%] bg-clip-text px-4 py-1 text-transparent text-white transition ease-out hover:text-neutral-400 hover:duration-300"
|
||||
>
|
||||
<span>{command}</span>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60"
|
||||
>
|
||||
<p class="bg-blue-700 py-1.5 text-center text-sm font-medium text-white">
|
||||
brought to you by
|
||||
@@ -35,7 +35,7 @@
|
||||
</svg>
|
||||
</a>
|
||||
</p>
|
||||
<div class="container flex h-14 max-w-screen-2xl items-center">
|
||||
<div class="flex h-14 max-w-5xl mx-auto items-center">
|
||||
<div class="mr-4 flex">
|
||||
<a href="/" class="mr-6 flex items-center">
|
||||
<img src="/logo-512.png" alt="Course App" class="h-8 w-8" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Santa from './santa.svg';
|
||||
import { BorderBeam } from '$lib/components/animation/border-beam';
|
||||
import { DotPattern } from '$lib/components/animation/dot-pattern';
|
||||
import { BorderBeam, DotPattern } from '@cio/ui/custom/animation';
|
||||
import { BuyTemplate } from '$lib/components/buy-template';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<SeparatorPrimitive.Root
|
||||
class={cn(
|
||||
"bg-border shrink-0",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "min-h-full w-[1px]",
|
||||
orientation === "horizontal" ? "h-px w-full" : "min-h-full w-px",
|
||||
className
|
||||
)}
|
||||
{orientation}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</script>
|
||||
|
||||
<Sonner
|
||||
theme={$mode}
|
||||
theme={mode.current}
|
||||
class="toaster group"
|
||||
toastOptions={{
|
||||
classes: {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { CardBody, CardContainer } from '$lib/components/animation/card';
|
||||
import { BlurFade } from '$lib/components/animation/blurfade';
|
||||
import { BlurIn } from '$lib/components/animation/blurin';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { TemplateCard, CustomTemplate } from '$lib/components/template-card';
|
||||
import { Meteors } from '$lib/components/animation/meteors';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { BorderBeam } from '$lib/components/animation/border-beam';
|
||||
import { ShimmerButton } from '$lib/components/animation/shimmerbutton';
|
||||
import { BuyTemplate } from '$lib/components/buy-template';
|
||||
import { Sparkle } from '$lib/components/animation/sparkle';
|
||||
import { HeroVideoDialog } from '$lib/components/animation/hero-video-dialog';
|
||||
import {
|
||||
BlurFade,
|
||||
BlurIn,
|
||||
BorderBeam,
|
||||
CardBody,
|
||||
CardContainer,
|
||||
HeroVideoDialog,
|
||||
Meteors,
|
||||
ShimmerButton,
|
||||
Sparkle
|
||||
} from '@cio/ui/custom/animation';
|
||||
|
||||
const templates = [
|
||||
{
|
||||
@@ -93,7 +96,7 @@
|
||||
className="py-10 max-w-4xl mx-auto flex flex-col items-center justify-center px-30 gap-2 md:gap-5"
|
||||
>
|
||||
<BlurIn
|
||||
class="pointer-events-none flex max-w-2xl flex-col items-center whitespace-pre-wrap bg-gradient-to-b from-black to-gray-300/80 bg-clip-text text-center text-2xl font-semibold leading-none tracking-tighter text-transparent md:mb-4 md:block md:text-4xl lg:text-6xl"
|
||||
class="pointer-events-none flex max-w-2xl flex-col items-center whitespace-pre-wrap bg-linear-to-b from-black to-gray-300/80 bg-clip-text text-center text-2xl font-semibold leading-none tracking-tighter text-transparent md:mb-4 md:block md:text-4xl lg:text-6xl"
|
||||
>
|
||||
Create a <span class="font-mono italic text-blue-700">Course</span>
|
||||
<span>Site for your Product in</span>
|
||||
@@ -157,7 +160,7 @@
|
||||
|
||||
<!-- Templates -->
|
||||
<section id="templates" class="my-5 w-full pt-0 md:my-0 md:pt-24">
|
||||
<div class="px-4 md:container">
|
||||
<div class="px-4 max-w- mx-auto">
|
||||
<h2 class="mb-5 text-center text-3xl font-semibold md:mb-20 md:text-start md:text-5xl">
|
||||
Browse Templates
|
||||
</h2>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess({
|
||||
script: true
|
||||
}),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
|
||||
alias: {
|
||||
$lib: './src/lib',
|
||||
$lib: './src/lib/*'
|
||||
$lib: path.resolve(__dirname, './src/lib'),
|
||||
'@cio/ui': path.resolve(__dirname, './node_modules/@cio/ui/src'),
|
||||
'@cio/ui/*': path.resolve(__dirname, './node_modules/@cio/ui/src/*')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { fontFamily } from 'tailwindcss/defaultTheme';
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ['class'],
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
safelist: ['dark'],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px'
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border) / <alpha-value>)',
|
||||
input: 'hsl(var(--input) / <alpha-value>)',
|
||||
ring: 'hsl(var(--ring) / <alpha-value>)',
|
||||
background: 'hsl(var(--background) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--foreground) / <alpha-value>)',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--accent-foreground) / <alpha-value>)'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--popover-foreground) / <alpha-value>)'
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--card-foreground) / <alpha-value>)'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [...fontFamily.sans],
|
||||
drawn: [
|
||||
'Virgil',
|
||||
'ui-sans-serif',
|
||||
'system-ui',
|
||||
'sans-serif',
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Segoe UI Symbol',
|
||||
'Noto Color Emoji'
|
||||
]
|
||||
},
|
||||
|
||||
animation: {
|
||||
'border-beam': 'border-beam calc(var(--duration)*1s) infinite linear',
|
||||
meteor: 'meteor 5s linear infinite',
|
||||
shimmer: 'shimmer 8s infinite',
|
||||
'spin-around': 'spin-around calc(var(--speed) * 2) infinite linear',
|
||||
magicslide: 'magicslide var(--speed) ease-in-out infinite alternate'
|
||||
},
|
||||
|
||||
keyframes: {
|
||||
'border-beam': {
|
||||
'100%': {
|
||||
'offset-distance': '100%'
|
||||
}
|
||||
},
|
||||
|
||||
meteor: {
|
||||
'0%': { transform: 'rotate(215deg) translateX(0)', opacity: '1' },
|
||||
'70%': { opacity: '1' },
|
||||
'100%': {
|
||||
transform: 'rotate(215deg) translateX(-500px)',
|
||||
opacity: '0'
|
||||
}
|
||||
},
|
||||
shimmer: {
|
||||
'0%, 90%, 100%': {
|
||||
'background-position': 'calc(-100% - var(--shimmer-width)) 0'
|
||||
},
|
||||
'30%, 60%': {
|
||||
'background-position': 'calc(100% + var(--shimmer-width)) 0'
|
||||
}
|
||||
},
|
||||
|
||||
'spin-around': {
|
||||
'0%': {
|
||||
transform: 'translateZ(0) rotate(0)'
|
||||
},
|
||||
'15%, 35%': {
|
||||
transform: 'translateZ(0) rotate(90deg)'
|
||||
},
|
||||
'65%, 85%': {
|
||||
transform: 'translateZ(0) rotate(270deg)'
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateZ(0) rotate(360deg)'
|
||||
}
|
||||
},
|
||||
|
||||
magicslide: {
|
||||
to: {
|
||||
transform: 'translate(calc(100cqw - 100%), 0)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -20,6 +20,10 @@ PUBLIC_IS_SELFHOSTED=false
|
||||
# CSP_FONT_SRC_DOMAINS=
|
||||
# CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src)
|
||||
|
||||
# Google Drive video picker (lesson editor). OAuth Web client ID + Browser API key from Google Cloud Console (Picker API + Drive API enabled).
|
||||
# PUBLIC_GOOGLE_PICKER_CLIENT_ID=
|
||||
# PUBLIC_GOOGLE_PICKER_API_KEY=
|
||||
|
||||
# Unsplash setup - Optional if you want to use Unsplash images
|
||||
UNSPLASH_API_KEY=
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"test": "jest",
|
||||
"test:watch": "pnpm run test --watch",
|
||||
"ci": "cp .env.example .env && vite build && vite preview",
|
||||
"script:translate": "node ./scripts/translate.cjs"
|
||||
"translate": "node ./scripts/translate.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/sso": "1.4.18",
|
||||
|
||||
@@ -9,6 +9,7 @@ const jessy = require('jessy');
|
||||
const set = require('lodash/set');
|
||||
|
||||
const englishTranslations = require('../src/lib/utils/translations/en.json');
|
||||
let prettierPromise;
|
||||
|
||||
// Load env variables
|
||||
dotenv.config();
|
||||
@@ -38,6 +39,25 @@ const selectedLanguageFiles = requestedLanguages.length
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const getPrettier = async () => {
|
||||
if (!prettierPromise) {
|
||||
prettierPromise = import('prettier');
|
||||
}
|
||||
|
||||
return prettierPromise;
|
||||
};
|
||||
|
||||
const writeFormattedTranslationFile = async (outputFilePath, translations) => {
|
||||
const prettier = await getPrettier();
|
||||
const prettierConfig = (await prettier.resolveConfig(outputFilePath)) ?? {};
|
||||
const formattedJson = await prettier.format(JSON.stringify(translations, null, 2), {
|
||||
...prettierConfig,
|
||||
filepath: outputFilePath
|
||||
});
|
||||
|
||||
fs.writeFileSync(outputFilePath, formattedJson);
|
||||
};
|
||||
|
||||
const getMissingTranslations = async (toLanguage, outputFilePath) => {
|
||||
const englishKeys = keys(englishTranslations);
|
||||
const targetTranslations = require(outputFilePath); // Load target language translations
|
||||
@@ -109,7 +129,7 @@ const translateLanguage = async (fromLanguage, toLanguage, outputFilePath) => {
|
||||
set(targetTranslations, key, value);
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputFilePath, JSON.stringify(targetTranslations, null, 2));
|
||||
await writeFormattedTranslationFile(outputFilePath, targetTranslations);
|
||||
console.log(`${toLanguage.toUpperCase()} translations updated successfully.`);
|
||||
} else {
|
||||
console.log(`Failed to update ${toLanguage.toUpperCase()} translations.`);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
@import '@cio/ui/output.css';
|
||||
|
||||
@source '../../packages/ui/src/custom/animation/**/*.svelte';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Geist variable font — self-hosted */
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { SortPopover } from '$features/ui';
|
||||
import type { OrganizationAudienceSortBy, OrganizationAudienceSortOrder } from '$features/org/utils/types';
|
||||
|
||||
interface Props {
|
||||
hasSelection: boolean;
|
||||
selectedCount: number;
|
||||
searchValue?: string;
|
||||
sortValue: string;
|
||||
onSortChange: (value: string) => void;
|
||||
sortBy: OrganizationAudienceSortBy;
|
||||
sortOrder: OrganizationAudienceSortOrder;
|
||||
onSortChange: (sortBy: OrganizationAudienceSortBy, sortOrder: OrganizationAudienceSortOrder) => void;
|
||||
onOpenAssign: () => void;
|
||||
}
|
||||
|
||||
@@ -18,19 +20,37 @@
|
||||
hasSelection,
|
||||
selectedCount,
|
||||
searchValue = $bindable(''),
|
||||
sortValue,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
onOpenAssign
|
||||
}: Props = $props();
|
||||
|
||||
const sortOptions = [
|
||||
{ label: t.get('audience.sort.newest'), value: 'createdAt:desc' },
|
||||
{ label: t.get('audience.sort.oldest'), value: 'createdAt:asc' },
|
||||
{ label: t.get('audience.sort.name_asc'), value: 'name:asc' },
|
||||
{ label: t.get('audience.sort.name_desc'), value: 'name:desc' },
|
||||
{ label: t.get('audience.sort.email_asc'), value: 'email:asc' },
|
||||
{ label: t.get('audience.sort.email_desc'), value: 'email:desc' }
|
||||
{ label: t.get('audience.date_joined'), value: 'createdAt' },
|
||||
{ label: t.get('audience.name'), value: 'name' },
|
||||
{ label: t.get('audience.email'), value: 'email' }
|
||||
];
|
||||
|
||||
let localSortKey = $state(sortBy);
|
||||
let localSortOrder = $state(sortOrder);
|
||||
|
||||
$effect(() => {
|
||||
localSortKey = sortBy;
|
||||
localSortOrder = sortOrder;
|
||||
});
|
||||
|
||||
function handleSortKeyChange(key: string) {
|
||||
onSortChange(key as OrganizationAudienceSortBy, localSortOrder);
|
||||
}
|
||||
|
||||
function handleOrderChange(order: 'asc' | 'desc') {
|
||||
onSortChange(localSortKey, order);
|
||||
}
|
||||
|
||||
function handleClearSort() {
|
||||
onSortChange('createdAt', 'desc');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.BodyHeader>
|
||||
@@ -46,20 +66,16 @@
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="w-full md:max-w-sm" />
|
||||
<Select.Root type="single" value={sortValue} onValueChange={onSortChange} name="audience-sort">
|
||||
<Select.Trigger class="w-full md:w-[220px]">
|
||||
{sortOptions.find((option) => option.value === sortValue)?.label ?? $t('audience.sort.label')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} disabled={option.value === sortValue}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<SortPopover
|
||||
{sortOptions}
|
||||
bind:sortKey={localSortKey}
|
||||
bind:selectedOrder={localSortOrder}
|
||||
defaultSortKey="createdAt"
|
||||
defaultSortOrder="desc"
|
||||
onSortKeyChange={handleSortKeyChange}
|
||||
onOrderChange={handleOrderChange}
|
||||
onClearFilters={handleClearSort}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
let deleteCandidate = $state<OrganizationAudienceMember | null>(null);
|
||||
let deleteDialogOpen = $state(false);
|
||||
let searchValue = $state(query.search ?? '');
|
||||
let sortValue = $state(`${query.sortBy}:${query.sortOrder}`);
|
||||
|
||||
async function handleResendInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
@@ -126,7 +125,6 @@
|
||||
|
||||
$effect(() => {
|
||||
searchValue = query.search ?? '';
|
||||
sortValue = `${query.sortBy}:${query.sortOrder}`;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -179,23 +177,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
function handleSortChange(nextSortValue: string) {
|
||||
if (nextSortValue === sortValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
sortValue = nextSortValue;
|
||||
const [sortBy, sortOrder] = nextSortValue.split(':') as [
|
||||
OrganizationAudienceQuery['sortBy'],
|
||||
OrganizationAudienceQuery['sortOrder']
|
||||
];
|
||||
|
||||
void navigateAudience({
|
||||
...query,
|
||||
page: 1,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
function handleSortChange(
|
||||
sortBy: OrganizationAudienceQuery['sortBy'],
|
||||
sortOrder: OrganizationAudienceQuery['sortOrder']
|
||||
) {
|
||||
void navigateAudience({ ...query, page: 1, sortBy, sortOrder });
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(member: OrganizationAudienceMember) {
|
||||
@@ -236,7 +222,8 @@
|
||||
{hasSelection}
|
||||
selectedCount={selectedIds.size}
|
||||
bind:searchValue
|
||||
{sortValue}
|
||||
sortBy={query.sortBy}
|
||||
sortOrder={query.sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onOpenAssign={() => (assignModalOpen = true)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MultiSelectList } from '@cio/ui';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
@@ -17,15 +16,23 @@
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
interface Program {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let { courses }: Props = $props();
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
programs: Program[];
|
||||
}
|
||||
|
||||
let { courses, programs }: Props = $props();
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let courseAccessMode = $state('none');
|
||||
let programAccessMode = $state('none');
|
||||
let selectedCourseIds = new SvelteSet<string>();
|
||||
let selectedProgramIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
@@ -37,9 +44,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleProgram(programId: string) {
|
||||
if (selectedProgramIds.has(programId)) {
|
||||
selectedProgramIds.delete(programId);
|
||||
} else {
|
||||
selectedProgramIds.add(programId);
|
||||
}
|
||||
}
|
||||
|
||||
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!recipientCsv.trim()) {
|
||||
snackbar.error('audience.import.snackbar_no_emails');
|
||||
return;
|
||||
@@ -51,7 +68,9 @@
|
||||
const result = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
allCourses: courseAccessMode === 'all',
|
||||
allPrograms: programAccessMode === 'all',
|
||||
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
|
||||
programIds: programAccessMode === 'select' ? [...selectedProgramIds] : undefined,
|
||||
sendEmail
|
||||
});
|
||||
|
||||
@@ -66,7 +85,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<form id="import-audience-form" class="space-y-6" onsubmit={handleSubmit}>
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('audience.import.description')}</p>
|
||||
|
||||
<TextareaField
|
||||
@@ -108,11 +127,38 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
|
||||
{#if programs.length > 0}
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
|
||||
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="program-none" />
|
||||
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="program-all" />
|
||||
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="program-select" />
|
||||
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button onclick={handleSubmit} loading={isSubmitting} disabled={isSubmitting}>
|
||||
{$t('audience.import.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if programAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.import.select_programs')}
|
||||
emptyMessage={$t('audience.import.select_programs_placeholder')}
|
||||
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
|
||||
isSelected={(id) => selectedProgramIds.has(id)}
|
||||
onToggle={toggleProgram}
|
||||
namePrefix="import-program"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
|
||||
</form>
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
|
||||
|
||||
import { BackButton } from '@cio/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -60,16 +58,12 @@
|
||||
</script>
|
||||
|
||||
{#if userAnalytics}
|
||||
<div class="p-5">
|
||||
<BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-1">
|
||||
<HeroProfileCard user={userAnalytics!.user} />
|
||||
|
||||
<div class="mt-5 px-0">
|
||||
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each userMetrics as activity}
|
||||
{#each userMetrics as activity (activity.title)}
|
||||
<ActivityCard {activity} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -114,54 +108,52 @@
|
||||
</div>
|
||||
|
||||
{#each filteredCourses as course (course.id)}
|
||||
{#key index}
|
||||
<div
|
||||
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
|
||||
course.lessons_count === course.lessons_completed
|
||||
? 'border-green-200 bg-green-50 dark:bg-green-100'
|
||||
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
|
||||
}`}
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex w-4/5 items-center gap-4">
|
||||
<img
|
||||
src={course.logo || '/images/classroomio-course-img-template.jpg'}
|
||||
alt={course.title}
|
||||
class="h-20 w-24 rounded-md"
|
||||
/>
|
||||
<div class="mb-4 gap-4">
|
||||
<a href={`/courses/${course.id}`}>
|
||||
<p class="text-lg font-semibold text-gray-600">
|
||||
{course.title}
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<p class="line-clamp-2 text-sm text-gray-600">
|
||||
{course.description}
|
||||
<div
|
||||
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
|
||||
course.lessons_count === course.lessons_completed
|
||||
? 'border-green-200 bg-green-50 dark:bg-green-100'
|
||||
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
|
||||
}`}
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex w-4/5 items-center gap-4">
|
||||
<img
|
||||
src={course.logo || '/images/classroomio-course-img-template.jpg'}
|
||||
alt={course.title}
|
||||
class="h-20 w-24 rounded-md"
|
||||
/>
|
||||
<div class="mb-4 gap-4">
|
||||
<a href={`/courses/${course.id}`}>
|
||||
<p class="text-lg font-semibold text-gray-600">
|
||||
{course.title}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<p class="line-clamp-2 text-sm text-gray-600">
|
||||
{course.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
class={`${
|
||||
course.lessons_count === course.lessons_completed
|
||||
? 'bg-green-200 text-green-700'
|
||||
: 'bg-yellow-200 text-yellow-700'
|
||||
}`}
|
||||
>
|
||||
{course.lessons_count === course.lessons_completed
|
||||
? $t('analytics.completed')
|
||||
: $t('analytics.incomplete')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<Progress value={course.progress_percentage} />
|
||||
<p>{course.progress_percentage}%</p>
|
||||
</div>
|
||||
<Badge
|
||||
class={`${
|
||||
course.lessons_count === course.lessons_completed
|
||||
? 'bg-green-200 text-green-700'
|
||||
: 'bg-yellow-200 text-yellow-700'
|
||||
}`}
|
||||
>
|
||||
{course.lessons_count === course.lessons_completed
|
||||
? $t('analytics.completed')
|
||||
: $t('analytics.incomplete')}
|
||||
</Badge>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<Progress value={course.progress_percentage} />
|
||||
<p>{course.progress_percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,8 @@ class AutomationApi extends BaseApiWithErrors {
|
||||
onSuccess: (response) => {
|
||||
this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key));
|
||||
if (this.usage) {
|
||||
const activeKeys = this.keys.filter((key) => key.type === 'mcp' && !key.revokedAt).length;
|
||||
const revokedKeyType = response.data.type;
|
||||
const activeKeys = this.keys.filter((key) => key.type === revokedKeyType && !key.revokedAt).length;
|
||||
this.usage = { ...this.usage, activeKeys };
|
||||
}
|
||||
snackbar.success('snackbar.automation.revoked');
|
||||
|
||||
@@ -1,14 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { automationApi } from '$features/automation/api/automation.svelte';
|
||||
import { getMaskedAutomationSecret } from '$features/automation/utils/automation-utils';
|
||||
import { isOrgAdmin, isEnterprisePlan } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import * as Alert from '@cio/ui/base/alert';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Code from '@cio/ui/custom/code';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import KeyIcon from '@lucide/svelte/icons/key';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
|
||||
let generatedSecret = $state<string | null>(null);
|
||||
let isCreateKeyModalOpen = $state(false);
|
||||
let keyLabel = $state('');
|
||||
|
||||
const canCreateKey = $derived(keyLabel.trim().length > 0 && !automationApi.isLoading);
|
||||
|
||||
function resetCreateKeyModal() {
|
||||
keyLabel = '';
|
||||
automationApi.resetErrors();
|
||||
}
|
||||
|
||||
function openCreateKeyModal() {
|
||||
if (!$isEnterprisePlan) {
|
||||
snackbar.error('upgrade.required');
|
||||
return;
|
||||
}
|
||||
resetCreateKeyModal();
|
||||
isCreateKeyModalOpen = true;
|
||||
}
|
||||
|
||||
async function onGenerateKey() {
|
||||
if (!$isEnterprisePlan) return;
|
||||
const result = await automationApi.createKey('api', keyLabel);
|
||||
if (!result) return;
|
||||
|
||||
generatedSecret = automationApi.generatedSecret;
|
||||
automationApi.clearGeneratedSecret();
|
||||
isCreateKeyModalOpen = false;
|
||||
resetCreateKeyModal();
|
||||
}
|
||||
|
||||
async function onRevokeKey(keyId: string) {
|
||||
if (!$isEnterprisePlan) return;
|
||||
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
|
||||
await automationApi.revokeKey(keyId);
|
||||
}
|
||||
|
||||
async function onRotateKey(keyId: string) {
|
||||
if (!$isEnterprisePlan) return;
|
||||
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
|
||||
await automationApi.rotateKey(keyId);
|
||||
generatedSecret = automationApi.generatedSecret;
|
||||
automationApi.clearGeneratedSecret();
|
||||
}
|
||||
|
||||
const apiKeys = $derived(automationApi.keys.filter((key) => key.type === 'api'));
|
||||
</script>
|
||||
|
||||
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('automation.tabs.api')}
|
||||
description={$t('automation.coming_soon')}
|
||||
class="w-full"
|
||||
/>
|
||||
<Field.Group class="mx-auto w-full space-y-2">
|
||||
<UpgradeBanner>{$t('upgrade.enterprise_required')}</UpgradeBanner>
|
||||
|
||||
{#if !$isOrgAdmin}
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('automation.permissions.disabled_title')}
|
||||
description={$t('automation.permissions.disabled_description')}
|
||||
class="w-full"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<KeyIcon class="size-5" />
|
||||
{$t('automation.api.keys.title')}
|
||||
</div>
|
||||
{#if $isOrgAdmin}
|
||||
<IconButton
|
||||
onclick={openCreateKeyModal}
|
||||
disabled={automationApi.isLoading || !$isEnterprisePlan}
|
||||
tooltip={$t('automation.api.keys.generate')}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('automation.api.keys.description')}</p>
|
||||
|
||||
{#if generatedSecret}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<p class="ui:text-amber-800 text-sm font-medium">{$t('automation.api.keys.secret_once')}</p>
|
||||
<div class="mt-3">
|
||||
<Code.Root code={generatedSecret} lang="bash" hideLines={true} class="ui:break-all ui:text-xs">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $isOrgAdmin}
|
||||
{#if apiKeys.length > 0}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{$t('automation.keys.table_key')}</Table.Head>
|
||||
<Table.Head>{$t('automation.keys.table_status')}</Table.Head>
|
||||
<Table.Head>{$t('automation.keys.table_created')}</Table.Head>
|
||||
<Table.Head>{$t('automation.keys.table_last_used')}</Table.Head>
|
||||
<Table.Head class="w-10"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each apiKeys as key (key.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<div>
|
||||
<p class="font-medium">{key.label}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{getMaskedAutomationSecret(key.secretPrefix)}
|
||||
</p>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
|
||||
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="ui:text-muted-foreground text-sm">
|
||||
{new Date(key.createdAt).toLocaleDateString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="ui:text-muted-foreground text-sm">
|
||||
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : $t('automation.keys.never_used')}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if !key.revokedAt}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
|
||||
<IconButton aria-label="actions">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => onRotateKey(key.id)}>
|
||||
{$t('automation.keys.rotate')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => onRevokeKey(key.id)} class="ui:text-destructive">
|
||||
{$t('automation.keys.revoke')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('automation.api.keys.empty_title')}
|
||||
description={$t('automation.api.keys.empty_description')}
|
||||
icon={KeyIcon}
|
||||
variant="page"
|
||||
>
|
||||
{#if $isOrgAdmin}
|
||||
<Button onclick={openCreateKeyModal} disabled={automationApi.isLoading || !$isEnterprisePlan}>
|
||||
<PlusIcon size={16} />
|
||||
{$t('automation.api.keys.generate')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Empty>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Field.Group>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={isCreateKeyModalOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetCreateKeyModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('automation.api.keys.modal.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('automation.api.keys.modal.description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="py-2">
|
||||
<InputField
|
||||
label={$t('automation.api.keys.modal.name_label')}
|
||||
placeholder={$t('automation.api.keys.modal.name_placeholder')}
|
||||
bind:value={keyLabel}
|
||||
errorMessage={automationApi.errors.label}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (isCreateKeyModalOpen = false)}>
|
||||
{$t('automation.api.keys.modal.cancel')}
|
||||
</Button>
|
||||
<Button onclick={onGenerateKey} loading={automationApi.isLoading} disabled={!canCreateKey}>
|
||||
{$t('automation.api.keys.modal.save')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Code from '@cio/ui/custom/code';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
@@ -97,6 +98,7 @@
|
||||
});
|
||||
|
||||
const isLastUsageCard = (index: number) => index === usageCards.length - 1;
|
||||
const hasActiveMcpKey = $derived(mcpKeys.some((key) => !key.revokedAt));
|
||||
</script>
|
||||
|
||||
<Field.Group class="mx-auto w-full space-y-2">
|
||||
@@ -209,64 +211,73 @@
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{:else}
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
<Empty
|
||||
title={$t('automation.keys.empty_title')}
|
||||
description={$t('automation.keys.empty_description')}
|
||||
class="w-full"
|
||||
/>
|
||||
icon={KeyIcon}
|
||||
variant="page"
|
||||
>
|
||||
{#if $isOrgAdmin}
|
||||
<Button onclick={openCreateKeyModal} disabled={automationApi.isLoading}>
|
||||
<PlusIcon size={16} />
|
||||
{$t('automation.mcp.keys.generate')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Empty>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Field.Set class="gap-3!">
|
||||
<Field.Legend class="flex items-center gap-2">
|
||||
<SparklesIcon class="size-5" />
|
||||
{$t('automation.mcp.setup.title')}
|
||||
</Field.Legend>
|
||||
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
|
||||
{#if hasActiveMcpKey}
|
||||
<Field.Set class="gap-3!">
|
||||
<Field.Legend class="flex items-center gap-2">
|
||||
<SparklesIcon class="size-5" />
|
||||
{$t('automation.mcp.setup.title')}
|
||||
</Field.Legend>
|
||||
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
|
||||
|
||||
<Tabs.Root bind:value={activeSetupTab} class="w-full">
|
||||
<Tabs.List class="inline-flex w-auto">
|
||||
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="opencode">{$t('automation.clients.opencode')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Root bind:value={activeSetupTab} class="w-full">
|
||||
<Tabs.List class="inline-flex w-auto">
|
||||
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="opencode">{$t('automation.clients.opencode')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="claude-code" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="claude-code" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="codex" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getCodexSnippet(generatedSecret)} lang="bash">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="codex" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getCodexSnippet(generatedSecret)} lang="bash">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cursor" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getCursorSnippet(generatedSecret)} lang="json">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="cursor" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getCursorSnippet(generatedSecret)} lang="json">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="opencode" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getOpenCodeSnippet(generatedSecret)} lang="json">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Field.Set>
|
||||
<Tabs.Content value="opencode" class="mt-4">
|
||||
<Code.Overflow>
|
||||
<Code.Root code={getOpenCodeSnippet(generatedSecret)} lang="json">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Field.Set>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
|
||||
<Dialog.Root
|
||||
|
||||
@@ -9,14 +9,14 @@ import type {
|
||||
UpvotePostRequest
|
||||
} from '../utils/types';
|
||||
import { ZCommunityComment, ZCommunityQuestionUpdate, ZCreateCommunityQuestion } from '@cio/utils/validation/community';
|
||||
import { currentOrg as currentOrgStore } from '$lib/utils/store/org';
|
||||
import { basePath, isStudentExperience } from '$lib/utils/store/app';
|
||||
|
||||
import type { TCreateCommunityQuestion } from '@cio/utils/validation/community';
|
||||
import { coursesApi } from '$features/course/api/courses.svelte';
|
||||
import { currentCommunityQuestion } from '../utils/store';
|
||||
import { currentOrg as currentOrgStore } from '$lib/utils/store/org';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { get } from 'svelte/store';
|
||||
import { basePath, isStudentExperience } from '$lib/utils/store/app';
|
||||
import { goto } from '$app/navigation';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile as profileStore } from '$lib/utils/store/user';
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
/>
|
||||
<Page.Title>{communityApi.question.title}</Page.Title>
|
||||
</div>
|
||||
<Page.Subtitle>{$t('community.question.page_subtitle')}</Page.Subtitle>
|
||||
{/if}
|
||||
</Page.HeaderContent>
|
||||
{#if communityApi.question.authorId === $profile.id}
|
||||
|
||||
@@ -36,10 +36,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
let isCoursesFetched = $state(false);
|
||||
$effect(() => {
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
if (!$profile.id || !$currentOrg.id || isCoursesFetched) return;
|
||||
|
||||
communityApi.fetchCoursesForOrg($profile.id, $currentOrg.id);
|
||||
isCoursesFetched = true;
|
||||
});
|
||||
|
||||
let filteredQuestions = $derived(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
GetOrgCoursesRequest,
|
||||
GetOrgCoursesRequestQuery,
|
||||
GetRecommendedCoursesRequest,
|
||||
GetUserEnrolledCoursesRequest,
|
||||
OrgCourses,
|
||||
OrgCoursesPagination,
|
||||
OrgCoursesQuery,
|
||||
RecommendedCourses,
|
||||
UserEnrolledCourses
|
||||
} from '$features/course/types';
|
||||
@@ -15,8 +18,15 @@ import { SvelteSet } from 'svelte/reactivity';
|
||||
*/
|
||||
export class CoursesApi extends BaseApiWithErrors {
|
||||
orgCourses = $state<OrgCourses>([]);
|
||||
orgCoursesPagination = $state<OrgCoursesPagination | null>(null);
|
||||
enrolledCourses = $state<UserEnrolledCourses>([]);
|
||||
recommendedCourses = $state<RecommendedCourses>([]);
|
||||
private activeOrgCoursesRequestController: AbortController | null = null;
|
||||
|
||||
cancelOrgCoursesRequest() {
|
||||
this.activeOrgCoursesRequestController?.abort();
|
||||
this.activeOrgCoursesRequestController = null;
|
||||
}
|
||||
|
||||
/** Keeps list UIs in sync after a course is deleted (server data is updated separately on navigation). */
|
||||
removeCourseFromLists(courseId: string) {
|
||||
@@ -30,26 +40,79 @@ export class CoursesApi extends BaseApiWithErrors {
|
||||
* Org ID is automatically added from currentOrg store
|
||||
*/
|
||||
async getOrgCourses(tagSlugs: string[] = []) {
|
||||
const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
const allCourses: OrgCourses = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
let lastResponse: Awaited<ReturnType<CoursesApi['getOrgCoursesPage']>> | undefined;
|
||||
|
||||
return this.execute<GetOrgCoursesRequest>({
|
||||
while (page <= totalPages) {
|
||||
const response = await this.getOrgCoursesPage({ page, limit: 100 }, tagSlugs);
|
||||
if (!response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
allCourses.push(...(response.data ?? []));
|
||||
totalPages = response.pagination?.totalPages ?? 1;
|
||||
lastResponse = response;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
this.orgCourses = allCourses;
|
||||
this.orgCoursesPagination = lastResponse?.pagination ?? null;
|
||||
|
||||
return lastResponse;
|
||||
}
|
||||
|
||||
async getOrgCoursesPage(
|
||||
query: Partial<OrgCoursesQuery> = {},
|
||||
tagSlugs: string[] = [],
|
||||
options: { abortPrevious?: boolean; signal?: AbortSignal } = {}
|
||||
) {
|
||||
const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
|
||||
const requestQuery: GetOrgCoursesRequestQuery = {
|
||||
page: String(query.page ?? 1),
|
||||
limit: String(query.limit ?? 20),
|
||||
search: query.search,
|
||||
tags: normalizedTagSlugs.length > 0 ? normalizedTagSlugs.join(',') : undefined
|
||||
};
|
||||
|
||||
let requestSignal = options.signal;
|
||||
let requestController: AbortController | null = null;
|
||||
|
||||
if (options.abortPrevious) {
|
||||
this.cancelOrgCoursesRequest();
|
||||
requestController = new AbortController();
|
||||
this.activeOrgCoursesRequestController = requestController;
|
||||
requestSignal = requestController.signal;
|
||||
}
|
||||
|
||||
const response = await this.execute<GetOrgCoursesRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.courses.$get(
|
||||
normalizedTagSlugs.length > 0
|
||||
? {
|
||||
query: {
|
||||
tags: normalizedTagSlugs.join(',')
|
||||
}
|
||||
}
|
||||
: { query: {} }
|
||||
{
|
||||
query: requestQuery
|
||||
},
|
||||
{
|
||||
init: {
|
||||
signal: requestSignal
|
||||
}
|
||||
}
|
||||
),
|
||||
logContext: 'fetching org courses',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.orgCourses = response.data;
|
||||
}
|
||||
|
||||
this.orgCoursesPagination = response.pagination;
|
||||
}
|
||||
});
|
||||
|
||||
if (requestController && this.activeOrgCoursesRequestController === requestController) {
|
||||
this.activeOrgCoursesRequestController = null;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -180,21 +180,20 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
snackbar.success('Post updated successfully');
|
||||
// Update local state, preserving author fields from existing feed
|
||||
const index = this.feeds.findIndex((f) => f.id === feedId);
|
||||
if (index !== -1) {
|
||||
const existingFeed = this.feeds[index];
|
||||
// Merge response data with existing feed to preserve author fields
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
this.feeds = this.feeds.map((feed) => {
|
||||
if (feed.id !== feedId) {
|
||||
return feed;
|
||||
}
|
||||
|
||||
return {
|
||||
...feed,
|
||||
...response.data,
|
||||
// Preserve author fields from existing feed
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl
|
||||
authorProfileId: feed.authorProfileId,
|
||||
authorFullname: feed.authorFullname,
|
||||
authorUsername: feed.authorUsername,
|
||||
authorAvatarUrl: feed.authorAvatarUrl
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
@@ -232,21 +231,20 @@ export class NewsfeedApi extends BaseApiWithErrors {
|
||||
logContext: 'updating newsfeed reaction',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Update local state, preserving author fields from existing feed
|
||||
const index = this.feeds.findIndex((f) => f.id === feedId);
|
||||
if (index !== -1) {
|
||||
const existingFeed = this.feeds[index];
|
||||
// Merge response data with existing feed to preserve author fields
|
||||
this.feeds[index] = {
|
||||
...existingFeed,
|
||||
this.feeds = this.feeds.map((feed) => {
|
||||
if (feed.id !== feedId) {
|
||||
return feed;
|
||||
}
|
||||
|
||||
return {
|
||||
...feed,
|
||||
...response.data,
|
||||
// Preserve author fields from existing feed
|
||||
authorProfileId: existingFeed.authorProfileId,
|
||||
authorFullname: existingFeed.authorFullname,
|
||||
authorUsername: existingFeed.authorUsername,
|
||||
authorAvatarUrl: existingFeed.authorAvatarUrl
|
||||
authorProfileId: feed.authorProfileId,
|
||||
authorFullname: feed.authorFullname,
|
||||
authorUsername: feed.authorUsername,
|
||||
authorAvatarUrl: feed.authorAvatarUrl
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
|
||||
import pluralize from 'pluralize';
|
||||
@@ -30,9 +28,11 @@
|
||||
isOnLandingPage?: boolean;
|
||||
isLMS?: boolean;
|
||||
isExplore?: boolean;
|
||||
href?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { course, isOnLandingPage, isLMS, isExplore }: Props = $props();
|
||||
let { course, isOnLandingPage, isLMS, isExplore, href, actions }: Props = $props();
|
||||
|
||||
let {
|
||||
bannerImage,
|
||||
@@ -42,6 +42,7 @@
|
||||
description = '',
|
||||
isPublished = false,
|
||||
totalLessons = 0,
|
||||
totalExercises = 0,
|
||||
totalStudents = 0,
|
||||
currency = 'USD',
|
||||
progressRate = 45,
|
||||
@@ -54,7 +55,7 @@
|
||||
} = $derived({
|
||||
id: course.id,
|
||||
slug: course.slug,
|
||||
bannerImage: course.logo || '/images/classroomio-course-img-template.jpg',
|
||||
bannerImage: course.logo || DEFAULT_COURSE_BANNER_IMAGE,
|
||||
title: course.title,
|
||||
type: course.type,
|
||||
description: course.description,
|
||||
@@ -66,6 +67,10 @@
|
||||
},
|
||||
currency: course.currency,
|
||||
totalLessons: course.lessonCount,
|
||||
totalExercises: (() => {
|
||||
const c = course as { exerciseCount?: number };
|
||||
return typeof c.exerciseCount === 'number' ? c.exerciseCount : 0;
|
||||
})(),
|
||||
progressRate: (() => {
|
||||
const c = course as { exerciseCount?: number; exercisesCompleted?: number };
|
||||
if (typeof c.exerciseCount === 'number' && typeof c.exercisesCompleted === 'number') {
|
||||
@@ -122,91 +127,104 @@
|
||||
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
|
||||
|
||||
let courseUrl = $derived(
|
||||
isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`
|
||||
href ?? (isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`)
|
||||
);
|
||||
|
||||
const typeBadge = $derived(
|
||||
type && COURSE_TAG[type]
|
||||
? {
|
||||
label: COURSE_TAG[type].label,
|
||||
icon: COURSE_TAG[type].icon,
|
||||
iconClass: COURSE_TAG[type].iconStyle
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<Item.Root variant="outline" class="group relative max-w-[320px] p-3!">
|
||||
{#snippet child({ props })}
|
||||
<a href={resolve(courseUrl, {})} {...props}>
|
||||
{#if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} />
|
||||
{/if}
|
||||
<CourseCard href={resolve(courseUrl, {})} {title} {description} {typeBadge} class="group relative">
|
||||
{#snippet media()}
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
{/snippet}
|
||||
|
||||
<Item.Header>
|
||||
<Item.Media variant="image" class="relative h-50! w-full!">
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
{#snippet overlay()}
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if type}
|
||||
{@const tag = COURSE_TAG[type]}
|
||||
<Badge class="absolute bottom-2 left-2 z-10 flex items-center rounded-md! capitalize" variant="secondary">
|
||||
<tag.icon class={tag.iconStyle} />
|
||||
{tag.label}
|
||||
</Badge>
|
||||
{/if}
|
||||
</Item.Media>
|
||||
</Item.Header>
|
||||
{#snippet tags()}
|
||||
{#if !isLMS}
|
||||
<CourseTagsOverflow tags={courseTags} variant="card" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-14 text-base!">
|
||||
{title}
|
||||
</Item.Title>
|
||||
|
||||
<Item.Description class="min-h-[63px]">{description}</Item.Description>
|
||||
{#if !isLMS}
|
||||
<CourseTagsOverflow tags={courseTags} variant="card" />
|
||||
{/if}
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<div class="flex justify-between {isLMS && 'items-center'} w-full">
|
||||
<div class="w-[50%]">
|
||||
<p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
|
||||
{#snippet footer()}
|
||||
<div class="flex justify-between {isLMS && 'items-center'} w-full">
|
||||
<div class="w-[60%]">
|
||||
{#if isLMS || isOnLandingPage}
|
||||
<p class="text-xs {!isLMS && 'pl-2'} flex gap-1 dark:text-white">
|
||||
<span>
|
||||
{totalLessons}
|
||||
{$t('courses.course_card.lessons_number')}
|
||||
</p>
|
||||
<div class="py-2 text-xs">
|
||||
{#if isOnLandingPage}
|
||||
<span class="px-2">
|
||||
{#if !cost}
|
||||
{$t('course.navItem.landing_page.pricing_section.free')}
|
||||
{:else if pricingData.showDiscount}
|
||||
{formatter.format(cost)}
|
||||
<span class="line-through">
|
||||
{formatter?.format(pricingData?.cost ?? 0)}
|
||||
</span>
|
||||
{:else}
|
||||
{formatter.format(cost)}
|
||||
{/if}
|
||||
</span>
|
||||
&
|
||||
<span>
|
||||
{pluralize($t('courses.course_card.exercise'), totalExercises, true)}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
<div class="py-2 text-xs">
|
||||
{#if isOnLandingPage}
|
||||
<span class="px-2">
|
||||
{#if !cost}
|
||||
{$t('course.navItem.landing_page.pricing_section.free')}
|
||||
{:else if pricingData.showDiscount}
|
||||
{formatter.format(cost)}
|
||||
<span class="line-through">
|
||||
{formatter?.format(pricingData?.cost ?? 0)}
|
||||
</span>
|
||||
{:else if isLMS}
|
||||
{#if !isExplore}
|
||||
<div class="flex w-3/4 items-center gap-2">
|
||||
<Progress value={progressRate} />
|
||||
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<CoursePublishBadge {isPublished} />
|
||||
{formatter.format(cost)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLMS}
|
||||
<Button variant="outline">
|
||||
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
|
||||
|
||||
<ArrowRightIcon class="custom" />
|
||||
</Button>
|
||||
{:else if !isOnLandingPage}
|
||||
<div class="flex flex-col justify-between">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('courses.course_card.students'), totalStudents, true)}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
{:else if isLMS}
|
||||
{#if !isExplore}
|
||||
<div class="flex w-3/4 items-center gap-2">
|
||||
<Progress value={progressRate} />
|
||||
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<CoursePublishBadge {isPublished} />
|
||||
{/if}
|
||||
</div>
|
||||
</Item.Content>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if isLMS}
|
||||
<Button variant="outline">
|
||||
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
|
||||
|
||||
<ArrowRightIcon class="custom" />
|
||||
</Button>
|
||||
{:else if !isOnLandingPage}
|
||||
<div class="flex flex-col justify-end gap-1 text-right">
|
||||
<p class="pl-2 text-xs whitespace-nowrap dark:text-white">
|
||||
<span>
|
||||
{totalLessons}
|
||||
{$t('courses.course_card.lessons_number')}
|
||||
</span>
|
||||
&
|
||||
<span>
|
||||
{pluralize($t('courses.course_card.exercise'), totalExercises, true)}
|
||||
</span>
|
||||
</p>
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('courses.course_card.students'), totalStudents, true)}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Item.Root>
|
||||
</CourseCard>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
COURSE_SORT_OPTIONS,
|
||||
CourseSortOrder,
|
||||
DEFAULT_COURSE_SORT,
|
||||
DEFAULT_SORT_ORDER,
|
||||
type CourseSortBy
|
||||
type CourseSortBy,
|
||||
type CourseSortOrder
|
||||
} from '../utils/constants';
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import * as ToggleGroup from '@cio/ui/base/toggle-group';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import { SortPopover } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface FilterTag {
|
||||
@@ -54,7 +49,9 @@
|
||||
onClearFilters = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
const translatedSortOptions = $derived(
|
||||
sortOptions.map((option) => ({ value: option.value, label: $t(option.label) }))
|
||||
);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
|
||||
@@ -65,117 +62,58 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open={isOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div class="relative">
|
||||
<IconButton {...props} aria-label={$t('courses.tag_filters.filter')} tooltip={$t('courses.tag_filters.filter')}>
|
||||
{#if isFiltering}
|
||||
<Spinner class="size-4" />
|
||||
{:else}
|
||||
<FilterIcon size={16} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
{#if hasActiveFilters}
|
||||
<span
|
||||
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content align="end" class="w-[360px] p-3">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold">{$t('courses.tag_filters.popover_title')}</p>
|
||||
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
|
||||
{$t('courses.tag_filters.clear_all')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (sortKey = option.value)}
|
||||
>
|
||||
{$t(option.label)}
|
||||
</Button>
|
||||
<SortPopover
|
||||
sortOptions={translatedSortOptions}
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
defaultSortKey={DEFAULT_COURSE_SORT}
|
||||
defaultSortOrder={DEFAULT_SORT_ORDER}
|
||||
{isFiltering}
|
||||
{hasActiveFilters}
|
||||
{onClearFilters}
|
||||
>
|
||||
{#snippet additionalContent()}
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
{#if tagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{:else}
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('courses.tag_filters.order')}
|
||||
</p>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={selectedOrder}
|
||||
onValueChange={(value) => {
|
||||
if (value === CourseSortOrder.Asc || value === CourseSortOrder.Desc) {
|
||||
selectedOrder = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroup.Item value={CourseSortOrder.Asc}>{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value={CourseSortOrder.Desc}>{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
{#if tagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{:else}
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/snippet}
|
||||
</SortPopover>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { Image } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { buildCoursePlaceholderAvatarUrl } from '$features/course/utils/course-list-row-utils';
|
||||
import CourseContentIcon from './course-content-icon.svelte';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
logo?: string | null;
|
||||
type?: string | null;
|
||||
description?: string;
|
||||
isPublished?: boolean;
|
||||
lessonCount?: number;
|
||||
exerciseCount?: number;
|
||||
totalStudents?: number;
|
||||
updatedAt?: string | null;
|
||||
tags?: Tag[];
|
||||
isExplore?: boolean;
|
||||
isLMS?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
slug = '',
|
||||
title,
|
||||
logo = null,
|
||||
type,
|
||||
description = '',
|
||||
isPublished = false,
|
||||
lessonCount = 0,
|
||||
exerciseCount = 0,
|
||||
totalStudents = 0,
|
||||
updatedAt,
|
||||
tags = [],
|
||||
isExplore = false,
|
||||
isLMS = false
|
||||
}: Props = $props();
|
||||
|
||||
const titleInitial = $derived(title.trim().charAt(0).toUpperCase() || 'C');
|
||||
|
||||
const bannerImage = $derived(logo?.trim() ? logo : '/images/classroomio-course-img-template.jpg');
|
||||
|
||||
const gridTemplateColumns = $derived(
|
||||
isLMS
|
||||
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
|
||||
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
|
||||
);
|
||||
|
||||
const studentPlaceholderAvatarUrls = $derived.by(() => [
|
||||
buildCoursePlaceholderAvatarUrl(`${id}:student:0`),
|
||||
buildCoursePlaceholderAvatarUrl(`${id}:student:1`)
|
||||
]);
|
||||
|
||||
const emptyStateAvatarUrl = $derived(buildCoursePlaceholderAvatarUrl(`${id}:student:empty`));
|
||||
|
||||
const typeLabel = $derived(type ? $t(`course.navItem.settings.${type.toLowerCase()}`) : null);
|
||||
|
||||
const updatedLabel = $derived.by(() => {
|
||||
if (!updatedAt) return null;
|
||||
const d = new Date(updatedAt);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return `Updated ${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
|
||||
});
|
||||
|
||||
function handleRowClick() {
|
||||
if (isExplore) {
|
||||
goto(resolve(`/course/${slug}`, {}));
|
||||
return;
|
||||
}
|
||||
if (isLMS) {
|
||||
goto(resolve(`/courses/${id}/lessons?next=true`, {}));
|
||||
return;
|
||||
}
|
||||
goto(resolve(`/courses/${id}`, {}));
|
||||
}
|
||||
|
||||
function handleOpen(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
goto(resolve(`/courses/${id}`, {}));
|
||||
}
|
||||
|
||||
function handleClone(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleDelete(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ResourceListRow.Root
|
||||
variant="default"
|
||||
size="sm"
|
||||
align="start"
|
||||
class="cursor-pointer py-4!"
|
||||
onclick={handleRowClick}
|
||||
role="row"
|
||||
>
|
||||
<div class="grid w-full items-start gap-3" style="grid-template-columns: {gridTemplateColumns}">
|
||||
<!-- Banner -->
|
||||
<div
|
||||
class="ui:border-border bg-muted relative size-28 shrink-0 overflow-hidden rounded-md border"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Image src={bannerImage} alt="" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<!-- Title / type / updated -->
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<p class="line-clamp-2 text-base">{title}</p>
|
||||
{#if typeLabel}
|
||||
<p class="ui:text-muted-foreground mt-0.5 text-sm">{typeLabel}</p>
|
||||
{/if}
|
||||
{#if updatedLabel}
|
||||
<p class="ui:text-muted-foreground mt-0.5 text-xs">{updatedLabel}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Published badge -->
|
||||
<div>
|
||||
{#if isPublished}
|
||||
<Badge variant="success" class="whitespace-nowrap">
|
||||
{$t('courses.course_card.published')}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary" class="whitespace-nowrap">
|
||||
{$t('courses.course_card.unpublished')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{#if tags.length === 0}
|
||||
<span class="ui:text-muted-foreground text-xs">—</span>
|
||||
{:else}
|
||||
{#each tags as tag (tag.id)}
|
||||
<Badge variant="outline" class="max-w-[140px] truncate">
|
||||
<span
|
||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Lessons / exercises -->
|
||||
<div class="flex flex-col gap-1 tabular-nums">
|
||||
<p class="flex items-center gap-1.5 text-sm" aria-label="{lessonCount} lessons">
|
||||
<CourseContentIcon type={ContentType.Lesson} size={14} />
|
||||
<span class="font-medium">{lessonCount}</span>
|
||||
</p>
|
||||
<p class="flex items-center gap-1.5 text-sm" aria-label="{exerciseCount} exercises">
|
||||
<CourseContentIcon type={ContentType.Exercise} size={14} />
|
||||
<span class="font-medium">{exerciseCount}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Students -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#if totalStudents > 0}
|
||||
{#if totalStudents < 3}
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||
<Avatar.Image src={studentPlaceholderAvatarUrls[0]} alt="" loading="lazy" decoding="async" />
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
{titleInitial}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="text-xs tabular-nums">{totalStudents}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex -space-x-2">
|
||||
{#each studentPlaceholderAvatarUrls as avatarUrl, slotIndex (slotIndex)}
|
||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||
<Avatar.Image src={avatarUrl} alt="" loading="lazy" decoding="async" />
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
{titleInitial}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/each}
|
||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||
<Avatar.Fallback class="text-[10px]">+{totalStudents - 2}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Avatar.Root class="size-6">
|
||||
<Avatar.Image src={emptyStateAvatarUrl} alt="" loading="lazy" decoding="async" />
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
{titleInitial}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="text-xs">0</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if !isLMS}
|
||||
<div class="flex justify-end">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
aria-label="Course actions"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={handleOpen}>
|
||||
{$t('courses.course_card.context_menu.open')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleClone}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ResourceListRow.Root>
|
||||
@@ -15,7 +15,7 @@
|
||||
const flipDurationMs = 300;
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
id: number | string;
|
||||
name: string;
|
||||
type: number;
|
||||
}
|
||||
@@ -36,23 +36,36 @@
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
function applyItemOrder(nextItems: Array<Question>) {
|
||||
questionnaire.update((currentQuestionnaire) => {
|
||||
const nextOrderById = new Map(nextItems.map((item, index) => [String(item.id), index + 1]));
|
||||
const nextQuestions = currentQuestionnaire.questions.map((question) => {
|
||||
const nextOrder = nextOrderById.get(String(question.id));
|
||||
if (nextOrder == null) {
|
||||
return question;
|
||||
}
|
||||
|
||||
return {
|
||||
...question,
|
||||
isDirty: true,
|
||||
order: nextOrder
|
||||
};
|
||||
});
|
||||
const activeQuestions = nextQuestions
|
||||
.filter((question) => !question.deletedAt)
|
||||
.sort((leftQuestion, rightQuestion) => (leftQuestion.order ?? 0) - (rightQuestion.order ?? 0));
|
||||
const deletedQuestions = nextQuestions.filter((question) => question.deletedAt);
|
||||
|
||||
return {
|
||||
...currentQuestionnaire,
|
||||
questions: [...activeQuestions, ...deletedQuestions]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
items.forEach((item, index) => {
|
||||
const order = index + 1;
|
||||
$questionnaire.questions.some((q) => {
|
||||
if (q.id === item.id) {
|
||||
q.isDirty = true;
|
||||
q.order = order;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
$questionnaire.questions = $questionnaire.questions.sort((a, b) => a.order - b.order);
|
||||
$questionnaireOrder.open = false;
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
applyItemOrder(e.detail.items);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -63,7 +76,9 @@
|
||||
<Dialog.Root
|
||||
bind:open={$questionnaireOrder.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleClose();
|
||||
if (!isOpen) {
|
||||
applyItemOrder(items);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96">
|
||||
@@ -81,7 +96,7 @@
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="w-full"
|
||||
class="max-h-[300px] w-full"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
|
||||
@@ -176,6 +176,29 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'numeric.review.out_of_range': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.out_of_range'
|
||||
),
|
||||
'star.edit.correct_value_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_label'
|
||||
),
|
||||
'star.edit.correct_value_info': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_info'
|
||||
),
|
||||
'star.edit.max_stars_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_label'
|
||||
),
|
||||
'star.edit.max_stars_info': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_info'
|
||||
),
|
||||
'star.edit.correct_value_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.correct_value_placeholder'
|
||||
),
|
||||
'star.edit.max_stars_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.edit.max_stars_placeholder'
|
||||
),
|
||||
'star.take.helper': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.star.take.helper'),
|
||||
'star.take.star_sr': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.star.take.star_sr'),
|
||||
'star.preview.correct_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.star.preview.correct_label'
|
||||
),
|
||||
'fill_blank.edit.accepted_answers_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
|
||||
),
|
||||
@@ -188,6 +211,54 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'fill_blank.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.review.your_answer_label'
|
||||
),
|
||||
'word_bank.edit.template_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_label'
|
||||
),
|
||||
'word_bank.edit.template_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_placeholder'
|
||||
),
|
||||
'word_bank.edit.template_helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.template_helper'
|
||||
),
|
||||
'word_bank.edit.no_blanks_warning': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.no_blanks_warning'
|
||||
),
|
||||
'word_bank.edit.correct_answers_heading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.correct_answers_heading'
|
||||
),
|
||||
'word_bank.edit.blank_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.blank_label'
|
||||
),
|
||||
'word_bank.edit.correct_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.correct_placeholder'
|
||||
),
|
||||
'word_bank.edit.distractors_heading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.distractors_heading'
|
||||
),
|
||||
'word_bank.edit.distractor_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.distractor_placeholder'
|
||||
),
|
||||
'word_bank.edit.add_distractor': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.edit.add_distractor'
|
||||
),
|
||||
'word_bank.take.helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.helper'
|
||||
),
|
||||
'word_bank.take.bank_heading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.bank_heading'
|
||||
),
|
||||
'word_bank.take.clear_blank_sr': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.take.clear_blank_sr'
|
||||
),
|
||||
'word_bank.preview.filled_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.preview.filled_label'
|
||||
),
|
||||
'word_bank.review.your_answer_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.review.your_answer_label'
|
||||
),
|
||||
'word_bank.review.correct_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.word_bank.review.correct_label'
|
||||
),
|
||||
'file_upload.edit.accepted_types_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
|
||||
),
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
case 'RADIO':
|
||||
case 'TRUE_FALSE':
|
||||
case 'NUMERIC':
|
||||
case 'STAR':
|
||||
return true;
|
||||
case 'CHECKBOX':
|
||||
return answerValue.optionIds.length > 0;
|
||||
@@ -244,6 +245,10 @@
|
||||
return answerValue.text.trim().length > 0;
|
||||
case 'FILL_BLANK':
|
||||
return answerValue.values.length > 0;
|
||||
case 'WORD_BANK': {
|
||||
const filled = answerValue.filledBlanks ?? [];
|
||||
return filled.length > 0 && filled.every((v) => String(v).trim().length > 0);
|
||||
}
|
||||
case 'FILE_UPLOAD':
|
||||
return !!answerValue.fileKey;
|
||||
case 'MATCHING':
|
||||
@@ -470,9 +475,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
|
||||
<div class="mb-6">
|
||||
<Progress value={$questionnaireMetaData.progressValue} />
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished && $questionnaireMetaData.currentQuestionIndex > 0}
|
||||
<div class="mb-6 flex min-w-0 items-center gap-3">
|
||||
<span class="ui:text-muted-foreground shrink-0 text-sm tabular-nums">
|
||||
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
|
||||
</span>
|
||||
<Progress class="min-w-0 flex-1" value={$questionnaireMetaData.progressValue} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -605,7 +613,9 @@
|
||||
{/if}
|
||||
|
||||
<Preview
|
||||
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
|
||||
questions={[...$questionnaire.questions].sort(
|
||||
(leftQuestion, rightQuestion) => leftQuestion.order - rightQuestion.order
|
||||
)}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
grades={$questionnaireMetaData.grades}
|
||||
disableGrading={true}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user