feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
* even though it isn't currently exposed to users.
|
||||
*/
|
||||
|
||||
export const AGENT_MODEL_IDS = ['gemini-2.5-flash', 'gpt-5.4-mini', 'claude-sonnet-3-5', 'kimi-k2.6'] as const;
|
||||
export const AGENT_MODEL_IDS = ['gemini-3.1-flash-lite', 'gpt-5.4-mini', 'claude-sonnet-3-5', 'kimi-k2.6'] as const;
|
||||
|
||||
export type AgentModelId = (typeof AGENT_MODEL_IDS)[number];
|
||||
export type AgentModelProvider = 'google' | 'openai' | 'anthropic' | 'moonshot';
|
||||
@@ -22,44 +22,50 @@ export interface AgentModelDescriptor {
|
||||
isFree: boolean;
|
||||
/** Cost tier shown in the model picker — 'low' ($) or 'high' ($$$). */
|
||||
costTier: AgentModelCostTier;
|
||||
/** Context window size in tokens. Used to show context usage indicator. */
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
export const AGENT_MODELS: Record<AgentModelId, AgentModelDescriptor> = {
|
||||
'gemini-2.5-flash': {
|
||||
'gemini-3.1-flash-lite': {
|
||||
provider: 'google',
|
||||
label: 'Gemini 2.5 Flash',
|
||||
backendModelId: 'gemini-2.5-flash',
|
||||
label: 'Gemini 3.1 Flash Lite',
|
||||
backendModelId: 'gemini-3.1-flash-lite',
|
||||
isFree: true,
|
||||
costTier: 'low'
|
||||
costTier: 'low',
|
||||
contextWindow: 1_048_576
|
||||
},
|
||||
'gpt-5.4-mini': {
|
||||
provider: 'openai',
|
||||
label: 'GPT-5.4 Mini',
|
||||
backendModelId: 'gpt-5.4-mini',
|
||||
isFree: false,
|
||||
costTier: 'low'
|
||||
costTier: 'low',
|
||||
contextWindow: 400_000
|
||||
},
|
||||
'claude-sonnet-3-5': {
|
||||
provider: 'anthropic',
|
||||
label: 'Claude Sonnet 4.6',
|
||||
backendModelId: 'claude-sonnet-4-6',
|
||||
isFree: false,
|
||||
costTier: 'high'
|
||||
costTier: 'high',
|
||||
contextWindow: 1_000_000
|
||||
},
|
||||
'kimi-k2.6': {
|
||||
provider: 'moonshot',
|
||||
label: 'Kimi K2.6',
|
||||
backendModelId: 'kimi-k2.6',
|
||||
isFree: true,
|
||||
costTier: 'low'
|
||||
costTier: 'low',
|
||||
contextWindow: 262_144
|
||||
}
|
||||
};
|
||||
|
||||
export const UI_PICKER_MODEL_IDS = [
|
||||
'gemini-2.5-flash',
|
||||
'kimi-k2.6',
|
||||
'gemini-3.1-flash-lite',
|
||||
'gpt-5.4-mini',
|
||||
'claude-sonnet-3-5'
|
||||
] as const satisfies readonly AgentModelId[];
|
||||
|
||||
export const DEFAULT_PICKER_MODEL_ID: AgentModelId = 'gemini-2.5-flash';
|
||||
export const DEFAULT_PICKER_MODEL_ID: AgentModelId = 'gemini-3.1-flash-lite';
|
||||
|
||||
@@ -75,6 +75,9 @@ export const ErrorCodes = {
|
||||
ASSET_USAGE_FETCH_FAILED: 'ASSET_USAGE_FETCH_FAILED',
|
||||
ASSET_EXPORT_FAILED: 'ASSET_EXPORT_FAILED',
|
||||
ASSET_STORAGE_FETCH_FAILED: 'ASSET_STORAGE_FETCH_FAILED',
|
||||
ASSET_NOT_TRANSCRIBABLE: 'ASSET_NOT_TRANSCRIBABLE',
|
||||
|
||||
OPENAI_KEY_MISSING: 'OPENAI_KEY_MISSING',
|
||||
|
||||
// Profile errors
|
||||
PROFILE_UPDATE_FAILED: 'PROFILE_UPDATE_FAILED',
|
||||
@@ -82,6 +85,12 @@ export const ErrorCodes = {
|
||||
|
||||
// Account errors
|
||||
ACCOUNT_NOT_FOUND: 'ACCOUNT_NOT_FOUND',
|
||||
WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND',
|
||||
WORKSPACE_LIMIT_REACHED: 'WORKSPACE_LIMIT_REACHED',
|
||||
WORKSPACE_READ_ONLY: 'WORKSPACE_READ_ONLY',
|
||||
WORKSPACE_IS_PRIMARY: 'WORKSPACE_IS_PRIMARY',
|
||||
PLAN_UPGRADE_REQUIRED: 'PLAN_UPGRADE_REQUIRED',
|
||||
NOT_PRIMARY_WORKSPACE: 'NOT_PRIMARY_WORKSPACE',
|
||||
|
||||
// Auth errors
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
@@ -158,6 +167,13 @@ export const ErrorCodes = {
|
||||
PROGRAM_NEWSFEED_NOT_FOUND: 'PROGRAM_NEWSFEED_NOT_FOUND',
|
||||
PROGRAM_NEWSFEED_COMMENT_NOT_FOUND: 'PROGRAM_NEWSFEED_COMMENT_NOT_FOUND',
|
||||
PROGRAM_MEMBER_CHECK_FAILED: 'PROGRAM_MEMBER_CHECK_FAILED',
|
||||
PROGRAM_GOAL_NOT_FOUND: 'PROGRAM_GOAL_NOT_FOUND',
|
||||
PROGRAM_GOAL_CREATE_FAILED: 'PROGRAM_GOAL_CREATE_FAILED',
|
||||
PROGRAM_GOAL_UPDATE_FAILED: 'PROGRAM_GOAL_UPDATE_FAILED',
|
||||
PROGRAM_GOAL_DELETE_FAILED: 'PROGRAM_GOAL_DELETE_FAILED',
|
||||
|
||||
// AI assistant chat
|
||||
NO_MESSAGES_TO_COMPACT: 'NO_MESSAGES_TO_COMPACT',
|
||||
|
||||
// Generic
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
|
||||
@@ -17,7 +17,8 @@ export const FEATURES = {
|
||||
EA_CERTIFICATE: 'EA_CERTIFICATE',
|
||||
EA_UPCOMING_FEATURES: 'EA_UPCOMING_FEATURES',
|
||||
ENTERPRISE_STUDENTS_UNLIMITED: 'ENTERPRISE_STUDENTS_UNLIMITED',
|
||||
ENTERPRISE_CUSTOM_DOMAIN: 'ENTERPRISE_CUSTOM_DOMAIN'
|
||||
ENTERPRISE_CUSTOM_DOMAIN: 'ENTERPRISE_CUSTOM_DOMAIN',
|
||||
ENTERPRISE_MULTI_WORKSPACE: 'ENTERPRISE_MULTI_WORKSPACE'
|
||||
};
|
||||
|
||||
export const BASIC_FEATURES = [FEATURES.BACIC_STUDENTS_50];
|
||||
@@ -33,7 +34,8 @@ export const EARYL_ADOPTER_FEATURES = [
|
||||
export const ENTERPRISE_FEATURES = [
|
||||
...EARYL_ADOPTER_FEATURES,
|
||||
FEATURES.ENTERPRISE_STUDENTS_UNLIMITED,
|
||||
FEATURES.ENTERPRISE_CUSTOM_DOMAIN
|
||||
FEATURES.ENTERPRISE_CUSTOM_DOMAIN,
|
||||
FEATURES.ENTERPRISE_MULTI_WORKSPACE
|
||||
];
|
||||
|
||||
export const PLANS_BY_FEATURE = {
|
||||
@@ -41,3 +43,22 @@ export const PLANS_BY_FEATURE = {
|
||||
[PLAN.EARLY_ADOPTER]: EARYL_ADOPTER_FEATURES,
|
||||
[PLAN.ENTERPRISE]: ENTERPRISE_FEATURES
|
||||
};
|
||||
|
||||
/**
|
||||
* Total workspaces (primary + secondaries) allowed per plan.
|
||||
* Multi-workspace is an Enterprise-only feature in v1.
|
||||
*/
|
||||
export const WORKSPACES_INCLUDED: Record<string, number> = {
|
||||
[PLAN.BASIC]: 1,
|
||||
[PLAN.EARLY_ADOPTER]: 1,
|
||||
[PLAN.ENTERPRISE]: 4
|
||||
};
|
||||
|
||||
export function getWorkspaceAllowance(planName: string | null | undefined): number {
|
||||
if (!planName) return WORKSPACES_INCLUDED[PLAN.BASIC];
|
||||
return WORKSPACES_INCLUDED[planName] ?? WORKSPACES_INCLUDED[PLAN.BASIC];
|
||||
}
|
||||
|
||||
export function canCreateWorkspaces(planName: string | null | undefined): boolean {
|
||||
return planName === PLAN.ENTERPRISE;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './account';
|
||||
export * from './profile';
|
||||
export * from './workspaces';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { blockedSubdomain } from '@cio/utils/constants';
|
||||
|
||||
export const ZCreateWorkspace = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(5)
|
||||
.refine((val) => !/^[-]|[-]$/.test(val), {
|
||||
message: 'Workspace name cannot start or end with a hyphen'
|
||||
}),
|
||||
siteName: z
|
||||
.string()
|
||||
.min(5)
|
||||
.refine((val) => !/^[-]|[-]$/.test(val), {
|
||||
message: 'Site name cannot start or end with a hyphen'
|
||||
})
|
||||
.refine((val) => !blockedSubdomain.includes(val), {
|
||||
message: 'Sitename already exists.'
|
||||
})
|
||||
});
|
||||
|
||||
export type TCreateWorkspace = z.infer<typeof ZCreateWorkspace>;
|
||||
|
||||
export const ZWorkspaceIdParam = z.object({
|
||||
workspaceId: z.uuid()
|
||||
});
|
||||
|
||||
export type TWorkspaceIdParam = z.infer<typeof ZWorkspaceIdParam>;
|
||||
@@ -102,6 +102,14 @@ export const ZAgentHistorySaveBody = z.object({
|
||||
|
||||
export type TAgentHistorySaveBody = z.infer<typeof ZAgentHistorySaveBody>;
|
||||
|
||||
// ─── PATCH /agent/history/:conversationId (rename only) ────────────────────
|
||||
|
||||
export const ZAgentHistoryRenameBody = z.object({
|
||||
title: z.string().min(1).max(120)
|
||||
});
|
||||
|
||||
export type TAgentHistoryRenameBody = z.infer<typeof ZAgentHistoryRenameBody>;
|
||||
|
||||
// ─── DELETE /agent/history/:conversationId ───────────────────────────────────
|
||||
|
||||
export const ZAgentHistoryDeleteParam = z.object({
|
||||
@@ -130,3 +138,24 @@ export const ZAgentGenerateCourseTitleBody = z.object({
|
||||
});
|
||||
|
||||
export type TAgentGenerateCourseTitleBody = z.infer<typeof ZAgentGenerateCourseTitleBody>;
|
||||
|
||||
// ─── POST /agent/summarize ────────────────────────────────────────────────────
|
||||
|
||||
export const ZAgentSummarizeBody = z.object({
|
||||
messages: z.array(z.any()),
|
||||
courseId: ZAgentCourseId
|
||||
});
|
||||
|
||||
export type TAgentSummarizeBody = z.infer<typeof ZAgentSummarizeBody>;
|
||||
|
||||
// ─── POST /agent/generate-text ───────────────────────────────────────────────
|
||||
|
||||
export const ZAgentGenerateTextBody = z.object({
|
||||
prompt: z.string().min(1).max(1000),
|
||||
tone: z.enum(['professional', 'casual', 'expert', 'friendly']),
|
||||
format: z.enum(['plain', 'html']).default('plain'),
|
||||
context: z.string().max(500).optional(),
|
||||
courseId: z.string().uuid().optional()
|
||||
});
|
||||
|
||||
export type TAgentGenerateTextBody = z.infer<typeof ZAgentGenerateTextBody>;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './agent';
|
||||
export * from './tutor-config';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schema for the resolved AI tutor settings shape.
|
||||
*
|
||||
* Mirrors `AiTutorSettings` exported from @cio/ai-assistant. Used for org-level
|
||||
* settings updates and as the source for `ZCourseAiTutorOverride` (which is the
|
||||
* `.partial()` variant plus an `inheritFromOrg` flag).
|
||||
*/
|
||||
|
||||
export const TutorPersonaIdSchema = z.enum(['friendly', 'formal', 'encouraging', 'socratic', 'custom']);
|
||||
export const TutorResponseLengthSchema = z.enum(['short', 'medium', 'long']);
|
||||
export const TutorAssessmentModeSchema = z.enum(['direct_answer', 'hint_only', 'block_during_exercise']);
|
||||
export const TutorCodePolicySchema = z.enum(['allowed', 'hints_only', 'forbidden']);
|
||||
export const TutorGroundingScopeSchema = z.enum(['lesson', 'course', 'workspace']);
|
||||
|
||||
const ZAiTutorEscalation = z.object({
|
||||
enabled: z.boolean(),
|
||||
email: z.union([z.literal(''), z.string().email()])
|
||||
});
|
||||
|
||||
export const ZAiTutorSettings = z.object({
|
||||
enabled: z.boolean(),
|
||||
persona: TutorPersonaIdSchema,
|
||||
customPersona: z.string().max(500).default(''),
|
||||
responseLength: TutorResponseLengthSchema,
|
||||
welcomeMessage: z.string().max(500).default(''),
|
||||
disclaimerFooter: z.string().max(200).default(''),
|
||||
thingsToSay: z.string().max(2000).default(''),
|
||||
thingsNotToSay: z.string().max(2000).default(''),
|
||||
forbiddenTopics: z.array(z.string().min(1).max(60)).max(20).default([]),
|
||||
groundingScope: TutorGroundingScopeSchema,
|
||||
requireCitations: z.boolean(),
|
||||
assessmentMode: TutorAssessmentModeSchema,
|
||||
revealSolutionsAfterAttempts: z.number().int().min(0).max(20),
|
||||
codePolicy: TutorCodePolicySchema,
|
||||
blockOffTopic: z.boolean(),
|
||||
profanityFilter: z.boolean(),
|
||||
escalation: ZAiTutorEscalation
|
||||
});
|
||||
|
||||
export type TAiTutorSettings = z.infer<typeof ZAiTutorSettings>;
|
||||
|
||||
/** Partial — used for org-level PUT and as the base for course override. */
|
||||
export const ZAiTutorSettingsUpdate = ZAiTutorSettings.partial();
|
||||
export type TAiTutorSettingsUpdate = z.infer<typeof ZAiTutorSettingsUpdate>;
|
||||
|
||||
/** Per-course override: any subset of settings + an inheritance flag. */
|
||||
export const ZCourseAiTutorOverride = ZAiTutorSettings.partial().extend({
|
||||
inheritFromOrg: z.boolean().optional()
|
||||
});
|
||||
export type TCourseAiTutorOverride = z.infer<typeof ZCourseAiTutorOverride>;
|
||||
|
||||
// ─── Fair-use admin queries ──────────────────────────────────────────────────
|
||||
|
||||
export const ZTutorUsagePeriod = z.enum(['current', 'previous', 'last90']);
|
||||
export type TTutorUsagePeriod = z.infer<typeof ZTutorUsagePeriod>;
|
||||
|
||||
export const ZTutorUsageQuery = z.object({
|
||||
period: ZTutorUsagePeriod.default('current'),
|
||||
search: z.string().max(120).optional(),
|
||||
sort: z.enum(['messages', 'tokens', 'capPct']).default('messages'),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20)
|
||||
});
|
||||
|
||||
export type TTutorUsageQuery = z.infer<typeof ZTutorUsageQuery>;
|
||||
|
||||
export const ZTutorUsageUserParam = z.object({
|
||||
userId: z.string().uuid()
|
||||
});
|
||||
|
||||
export type TTutorUsageUserParam = z.infer<typeof ZTutorUsageUserParam>;
|
||||
@@ -1,5 +1,11 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
/**
|
||||
* Background motion options for the public course callout.
|
||||
*/
|
||||
export const ZCourseCalloutAnimation = z.enum(['waves', 'dotted', 'none']);
|
||||
export type TCourseCalloutAnimation = z.infer<typeof ZCourseCalloutAnimation>;
|
||||
|
||||
/**
|
||||
* Creator-configured callout shown on public course lesson pages.
|
||||
* Rendered inline at the bottom of every item body, and as the full replacement
|
||||
@@ -9,7 +15,8 @@ export const ZCourseCallout = z.object({
|
||||
title: z.string().min(1).max(120),
|
||||
description: z.string().min(1).max(500),
|
||||
buttonLabel: z.string().min(1).max(40),
|
||||
buttonUrl: z.string().url()
|
||||
buttonUrl: z.string().url(),
|
||||
animation: ZCourseCalloutAnimation.default('waves')
|
||||
});
|
||||
export type TCourseCallout = z.infer<typeof ZCourseCallout>;
|
||||
|
||||
|
||||
@@ -46,16 +46,42 @@ export const ZCourseDownloadParam = z.object({
|
||||
});
|
||||
export type TCourseDownloadParam = z.infer<typeof ZCourseDownloadParam>;
|
||||
|
||||
export const ZCertificateDownload = z.object({
|
||||
theme: z.string().min(1),
|
||||
studentName: z.string().min(1),
|
||||
courseName: z.string().min(1),
|
||||
courseDescription: z.string().min(1),
|
||||
orgName: z.string().min(1),
|
||||
orgLogoUrl: z.string().url().optional(),
|
||||
facilitator: z.string().optional()
|
||||
/**
|
||||
* Per-course certificate design. Stored on `course.certificate.design`.
|
||||
* The 5 supported template ids match `@cio/certificates`.
|
||||
*/
|
||||
export const ZCertificateSignatory = z.object({
|
||||
name: z.string().max(80).default(''),
|
||||
role: z.string().max(80).default('')
|
||||
});
|
||||
export type TCertificateDownload = z.infer<typeof ZCertificateDownload>;
|
||||
export type TCertificateSignatory = z.infer<typeof ZCertificateSignatory>;
|
||||
|
||||
export const ZCertificateTemplateId = z.enum(['classique', 'brutalist', 'noir', 'poster', 'minimal']);
|
||||
export type TCertificateTemplateId = z.infer<typeof ZCertificateTemplateId>;
|
||||
|
||||
export const ZCertificateDesign = z.object({
|
||||
templateId: ZCertificateTemplateId,
|
||||
accentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, { message: 'Accent must be a 6-digit hex color' }),
|
||||
subtitle: z.string().max(120).optional(),
|
||||
descriptionOverride: z.string().max(500).optional(),
|
||||
signatories: z.tuple([ZCertificateSignatory, ZCertificateSignatory]),
|
||||
idFormat: z.string().max(40).optional()
|
||||
});
|
||||
export type TCertificateDesign = z.infer<typeof ZCertificateDesign>;
|
||||
|
||||
/**
|
||||
* Body for `POST /:courseId/download/certificate(/png)`.
|
||||
* The server reads design + course + org from the DB; the client only sends
|
||||
* the per-recipient fields it cannot infer.
|
||||
*/
|
||||
export const ZCertificateDownloadRequest = z.object({
|
||||
studentName: z.string().min(1).max(120),
|
||||
studentId: z.string().min(1).max(64).optional(),
|
||||
issuedAt: z.string().datetime().optional(),
|
||||
/** When true, skips the eligibility check (course owner previewing their own design). */
|
||||
previewMode: z.boolean().optional()
|
||||
});
|
||||
export type TCertificateDownloadRequest = z.infer<typeof ZCertificateDownloadRequest>;
|
||||
|
||||
export const ZCourseDownloadContent = z.object({
|
||||
courseTitle: z.string().min(1),
|
||||
@@ -321,7 +347,10 @@ export type TCourseLandingPageMetadataUpdate = z.infer<typeof ZCourseLandingPage
|
||||
|
||||
export const ZCertificationSettings = z.object({
|
||||
isDownloadable: z.boolean().optional(),
|
||||
/** Legacy theme id (one of the 6 original themes). Kept for read compatibility; new courses use `design.templateId`. */
|
||||
theme: z.string().optional(),
|
||||
/** Atelier-era certificate design (template, accent, signatories, subtitle, etc.). */
|
||||
design: ZCertificateDesign.optional(),
|
||||
/** ISO 8601 datetime string or null to clear */
|
||||
deadline: z.union([z.string().min(1), z.null()]).optional(),
|
||||
threshold: z.number().int().min(0).max(100).optional(),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const ZDashAnalyticsRange = z.object({
|
||||
orgId: z.string().uuid(),
|
||||
days: z.coerce.number().int().min(1).max(365).default(30)
|
||||
});
|
||||
|
||||
export const ZDashCourseFunnel = z.object({
|
||||
orgId: z.string().uuid(),
|
||||
days: z.coerce.number().int().min(1).max(365).default(30),
|
||||
courseId: z.string().uuid().optional()
|
||||
});
|
||||
|
||||
export const ZDashComplianceOverview = z.object({
|
||||
orgId: z.string().uuid()
|
||||
});
|
||||
|
||||
export type TDashAnalyticsRange = z.infer<typeof ZDashAnalyticsRange>;
|
||||
export type TDashCourseFunnel = z.infer<typeof ZDashCourseFunnel>;
|
||||
export type TDashComplianceOverview = z.infer<typeof ZDashComplianceOverview>;
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './stats';
|
||||
export * from './login-activity';
|
||||
export * from './track';
|
||||
export * from './analytics';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
const CLIENT_EVENT_TYPES = [
|
||||
'landing_view',
|
||||
'course_page_view',
|
||||
'signup_view',
|
||||
'signin_view',
|
||||
'pricing_view',
|
||||
'cta_click'
|
||||
] as const;
|
||||
|
||||
export const ZTrackEvent = z.object({
|
||||
eventType: z.enum(CLIENT_EVENT_TYPES),
|
||||
occurredAt: z.string().optional(),
|
||||
orgId: z.string().uuid().optional(),
|
||||
courseId: z.string().uuid().optional(),
|
||||
path: z.string().max(2048).optional(),
|
||||
referrerHost: z.string().max(255).optional(),
|
||||
utmSource: z.string().max(128).optional(),
|
||||
utmMedium: z.string().max(128).optional(),
|
||||
utmCampaign: z.string().max(128).optional(),
|
||||
locale: z.string().max(8).optional(),
|
||||
props: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional()
|
||||
});
|
||||
|
||||
export const ZIngestBatch = z.object({
|
||||
sessionId: z.string().min(8).max(128),
|
||||
events: z.array(ZTrackEvent).min(1).max(50)
|
||||
});
|
||||
|
||||
export const ZDeidentifyUser = z.object({
|
||||
userId: z.string().uuid()
|
||||
});
|
||||
|
||||
export type TTrackEvent = z.infer<typeof ZTrackEvent>;
|
||||
export type TIngestBatch = z.infer<typeof ZIngestBatch>;
|
||||
export type TDeidentifyUser = z.infer<typeof ZDeidentifyUser>;
|
||||
@@ -20,3 +20,4 @@ export * from './dash';
|
||||
export * from './tag';
|
||||
export * from './program';
|
||||
export * from './widget';
|
||||
export * from './jobs';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './jobs';
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const ZJobIdParam = z.object({
|
||||
jobId: z.string().uuid()
|
||||
});
|
||||
export type TJobIdParam = z.infer<typeof ZJobIdParam>;
|
||||
|
||||
export const ZAssetJobsParam = z.object({
|
||||
assetId: z.string().uuid()
|
||||
});
|
||||
export type TAssetJobsParam = z.infer<typeof ZAssetJobsParam>;
|
||||
|
||||
export const ZJobBatchQuery = z.object({
|
||||
/** Comma-separated list of job ids; capped at 50 to keep URLs reasonable. */
|
||||
ids: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((value) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
.pipe(z.array(z.string().uuid()).min(1).max(50))
|
||||
});
|
||||
export type TJobBatchQuery = z.infer<typeof ZJobBatchQuery>;
|
||||
@@ -11,3 +11,18 @@ export const ZMediaImagePresignUrlUpload = z.object({
|
||||
fileType: z.enum(ALLOWED_IMAGE_TYPES)
|
||||
});
|
||||
export type TMediaImagePresignUrlUpload = z.infer<typeof ZMediaImagePresignUrlUpload>;
|
||||
|
||||
export const ZTranscriptSegment = z.object({
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
text: z.string()
|
||||
});
|
||||
|
||||
export const ZTranscriptResponse = z.object({
|
||||
language: z.string(),
|
||||
segments: z.array(ZTranscriptSegment),
|
||||
vttUrl: z.string().min(1),
|
||||
vttUrlExpiresAt: z.string(),
|
||||
durationSeconds: z.number().nullable()
|
||||
});
|
||||
export type TTranscriptResponse = z.infer<typeof ZTranscriptResponse>;
|
||||
|
||||
@@ -69,3 +69,117 @@ export const ZCreateProgramNewsfeedComment = z.object({
|
||||
content: z.string().min(1)
|
||||
});
|
||||
export type TCreateProgramNewsfeedComment = z.infer<typeof ZCreateProgramNewsfeedComment>;
|
||||
|
||||
export const ZInviteStudentsToProgram = z.object({
|
||||
recipientCsv: z.string().min(1).max(25000),
|
||||
sendEmail: z.boolean().default(true)
|
||||
});
|
||||
export type TInviteStudentsToProgram = z.infer<typeof ZInviteStudentsToProgram>;
|
||||
|
||||
export const ZAssignExistingStudentsToProgram = z.object({
|
||||
profileIds: z.array(z.string().uuid()).min(1).max(500),
|
||||
sendEmail: z.boolean().default(true)
|
||||
});
|
||||
export type TAssignExistingStudentsToProgram = z.infer<typeof ZAssignExistingStudentsToProgram>;
|
||||
|
||||
// ─── Program Goals ────────────────────────────────────────────────────────────
|
||||
|
||||
export const PROGRAM_GOAL_TYPES = ['complete_all', 'n_of_m', 'score', 'pass_rate', 'readiness'] as const;
|
||||
export const PROGRAM_GOAL_DEADLINE_KINDS = ['absolute', 'relative_to_join', 'recurring', 'none'] as const;
|
||||
|
||||
const goalCommonFields = {
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
courseIds: z.array(z.string().uuid()).max(50),
|
||||
reminderDaysBefore: z.array(z.number().int().min(0).max(365)).max(5).default([7, 1])
|
||||
};
|
||||
|
||||
const goalDeadlineFields = {
|
||||
deadlineKind: z.enum(PROGRAM_GOAL_DEADLINE_KINDS).default('none'),
|
||||
deadlineDate: z.string().datetime().nullable().optional(),
|
||||
relativeDays: z.number().int().min(1).max(3650).nullable().optional(),
|
||||
recurringMonths: z.number().int().min(1).max(120).nullable().optional()
|
||||
};
|
||||
|
||||
/**
|
||||
* Goal config across the five v1 types. We keep this as one schema (not a
|
||||
* discriminated union) so the form layer can update fields incrementally; the
|
||||
* service layer enforces the per-type required fields.
|
||||
*/
|
||||
export const ZCreateProgramGoal = z
|
||||
.object({
|
||||
type: z.enum(PROGRAM_GOAL_TYPES),
|
||||
requiredCount: z.number().int().min(1).max(50).nullable().optional(),
|
||||
scoreThreshold: z.number().int().min(0).max(100).nullable().optional(),
|
||||
teamPassRateThreshold: z.number().int().min(0).max(100).nullable().optional(),
|
||||
...goalCommonFields,
|
||||
...goalDeadlineFields
|
||||
})
|
||||
.refine((data) => data.courseIds.length > 0, {
|
||||
message: 'At least one course is required',
|
||||
path: ['courseIds']
|
||||
})
|
||||
.refine((data) => data.type !== 'n_of_m' || (data.requiredCount && data.requiredCount > 0), {
|
||||
message: 'requiredCount is required for n_of_m goals',
|
||||
path: ['requiredCount']
|
||||
})
|
||||
.refine((data) => data.type !== 'n_of_m' || !data.requiredCount || data.requiredCount <= data.courseIds.length, {
|
||||
message: 'requiredCount cannot exceed selected courses',
|
||||
path: ['requiredCount']
|
||||
})
|
||||
.refine((data) => data.type !== 'score' || (data.scoreThreshold !== null && data.scoreThreshold !== undefined), {
|
||||
message: 'scoreThreshold is required for score goals',
|
||||
path: ['scoreThreshold']
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.type !== 'pass_rate' ||
|
||||
(data.scoreThreshold !== null &&
|
||||
data.scoreThreshold !== undefined &&
|
||||
data.teamPassRateThreshold !== null &&
|
||||
data.teamPassRateThreshold !== undefined),
|
||||
{
|
||||
message: 'scoreThreshold and teamPassRateThreshold are required for pass_rate goals',
|
||||
path: ['teamPassRateThreshold']
|
||||
}
|
||||
)
|
||||
.refine((data) => data.deadlineKind !== 'absolute' || !!data.deadlineDate, {
|
||||
message: 'deadlineDate is required for absolute deadlines',
|
||||
path: ['deadlineDate']
|
||||
})
|
||||
.refine((data) => data.deadlineKind !== 'relative_to_join' || !!data.relativeDays, {
|
||||
message: 'relativeDays is required for relative-to-join deadlines',
|
||||
path: ['relativeDays']
|
||||
})
|
||||
.refine((data) => data.deadlineKind !== 'recurring' || !!data.recurringMonths, {
|
||||
message: 'recurringMonths is required for recurring deadlines',
|
||||
path: ['recurringMonths']
|
||||
})
|
||||
.refine((data) => data.type !== 'readiness' || data.deadlineKind === 'none', {
|
||||
message: 'Readiness goals do not support deadlines',
|
||||
path: ['deadlineKind']
|
||||
});
|
||||
|
||||
export type TCreateProgramGoal = z.infer<typeof ZCreateProgramGoal>;
|
||||
|
||||
export const ZUpdateProgramGoal = z
|
||||
.object({
|
||||
type: z.enum(PROGRAM_GOAL_TYPES).optional(),
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
courseIds: z.array(z.string().uuid()).max(50).optional(),
|
||||
requiredCount: z.number().int().min(1).max(50).nullable().optional(),
|
||||
scoreThreshold: z.number().int().min(0).max(100).nullable().optional(),
|
||||
teamPassRateThreshold: z.number().int().min(0).max(100).nullable().optional(),
|
||||
reminderDaysBefore: z.array(z.number().int().min(0).max(365)).max(5).optional(),
|
||||
deadlineKind: z.enum(PROGRAM_GOAL_DEADLINE_KINDS).optional(),
|
||||
deadlineDate: z.string().datetime().nullable().optional(),
|
||||
relativeDays: z.number().int().min(1).max(3650).nullable().optional(),
|
||||
recurringMonths: z.number().int().min(1).max(120).nullable().optional(),
|
||||
status: z.enum(['active', 'archived']).optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
message: 'No fields to update'
|
||||
});
|
||||
|
||||
export type TUpdateProgramGoal = z.infer<typeof ZUpdateProgramGoal>;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
ZWidgetPayload,
|
||||
getLayoutCourseLimit,
|
||||
normalizeWidgetConfig,
|
||||
type TWidgetConfig,
|
||||
type TWidgetEditorCourse,
|
||||
type TWidgetLayoutType,
|
||||
type TWidgetPayload,
|
||||
type TWidgetPayloadCategory,
|
||||
type TWidgetPayloadCourseTag,
|
||||
type TWidgetPlanGatedFields,
|
||||
type TWidgetSelectionMode
|
||||
} from './widget';
|
||||
|
||||
export type BuildWidgetPayloadCourse = TWidgetEditorCourse;
|
||||
|
||||
export type BuildWidgetPayloadOrganization = {
|
||||
id: string;
|
||||
name: string;
|
||||
siteName: string;
|
||||
customDomain?: string | null;
|
||||
};
|
||||
|
||||
export type BuildWidgetPayloadWidget = {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
layoutType: TWidgetLayoutType;
|
||||
selectionMode: TWidgetSelectionMode;
|
||||
config: TWidgetConfig | Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type BuildWidgetPayloadInput = {
|
||||
widget: BuildWidgetPayloadWidget;
|
||||
organization: BuildWidgetPayloadOrganization;
|
||||
orgBaseUrl: string;
|
||||
allCourses: BuildWidgetPayloadCourse[];
|
||||
selectedCourseIds: string[];
|
||||
planGatedFields: TWidgetPlanGatedFields;
|
||||
planName?: string | null;
|
||||
/** Pre-sanitized custom CSS string. Pass '' to skip in preview contexts. */
|
||||
sanitizedCustomCss?: string;
|
||||
/** Override the "Powered by" footer label. */
|
||||
poweredByLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure, dependency-free assembly of a `TWidgetPayload` from already-loaded data.
|
||||
* Server callers pre-sanitize custom CSS and pass it via `sanitizedCustomCss`;
|
||||
* preview callers can omit it so the iframe renders the in-flight draft as-is.
|
||||
*/
|
||||
export function buildWidgetPayload(input: BuildWidgetPayloadInput): TWidgetPayload {
|
||||
const {
|
||||
widget,
|
||||
organization,
|
||||
orgBaseUrl,
|
||||
allCourses,
|
||||
selectedCourseIds,
|
||||
planGatedFields,
|
||||
planName,
|
||||
sanitizedCustomCss,
|
||||
poweredByLabel = 'Powered by Classroomio'
|
||||
} = input;
|
||||
|
||||
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, planName);
|
||||
const allPublishedCourses = allCourses.filter((course) => course.isPublished);
|
||||
const manualOrderLookup = new Map(selectedCourseIds.map((courseId, index) => [courseId, index]));
|
||||
|
||||
let resolvedCourses =
|
||||
widget.selectionMode === 'manual'
|
||||
? allCourses.filter((course) => manualOrderLookup.has(course.id))
|
||||
: allPublishedCourses;
|
||||
|
||||
const tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]> = {};
|
||||
for (const course of allCourses) {
|
||||
tagsByCourseId[course.id] = course.tags;
|
||||
}
|
||||
|
||||
if (normalizedConfig.filters.includeTagIds.length > 0 || normalizedConfig.filters.excludeTagIds.length > 0) {
|
||||
resolvedCourses = resolvedCourses.filter((course) => {
|
||||
const courseTagIds = new Set((tagsByCourseId[course.id] ?? []).map((tag) => tag.id));
|
||||
const includesRequired =
|
||||
normalizedConfig.filters.includeTagIds.length === 0 ||
|
||||
normalizedConfig.filters.includeTagIds.some((tagId) => courseTagIds.has(tagId));
|
||||
const excludesBlocked = normalizedConfig.filters.excludeTagIds.every((tagId) => !courseTagIds.has(tagId));
|
||||
|
||||
return includesRequired && excludesBlocked;
|
||||
});
|
||||
}
|
||||
|
||||
if (widget.selectionMode === 'manual' || normalizedConfig.sortBy === 'manual') {
|
||||
resolvedCourses = [...resolvedCourses].sort(
|
||||
(courseA, courseB) =>
|
||||
(manualOrderLookup.get(courseA.id) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(manualOrderLookup.get(courseB.id) ?? Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
} else if (normalizedConfig.sortBy === 'title') {
|
||||
resolvedCourses = [...resolvedCourses].sort((courseA, courseB) => courseA.title.localeCompare(courseB.title));
|
||||
} else {
|
||||
resolvedCourses = [...resolvedCourses].sort(
|
||||
(courseA, courseB) => new Date(courseB.createdAt ?? '').getTime() - new Date(courseA.createdAt ?? '').getTime()
|
||||
);
|
||||
}
|
||||
|
||||
const layoutType = widget.layoutType;
|
||||
const layoutOptions = normalizedConfig.layoutOptions;
|
||||
|
||||
if (layoutType === 'primary_course' && layoutOptions.primaryCourse.featuredCourseId) {
|
||||
const featuredId = layoutOptions.primaryCourse.featuredCourseId;
|
||||
const featuredIndex = resolvedCourses.findIndex((course) => course.id === featuredId);
|
||||
if (featuredIndex > 0) {
|
||||
const [featuredCourse] = resolvedCourses.splice(featuredIndex, 1);
|
||||
resolvedCourses = [featuredCourse, ...resolvedCourses];
|
||||
}
|
||||
} else if (layoutType === 'editorial_spotlight' && layoutOptions.editorialSpotlight.mainCourseId) {
|
||||
const mainId = layoutOptions.editorialSpotlight.mainCourseId;
|
||||
const mainIndex = resolvedCourses.findIndex((course) => course.id === mainId);
|
||||
if (mainIndex > 0) {
|
||||
const [mainCourse] = resolvedCourses.splice(mainIndex, 1);
|
||||
resolvedCourses = [mainCourse, ...resolvedCourses];
|
||||
}
|
||||
}
|
||||
|
||||
if (layoutType !== 'category_shelf') {
|
||||
const layoutLimit = getLayoutCourseLimit(layoutType, layoutOptions);
|
||||
resolvedCourses = resolvedCourses.slice(0, Math.max(layoutLimit, 1));
|
||||
} else {
|
||||
resolvedCourses = resolvedCourses.slice(0, getLayoutCourseLimit(layoutType, layoutOptions));
|
||||
}
|
||||
|
||||
const heroCourseId =
|
||||
layoutType === 'primary_course'
|
||||
? (layoutOptions.primaryCourse.featuredCourseId ?? resolvedCourses[0]?.id ?? null)
|
||||
: layoutType === 'editorial_spotlight'
|
||||
? (layoutOptions.editorialSpotlight.mainCourseId ?? resolvedCourses[0]?.id ?? null)
|
||||
: null;
|
||||
|
||||
const tagPool = buildTagPool(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId);
|
||||
const categories = buildCategoriesFromShelf(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId);
|
||||
|
||||
return ZWidgetPayload.parse({
|
||||
version: 'v1',
|
||||
widgetId: widget.id,
|
||||
publicKey: widget.publicKey,
|
||||
organization: {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
siteName: organization.siteName,
|
||||
customDomain: organization.customDomain
|
||||
},
|
||||
layoutType,
|
||||
selectionMode: widget.selectionMode,
|
||||
design: {
|
||||
...normalizedConfig,
|
||||
advanced: {
|
||||
...normalizedConfig.advanced,
|
||||
customCss: sanitizedCustomCss ?? normalizedConfig.advanced.customCss
|
||||
}
|
||||
},
|
||||
planGatedFields,
|
||||
labels: {
|
||||
loadMoreLabel: normalizedConfig.content.loadMoreLabel,
|
||||
poweredByLabel
|
||||
},
|
||||
courses: resolvedCourses.map((course) => ({
|
||||
id: course.id,
|
||||
slug: course.slug ?? course.id,
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
imageUrl: course.imageUrl ?? null,
|
||||
price: course.price ?? 'Free',
|
||||
lessonCount: course.lessonCount,
|
||||
exerciseCount: course.exerciseCount ?? 0,
|
||||
rating: null,
|
||||
ratingCount: 0,
|
||||
courseType: course.courseType,
|
||||
createdAt: course.createdAt ?? new Date().toISOString(),
|
||||
tags: tagsByCourseId[course.id] ?? [],
|
||||
featured: heroCourseId ? course.id === heroCourseId : false,
|
||||
url: `${orgBaseUrl}/course/${course.slug ?? course.id}`
|
||||
})),
|
||||
tagPool,
|
||||
categories,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
function buildTagPool(
|
||||
layoutType: TWidgetLayoutType,
|
||||
config: TWidgetConfig,
|
||||
courses: Array<{ id: string }>,
|
||||
tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]>
|
||||
): TWidgetPayloadCourseTag[] {
|
||||
if (layoutType !== 'tag_filter') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allTagsFromCourses = new Map<string, TWidgetPayloadCourseTag>();
|
||||
for (const course of courses) {
|
||||
for (const tag of tagsByCourseId[course.id] ?? []) {
|
||||
if (!allTagsFromCourses.has(tag.id)) {
|
||||
allTagsFromCourses.set(tag.id, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.layoutOptions.tagFilter.tagSource === 'manual') {
|
||||
const allowedIds = new Set(config.layoutOptions.tagFilter.manualTagIds);
|
||||
return [...allTagsFromCourses.values()].filter((tag) => allowedIds.has(tag.id));
|
||||
}
|
||||
|
||||
return [...allTagsFromCourses.values()];
|
||||
}
|
||||
|
||||
function buildCategoriesFromShelf(
|
||||
layoutType: TWidgetLayoutType,
|
||||
config: TWidgetConfig,
|
||||
courses: Array<{ id: string }>,
|
||||
tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]>
|
||||
): TWidgetPayloadCategory[] {
|
||||
if (layoutType !== 'category_shelf') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const orderedCategoryIds = config.layoutOptions.categoryShelf.categoryTagIds;
|
||||
if (orderedCategoryIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tagsById = new Map<string, TWidgetPayloadCourseTag>();
|
||||
for (const tags of Object.values(tagsByCourseId)) {
|
||||
for (const tag of tags) {
|
||||
tagsById.set(tag.id, tag);
|
||||
}
|
||||
}
|
||||
|
||||
const maxPerCategory = config.layoutOptions.categoryShelf.maxPerCategory;
|
||||
|
||||
return orderedCategoryIds
|
||||
.map((tagId) => {
|
||||
const tagInfo = tagsById.get(tagId);
|
||||
if (!tagInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchingCourses = courses.filter((course) =>
|
||||
(tagsByCourseId[course.id] ?? []).some((tag) => tag.id === tagId)
|
||||
);
|
||||
|
||||
return {
|
||||
tagId,
|
||||
name: tagInfo.name,
|
||||
slug: tagInfo.slug,
|
||||
color: tagInfo.color,
|
||||
courseIds: matchingCourses.slice(0, maxPerCategory).map((course) => course.id)
|
||||
} satisfies TWidgetPayloadCategory;
|
||||
})
|
||||
.filter((entry): entry is TWidgetPayloadCategory => entry !== null);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './widget';
|
||||
export * from './build-payload';
|
||||
export * from './google-fonts';
|
||||
|
||||
@@ -393,6 +393,13 @@ export const ZWidgetDetail = z.object({
|
||||
embedCode: z.string(),
|
||||
publicScriptUrl: z.string()
|
||||
}),
|
||||
organization: z.object({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
siteName: z.string(),
|
||||
customDomain: z.string().nullable().optional()
|
||||
}),
|
||||
orgBaseUrl: z.string(),
|
||||
availableCourses: z.array(ZWidgetEditorCourse),
|
||||
availableTags: z.array(
|
||||
z.object({
|
||||
@@ -403,9 +410,9 @@ export const ZWidgetDetail = z.object({
|
||||
groupId: z.uuid()
|
||||
})
|
||||
),
|
||||
previewPayload: ZWidgetPayload.nullable(),
|
||||
versions: z.array(ZWidgetVersionRecord),
|
||||
planGatedFields: ZWidgetPlanGatedFields
|
||||
planGatedFields: ZWidgetPlanGatedFields,
|
||||
planName: z.string().nullable()
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -496,13 +503,6 @@ export const ZRollbackWidget = z.object({
|
||||
versionId: z.uuid()
|
||||
});
|
||||
|
||||
export const ZPreviewWidget = z.object({
|
||||
layoutType: ZWidgetLayoutType.optional(),
|
||||
selectionMode: ZWidgetSelectionMode.optional(),
|
||||
config: ZWidgetConfig.optional(),
|
||||
selectedCourseIds: z.array(z.uuid()).optional()
|
||||
});
|
||||
|
||||
export const ZWidgetIdParams = z.object({
|
||||
widgetId: z.uuid()
|
||||
});
|
||||
@@ -536,7 +536,6 @@ export type TWidgetDetail = z.infer<typeof ZWidgetDetail>;
|
||||
export type TCreateWidget = z.infer<typeof ZCreateWidget>;
|
||||
export type TUpdateWidget = z.infer<typeof ZUpdateWidget>;
|
||||
export type TRollbackWidget = z.infer<typeof ZRollbackWidget>;
|
||||
export type TPreviewWidget = z.infer<typeof ZPreviewWidget>;
|
||||
|
||||
export function getDefaultWidgetConfig(): TWidgetConfig {
|
||||
return ZWidgetConfig.parse(DEFAULT_WIDGET_CONFIG);
|
||||
|
||||
Reference in New Issue
Block a user