feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@cio/ai-assistant",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./providers": {
"types": "./dist/providers/index.d.ts",
"default": "./dist/providers/index.js"
},
"./tools": {
"types": "./dist/tools/index.d.ts",
"default": "./dist/tools/index.js"
},
"./prompt": {
"types": "./dist/prompt/index.d.ts",
"default": "./dist/prompt/index.js"
}
},
"scripts": {
"build": "tsc && tsc-alias",
"dev": "tsc --watch",
"format": "prettier --write \"src/**/*.ts\""
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.69",
"@ai-sdk/google": "^3.0.0",
"@ai-sdk/openai": "^3.0.52",
"ai": "^6.0.158",
"zod": "^3.24.4"
},
"devDependencies": {
"@cio/tsconfig": "workspace:*",
"@types/node": "^24.10.1",
"prettier": "^3.2.5",
"tsc-alias": "^1.8.16",
"typescript": "^5.5.2"
}
}
+38
View File
@@ -0,0 +1,38 @@
// Types
export {
AgentRole,
AIProvider,
ToolName,
CoursePlanSchema,
CoursePlanSectionSchema,
CoursePlanItemSchema,
MAX_DOCUMENT_TEXT_LENGTH,
MAX_AGENT_DOCUMENT_SIZE,
DOCUMENT_REDIS_TTL,
MAX_STEPS_PER_ROUND,
SUPPORTED_DOCUMENT_TYPES,
SUPPORTED_DOCUMENT_EXTENSIONS,
TOKEN_COST_ESTIMATES
} from './types';
export type {
AIProviderConfig,
AgentContext,
CoursePlan,
CoursePlanSection,
CoursePlanItem,
TokenUsage,
TokenBalance,
AgentStatus,
DocumentUploadResult
} from './types';
// Providers
export { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from './providers';
// Tools
export { getToolSchemas } from './tools';
export type { ToolSchema } from './tools';
// Prompts
export { buildSystemPrompt, buildTeacherSystemPrompt, buildStudentSystemPrompt } from './prompt';
+21
View File
@@ -0,0 +1,21 @@
import { AgentRole, type AgentContext } from '../types';
import { buildTeacherSystemPrompt } from './teacher';
import { buildStudentSystemPrompt } from './student';
/**
* Builds a role-aware system prompt for the AI assistant.
* Teacher prompts include Plan/Agent mode instructions and content writing guidelines.
* Student prompts (v2) are read-only focused.
*/
export function buildSystemPrompt(context: AgentContext): string {
switch (context.role) {
case AgentRole.TEACHER:
return buildTeacherSystemPrompt(context);
case AgentRole.STUDENT:
return buildStudentSystemPrompt(context);
default:
return buildTeacherSystemPrompt(context);
}
}
export { buildTeacherSystemPrompt, buildStudentSystemPrompt };
@@ -0,0 +1,25 @@
import type { AgentContext } from '../types';
/**
* Student system prompt (v2 stub).
* Will be implemented in Phase 5 when student assistant is enabled.
*/
export function buildStudentSystemPrompt(context: AgentContext): string {
return `You are an AI assistant for ClassroomIO, helping a student learn course material.
You can explain concepts, answer questions about the material, and help with exercises. You CANNOT modify any course content.
## Rules
- NEVER pretend to perform an action you don't have a tool for
- NEVER fabricate data you haven't retrieved via a tool
- Only use information from the course content to answer questions
- If the student asks about something not covered in the course, say so clearly
## Current Context
Course: "${context.courseTitle}" (ID: ${context.courseId})
${context.courseDescription ? `Course description: ${context.courseDescription}` : ''}
${context.lessonId ? `Currently viewing lesson ID: ${context.lessonId}` : ''}
${context.exerciseId ? `Currently viewing exercise ID: ${context.exerciseId}` : ''}`;
}
+214
View File
@@ -0,0 +1,214 @@
import type { AgentContext } from '../types';
export function buildTeacherSystemPrompt(context: AgentContext): string {
const contextLines: string[] = [];
contextLines.push(`Course: "${context.courseTitle}" (ID: ${context.courseId})`);
if (context.courseDescription) {
contextLines.push(`Course description: ${context.courseDescription}`);
}
if (context.lessonId) {
const lessonInfo = context.lessonTitle
? `The teacher is currently viewing lesson "${context.lessonTitle}" (ID: ${context.lessonId})`
: `The teacher is currently viewing lesson ID: ${context.lessonId}`;
contextLines.push(lessonInfo);
if (context.lessonContent) {
contextLines.push(`Current lesson content:\n\n<lesson_content>\n${context.lessonContent}\n</lesson_content>`);
} else {
contextLines.push('This lesson has no content yet.');
}
}
if (context.exerciseId) {
const exerciseInfo = context.exerciseTitle
? `The teacher is currently viewing exercise "${context.exerciseTitle}" (ID: ${context.exerciseId})`
: `The teacher is currently viewing exercise ID: ${context.exerciseId}`;
contextLines.push(exerciseInfo);
}
if (context.documentText) {
contextLines.push(
`The teacher has uploaded a document. Use this content as the source material for course planning and content generation:\n\n<document>\n${context.documentText}\n</document>`
);
}
if (context.existingSectionCount && context.existingSectionCount > 0) {
contextLines.push(
`This course already has ${context.existingSectionCount} sections. When creating new sections, set their order values starting after the existing sections.`
);
}
const currentContext = contextLines.length > 0 ? `\n\n## Current Context\n\n${contextLines.join('\n\n')}` : '';
return `You are an AI assistant for ClassroomIO, helping a teacher create and organize course content.
## Your Capabilities
You have access to specific tools listed below. Use them to read course content, create sections and lessons, update existing section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, edit exercise metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts) via update_exercise, add or update questions inside an existing exercise, update course landing-page copy/settings, check go-live readiness, and publish the course when it is ready. To change a question's text, points, options, or correct answer, use update_questions; to add new questions, use add_questions.
## Plan Mode vs Agent Mode
**Plan Mode** — When the teacher asks you to design a course structure, plan a course, or uploads a document:
1. Analyze the input (document content, topic description, or teacher's request). If the teacher has not provided additional details, use the course title (and description, if available) from the Current Context as the subject — do NOT ask the teacher what the course is about, you already have that information.
2. Use the generate_course_plan tool to propose a structured course plan with sections, lessons, and exercises
3. Each item has a type: "lesson" for content lessons, "exercise" for standalone quizzes/assessments
4. Place standalone exercises (like section quizzes) as separate items with type "exercise" at the end of a section — do NOT create them as lessons
5. For lessons that should also have a linked exercise, set hasExercise: true on the lesson item
6. Wait for the teacher to approve, request changes, or reject the plan
7. Do NOT create any sections, lessons, or exercises until the teacher explicitly approves
**Agent Mode** — When the teacher approves a plan or asks you to perform a specific action:
1. Execute the requested actions using the appropriate tools
2. When implementing an approved plan:
- Create sections first
- For each section, iterate through its items in order
- Items with type "lesson": use create_lesson, then update_lesson_content to write content
- Items with type "exercise": use create_exercise with quiz questions (MCQ, true/false, etc.)
- Items with type "lesson" and hasExercise: true: create the lesson, write content, then also create a linked exercise
3. If the teacher asks to rename or otherwise edit an existing section or lesson, use update_section or update_lesson on the existing item instead of creating a new one
4. Report progress as you go
5. When implementing an approved plan or adding net-new content, append new sections after existing ones. Do not modify existing content unless the teacher explicitly asked you to edit, rename, or reorganize existing items.
**Go-Live / Publishing** — When the teacher asks whether the course is ready, wants a launch checklist, or asks to go live:
1. Use check_course_go_live_readiness first to inspect required course details, landing-page fields, lessons, and exercises
2. If blockers are returned, explain the blockers clearly and use available tools to fix only the items the teacher asks you to fix
3. Use update_course_landing_page for course-level public copy, overview, goals, requirements, instructor metadata, pricing, and banner image fields
4. Use go_live_course only when the teacher explicitly asks to publish/go live; it runs the readiness checklist again and will fail if blockers remain
5. Never claim the course is live unless go_live_course returns success
### Ordering within a section
Lessons and exercises share the SAME 0-based order space within a section. When you create items in a section, increment \`order\` by 1 across both lessons and exercises in the sequence the teacher should encounter them. An end-of-section quiz must have a higher \`order\` than every lesson it follows — never reuse a lesson's order for an exercise, and never leave \`order\` unset when creating into a section.
Example for a section that ends with a recap quiz:
- create_lesson "Intro" → order 0
- create_lesson "Core concepts" → order 1
- create_lesson "Examples" → order 2
- create_exercise "Section quiz" → order 3
If a lesson has \`hasExercise: true\`, the linked exercise takes the next order after that lesson, and subsequent items shift up accordingly.
**IMPORTANT**: Quizzes and assessments MUST be created using create_exercise (not create_lesson). An exercise contains questions with answer options. A lesson contains text content. Never confuse the two.
## Updating Existing Metadata
- If the teacher asks to change a section name or order, first call get_course_structure, then use update_section with the existing section ID. Do NOT create a replacement section.
- If the teacher asks to change a lesson name, move a lesson to another section, reorder it, schedule it, or change its visibility/unlock settings, first call get_course_structure, then use update_lesson with the existing lesson ID. Do NOT create a replacement lesson.
- If the teacher asks to change exercise metadata such as title, description, linked lesson, section, order, due date, lock state, or multiple-attempt behavior, use update_exercise. Do NOT create a replacement exercise.
- Only use create_section or create_lesson when the teacher clearly wants a brand-new item added to the course.
## Editing Existing Questions
To change an existing question (its text, points, order, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Where the correct answer lives depends on the question type:
- RADIO, CHECKBOX, TRUE_FALSE: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option.
- NUMERIC: on \`settings.correctValue\` (a number). Optional \`settings.tolerance\`. Do NOT add options to NUMERIC questions.
- STAR: on \`settings.correctValue\` (1..max stars).
- WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`.
Always call get_exercise_details first to read current ids and settings before patching.
## IDs and Tool Arguments
- UUIDs and database IDs must be copied EXACTLY from the most recent tool output that produced them. Never rewrite, shorten, reformat, "fix", or invent IDs from memory or pattern-matching.
- NEVER pass placeholder strings like \`"string"\`, \`"uuid"\`, \`"<id>"\`, or example IDs from this prompt as tool arguments. If you don't have a real ID in your context, call get_course_structure first to fetch one.
- Before calling any tool that takes a sectionId, lessonId, or exerciseId you didn't just create yourself, call get_course_structure and copy the exact UUID from its response.
- After create_section / create_lesson / create_exercise returns, the \`id\` it returns is the only valid ID for that new resource — use that exact value, never a guess.
- If a tool call fails with "does not exist in this course", "is not a valid UUID", or "belongs to a different course": stop, call get_course_structure, and use the IDs from its response. Do NOT retry with another guessed ID.
- Do not restate raw lesson, exercise, or section IDs in user-facing text unless the teacher explicitly asks for the IDs.
- If a tool call fails repeatedly with ID errors, surface that to the teacher rather than continuing to guess.
## Content Writing Guidelines
When generating lesson content with update_lesson_content:
- Put only the lesson body in the content. Do NOT include the lesson title — ClassroomIO already renders it separately in the UI
- Do NOT use <h1> or <h2> anywhere in lesson HTML. Start headings at <h3> because that is the highest heading level allowed in lesson content
- Use only these HTML elements: <h3>, <h4>, <h5> for section headings, <p> for paragraphs, <ul><li> and <ol><li> for lists, <strong> for bold, <em> for italic, <blockquote> for callouts, <code> for inline code, <pre><code> for code blocks, <a href="..."> for links
- You may use inline <svg> elements to create diagrams, illustrations, or visual aids that help students understand concepts. Use descriptive shapes, labels, colors, and layout. Keep SVGs self-contained (no external references). Do NOT use <foreignObject> inside SVGs
- Do NOT use: <div>, <span>, <table>, <img>, <iframe>, <script>, <style>, or any custom elements
- Use headings to break content into scannable sections
- Include practical examples where relevant
- Match the depth to the lesson description — a "brief overview" should be shorter than a "deep dive"
### Depth target when generating a full course (Plan → Implement)
When implementing an approved course plan (i.e. you are filling out lessons end-to-end, not making a one-off edit), default to **comprehensive, in-depth lessons** rather than summaries. Each lesson should fully teach its topic so a student could learn from it without external reading. Use this as the baseline:
- 1,500–3,000 words per standard lesson; longer ("deep dive") lessons may run to 4,000+ words
- An <h3> introduction (1–2 short paragraphs) framing why the topic matters and what the student will be able to do after the lesson
- 3–6 sub-sections, each opened with an <h3> or <h4>, that walk through the concept step by step. Each sub-section should include explanation + at least one concrete example, worked problem, code snippet, mini case study, or annotated diagram (inline <svg>) — not just bullet points
- A "Common pitfalls" or "Key takeaways" sub-section at the end summarizing what students should remember
- Avoid filler. Prefer specificity (real examples, real numbers, real code) over abstractions. Do not pad word count with restatement
- If the topic is genuinely thin, prefer fewer but richer lessons over many shallow ones — say so to the teacher rather than producing skeletal content
For a one-off edit on an existing lesson, match the depth the teacher asks for; do not unilaterally rewrite an existing 600-word lesson into 3,000 words.
## Exercise Quality Bar
When you create an exercise (especially during plan implementation), it must actually verify understanding, not just acknowledge that the lesson was read. Apply these rules:
### Number of questions
- Default to **6–10 questions per exercise** (more for long or content-heavy lessons, fewer only when the lesson is intentionally narrow).
- Cover the full lesson, not just the first section. Spread questions across every <h3>/<h4> sub-section.
- Mix levels of difficulty: ~30% recall, ~50% applied/conceptual, ~20% analytical or scenario-based.
### Options per question (RADIO and CHECKBOX)
- **Minimum of 4 options** per RADIO question; **at least one correct** option, the rest plausible distractors.
- **Minimum of 4 options** per CHECKBOX (multi-select) question; at least 2 correct and at least 1 incorrect distractor.
- Distractors must be plausible — represent common misconceptions or near-miss answers, not obviously wrong filler. Avoid joke options, "all of the above" / "none of the above" as the correct answer, or distractors that are syntactic restatements of the correct one.
- Keep options roughly the same length and grammatical structure; do not let the correct answer stand out by being the longest or most detailed.
### Question writing
- Each question must reference something specific the lesson taught (a concept, a worked example, a definition, a step in a procedure). Do not write questions whose answer cannot be derived from the lesson.
- Prefer scenario / "what would happen if…" / "why does X work this way?" phrasing over rote definition lookup, except for foundational vocabulary checks.
- For TRUE_FALSE: the statement should target a real misconception, not a trivial fact. Use sparingly.
- For NUMERIC / STAR / WORD_BANK: still ensure the answer is unambiguously derivable from the lesson.
### Per-exercise structure
- Vary question types in the same exercise (e.g. mix RADIO, CHECKBOX, and TRUE_FALSE) — do not produce 10 RADIO questions in a row unless that's genuinely the right format.
- Set non-zero \`points\` per question (default 1; harder questions can be 2).
## Locale
Default to locale "${context.locale}" when creating or updating lesson content. If the teacher requests a specific language, use that locale instead.
## Confirmation Before Changes
- When drafting a new lesson, present the full draft in your response and ask the teacher to confirm before creating it
- When updating existing lesson content, show the proposed changes and ask for confirmation before applying
- For plan execution (after explicit approval), proceed without asking for confirmation on each step
- For additive actions like generating questions, execute directly and confirm after
## Linking to Created or Updated Content
After creating or updating a lesson or exercise, always include a clickable link to it in your response using this exact syntax:
- Lesson: \`@[Lesson Title](lesson:LESSON_ID)\`
- Exercise: \`@[Exercise Title](exercise:EXERCISE_ID)\`
Use the actual title and ID returned by the tool. These render as clickable navigation links for the teacher.
Example: "I've written the content for @[Introduction to Python](lesson:abc123). Click it to review."
## Rules
- NEVER pretend to perform an action you don't have a tool for
- NEVER fabricate data you haven't retrieved via a tool
- NEVER claim to have made a change unless a tool call returned success
- NEVER paraphrase or regenerate UUIDs; copy them exactly from tool output only
- When a user asks for something outside your capabilities, respond clearly: "I can't do that yet. [Brief reason]. Here's what I can help with: [closest available action]."
- Always preserve existing content when updating lessons — only modify the specific section the teacher asked about
## Things You CANNOT Do
- Cannot create, delete, or clone courses
- Cannot delete sections, lessons, or exercises
- Cannot manage course members, invitations, or roles
- Cannot grade submissions or assign marks
- Cannot handle payments or attendance
- Cannot upload files, videos, or documents to lessons (but CAN generate inline SVG diagrams)
- Cannot generate raster images (PNG, JPG, GIF, etc.). If the teacher asks for an image or picture, explain that you cannot generate images but you can create an inline SVG diagram to visually illustrate the concept
- Cannot manage org settings, members, or billing
- Cannot access data from other courses or organizations
- Cannot browse the internet or fetch external URLs
- Cannot send emails or notifications${currentContext}`;
}
@@ -0,0 +1,67 @@
import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { AIProvider, type AIProviderConfig } from '../types';
const DEFAULT_MODELS: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'gpt-4o',
[AIProvider.ANTHROPIC]: 'claude-sonnet-4-20250514',
[AIProvider.GOOGLE]: 'gemini-2.5-flash'
};
const PROVIDER_API_KEY_ENV: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'OPENAI_API_KEY',
[AIProvider.ANTHROPIC]: 'ANTHROPIC_API_KEY',
[AIProvider.GOOGLE]: 'GOOGLE_API_KEY'
};
/**
* Creates an AI SDK LanguageModel from provider configuration.
* Normalizes OpenAI, Anthropic, and Google into a single interface for streamText().
*/
export function createModel(config: AIProviderConfig) {
const modelName = config.model || DEFAULT_MODELS[config.provider];
switch (config.provider) {
case AIProvider.OPENAI: {
const openai = createOpenAI({ apiKey: config.apiKey });
return openai(modelName);
}
case AIProvider.ANTHROPIC: {
const anthropic = createAnthropic({ apiKey: config.apiKey });
return anthropic(modelName);
}
case AIProvider.GOOGLE: {
const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
return google(modelName);
}
default:
throw new Error(`Unsupported AI provider: ${config.provider}`);
}
}
/**
* Reads the API key for a specific provider from its dedicated env var.
* Returns null when the key is unset, so callers can decide whether to 503.
*/
export function getProviderConfigForProvider(provider: AIProvider): AIProviderConfig | null {
const apiKey = process.env[PROVIDER_API_KEY_ENV[provider]];
if (!apiKey) return null;
return { provider, apiKey };
}
/**
* Returns the first provider that has a key configured, in preference order.
* Used by routes that don't take an explicit model (status check, title generation).
*/
export function pickAnyConfiguredProvider(): AIProviderConfig | null {
const order: AIProvider[] = [AIProvider.GOOGLE, AIProvider.OPENAI, AIProvider.ANTHROPIC];
for (const provider of order) {
const config = getProviderConfigForProvider(provider);
if (config) return config;
}
return null;
}
+86
View File
@@ -0,0 +1,86 @@
import { AgentRole, ToolName } from '../types';
import type { ZodType } from 'zod';
import { getCourseStructureSchema, getLessonContentSchema, getExerciseDetailsSchema } from './shared';
import {
createSectionSchema,
updateSectionSchema,
createLessonSchema,
updateLessonSchema,
updateLessonContentSchema,
createExerciseSchema,
updateExerciseSchema,
addQuestionsSchema,
updateQuestionsSchema,
updateCourseLandingPageSchema,
checkCourseGoLiveReadinessSchema,
goLiveCourseSchema,
generateCoursePlanSchema
} from './teacher';
export type ToolSchema = {
description: string;
parameters: ZodType;
};
type ToolRegistry = Record<string, ToolSchema>;
const sharedTools: ToolRegistry = {
[ToolName.GET_COURSE_STRUCTURE]: getCourseStructureSchema,
[ToolName.GET_LESSON_CONTENT]: getLessonContentSchema,
[ToolName.GET_EXERCISE_DETAILS]: getExerciseDetailsSchema
};
const teacherTools: ToolRegistry = {
[ToolName.CREATE_SECTION]: createSectionSchema,
[ToolName.UPDATE_SECTION]: updateSectionSchema,
[ToolName.CREATE_LESSON]: createLessonSchema,
[ToolName.UPDATE_LESSON]: updateLessonSchema,
[ToolName.UPDATE_LESSON_CONTENT]: updateLessonContentSchema,
[ToolName.CREATE_EXERCISE]: createExerciseSchema,
[ToolName.UPDATE_EXERCISE]: updateExerciseSchema,
[ToolName.ADD_QUESTIONS]: addQuestionsSchema,
[ToolName.UPDATE_QUESTIONS]: updateQuestionsSchema,
[ToolName.UPDATE_COURSE_LANDING_PAGE]: updateCourseLandingPageSchema,
[ToolName.CHECK_COURSE_GO_LIVE_READINESS]: checkCourseGoLiveReadinessSchema,
[ToolName.GO_LIVE_COURSE]: goLiveCourseSchema,
[ToolName.GENERATE_COURSE_PLAN]: generateCoursePlanSchema
};
// v2: student tools will be added here
const studentTools: ToolRegistry = {};
/**
* Returns tool schemas available for a given role.
* Teachers get shared + teacher tools.
* Students get shared + student tools (v2).
*/
export function getToolSchemas(role: AgentRole): ToolRegistry {
switch (role) {
case AgentRole.TEACHER:
return { ...sharedTools, ...teacherTools };
case AgentRole.STUDENT:
return { ...sharedTools, ...studentTools };
default:
return sharedTools;
}
}
// Re-export individual schemas for direct access
export {
getCourseStructureSchema,
getLessonContentSchema,
getExerciseDetailsSchema,
createSectionSchema,
updateSectionSchema,
createLessonSchema,
updateLessonSchema,
updateLessonContentSchema,
createExerciseSchema,
updateExerciseSchema,
addQuestionsSchema,
updateQuestionsSchema,
updateCourseLandingPageSchema,
checkCourseGoLiveReadinessSchema,
goLiveCourseSchema,
generateCoursePlanSchema
};
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod';
/**
* Read-only tool schemas available to both teacher and student roles.
*/
export const getCourseStructureSchema = {
description:
'Get the full course structure including sections, lessons, and exercises as a tree. Use this to understand what content already exists before creating new content.',
parameters: z.object({
courseId: z.string().describe('The course ID')
})
};
export const getLessonContentSchema = {
description:
'Get the HTML content of a specific lesson. Use this to read existing lesson text before updating it or to understand the writing style.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
lessonId: z.string().describe('The lesson ID to read'),
locale: z.string().default('en').describe('The locale to read content in')
})
};
export const getExerciseDetailsSchema = {
description:
'Get an exercise with all its questions and answer options. Use this to understand existing questions before adding new ones.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The exercise ID to read')
})
};
@@ -0,0 +1,7 @@
/**
* Student tool schemas (v2 stubs).
* These will be implemented in Phase 5 when student assistant is enabled.
* For now, this file exists to establish the pattern and keep the registry clean.
*/
// v2: explain_concept, quiz_me, clarify_exercise, get_my_progress, get_my_submissions
+311
View File
@@ -0,0 +1,311 @@
import { z } from 'zod';
import { CoursePlanSchema } from '../types';
/**
* Mutation tool schemas available only to teachers.
*/
export const createSectionSchema = {
description:
'Create a new section in the course. Sections group lessons together. Use this when building a course structure from a plan.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
title: z.string().min(1).describe('Section title'),
order: z.number().int().min(0).describe('Display order (0-based). Set after existing sections when appending.')
})
};
export const updateSectionSchema = {
description:
'Update metadata for an existing section in the course. Use this to rename a section or change its display order instead of creating a new section.',
parameters: z
.object({
courseId: z.string().describe('The course ID'),
sectionId: z.string().describe('The section ID to update'),
title: z.string().min(1).optional().describe('New section title'),
order: z.number().int().min(0).optional().describe('New section display order (0-based)')
})
.refine((data) => data.title !== undefined || data.order !== undefined, {
message: 'Provide at least one field to update'
})
};
export const createLessonSchema = {
description:
'Create a new lesson within a section. The lesson is created with a title but no content — use update_lesson_content to add content after creation.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
sectionId: z.string().describe('The section ID to place the lesson in'),
title: z.string().min(1).describe('Lesson title'),
order: z
.number()
.int()
.min(0)
.describe(
'Display order within the section (0-based). Lessons and exercises share the SAME order space within a section, so increment by 1 across both. Example: lesson1=0, lesson2=1, exercise=2.'
)
})
};
export const updateLessonSchema = {
description:
'Update metadata for an existing lesson in the course. Use this to rename, move, reorder, schedule, or change visibility/unlock settings for a lesson instead of creating a new lesson.',
parameters: z
.object({
courseId: z.string().describe('The course ID'),
lessonId: z.string().describe('The lesson ID to update'),
title: z.string().min(1).optional().describe('New lesson title'),
sectionId: z.string().optional().describe('Move the lesson into a different section'),
order: z.number().int().min(0).optional().describe('New order within the section (0-based)'),
lessonAt: z.string().optional().describe('Scheduled lesson datetime'),
callUrl: z.string().optional().describe('Optional live session URL'),
isUnlocked: z.boolean().optional().describe('Whether the lesson is unlocked for students'),
public: z.boolean().optional().describe('Whether the lesson is public')
})
.refine(
(data) =>
data.title !== undefined ||
data.sectionId !== undefined ||
data.order !== undefined ||
data.lessonAt !== undefined ||
data.callUrl !== undefined ||
data.isUnlocked !== undefined ||
data.public !== undefined,
{
message: 'Provide at least one field to update'
}
)
};
export const updateLessonContentSchema = {
description:
'Update the text content of a lesson. Replaces the full lesson HTML for the given locale. When modifying only part of a lesson, always preserve all existing content and only change the specific section requested.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
lessonId: z.string().describe('The lesson ID to update'),
locale: z.string().default('en').describe('The locale to write content in'),
content: z
.string()
.min(1)
.describe(
'The full lesson HTML content. Use only: h2, h3, p, ul, ol, li, strong, em, blockquote, code, pre, a. Never use div, span, table, img, iframe, script, or style.'
)
})
};
export const createExerciseSchema = {
description:
'Create a new exercise with questions and answer options. Each question can be multiple choice (RADIO), multiple select (CHECKBOX), true/false, or other types. Quality bar: aim for 6-10 questions covering the full lesson, mixing recall, applied, and scenario-based questions; RADIO and CHECKBOX questions must have at least 4 plausible options.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
lessonId: z.string().optional().describe('Optional lesson ID to link the exercise to'),
sectionId: z.string().optional().describe('Optional section ID'),
title: z.string().min(1).describe('Exercise title'),
description: z.string().optional().describe('Optional short description shown to students above the questions.'),
order: z
.number()
.int()
.min(0)
.describe(
'Display order within the section (0-based). REQUIRED whenever sectionId is provided. Lessons and exercises share the SAME order space within a section, so an end-of-section quiz must have a higher order than every lesson in that section. Example: lesson1=0, lesson2=1, end-of-section exercise=2.'
),
questions: z
.array(
z.object({
question: z.string().min(1).describe('The question text'),
questionTypeId: z
.number()
.int()
.min(1)
.max(14)
.default(1)
.describe('Question type: 1=RADIO (single answer), 2=CHECKBOX (multiple), 4=TRUE_FALSE, 3=TEXTAREA'),
points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'),
options: z
.array(
z.object({
label: z.string().min(1).describe('Option text'),
isCorrect: z.boolean().describe('Whether this is a correct answer')
})
)
.describe(
'Answer options. RADIO and CHECKBOX questions MUST have at least 4 options with plausible distractors (not obvious filler). RADIO needs exactly one correct option; CHECKBOX needs at least 2 correct and at least 1 incorrect. TRUE_FALSE uses exactly 2 options. Leave empty for NUMERIC/STAR/WORD_BANK.'
)
})
)
.min(1)
.describe(
'Aim for 6-10 questions per exercise when assessing a full lesson. Spread across all sub-topics, mix difficulty, and vary question types.'
)
})
};
export const updateExerciseSchema = {
description:
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this to edit the exercise itself — not its questions. To change questions, use update_questions; to add questions, use add_questions.",
parameters: z
.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The exercise to update. Must be a real UUID returned by a prior tool call.'),
title: z.string().min(1).optional().describe('New exercise title. Omit to keep unchanged.'),
description: z
.string()
.optional()
.describe('New short description shown to students. Pass an empty string to clear it.'),
lessonId: z.string().optional().describe('Link the exercise to a lesson in this course'),
sectionId: z.string().optional().describe('Place the exercise in a section in this course'),
order: z.number().int().min(0).optional().describe('New order within the section (0-based)'),
dueBy: z
.string()
.optional()
.describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'),
isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'),
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.')
})
.refine(
(data) =>
data.title !== undefined ||
data.description !== undefined ||
data.lessonId !== undefined ||
data.sectionId !== undefined ||
data.order !== undefined ||
data.dueBy !== undefined ||
data.isUnlocked !== undefined ||
data.allowMultipleAttempts !== undefined,
{
message: 'Provide at least one field to update'
}
)
};
export const addQuestionsSchema = {
description:
'Add questions to an existing exercise. Use this when the exercise already exists and you want to add more questions.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
questions: z.array(
z.object({
question: z.string().min(1).describe('The question text'),
questionTypeId: z
.number()
.int()
.min(1)
.max(14)
.default(1)
.describe('Question type: 1=RADIO, 2=CHECKBOX, 4=TRUE_FALSE, 3=TEXTAREA'),
points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'),
options: z.array(
z.object({
label: z.string().min(1).describe('Option text'),
isCorrect: z.boolean().describe('Whether this is a correct answer')
})
)
})
)
})
};
export const updateQuestionsSchema = {
description:
'Update existing questions in an exercise. Pass only the fields you want to change; `id` identifies the question. For NUMERIC the correct answer is `settings.correctValue` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue` (1..max). For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect`: include an option `id` to edit it, omit `id` to add a new one. `settings` is shallow-merged with the existing settings. Omit `options` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
questions: z.array(
z.object({
id: z.number().int().describe('Existing question id. Required.'),
question: z.string().min(1).optional().describe('New question text. Omit to keep unchanged.'),
questionTypeId: z.number().int().min(1).max(14).optional(),
points: z.number().min(0).optional(),
order: z.number().int().min(0).optional(),
settings: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }. Shallow-merged with existing settings.'
),
options: z
.array(
z.object({
id: z
.number()
.int()
.optional()
.describe('Include to update an existing option; omit to create a new one.'),
label: z.string().min(1).optional(),
isCorrect: z.boolean().optional(),
settings: z.record(z.string(), z.unknown()).optional()
})
)
.optional()
})
)
})
};
export const updateCourseLandingPageSchema = {
description:
'Update public course landing-page fields, including title, description, overview, goals, requirements, instructor details, pricing, and banner image.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
title: z.string().min(1).optional().describe('Public course title'),
description: z.string().min(1).optional().describe('Short public course description'),
overview: z.string().optional().describe('Landing-page overview copy'),
cost: z.number().int().min(0).optional().describe('Course price in the selected currency'),
currency: z.enum(['NGN', 'USD']).optional().describe('Course price currency'),
imageUrl: z.string().url().optional().describe('Banner image URL to use directly'),
generateImage: z.boolean().optional().describe('Whether to generate/select a banner image from the image query'),
imageQuery: z.string().min(1).max(120).optional().describe('Search query for a generated/selected banner image'),
metadata: z
.object({
requirements: z.string().optional(),
description: z.string().optional(),
goals: z.string().optional(),
videoUrl: z.string().optional(),
showDiscount: z.boolean().optional(),
discount: z.number().optional(),
paymentLink: z.string().optional(),
instructor: z
.object({
name: z.string().optional(),
role: z.string().optional(),
coursesNo: z.number().optional(),
description: z.string().optional(),
imgUrl: z.string().optional()
})
.optional()
})
.optional()
.describe('Landing-page metadata fields')
})
};
export const checkCourseGoLiveReadinessSchema = {
description:
'Check whether a course is ready to publish. Returns blockers, warnings, suggested fixes, and a public course URL when available. Does not publish the course.',
parameters: z.object({
courseId: z.string().describe('The course ID')
})
};
export const goLiveCourseSchema = {
description:
'Publish the course after the teacher explicitly asks to go live. Runs readiness checks first and fails with blockers if required fields or content are missing.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
confirmPublish: z.literal(true).describe('Must be true after explicit teacher confirmation to publish')
})
};
export const generateCoursePlanSchema = {
description:
'Generate a structured course plan with sections and lessons based on a topic or uploaded document. Returns a JSON plan that the teacher can review and approve before any content is created. Always use this tool when the teacher asks you to design, structure, or plan a course.',
parameters: z.object({
plan: CoursePlanSchema.describe(
'The proposed course structure with sections and lessons. Each lesson includes a short description and whether it should have an exercise.'
)
})
};
+171
View File
@@ -0,0 +1,171 @@
import { z } from 'zod';
// ─── Agent Roles ─────────────────────────────────────────────────────────────
export const AgentRole = {
TEACHER: 'teacher',
STUDENT: 'student'
} as const;
export type AgentRole = (typeof AgentRole)[keyof typeof AgentRole];
// ─── AI Provider Configuration ───────────────────────────────────────────────
export const AIProvider = {
OPENAI: 'openai',
ANTHROPIC: 'anthropic',
GOOGLE: 'google'
} as const;
export type AIProvider = (typeof AIProvider)[keyof typeof AIProvider];
export interface AIProviderConfig {
provider: AIProvider;
apiKey: string;
model?: string;
}
// ─── Agent Context (passed with each chat message) ───────────────────────────
export interface AgentContext {
orgId: string;
courseId: string;
courseTitle: string;
courseDescription?: string;
userId: string;
role: AgentRole;
locale: string;
lessonId?: string;
lessonTitle?: string;
lessonContent?: string;
exerciseId?: string;
exerciseTitle?: string;
documentId?: string;
documentText?: string;
existingSectionCount?: number;
}
// ─── Course Plan (generated by the agent in Plan Mode) ───────────────────────
export const CoursePlanItemSchema = z.object({
type: z.enum(['lesson', 'exercise']).describe("'lesson' for content lessons, 'exercise' for quizzes/assessments"),
title: z.string().min(1),
description: z.string().min(1),
order: z.number().int().min(0),
hasExercise: z
.boolean()
.default(false)
.describe('Only for lessons: whether to also create an exercise linked to this lesson')
});
export const CoursePlanSectionSchema = z.object({
title: z.string().min(1),
order: z.number().int().min(0),
items: z
.array(CoursePlanItemSchema)
.min(1)
.describe(
'Ordered list of lessons and exercises in this section. Use type "exercise" for standalone quizzes/assessments at the end of a section.'
)
});
export const CoursePlanSchema = z.object({
title: z.string().min(1),
sections: z.array(CoursePlanSectionSchema).min(1)
});
export type CoursePlanItem = z.infer<typeof CoursePlanItemSchema>;
export type CoursePlanSection = z.infer<typeof CoursePlanSectionSchema>;
export type CoursePlan = z.infer<typeof CoursePlanSchema>;
// ─── Tool Names ──────────────────────────────────────────────────────────────
export const ToolName = {
// Read tools (shared)
GET_COURSE_STRUCTURE: 'get_course_structure',
GET_LESSON_CONTENT: 'get_lesson_content',
GET_EXERCISE_DETAILS: 'get_exercise_details',
// Write tools (teacher only)
CREATE_SECTION: 'create_section',
UPDATE_SECTION: 'update_section',
CREATE_LESSON: 'create_lesson',
UPDATE_LESSON: 'update_lesson',
UPDATE_LESSON_CONTENT: 'update_lesson_content',
CREATE_EXERCISE: 'create_exercise',
UPDATE_EXERCISE: 'update_exercise',
ADD_QUESTIONS: 'add_questions',
UPDATE_QUESTIONS: 'update_questions',
UPDATE_COURSE_LANDING_PAGE: 'update_course_landing_page',
CHECK_COURSE_GO_LIVE_READINESS: 'check_course_go_live_readiness',
GO_LIVE_COURSE: 'go_live_course',
// Agent tools
GENERATE_COURSE_PLAN: 'generate_course_plan'
} as const;
export type ToolName = (typeof ToolName)[keyof typeof ToolName];
// ─── Token Usage ─────────────────────────────────────────────────────────────
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface TokenBalance {
used: number;
allowance: number;
creditBalance: number;
remaining: number;
}
// ─── Agent Status (returned by GET /agent/status) ────────────────────────────
export interface AgentStatus {
enabled: boolean;
role: AgentRole;
usage: TokenBalance;
}
// ─── Document Upload ─────────────────────────────────────────────────────────
export interface DocumentUploadResult {
documentId: string;
fileName: string;
mimeType: string;
pageCount: number | null;
wordCount: number;
textPreview: string;
truncated: boolean;
}
// ─── Constants ───────────────────────────────────────────────────────────────
/** Maximum characters of extracted document text to include in LLM context */
export const MAX_DOCUMENT_TEXT_LENGTH = 100_000;
/** Maximum file size for document upload (5MB) */
export const MAX_AGENT_DOCUMENT_SIZE = 5 * 1024 * 1024;
/** Redis TTL for uploaded document text (1 hour) */
export const DOCUMENT_REDIS_TTL = 3600;
/** Maximum steps per streamText() round */
export const MAX_STEPS_PER_ROUND = 15;
/** Supported MIME types for document upload */
export const SUPPORTED_DOCUMENT_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
] as const;
/** Supported file extensions for document upload */
export const SUPPORTED_DOCUMENT_EXTENSIONS = ['.pdf', '.docx', '.pptx'] as const;
/** Token cost estimation heuristics (tokens per item) */
export const TOKEN_COST_ESTIMATES = {
SECTION_CREATE: 500,
LESSON_CREATE_AND_CONTENT: 3000,
EXERCISE_CREATE: 1500
} as const;
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "@cio/tsconfig/base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"module": "ES2022",
"target": "ES2020",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"],
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
}
}
+54
View File
@@ -61,3 +61,57 @@ It performs three steps in order:
**Optional:** Add `--seed` (or `-s`) to run the full seed, including demo users, organizations, courses, and other example data.
Requires `DATABASE_URL` or `PRIVATE_DATABASE_URL` in the environment.
## Seeding
The seed script populates the database with demo data for local development. It is idempotent — running it multiple times is safe; existing records are skipped.
### Run all seeds
```bash
pnpm seed
# or explicitly
pnpm seed --all
```
### Run individual seeds
Pass one or more flags to run only specific steps:
```bash
pnpm seed --users --profiles
pnpm seed --organizations --organization-members --organization-plan
```
| Flag | What it seeds |
| ------------------------ | ------------------------------------------------------ |
| `--roles` | ADMIN, TUTOR, STUDENT roles |
| `--submissions` | Submission statuses |
| `--question-types` | Question types |
| `--users` | Auth users (from `users.json`) |
| `--profiles` | User profiles |
| `--accounts` | User accounts |
| `--organizations` | Demo organizations |
| `--organization-members` | Org membership records |
| `--organization-plan` | Organization plan records (enterprise + early adopter) |
| `--groups` | Course groups |
| `--group-members` | Group membership records |
| `--courses` | Demo courses |
| `--sections` | Course sections |
| `--lessons` | Lessons |
| `--exercises` | Exercises |
| `--questions` | Questions |
| `--templates` | Exercise templates |
### Demo accounts
| Email | Password | Role | Plan |
| -------------------------------- | ---------- | ------- | ------------- |
| `test@test.com` | `password` | Admin | Basic (free) |
| `student@test.com` | `password` | Student | Basic (free) |
| `enterprise@test.com` | `password` | Admin | Enterprise |
| `enterprise-student@test.com` | `password` | Student | Enterprise |
| `early-adopter@test.com` | `password` | Admin | Early Adopter |
| `early-adopter-student@test.com` | `password` | Student | Early Adopter |
Mock user credentials are defined in `users.json`. The encrypted password above corresponds to the literal string `password`.
+30 -1
View File
@@ -2,6 +2,7 @@ import * as CONSTANTS from './constants';
import * as schema from '@db/schema';
import { admin, anonymous } from 'better-auth/plugins';
import { customSession } from 'better-auth/plugins/custom-session';
import { sendChangeEmailConfirmation, sendVerificationEmail } from './auth/email-verification';
import { betterAuth } from 'better-auth/minimal';
@@ -13,6 +14,8 @@ import { loginLink } from './auth/plugins/login-link';
import { sso } from '@better-auth/sso';
import { syncUserWithProfile } from './auth/hooks/sync-user';
import { tokenExchange } from './auth/plugins/token-exchange';
import { trackLoginHook } from './auth/hooks/track-login';
import { getUserOrgRolesMap } from './queries/organization/organization';
export const auth: ReturnType<typeof betterAuth> = betterAuth({
baseURL: CONSTANTS.BASE_URL,
@@ -76,6 +79,18 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
await syncUserWithProfile(user);
}
}
},
session: {
create: {
after: async (session) => {
await trackLoginHook(session);
}
},
update: {
after: async (session) => {
await trackLoginHook(session);
}
}
}
},
plugins: [
@@ -86,6 +101,20 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
// via the admin API (auth.api.registerSSOProvider)
}),
loginLink(),
tokenExchange()
tokenExchange(),
// Attaches the user's org memberships ({ [orgId]: roleId }) to the session
// so org-scoped middlewares can authorize without a per-request DB query.
// Refreshes when the session cookie cache expires (see session.cookieCache.maxAge).
customSession(async ({ user, session }) => {
let orgRoles: Record<string, number> = {};
try {
if (user?.id) {
orgRoles = await getUserOrgRolesMap(user.id);
}
} catch (error) {
console.error('customSession: failed to load orgRoles', error);
}
return { user, session, orgRoles };
})
]
});
+26
View File
@@ -0,0 +1,26 @@
import * as schema from '@db/schema';
import { Session } from 'better-auth';
import { db } from '@db/drizzle';
/**
* Records one login per user per day in analytics_login_events.
*
* Why: powers the org admin "most active login days" chart. The unique
* constraint on (user_id, logged_in_date) plus onConflictDoNothing keeps
* the row count at most one per user per calendar day.
*/
export const trackLoginHook = async (session: Pick<Session, 'userId'>) => {
if (!session?.userId) return;
try {
await db
.insert(schema.analyticsLoginEvents)
.values({ userId: session.userId })
.onConflictDoNothing({
target: [schema.analyticsLoginEvents.userId, schema.analyticsLoginEvents.loggedInDate]
});
} catch (error) {
console.error('trackLoginHook error:', error);
}
};
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS "ai_chat_document" (
"id" text PRIMARY KEY NOT NULL,
"conversation_id" uuid NOT NULL,
"course_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"asset_id" uuid,
"file_name" text NOT NULL,
"mime_type" text NOT NULL,
"text" text NOT NULL,
"word_count" integer DEFAULT 0 NOT NULL,
"page_count" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "ai_chat_document" ADD CONSTRAINT "ai_chat_document_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "ai_chat_conversation"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_ai_chat_document_conversation" ON "ai_chat_document" ("conversation_id");
@@ -1,2 +0,0 @@
CREATE INDEX "idx_course_group_id" ON "course" USING btree ("group_id");--> statement-breakpoint
CREATE INDEX "idx_group_organization_id" ON "group" USING btree ("organization_id");
@@ -1,101 +0,0 @@
CREATE TYPE "public"."COURSE_INVITE_EVENT_TYPE" AS ENUM('CREATED', 'REVOKED', 'PREVIEWED', 'ACCEPTED', 'EMAIL_SENT', 'EMAIL_FAILED', 'ABUSE_BLOCKED');--> statement-breakpoint
CREATE TYPE "public"."ORGANIZATION_INVITE_EVENT_TYPE" AS ENUM('CREATED', 'REVOKED', 'PREVIEWED', 'ACCEPTED', 'EMAIL_SENT', 'EMAIL_FAILED', 'ABUSE_BLOCKED');--> statement-breakpoint
CREATE TABLE "course_invite" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"course_id" uuid NOT NULL,
"role_id" bigint DEFAULT '3' NOT NULL,
"token_hash" text NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"revoked_by_profile_id" uuid,
"revoked_at" timestamp with time zone,
"expires_at" timestamp with time zone NOT NULL,
"max_uses" integer DEFAULT 1 NOT NULL,
"used_count" integer DEFAULT 0 NOT NULL,
"is_revoked" boolean DEFAULT false NOT NULL,
"allowed_emails" text[],
"allowed_domains" text[],
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"last_used_at" timestamp with time zone,
CONSTRAINT "course_invite_token_hash_key" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "course_invite_audit" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invite_id" uuid NOT NULL,
"course_id" uuid NOT NULL,
"event_type" "COURSE_INVITE_EVENT_TYPE" NOT NULL,
"actor_profile_id" uuid,
"target_email" varchar,
"ip_address" text,
"user_agent" text,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_invite" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"role_id" bigint NOT NULL,
"email" text NOT NULL,
"token_hash" text NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"accepted_by_profile_id" uuid,
"accepted_at" timestamp with time zone,
"revoked_by_profile_id" uuid,
"revoked_at" timestamp with time zone,
"expires_at" timestamp with time zone NOT NULL,
"is_revoked" boolean DEFAULT false NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "organization_invite_token_hash_key" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "organization_invite_audit" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invite_id" uuid NOT NULL,
"organization_id" uuid NOT NULL,
"event_type" "ORGANIZATION_INVITE_EVENT_TYPE" NOT NULL,
"actor_profile_id" uuid,
"target_email" varchar,
"ip_address" text,
"user_agent" text,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE "course_invite" ADD CONSTRAINT "course_invite_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite" ADD CONSTRAINT "course_invite_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite" ADD CONSTRAINT "course_invite_revoked_by_profile_id_fkey" FOREIGN KEY ("revoked_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite" ADD CONSTRAINT "course_invite_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite_audit" ADD CONSTRAINT "course_invite_audit_invite_id_fkey" FOREIGN KEY ("invite_id") REFERENCES "public"."course_invite"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite_audit" ADD CONSTRAINT "course_invite_audit_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite_audit" ADD CONSTRAINT "course_invite_audit_actor_profile_id_fkey" FOREIGN KEY ("actor_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite" ADD CONSTRAINT "organization_invite_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite" ADD CONSTRAINT "organization_invite_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite" ADD CONSTRAINT "organization_invite_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite" ADD CONSTRAINT "organization_invite_accepted_by_profile_id_fkey" FOREIGN KEY ("accepted_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite" ADD CONSTRAINT "organization_invite_revoked_by_profile_id_fkey" FOREIGN KEY ("revoked_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite_audit" ADD CONSTRAINT "organization_invite_audit_invite_id_fkey" FOREIGN KEY ("invite_id") REFERENCES "public"."organization_invite"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite_audit" ADD CONSTRAINT "organization_invite_audit_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite_audit" ADD CONSTRAINT "organization_invite_audit_actor_profile_id_fkey" FOREIGN KEY ("actor_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_course_invite_course_id" ON "course_invite" USING btree ("course_id");--> statement-breakpoint
CREATE INDEX "idx_course_invite_expires_at" ON "course_invite" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "idx_course_invite_created_by" ON "course_invite" USING btree ("created_by_profile_id");--> statement-breakpoint
CREATE INDEX "idx_course_invite_revoked_by" ON "course_invite" USING btree ("revoked_by_profile_id");--> statement-breakpoint
CREATE INDEX "idx_course_invite_audit_invite_id" ON "course_invite_audit" USING btree ("invite_id");--> statement-breakpoint
CREATE INDEX "idx_course_invite_audit_course_id" ON "course_invite_audit" USING btree ("course_id");--> statement-breakpoint
CREATE INDEX "idx_course_invite_audit_event_type" ON "course_invite_audit" USING btree ("event_type");--> statement-breakpoint
CREATE INDEX "idx_course_invite_audit_created_at" ON "course_invite_audit" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_organization_id" ON "organization_invite" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_email_org" ON "organization_invite" USING btree ("email","organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_expires_at" ON "organization_invite" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_created_by" ON "organization_invite" USING btree ("created_by_profile_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_accepted_by" ON "organization_invite" USING btree ("accepted_by_profile_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_revoked_by" ON "organization_invite" USING btree ("revoked_by_profile_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_audit_invite_id" ON "organization_invite_audit" USING btree ("invite_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_audit_org_id" ON "organization_invite_audit" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_audit_event_type" ON "organization_invite_audit" USING btree ("event_type");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_audit_created_at" ON "organization_invite_audit" USING btree ("created_at");
@@ -1,52 +0,0 @@
CREATE TABLE "assets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"kind" varchar DEFAULT 'video' NOT NULL,
"provider" varchar DEFAULT 'upload' NOT NULL,
"storage_provider" varchar DEFAULT 's3' NOT NULL,
"storage_key" text,
"source_url" text,
"mime_type" text,
"byte_size" bigint,
"checksum" text,
"title" text,
"description" text,
"thumbnail_url" text,
"duration_seconds" integer,
"aspect_ratio" text,
"is_external" boolean DEFAULT false NOT NULL,
"status" varchar DEFAULT 'active' NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb,
"created_by_profile_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "assets_org_provider_storage_key_unique" UNIQUE("organization_id","provider","storage_key")
);
--> statement-breakpoint
CREATE TABLE "asset_usages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"asset_id" uuid NOT NULL,
"target_type" varchar NOT NULL,
"target_id" text NOT NULL,
"slot_type" varchar DEFAULT 'lesson_video' NOT NULL,
"slot_key" text,
"position" integer,
"created_by_profile_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "asset_usages_asset_target_slot_unique" UNIQUE("asset_id","target_type","target_id","slot_type","slot_key","position")
);
--> statement-breakpoint
ALTER TABLE "assets" ADD CONSTRAINT "assets_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "assets" ADD CONSTRAINT "assets_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "asset_usages" ADD CONSTRAINT "asset_usages_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "asset_usages" ADD CONSTRAINT "asset_usages_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "asset_usages" ADD CONSTRAINT "asset_usages_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
CREATE INDEX "idx_assets_organization_id" ON "assets" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_assets_organization_status" ON "assets" USING btree ("organization_id","status");--> statement-breakpoint
CREATE INDEX "idx_assets_organization_kind_status" ON "assets" USING btree ("organization_id","kind","status");--> statement-breakpoint
CREATE INDEX "idx_assets_organization_created_at" ON "assets" USING btree ("organization_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_assets_organization_byte_size" ON "assets" USING btree ("organization_id","byte_size");--> statement-breakpoint
CREATE INDEX "idx_asset_usages_org_id" ON "asset_usages" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_asset_usages_asset_id" ON "asset_usages" USING btree ("asset_id");--> statement-breakpoint
CREATE INDEX "idx_asset_usages_target" ON "asset_usages" USING btree ("target_type","target_id");
@@ -1,184 +0,0 @@
CREATE TYPE "public"."AUTOMATION_USAGE_CATEGORY" AS ENUM('read', 'write', 'publish');--> statement-breakpoint
CREATE TYPE "public"."COURSE_IMPORT_DRAFT_STATUS" AS ENUM('DRAFT', 'PUBLISHED', 'ARCHIVED');--> statement-breakpoint
CREATE TYPE "public"."COURSE_IMPORT_SOURCE_TYPE" AS ENUM('prompt', 'pdf', 'course');--> statement-breakpoint
CREATE TYPE "public"."ORGANIZATION_API_KEY_TYPE" AS ENUM('mcp', 'api', 'zapier');--> statement-breakpoint
CREATE TYPE "public"."SSO_PROVIDER" AS ENUM('OKTA', 'GOOGLE_WORKSPACE', 'AUTH0');--> statement-breakpoint
CREATE TABLE "course_import_draft" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"source_type" "COURSE_IMPORT_SOURCE_TYPE" NOT NULL,
"status" "COURSE_IMPORT_DRAFT_STATUS" DEFAULT 'DRAFT' NOT NULL,
"title" varchar NOT NULL,
"locale" "LOCALE" DEFAULT 'en' NOT NULL,
"idempotency_key" varchar,
"summary" jsonb DEFAULT '{}'::jsonb NOT NULL,
"draft" jsonb NOT NULL,
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
"sourceArtifacts" jsonb DEFAULT '[]'::jsonb NOT NULL,
"published_course_id" uuid,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_api_key" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"type" "ORGANIZATION_API_KEY_TYPE" NOT NULL,
"label" varchar(120) NOT NULL,
"secret_prefix" varchar(32) NOT NULL,
"secret_hash" text NOT NULL,
"scopes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"last_used_at" timestamp with time zone,
"expires_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_auth_policy" (
"organization_id" uuid PRIMARY KEY NOT NULL,
"force_sso" boolean DEFAULT false NOT NULL,
"auto_join_sso_domains" boolean DEFAULT false NOT NULL,
"break_glass_enabled" boolean DEFAULT true NOT NULL,
"default_role_id" bigint DEFAULT '3' NOT NULL,
"role_mapping" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_automation_usage" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"organization_api_key_id" uuid,
"type" "ORGANIZATION_API_KEY_TYPE" NOT NULL,
"action" varchar(120) NOT NULL,
"category" "AUTOMATION_USAGE_CATEGORY" NOT NULL,
"credits_consumed" integer DEFAULT 0 NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_sso_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"better_auth_provider_id" text NOT NULL,
"provider" "SSO_PROVIDER" NOT NULL,
"display_name" text NOT NULL,
"domain" text NOT NULL,
"is_active" boolean DEFAULT false NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"updated_by_profile_id" uuid,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "organization_sso_config_organization_id_unique" UNIQUE("organization_id")
);
--> statement-breakpoint
CREATE TABLE "organization_token_auth" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"signing_secret" text NOT NULL,
"is_active" boolean DEFAULT false NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "organization_token_auth_organization_id_unique" UNIQUE("organization_id")
);
--> statement-breakpoint
CREATE TABLE "sso_provider" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"issuer" text NOT NULL,
"oidc_config" text,
"saml_config" text,
"user_id" uuid NOT NULL,
"provider_id" text NOT NULL,
"organization_id" text,
"domain" text NOT NULL,
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
CREATE TABLE "tag" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"group_id" uuid NOT NULL,
"name" varchar NOT NULL,
"slug" text NOT NULL,
"description" text,
"color" varchar NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "tag_org_slug_key" UNIQUE("organization_id","slug")
);
--> statement-breakpoint
CREATE TABLE "tag_assignment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tag_id" uuid NOT NULL,
"course_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "tag_assignment_tag_course_key" UNIQUE("tag_id","course_id")
);
--> statement-breakpoint
CREATE TABLE "tag_group" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" varchar NOT NULL,
"slug" text NOT NULL,
"description" text,
"order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "tag_group_org_slug_key" UNIQUE("organization_id","slug")
);
--> statement-breakpoint
ALTER TABLE "organization" ALTER COLUMN "theme" SET DEFAULT 'blue';--> statement-breakpoint
ALTER TABLE "option" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_signup" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_signup_message" text;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_email_password" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_google_auth" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "question" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
ALTER TABLE "question_answer" ADD COLUMN "answer_data" jsonb;--> statement-breakpoint
ALTER TABLE "submission" ADD COLUMN "grading_state" varchar DEFAULT 'queued' NOT NULL;--> statement-breakpoint
ALTER TABLE "submission" ADD COLUMN "overall_status" varchar DEFAULT 'manual_required' NOT NULL;--> statement-breakpoint
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_published_course_id_course_id_fk" FOREIGN KEY ("published_course_id") REFERENCES "public"."course"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_default_role_id_fkey" FOREIGN KEY ("default_role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_api_key_id_organization_api_key_id_fk" FOREIGN KEY ("organization_api_key_id") REFERENCES "public"."organization_api_key"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_updated_by_profile_id_profile_id_fk" FOREIGN KEY ("updated_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag" ADD CONSTRAINT "tag_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag" ADD CONSTRAINT "tag_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."tag_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag_group" ADD CONSTRAINT "tag_group_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_course_import_draft_org_id" ON "course_import_draft" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_course_import_draft_status" ON "course_import_draft" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "course_import_draft_org_idempotency_key" ON "course_import_draft" USING btree ("organization_id","idempotency_key") WHERE "course_import_draft"."idempotency_key" IS NOT NULL;--> statement-breakpoint
CREATE INDEX "idx_organization_api_key_org_id" ON "organization_api_key" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_api_key_type" ON "organization_api_key" USING btree ("type");--> statement-breakpoint
CREATE UNIQUE INDEX "organization_api_key_secret_hash_unique" ON "organization_api_key" USING btree ("secret_hash");--> statement-breakpoint
CREATE INDEX "idx_organization_auth_policy_org_id" ON "organization_auth_policy" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_org_automation_usage_org_created_at" ON "organization_automation_usage" USING btree ("organization_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_org_automation_usage_key_created_at" ON "organization_automation_usage" USING btree ("organization_api_key_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_org_automation_usage_type_created_at" ON "organization_automation_usage" USING btree ("type","created_at");--> statement-breakpoint
CREATE INDEX "idx_organization_sso_config_org_id" ON "organization_sso_config" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_sso_config_domain" ON "organization_sso_config" USING btree ("domain");--> statement-breakpoint
CREATE INDEX "idx_organization_sso_config_provider_id" ON "organization_sso_config" USING btree ("better_auth_provider_id");--> statement-breakpoint
CREATE INDEX "idx_organization_token_auth_org_id" ON "organization_token_auth" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_tag_organization_id" ON "tag" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_tag_group_id" ON "tag" USING btree ("group_id");--> statement-breakpoint
CREATE INDEX "idx_tag_assignment_tag_id" ON "tag_assignment" USING btree ("tag_id");--> statement-breakpoint
CREATE INDEX "idx_tag_assignment_course_id" ON "tag_assignment" USING btree ("course_id");--> statement-breakpoint
CREATE INDEX "idx_tag_group_organization_id" ON "tag_group" USING btree ("organization_id");--> statement-breakpoint
CREATE UNIQUE INDEX "organizationmember_org_profile_unique" ON "organizationmember" USING btree ("organization_id","profile_id") WHERE "organizationmember"."profile_id" IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "organizationmember_org_email_unique" ON "organizationmember" USING btree ("organization_id","email") WHERE "organizationmember"."email" IS NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -29,34 +29,6 @@
"when": 1769931903954,
"tag": "0003_fancy_harry_osborn",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1769982690239,
"tag": "0004_medical_true_believers",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1770622627497,
"tag": "0005_outgoing_doomsday",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1771176910627,
"tag": "0006_modern_the_initiative",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1774154467188,
"tag": "0007_rainy_vivisector",
"breakpoints": true
}
]
}
@@ -0,0 +1,96 @@
import { and, desc, eq, inArray } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
/** Cap on how many documents we keep per conversation. Older ones get pruned on insert. */
const MAX_DOCUMENTS_PER_CONVERSATION = 10;
export interface ChatDocumentRecord {
id: string;
conversationId: string;
courseId: string;
userId: string;
assetId: string | null;
fileName: string;
mimeType: string;
text: string;
wordCount: number;
pageCount: number | null;
createdAt: string;
}
export async function createChatDocument(record: {
id: string;
conversationId: string;
courseId: string;
userId: string;
assetId: string | null;
fileName: string;
mimeType: string;
text: string;
wordCount: number;
pageCount: number | null;
}): Promise<void> {
try {
await db.transaction(async (tx) => {
await tx.insert(schema.aiChatDocument).values(record);
const all = await tx
.select({ id: schema.aiChatDocument.id })
.from(schema.aiChatDocument)
.where(eq(schema.aiChatDocument.conversationId, record.conversationId))
.orderBy(desc(schema.aiChatDocument.createdAt));
if (all.length > MAX_DOCUMENTS_PER_CONVERSATION) {
const toDelete = all.slice(MAX_DOCUMENTS_PER_CONVERSATION).map((row) => row.id);
await tx.delete(schema.aiChatDocument).where(inArray(schema.aiChatDocument.id, toDelete));
}
});
} catch (error) {
console.error('createChatDocument error:', error);
throw new Error('Failed to persist chat document');
}
}
export async function getChatDocument(documentId: string): Promise<ChatDocumentRecord | null> {
try {
const [row] = await db
.select()
.from(schema.aiChatDocument)
.where(eq(schema.aiChatDocument.id, documentId))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getChatDocument error:', error);
throw new Error('Failed to fetch chat document');
}
}
export async function getChatDocumentsByIds(documentIds: string[]): Promise<ChatDocumentRecord[]> {
if (documentIds.length === 0) return [];
try {
return await db.select().from(schema.aiChatDocument).where(inArray(schema.aiChatDocument.id, documentIds));
} catch (error) {
console.error('getChatDocumentsByIds error:', error);
throw new Error('Failed to fetch chat documents');
}
}
export async function listChatDocumentsByConversation(
conversationId: string,
userId: string
): Promise<ChatDocumentRecord[]> {
try {
return await db
.select()
.from(schema.aiChatDocument)
.where(and(eq(schema.aiChatDocument.conversationId, conversationId), eq(schema.aiChatDocument.userId, userId)))
.orderBy(desc(schema.aiChatDocument.createdAt));
} catch (error) {
console.error('listChatDocumentsByConversation error:', error);
throw new Error('Failed to list chat documents');
}
}
@@ -0,0 +1,158 @@
import { and, eq, desc } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
const MAX_CONVERSATIONS_PER_COURSE = 50;
export interface ChatConversationSummary {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
}
// ─── List all conversations for a user in a course ───────────────────────────
export async function listChatConversations(courseId: string, userId: string): Promise<ChatConversationSummary[]> {
try {
const rows = await db
.select({
id: schema.aiChatConversation.id,
title: schema.aiChatConversation.title,
createdAt: schema.aiChatConversation.createdAt,
updatedAt: schema.aiChatConversation.updatedAt
})
.from(schema.aiChatConversation)
.where(and(eq(schema.aiChatConversation.courseId, courseId), eq(schema.aiChatConversation.userId, userId)))
.orderBy(desc(schema.aiChatConversation.updatedAt))
.limit(MAX_CONVERSATIONS_PER_COURSE);
return rows;
} catch (error) {
console.error('listChatConversations error:', error);
throw new Error('Failed to list chat conversations');
}
}
// ─── Get a single conversation with messages ─────────────────────────────────
export async function getChatConversation(conversationId: string, userId: string) {
try {
const [row] = await db
.select()
.from(schema.aiChatConversation)
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getChatConversation error:', error);
throw new Error('Failed to fetch chat conversation');
}
}
// ─── Create a new conversation ───────────────────────────────────────────────
export async function createChatConversation(
courseId: string,
userId: string,
title?: string
): Promise<{ id: string; title: string | null }> {
try {
const [row] = await db
.insert(schema.aiChatConversation)
.values({
courseId,
userId,
title: title || 'New conversation',
messages: []
})
.returning({ id: schema.aiChatConversation.id, title: schema.aiChatConversation.title });
return row;
} catch (error) {
console.error('createChatConversation error:', error);
throw new Error('Failed to create chat conversation');
}
}
// ─── Save messages to a conversation ─────────────────────────────────────────
export async function saveChatMessages(conversationId: string, userId: string, messages: unknown[], title?: string) {
try {
const updateData: Record<string, unknown> = {
messages,
updatedAt: new Date().toISOString()
};
if (title) {
updateData.title = title;
}
await db
.update(schema.aiChatConversation)
.set(updateData)
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)));
} catch (error) {
console.error('saveChatMessages error:', error);
throw new Error('Failed to save chat messages');
}
}
// ─── Delete a conversation ───────────────────────────────────────────────────
export async function deleteChatConversation(conversationId: string, userId: string) {
try {
await db
.delete(schema.aiChatConversation)
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)));
} catch (error) {
console.error('deleteChatConversation error:', error);
throw new Error('Failed to delete chat conversation');
}
}
// ─── Update conversation title ───────────────────────────────────────────────
export async function updateConversationTitle(conversationId: string, userId: string, title: string): Promise<void> {
try {
await db
.update(schema.aiChatConversation)
.set({ title, updatedAt: new Date().toISOString() })
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)));
} catch (error) {
console.error('updateConversationTitle error:', error);
throw new Error('Failed to update conversation title');
}
}
// ─── Legacy compat (keep old exports working during migration) ───────────────
export async function getChatHistory(courseId: string, userId: string) {
const conversations = await listChatConversations(courseId, userId);
if (conversations.length === 0) return [];
const latestConversation = await getChatConversation(conversations[0].id, userId);
return (latestConversation?.messages ?? []) as unknown[];
}
export async function saveChatHistory(courseId: string, userId: string, messages: unknown[]) {
const conversations = await listChatConversations(courseId, userId);
if (conversations.length > 0) {
await saveChatMessages(conversations[0].id, userId, messages);
} else {
const newConversation = await createChatConversation(courseId, userId);
await saveChatMessages(newConversation.id, userId, messages);
}
}
export async function deleteChatHistory(courseId: string, userId: string) {
const conversations = await listChatConversations(courseId, userId);
for (const conversation of conversations) {
await deleteChatConversation(conversation.id, userId);
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './chat-history';
export * from './chat-document';
@@ -0,0 +1,66 @@
import * as schema from '@db/schema';
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
export interface TSearchOrgAudienceMember {
memberId: number;
profileId: string | null;
name: string;
email: string;
avatarUrl: string | null;
createdAt: string;
}
export async function searchOrgAudience(
orgId: string,
search: string,
limit: number
): Promise<TSearchOrgAudienceMember[]> {
try {
const searchValue = `%${search.trim()}%`;
const audienceNameSql = sql<string>`COALESCE(NULLIF(${schema.profile.fullname}, ''), ${schema.profile.email}, ${schema.organizationmember.email})`;
const audienceEmailSql = sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`;
const audienceCreatedAtSql = sql<string>`COALESCE(${schema.profile.createdAt}, ${schema.organizationmember.createdAt})`;
const rows = await db
.select({
memberId: schema.organizationmember.id,
profileId: schema.profile.id,
name: audienceNameSql.as('name'),
email: audienceEmailSql.as('email'),
avatarUrl: schema.profile.avatarUrl,
createdAt: audienceCreatedAtSql.as('createdAt')
})
.from(schema.organizationmember)
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
eq(schema.organizationmember.roleId, ROLE.STUDENT),
or(
ilike(schema.profile.fullname, searchValue),
ilike(schema.profile.email, searchValue),
ilike(schema.organizationmember.email, searchValue)
)
)
)
.orderBy(desc(audienceCreatedAtSql), desc(schema.organizationmember.id))
.limit(limit);
return rows.map((row) => {
const email = row.email?.trim() ?? '';
const name = row.name?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
return {
...row,
name,
email
};
});
} catch (error) {
console.error('searchOrgAudience error:', error);
throw new Error(`Failed to search org audience: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -0,0 +1 @@
export * from './audience';
@@ -0,0 +1,474 @@
import * as schema from '@db/schema';
import { ROLE } from '@cio/utils/constants';
import type {
TCourse,
TCourseCertificateIssue,
TCourseCompletionNotificationEvent,
TCourseCompletionRecord,
TGroupmember,
TNewCourseCertificateIssue,
TNewCourseCompletionNotificationEvent,
TNewCourseCompletionRecord
} from '@db/types';
import { and, asc, desc, eq, inArray, isNotNull, lte, or, sql } from 'drizzle-orm';
import { db, type DbOrTxClient } from '@db/drizzle';
type CourseMemberProfile = {
id: string;
fullname: string | null;
username: string | null;
avatarUrl: string | null;
email: string | null;
};
export type CourseComplianceCurrentRow = {
member: TGroupmember;
profile: CourseMemberProfile | null;
record: TCourseCompletionRecord | null;
};
export type CourseComplianceReminderRow = {
course: TCourse;
member: TGroupmember;
profile: CourseMemberProfile | null;
record: TCourseCompletionRecord;
};
export async function getStudentCourseMembersForCompliance(
courseId: string,
profileIds?: string[]
): Promise<Array<{ member: TGroupmember; profile: CourseMemberProfile | null }>> {
try {
const conditions = [
eq(schema.course.id, courseId),
eq(schema.groupmember.roleId, ROLE.STUDENT),
isNotNull(schema.groupmember.profileId)
];
if (profileIds && profileIds.length > 0) {
conditions.push(inArray(schema.groupmember.profileId, profileIds));
}
const result = await db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
}
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(and(...conditions))
.orderBy(asc(schema.profile.fullname), asc(schema.groupmember.createdAt));
return result.map((row) => ({
member: row.member,
profile: row.profile ?? null
}));
} catch (error) {
console.error('getStudentCourseMembersForCompliance error:', error);
throw new Error(
`Failed to get student course members for compliance: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCourseCurrentComplianceRows(courseId: string): Promise<CourseComplianceCurrentRow[]> {
try {
const latestCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.where(eq(schema.courseCompletionRecord.courseId, courseId))
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_course_compliance_cycles');
const result = await db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
},
record: schema.courseCompletionRecord
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.leftJoin(
latestCycles,
and(eq(latestCycles.courseId, schema.course.id), eq(latestCycles.profileId, schema.groupmember.profileId))
)
.leftJoin(
schema.courseCompletionRecord,
and(
eq(schema.courseCompletionRecord.courseId, schema.course.id),
eq(schema.courseCompletionRecord.profileId, schema.groupmember.profileId),
eq(schema.courseCompletionRecord.cycleNumber, latestCycles.latestCycleNumber)
)
)
.where(
and(
eq(schema.course.id, courseId),
eq(schema.groupmember.roleId, ROLE.STUDENT),
isNotNull(schema.groupmember.profileId)
)
)
.orderBy(asc(schema.profile.fullname), asc(schema.groupmember.createdAt));
return result.map((row) => ({
member: row.member,
profile: row.profile ?? null,
record: row.record ?? null
}));
} catch (error) {
console.error('getCourseCurrentComplianceRows error:', error);
throw new Error(
`Failed to get course current compliance rows: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCourseComplianceHistoryRows(
courseId: string,
profileId: string
): Promise<{
member: TGroupmember | null;
profile: CourseMemberProfile | null;
records: TCourseCompletionRecord[];
}> {
try {
const [memberRow, records] = await Promise.all([
db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
}
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(
and(
eq(schema.course.id, courseId),
eq(schema.groupmember.roleId, ROLE.STUDENT),
eq(schema.groupmember.profileId, profileId)
)
)
.limit(1),
db
.select()
.from(schema.courseCompletionRecord)
.where(
and(
eq(schema.courseCompletionRecord.courseId, courseId),
eq(schema.courseCompletionRecord.profileId, profileId)
)
)
.orderBy(desc(schema.courseCompletionRecord.cycleNumber))
]);
return {
member: memberRow[0]?.member ?? null,
profile: memberRow[0]?.profile ?? null,
records
};
} catch (error) {
console.error('getCourseComplianceHistoryRows error:', error);
throw new Error(
`Failed to get course compliance history rows: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getLatestComplianceRecordsByProfiles(
courseId: string,
profileIds: string[]
): Promise<TCourseCompletionRecord[]> {
try {
if (profileIds.length === 0) {
return [];
}
const latestCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.where(
and(
eq(schema.courseCompletionRecord.courseId, courseId),
inArray(schema.courseCompletionRecord.profileId, profileIds)
)
)
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_course_compliance_cycles_by_profile');
return await db
.select({
id: schema.courseCompletionRecord.id,
courseId: schema.courseCompletionRecord.courseId,
groupMemberId: schema.courseCompletionRecord.groupMemberId,
profileId: schema.courseCompletionRecord.profileId,
cycleNumber: schema.courseCompletionRecord.cycleNumber,
status: schema.courseCompletionRecord.status,
dueDate: schema.courseCompletionRecord.dueDate,
startedAt: schema.courseCompletionRecord.startedAt,
completedAt: schema.courseCompletionRecord.completedAt,
validUntil: schema.courseCompletionRecord.validUntil,
expiredAt: schema.courseCompletionRecord.expiredAt,
score: schema.courseCompletionRecord.score,
attempts: schema.courseCompletionRecord.attempts,
timeSpentMinutes: schema.courseCompletionRecord.timeSpentMinutes,
waivedBy: schema.courseCompletionRecord.waivedBy,
waiverReason: schema.courseCompletionRecord.waiverReason,
waiverExpiresAt: schema.courseCompletionRecord.waiverExpiresAt,
createdAt: schema.courseCompletionRecord.createdAt,
updatedAt: schema.courseCompletionRecord.updatedAt
})
.from(schema.courseCompletionRecord)
.innerJoin(
latestCycles,
and(
eq(latestCycles.courseId, schema.courseCompletionRecord.courseId),
eq(latestCycles.profileId, schema.courseCompletionRecord.profileId),
eq(latestCycles.latestCycleNumber, schema.courseCompletionRecord.cycleNumber)
)
);
} catch (error) {
console.error('getLatestComplianceRecordsByProfiles error:', error);
throw new Error(
`Failed to get latest compliance records by profiles: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCourseCompletionRecord(
values: TNewCourseCompletionRecord,
dbClient: DbOrTxClient = db
): Promise<TCourseCompletionRecord> {
try {
const [created] = await dbClient.insert(schema.courseCompletionRecord).values(values).returning();
if (!created) {
throw new Error('Failed to create course completion record');
}
return created;
} catch (error) {
console.error('createCourseCompletionRecord error:', error);
throw new Error(
`Failed to create course completion record: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateCourseCompletionRecord(
recordId: string,
values: Partial<TNewCourseCompletionRecord>,
dbClient: DbOrTxClient = db
): Promise<TCourseCompletionRecord | null> {
try {
const [updated] = await dbClient
.update(schema.courseCompletionRecord)
.set({
...values,
updatedAt: new Date().toISOString()
})
.where(eq(schema.courseCompletionRecord.id, recordId))
.returning();
return updated ?? null;
} catch (error) {
console.error('updateCourseCompletionRecord error:', error);
throw new Error(
`Failed to update course completion record: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCourseCompletionNotificationEvent(
values: TNewCourseCompletionNotificationEvent,
dbClient: DbOrTxClient = db
): Promise<TCourseCompletionNotificationEvent | null> {
try {
const [created] = await dbClient
.insert(schema.courseCompletionNotificationEvent)
.values(values)
.onConflictDoNothing({
target: [
schema.courseCompletionNotificationEvent.courseCompletionRecordId,
schema.courseCompletionNotificationEvent.channel,
schema.courseCompletionNotificationEvent.eventType
]
})
.returning();
return created ?? null;
} catch (error) {
console.error('createCourseCompletionNotificationEvent error:', error);
throw new Error(
`Failed to create course completion notification event: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCourseCertificateIssue(
values: TNewCourseCertificateIssue,
dbClient: DbOrTxClient = db
): Promise<TCourseCertificateIssue | null> {
try {
const [created] = await dbClient
.insert(schema.courseCertificateIssue)
.values(values)
.onConflictDoNothing({
target: [schema.courseCertificateIssue.courseCompletionRecordId]
})
.returning();
return created ?? null;
} catch (error) {
console.error('createCourseCertificateIssue error:', error);
throw new Error(
`Failed to create course certificate issue: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateCourseCertificateIssueStatusByRecordId(
courseCompletionRecordId: string,
status: string,
dbClient: DbOrTxClient = db
): Promise<TCourseCertificateIssue | null> {
try {
const [updated] = await dbClient
.update(schema.courseCertificateIssue)
.set({
status
})
.where(eq(schema.courseCertificateIssue.courseCompletionRecordId, courseCompletionRecordId))
.returning();
return updated ?? null;
} catch (error) {
console.error('updateCourseCertificateIssueStatusByRecordId error:', error);
throw new Error(
`Failed to update course certificate issue status by record ID: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function listComplianceRecordsReadyForExpiry(nowIso: string): Promise<TCourseCompletionRecord[]> {
try {
return await db
.select({
record: schema.courseCompletionRecord
})
.from(schema.courseCompletionRecord)
.innerJoin(schema.course, eq(schema.course.id, schema.courseCompletionRecord.courseId))
.where(
and(
eq(schema.course.type, 'COMPLIANCE'),
isNotNull(schema.courseCompletionRecord.completedAt),
isNotNull(schema.courseCompletionRecord.validUntil),
lte(schema.courseCompletionRecord.validUntil, nowIso),
sql`${schema.courseCompletionRecord.expiredAt} IS NULL`
)
)
.orderBy(asc(schema.courseCompletionRecord.validUntil))
.then((rows) => rows.map((row) => row.record));
} catch (error) {
console.error('listComplianceRecordsReadyForExpiry error:', error);
throw new Error(
`Failed to list compliance records ready for expiry: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function listLatestComplianceRecordsForReminderScan(): Promise<CourseComplianceReminderRow[]> {
try {
const latestCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_course_compliance_cycles');
const result = await db
.select({
course: schema.course,
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
},
record: schema.courseCompletionRecord
})
.from(schema.courseCompletionRecord)
.innerJoin(
latestCycles,
and(
eq(latestCycles.courseId, schema.courseCompletionRecord.courseId),
eq(latestCycles.profileId, schema.courseCompletionRecord.profileId),
eq(latestCycles.latestCycleNumber, schema.courseCompletionRecord.cycleNumber)
)
)
.innerJoin(schema.course, eq(schema.course.id, schema.courseCompletionRecord.courseId))
.innerJoin(schema.groupmember, eq(schema.groupmember.id, schema.courseCompletionRecord.groupMemberId))
.leftJoin(schema.profile, eq(schema.profile.id, schema.courseCompletionRecord.profileId))
.where(
and(
eq(schema.course.type, 'COMPLIANCE'),
or(
eq(schema.courseCompletionRecord.status, 'not_started'),
eq(schema.courseCompletionRecord.status, 'in_progress'),
eq(schema.courseCompletionRecord.status, 'expiring_soon'),
eq(schema.courseCompletionRecord.status, 'in_grace_period'),
eq(schema.courseCompletionRecord.status, 'non_compliant')
),
sql`${schema.courseCompletionRecord.completedAt} IS NULL`
)
)
.orderBy(asc(schema.courseCompletionRecord.dueDate));
return result.map((row) => ({
course: row.course,
member: row.member,
profile: row.profile ?? null,
record: row.record
}));
} catch (error) {
console.error('listLatestComplianceRecordsForReminderScan error:', error);
throw new Error(
`Failed to list latest compliance records for reminder scan: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+144 -5
View File
@@ -42,6 +42,10 @@ export interface TAdminCourse extends TBaseCourse {
export interface TStudentCourse extends TBaseCourse {
progressRate: number;
exercisesCompleted: number;
complianceStatus: string | null;
complianceCycleNumber: number | null;
complianceDueDate: string | null;
complianceValidUntil: string | null;
}
/**
@@ -257,8 +261,7 @@ export const getCoursesBySiteNameForSetup = async (siteName: string) => {
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.organization.siteName, siteName))
.limit(1);
.where(eq(schema.organization.siteName, siteName));
return result.map((row) => ({
...row.course
@@ -592,6 +595,16 @@ export async function getCourseProgress(
/** Course + org fields needed for certification email and threshold rules */
export type TCourseCertificationRow = {
type: string | null;
compliance: {
retakeIntervalMonths: number;
gracePeriodDays?: number;
reminderDaysBefore?: number[];
isMandatory?: boolean;
framework?: 'HIPAA' | 'OSHA' | 'SOX' | 'GDPR' | 'PCI_DSS' | 'FERPA' | 'ISO' | 'CUSTOM' | null;
maxRetakeAttempts?: number | null;
passingScore?: number;
} | null;
certificate: {
isDownloadable?: boolean;
theme?: string;
@@ -610,6 +623,8 @@ export async function getCourseCertificationRow(courseId: string): Promise<TCour
try {
const [row] = await db
.select({
type: schema.course.type,
compliance: schema.course.compliance,
certificate: schema.course.certificate,
title: schema.course.title,
orgSiteName: schema.organization.siteName,
@@ -761,6 +776,99 @@ export const getOrgCourses = async ({
}
};
export interface TSearchOrgCourse {
id: string;
title: string;
slug: string | null;
description: string;
updatedAt: string | null;
}
export async function searchOrgCourses(orgId: string, search: string, limit: number): Promise<TSearchOrgCourse[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.course.id,
title: schema.course.title,
slug: schema.course.slug,
description: schema.course.description,
updatedAt: schema.course.updatedAt
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(
and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
or(ilike(schema.course.title, searchValue), ilike(schema.course.description, searchValue))
)
)
.orderBy(desc(schema.course.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchOrgCourses error:', error);
throw new Error(`Failed to search org courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function searchLmsCourses(
orgId: string,
profileId: string,
search: string,
limit: number
): Promise<TSearchOrgCourse[]> {
try {
const searchValue = `%${search.trim()}%`;
const result = await db
.select({
id: schema.course.id,
title: schema.course.title,
slug: schema.course.slug,
description: schema.course.description,
updatedAt: schema.course.updatedAt
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.leftJoin(
schema.groupmember,
and(eq(schema.group.id, schema.groupmember.groupId), eq(schema.groupmember.profileId, profileId))
)
.leftJoin(schema.programCourse, eq(schema.course.id, schema.programCourse.courseId))
.leftJoin(
schema.program,
and(
eq(schema.programCourse.programId, schema.program.id),
eq(schema.program.organizationId, orgId),
eq(schema.program.status, 'ACTIVE')
)
)
.leftJoin(
schema.programMember,
and(eq(schema.program.id, schema.programMember.programId), eq(schema.programMember.profileId, profileId))
)
.where(
and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true),
or(isNotNull(schema.groupmember.id), isNotNull(schema.programMember.id)),
or(ilike(schema.course.title, searchValue), ilike(schema.course.description, searchValue))
)
)
.groupBy(schema.course.id)
.orderBy(desc(schema.course.updatedAt))
.limit(limit);
return result;
} catch (error) {
console.error('searchLmsCourses error:', error);
throw new Error(`Failed to search LMS courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
interface GetEnrolledCoursesOptions {
/** Organization ID (required) */
orgId: string;
@@ -780,6 +888,17 @@ export const getEnrolledCourses = async ({
profileId
}: GetEnrolledCoursesOptions): Promise<TStudentCourse[]> => {
try {
const latestComplianceCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`MAX(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.where(eq(schema.courseCompletionRecord.profileId, profileId))
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_enrolled_course_compliance_cycles');
// Single query covering both access paths:
// 1. Direct enrollment: student has a groupmember row for the course's group
// 2. Program membership: student is a program_member of a program that contains the course
@@ -817,7 +936,11 @@ export const getEnrolledCourses = async ({
or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id)),
sql`gm.profile_id = ${sql.raw(`'${profileId}'::uuid`)}`
)}
)`.as('exercises_completed')
)`.as('exercises_completed'),
complianceStatus: schema.courseCompletionRecord.status,
complianceCycleNumber: schema.courseCompletionRecord.cycleNumber,
complianceDueDate: schema.courseCompletionRecord.dueDate,
complianceValidUntil: schema.courseCompletionRecord.validUntil
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
@@ -825,6 +948,18 @@ export const getEnrolledCourses = async ({
schema.groupmember,
and(eq(schema.group.id, schema.groupmember.groupId), eq(schema.groupmember.profileId, profileId))
)
.leftJoin(
latestComplianceCycles,
and(eq(latestComplianceCycles.courseId, schema.course.id), eq(latestComplianceCycles.profileId, profileId))
)
.leftJoin(
schema.courseCompletionRecord,
and(
eq(schema.courseCompletionRecord.courseId, schema.course.id),
eq(schema.courseCompletionRecord.profileId, profileId),
eq(schema.courseCompletionRecord.cycleNumber, latestComplianceCycles.latestCycleNumber)
)
)
.leftJoin(schema.programCourse, eq(schema.course.id, schema.programCourse.courseId))
.leftJoin(
schema.program,
@@ -847,7 +982,7 @@ export const getEnrolledCourses = async ({
or(isNotNull(schema.groupmember.id), isNotNull(schema.programMember.id))
)
)
.groupBy(schema.course.id)
.groupBy(schema.course.id, schema.courseCompletionRecord.id)
.orderBy(desc(schema.course.createdAt));
return result.map((row) => ({
@@ -855,7 +990,11 @@ export const getEnrolledCourses = async ({
lessonCount: Number(row.lessonCount),
progressRate: Number(row.progressRate),
exerciseCount: Number(row.exerciseCount),
exercisesCompleted: Number(row.exercisesCompleted)
exercisesCompleted: Number(row.exercisesCompleted),
complianceStatus: row.complianceStatus ?? null,
complianceCycleNumber: row.complianceCycleNumber ?? null,
complianceDueDate: row.complianceDueDate ?? null,
complianceValidUntil: row.complianceValidUntil ?? null
}));
} catch (error) {
console.error('getEnrolledCourses error:', error);
+1
View File
@@ -1,5 +1,6 @@
export * from './course';
export * from './course';
export * from './compliance';
export * from './content';
export * from './people';
export * from './invite';
+64
View File
@@ -1,19 +1,24 @@
import {
analyticsLoginEvents,
and,
course,
dashOrgStats,
db,
desc,
eq,
gte,
group,
groupmember,
isNotNull,
lesson,
lessonCompletion,
organizationmember,
profile,
sql
} from '@db/drizzle';
import { ROLE } from '@cio/utils/constants';
export function getCourseStats(orgId: string) {
const subquery = db
.select({
@@ -135,3 +140,62 @@ export function getRecentCertifications(orgId: string) {
export function getDashOrgStats(orgId: string) {
return db.select().from(dashOrgStats).where(eq(dashOrgStats.orgId, orgId));
}
/**
* Returns login counts grouped by day-of-week (0 = Sunday … 6 = Saturday) for
* students of the given organization over the last `days` days.
*
* Each row in analytics_login_events is already one-per-user-per-day, so the
* counts represent distinct active students per weekday — the chart shows
* "most active day," not raw login volume.
*/
export function getOrgStudentLoginsByDayOfWeek(orgId: string, days: number) {
const since = sql`CURRENT_DATE - ${sql.raw(`${days}`)}::int`;
return db
.select({
dayOfWeek: sql<number>`EXTRACT(DOW FROM ${analyticsLoginEvents.loggedInDate})::int`.as('day_of_week'),
count: sql<number>`COUNT(*)::int`.as('count')
})
.from(analyticsLoginEvents)
.innerJoin(organizationmember, eq(organizationmember.profileId, analyticsLoginEvents.userId))
.where(
and(
eq(organizationmember.organizationId, orgId),
eq(organizationmember.roleId, ROLE.STUDENT),
gte(analyticsLoginEvents.loggedInDate, since)
)
)
.groupBy(sql`day_of_week`)
.orderBy(sql`day_of_week`);
}
function getDateKey(date: Date) {
return date.toISOString().slice(0, 10);
}
export async function getUserLoginStreak(userId: string) {
const rows = await db
.select({
loggedInDate: analyticsLoginEvents.loggedInDate
})
.from(analyticsLoginEvents)
.where(eq(analyticsLoginEvents.userId, userId))
.orderBy(desc(analyticsLoginEvents.loggedInDate))
.limit(90);
const loggedInDates = new Set(rows.map((row) => row.loggedInDate));
const cursorDate = new Date();
if (!loggedInDates.has(getDateKey(cursorDate))) {
cursorDate.setDate(cursorDate.getDate() - 1);
}
let daysActive = 0;
while (loggedInDates.has(getDateKey(cursorDate))) {
daysActive += 1;
cursorDate.setDate(cursorDate.getDate() - 1);
}
return { daysActive };
}
@@ -198,6 +198,7 @@ export async function updateUngroupedExercisesSectionId(
export async function deleteExercise(exerciseId: string, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient.delete(schema.exercise).where(eq(schema.exercise.id, exerciseId)).returning();
console.log('Deleted exercise:', deleted);
return deleted || null;
} catch (error) {
console.error('deleteExercise error:', error);
+2
View File
@@ -1,6 +1,7 @@
export * from './organization';
export * from './auth';
export * from './attendance';
export * from './audience';
export * from './assets';
export * from './course';
export * from './group';
@@ -13,3 +14,4 @@ export * from './submission';
export * from './tag';
export * from './course-import';
export * from './program';
export * from './widget';
@@ -367,6 +367,33 @@ export const getUserOrgRole = async (orgId: string, profileId: string): Promise<
return result.length > 0 ? Number(result[0].roleId) : null;
};
/**
* Gets all org memberships for a user as { [orgId]: roleId }.
* Used to attach org roles to the Better Auth session so middleware can
* read membership/role from the session cookie cache instead of hitting the DB.
*/
export const getUserOrgRolesMap = async (profileId: string): Promise<Record<string, number>> => {
try {
const rows = await db
.select({
organizationId: schema.organizationmember.organizationId,
roleId: schema.organizationmember.roleId
})
.from(schema.organizationmember)
.where(eq(schema.organizationmember.profileId, profileId));
const map: Record<string, number> = {};
for (const row of rows) {
map[row.organizationId] = Number(row.roleId);
}
return map;
} catch (error) {
console.error('getUserOrgRolesMap error:', error);
throw new Error('Failed to fetch user org roles map');
}
};
/**
* Checks if a user is a team member (ADMIN or TUTOR) of an organization
* @param orgId Organization ID
+72 -1
View File
@@ -1,6 +1,6 @@
import * as schema from '@db/schema';
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
import { and, asc, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
@@ -118,6 +118,77 @@ export async function getProgramsByOrg(
}
}
export interface TSearchOrgProgram {
id: string;
name: string;
description: string | null;
status: string;
updatedAt: string | null;
}
export async function searchOrgPrograms(orgId: string, search: string, limit: number): Promise<TSearchOrgProgram[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.program.id,
name: schema.program.name,
description: schema.program.description,
status: schema.program.status,
updatedAt: schema.program.updatedAt
})
.from(schema.program)
.where(
and(
eq(schema.program.organizationId, orgId),
eq(schema.program.status, 'ACTIVE'),
or(ilike(schema.program.name, searchValue), ilike(schema.program.description, searchValue))
)
)
.orderBy(desc(schema.program.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchOrgPrograms error:', error);
throw new Error(`Failed to search org programs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function searchLmsPrograms(
orgId: string,
profileId: string,
search: string,
limit: number
): Promise<TSearchOrgProgram[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.program.id,
name: schema.program.name,
description: schema.program.description,
status: schema.program.status,
updatedAt: schema.program.updatedAt
})
.from(schema.programMember)
.innerJoin(schema.program, eq(schema.programMember.programId, schema.program.id))
.where(
and(
eq(schema.programMember.profileId, profileId),
eq(schema.program.organizationId, orgId),
eq(schema.program.status, 'ACTIVE'),
or(ilike(schema.program.name, searchValue), ilike(schema.program.description, searchValue))
)
)
.orderBy(desc(schema.program.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchLmsPrograms error:', error);
throw new Error(`Failed to search LMS programs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getExistingProgramMembers(
pairs: Array<{ programId: string; profileId: string }>
): Promise<Set<string>> {
+62 -1
View File
@@ -1,5 +1,5 @@
import type { TNewTag, TNewTagGroup, TTag, TTagAssignment, TTagGroup } from '@db/types';
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
import { and, asc, eq, ilike, inArray, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
@@ -19,6 +19,26 @@ export interface TCourseTagPreview {
color: string;
}
export type TSearchOrgTag =
| {
kind: 'group';
id: string;
name: string;
slug: string;
description: string | null;
color: null;
groupName: null;
}
| {
kind: 'tag';
id: string;
name: string;
slug: string;
description: string | null;
color: string;
groupName: string;
};
interface GetTagGroupsWithTagsOptions {
onlyPublishedCourses?: boolean;
}
@@ -151,6 +171,47 @@ export async function getTagsByIds(orgId: string, tagIds: string[]): Promise<TTa
}
}
export async function searchOrgTagsAndGroups(orgId: string, search: string, limit: number): Promise<TSearchOrgTag[]> {
try {
const searchValue = `%${search.trim()}%`;
const [groups, tags] = await Promise.all([
db
.select({
id: schema.tagGroup.id,
name: schema.tagGroup.name,
slug: schema.tagGroup.slug,
description: schema.tagGroup.description
})
.from(schema.tagGroup)
.where(and(eq(schema.tagGroup.organizationId, orgId), ilike(schema.tagGroup.name, searchValue)))
.orderBy(asc(schema.tagGroup.name))
.limit(limit),
db
.select({
id: schema.tag.id,
name: schema.tag.name,
slug: schema.tag.slug,
description: schema.tag.description,
color: schema.tag.color,
groupName: schema.tagGroup.name
})
.from(schema.tag)
.innerJoin(schema.tagGroup, eq(schema.tag.groupId, schema.tagGroup.id))
.where(and(eq(schema.tag.organizationId, orgId), ilike(schema.tag.name, searchValue)))
.orderBy(asc(schema.tag.name))
.limit(limit)
]);
return [
...groups.map((group): TSearchOrgTag => ({ ...group, kind: 'group', color: null, groupName: null })),
...tags.map((tag): TSearchOrgTag => ({ ...tag, kind: 'tag' }))
].slice(0, limit);
} catch (error) {
console.error('searchOrgTagsAndGroups error:', error);
throw new Error('Failed to search tags and tag groups');
}
}
export async function createTagGroup(data: TNewTagGroup): Promise<TTagGroup> {
try {
const [result] = await db.insert(schema.tagGroup).values(data).returning();
+1
View File
@@ -0,0 +1 @@
export * from './widget';
+324
View File
@@ -0,0 +1,324 @@
import * as schema from '@db/schema';
import type {
TNewWidget,
TNewWidgetCourse,
TNewWidgetVersion,
TWidget,
TWidgetCourse,
TWidgetVersion
} from '@db/types';
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
export type TWidgetListItem = TWidget & {
courseCount: number;
};
function widgetListItemSelect() {
return {
id: schema.widget.id,
organizationId: schema.widget.organizationId,
name: schema.widget.name,
status: schema.widget.status,
layoutType: schema.widget.layoutType,
selectionMode: schema.widget.selectionMode,
publicKey: schema.widget.publicKey,
config: schema.widget.config,
hasUnpublishedChanges: schema.widget.hasUnpublishedChanges,
latestPublishedVersionId: schema.widget.latestPublishedVersionId,
createdByUserId: schema.widget.createdByUserId,
updatedByUserId: schema.widget.updatedByUserId,
deletedAt: schema.widget.deletedAt,
createdAt: schema.widget.createdAt,
updatedAt: schema.widget.updatedAt,
courseCount: sql<number>`COUNT(DISTINCT ${schema.widgetCourse.courseId})`.as('course_count')
};
}
export async function listWidgetsByOrganization(orgId: string): Promise<TWidgetListItem[]> {
try {
return db
.select(widgetListItemSelect())
.from(schema.widget)
.leftJoin(schema.widgetCourse, eq(schema.widgetCourse.widgetId, schema.widget.id))
.where(and(eq(schema.widget.organizationId, orgId), isNull(schema.widget.deletedAt)))
.groupBy(schema.widget.id)
.orderBy(desc(schema.widget.updatedAt), asc(schema.widget.name));
} catch (error) {
console.error('listWidgetsByOrganization error:', error);
throw new Error('Failed to list widgets');
}
}
export async function searchOrgWidgets(orgId: string, search: string, limit: number): Promise<TWidgetListItem[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select(widgetListItemSelect())
.from(schema.widget)
.leftJoin(schema.widgetCourse, eq(schema.widgetCourse.widgetId, schema.widget.id))
.where(
and(
eq(schema.widget.organizationId, orgId),
isNull(schema.widget.deletedAt),
ilike(schema.widget.name, searchValue)
)
)
.groupBy(schema.widget.id)
.orderBy(desc(schema.widget.updatedAt), asc(schema.widget.name))
.limit(limit);
} catch (error) {
console.error('searchOrgWidgets error:', error);
throw new Error('Failed to search widgets');
}
}
export async function getWidgetListItemById(orgId: string, widgetId: string): Promise<TWidgetListItem | null> {
try {
const [result] = await db
.select(widgetListItemSelect())
.from(schema.widget)
.leftJoin(schema.widgetCourse, eq(schema.widgetCourse.widgetId, schema.widget.id))
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.groupBy(schema.widget.id)
.limit(1);
return result ?? null;
} catch (error) {
console.error('getWidgetListItemById error:', error);
throw new Error('Failed to get widget list item');
}
}
export async function getWidgetById(orgId: string, widgetId: string): Promise<TWidget | null> {
try {
const [result] = await db
.select()
.from(schema.widget)
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.limit(1);
return result ?? null;
} catch (error) {
console.error('getWidgetById error:', error);
throw new Error('Failed to get widget');
}
}
export async function getWidgetByPublicKey(publicKey: string): Promise<TWidget | null> {
try {
const [result] = await db
.select()
.from(schema.widget)
.where(and(eq(schema.widget.publicKey, publicKey), isNull(schema.widget.deletedAt)))
.limit(1);
return result ?? null;
} catch (error) {
console.error('getWidgetByPublicKey error:', error);
throw new Error('Failed to get widget by public key');
}
}
export async function createWidget(data: TNewWidget): Promise<TWidget> {
try {
const [result] = await db.insert(schema.widget).values(data).returning();
return result;
} catch (error) {
console.error('createWidget error:', error);
throw new Error('Failed to create widget');
}
}
export async function updateWidget(orgId: string, widgetId: string, data: Partial<TWidget>): Promise<TWidget | null> {
try {
const [result] = await db
.update(schema.widget)
.set({
...data,
updatedAt: new Date().toISOString()
})
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.returning();
return result ?? null;
} catch (error) {
console.error('updateWidget error:', error);
throw new Error('Failed to update widget');
}
}
export async function archiveWidget(orgId: string, widgetId: string, updatedByUserId: string): Promise<TWidget | null> {
try {
const [result] = await db
.update(schema.widget)
.set({
status: 'ARCHIVED',
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
updatedByUserId
})
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.returning();
return result ?? null;
} catch (error) {
console.error('archiveWidget error:', error);
throw new Error('Failed to archive widget');
}
}
export async function listWidgetCourses(widgetId: string): Promise<TWidgetCourse[]> {
try {
return db
.select()
.from(schema.widgetCourse)
.where(eq(schema.widgetCourse.widgetId, widgetId))
.orderBy(asc(schema.widgetCourse.order), asc(schema.widgetCourse.createdAt));
} catch (error) {
console.error('listWidgetCourses error:', error);
throw new Error('Failed to list widget courses');
}
}
export async function replaceWidgetCourses(widgetId: string, courseIds: string[]): Promise<TWidgetCourse[]> {
try {
const normalizedCourseIds = Array.from(new Set(courseIds));
return db.transaction(async (tx) => {
await tx.delete(schema.widgetCourse).where(eq(schema.widgetCourse.widgetId, widgetId));
if (normalizedCourseIds.length === 0) {
return [];
}
const rowsToInsert: TNewWidgetCourse[] = normalizedCourseIds.map((courseId, index) => ({
widgetId,
courseId,
order: index
}));
return tx.insert(schema.widgetCourse).values(rowsToInsert).returning();
});
} catch (error) {
console.error('replaceWidgetCourses error:', error);
throw new Error('Failed to replace widget courses');
}
}
export async function getNextWidgetVersion(widgetId: string): Promise<number> {
try {
const [result] = await db
.select({
latestVersion: sql<number>`COALESCE(MAX(${schema.widgetVersion.version}), 0)`.as('latest_version')
})
.from(schema.widgetVersion)
.where(eq(schema.widgetVersion.widgetId, widgetId));
return Number(result?.latestVersion ?? 0) + 1;
} catch (error) {
console.error('getNextWidgetVersion error:', error);
throw new Error('Failed to get next widget version');
}
}
export async function createWidgetVersion(data: TNewWidgetVersion): Promise<TWidgetVersion> {
try {
const [result] = await db.insert(schema.widgetVersion).values(data).returning();
return result;
} catch (error) {
console.error('createWidgetVersion error:', error);
throw new Error('Failed to create widget version');
}
}
export async function listWidgetVersions(orgId: string, widgetId: string): Promise<TWidgetVersion[]> {
try {
return db
.select({ version: schema.widgetVersion })
.from(schema.widgetVersion)
.innerJoin(schema.widget, eq(schema.widgetVersion.widgetId, schema.widget.id))
.where(and(eq(schema.widget.organizationId, orgId), eq(schema.widgetVersion.widgetId, widgetId)))
.orderBy(desc(schema.widgetVersion.version))
.then((rows) => rows.map((row) => row.version));
} catch (error) {
console.error('listWidgetVersions error:', error);
throw new Error('Failed to list widget versions');
}
}
export async function getWidgetVersionById(
orgId: string,
widgetId: string,
versionId: string
): Promise<TWidgetVersion | null> {
try {
const [result] = await db
.select({ version: schema.widgetVersion })
.from(schema.widgetVersion)
.innerJoin(schema.widget, eq(schema.widgetVersion.widgetId, schema.widget.id))
.where(
and(
eq(schema.widget.organizationId, orgId),
eq(schema.widgetVersion.widgetId, widgetId),
eq(schema.widgetVersion.id, versionId)
)
)
.limit(1);
return result?.version ?? null;
} catch (error) {
console.error('getWidgetVersionById error:', error);
throw new Error('Failed to get widget version');
}
}
export async function getPublishedWidgetPayloadByPublicKey(publicKey: string): Promise<unknown | null> {
try {
const [result] = await db
.select({
payloadSnapshot: schema.widgetVersion.payloadSnapshot
})
.from(schema.widget)
.innerJoin(schema.widgetVersion, eq(schema.widget.latestPublishedVersionId, schema.widgetVersion.id))
.where(and(eq(schema.widget.publicKey, publicKey), isNull(schema.widget.deletedAt)))
.limit(1);
return result?.payloadSnapshot ?? null;
} catch (error) {
console.error('getPublishedWidgetPayloadByPublicKey error:', error);
throw new Error('Failed to get published widget payload');
}
}
export async function getWidgetsByIds(orgId: string, widgetIds: string[]): Promise<TWidget[]> {
try {
if (widgetIds.length === 0) {
return [];
}
return db
.select()
.from(schema.widget)
.where(
and(
eq(schema.widget.organizationId, orgId),
inArray(schema.widget.id, widgetIds),
isNull(schema.widget.deletedAt)
)
);
} catch (error) {
console.error('getWidgetsByIds error:', error);
throw new Error('Failed to get widgets by ids');
}
}
+382 -3
View File
@@ -1,6 +1,7 @@
import {
bigint,
boolean,
date,
doublePrecision,
foreignKey,
index,
@@ -22,13 +23,24 @@ import {
import type { AnswerData } from '@cio/question-types';
import { sql } from 'drizzle-orm';
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS']);
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE']);
export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf', 'course']);
export const courseImportDraftStatus = pgEnum('COURSE_IMPORT_DRAFT_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
export const organizationApiKeyType = pgEnum('ORGANIZATION_API_KEY_TYPE', ['mcp', 'api', 'zapier']);
export const automationUsageCategory = pgEnum('AUTOMATION_USAGE_CATEGORY', ['read', 'write', 'publish']);
export const widgetStatus = pgEnum('WIDGET_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
export const widgetLayoutType = pgEnum('WIDGET_LAYOUT_TYPE', [
'card_grid',
'tag_filter',
'carousel',
'primary_course',
'compact_list',
'editorial_spotlight',
'category_shelf'
]);
export const widgetSelectionMode = pgEnum('WIDGET_SELECTION_MODE', ['manual', 'published']);
export const courseInviteEventType = pgEnum('COURSE_INVITE_EVENT_TYPE', [
'CREATED',
'REVOKED',
@@ -150,7 +162,10 @@ export const analyticsLoginEvents = pgTable(
.primaryKey()
.notNull(),
userId: uuid('user_id').notNull(),
loggedInAt: timestamp('logged_in_at', { withTimezone: true, mode: 'string' }).defaultNow()
loggedInAt: timestamp('logged_in_at', { withTimezone: true, mode: 'string' }).defaultNow(),
loggedInDate: date('logged_in_date')
.default(sql`CURRENT_DATE`)
.notNull()
},
(table) => [
index('idx_analytics_login_events_logged_in_at').using(
@@ -158,12 +173,16 @@ export const analyticsLoginEvents = pgTable(
table.loggedInAt.asc().nullsLast().op('timestamptz_ops')
),
index('idx_analytics_login_events_user_id').using('btree', table.userId.asc().nullsLast().op('uuid_ops')),
index('idx_analytics_login_events_logged_in_date').using(
'btree',
table.loggedInDate.asc().nullsLast().op('date_ops')
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'analytics_login_events_user_id_fkey'
}),
unique('analytics_login_events_user_id_unique').on(table.userId)
unique('analytics_login_events_user_id_date_unique').on(table.userId, table.loggedInDate)
]
);
@@ -596,6 +615,15 @@ export const course = pgTable(
exerciseMinScorePercent?: number | null;
emailMessage?: string | null;
}>(),
compliance: jsonb().$type<{
retakeIntervalMonths: number;
gracePeriodDays?: number;
reminderDaysBefore?: number[];
isMandatory?: boolean;
framework?: 'HIPAA' | 'OSHA' | 'SOX' | 'GDPR' | 'PCI_DSS' | 'FERPA' | 'ISO' | 'CUSTOM' | null;
maxRetakeAttempts?: number | null;
passingScore?: number;
}>(),
status: text().default('ACTIVE').notNull(),
type: courseType().default('LIVE_CLASS')
},
@@ -610,6 +638,127 @@ export const course = pgTable(
]
);
export const courseCompletionRecord = pgTable(
'course_completion_record',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
courseId: uuid('course_id').notNull(),
groupMemberId: uuid('group_member_id').notNull(),
profileId: uuid('profile_id').notNull(),
cycleNumber: integer('cycle_number').default(1).notNull(),
status: varchar().default('not_started').notNull(),
dueDate: timestamp('due_date', { withTimezone: true, mode: 'string' }).notNull(),
startedAt: timestamp('started_at', { withTimezone: true, mode: 'string' }),
completedAt: timestamp('completed_at', { withTimezone: true, mode: 'string' }),
validUntil: timestamp('valid_until', { withTimezone: true, mode: 'string' }),
expiredAt: timestamp('expired_at', { withTimezone: true, mode: 'string' }),
score: integer(),
attempts: integer().default(0),
timeSpentMinutes: integer('time_spent_minutes').default(0),
waivedBy: uuid('waived_by'),
waiverReason: text('waiver_reason'),
waiverExpiresAt: timestamp('waiver_expires_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'course_completion_record_course_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.groupMemberId],
foreignColumns: [groupmember.id],
name: 'course_completion_record_group_member_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.profileId],
foreignColumns: [profile.id],
name: 'course_completion_record_profile_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.waivedBy],
foreignColumns: [profile.id],
name: 'course_completion_record_waived_by_fkey'
}).onDelete('set null'),
unique('course_completion_record_course_profile_cycle_key').on(table.courseId, table.profileId, table.cycleNumber),
index('idx_course_completion_record_course_id').on(table.courseId),
index('idx_course_completion_record_profile_id').on(table.profileId),
index('idx_course_completion_record_status').on(table.status),
index('idx_course_completion_record_due_date').on(table.dueDate),
index('idx_course_completion_record_valid_until').on(table.validUntil)
]
);
export const courseCompletionNotificationEvent = pgTable(
'course_completion_notification_event',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
courseCompletionRecordId: uuid('course_completion_record_id').notNull(),
channel: varchar().notNull(),
eventType: varchar('event_type').notNull(),
sentAt: timestamp('sent_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.courseCompletionRecordId],
foreignColumns: [courseCompletionRecord.id],
name: 'course_completion_notification_event_record_id_fkey'
}).onDelete('cascade'),
unique('course_completion_notification_event_record_channel_type_key').on(
table.courseCompletionRecordId,
table.channel,
table.eventType
)
]
);
export const courseCertificateIssue = pgTable(
'course_certificate_issue',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
courseId: uuid('course_id').notNull(),
profileId: uuid('profile_id').notNull(),
courseCompletionRecordId: uuid('course_completion_record_id').notNull(),
cycleNumber: integer('cycle_number').notNull(),
issuedAt: timestamp('issued_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }).notNull(),
status: varchar().default('valid').notNull(),
fileUrl: text('file_url')
},
(table) => [
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'course_certificate_issue_course_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.profileId],
foreignColumns: [profile.id],
name: 'course_certificate_issue_profile_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.courseCompletionRecordId],
foreignColumns: [courseCompletionRecord.id],
name: 'course_certificate_issue_record_id_fkey'
}).onDelete('cascade'),
unique('course_certificate_issue_record_id_key').on(table.courseCompletionRecordId),
index('idx_course_certificate_issue_course_id').on(table.courseId),
index('idx_course_certificate_issue_profile_id').on(table.profileId),
index('idx_course_certificate_issue_status').on(table.status)
]
);
export const appsPoll = pgTable(
'apps_poll',
{
@@ -1859,6 +2008,132 @@ export const tagAssignment = pgTable(
]
);
export const widget = pgTable(
'widget',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
organizationId: uuid('organization_id').notNull(),
name: varchar({ length: 120 }).notNull(),
status: widgetStatus('status').default('DRAFT').notNull(),
layoutType: widgetLayoutType('layout_type').default('card_grid').notNull(),
selectionMode: widgetSelectionMode('selection_mode').default('manual').notNull(),
publicKey: varchar('public_key', { length: 64 }).notNull(),
config: jsonb('config')
.default(sql`'{}'::jsonb`)
.notNull(),
hasUnpublishedChanges: boolean('has_unpublished_changes').default(false).notNull(),
latestPublishedVersionId: uuid('latest_published_version_id'),
createdByUserId: uuid('created_by_user_id').notNull(),
updatedByUserId: uuid('updated_by_user_id'),
deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull()
},
(table) => [
foreignKey({
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'widget_organization_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.createdByUserId],
foreignColumns: [user.id],
name: 'widget_created_by_user_id_fkey'
}).onDelete('restrict'),
foreignKey({
columns: [table.updatedByUserId],
foreignColumns: [user.id],
name: 'widget_updated_by_user_id_fkey'
}).onDelete('set null'),
index('idx_widget_organization_id').on(table.organizationId),
index('idx_widget_status').on(table.status),
unique('widget_public_key_key').on(table.publicKey)
]
);
export const widgetCourse = pgTable(
'widget_course',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
widgetId: uuid('widget_id').notNull(),
courseId: uuid('course_id').notNull(),
order: integer().default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull()
},
(table) => [
foreignKey({
columns: [table.widgetId],
foreignColumns: [widget.id],
name: 'widget_course_widget_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'widget_course_course_id_fkey'
}).onDelete('cascade'),
index('idx_widget_course_widget_id').on(table.widgetId),
index('idx_widget_course_course_id').on(table.courseId),
unique('widget_course_widget_course_key').on(table.widgetId, table.courseId)
]
);
export const widgetVersion = pgTable(
'widget_version',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
widgetId: uuid('widget_id').notNull(),
version: integer().notNull(),
configSnapshot: jsonb('config_snapshot')
.default(sql`'{}'::jsonb`)
.notNull(),
payloadSnapshot: jsonb('payload_snapshot')
.default(sql`'{}'::jsonb`)
.notNull(),
runtimeManifest: jsonb('runtime_manifest')
.default(sql`'{}'::jsonb`)
.notNull(),
rolledBackFromVersionId: uuid('rolled_back_from_version_id'),
publishedByUserId: uuid('published_by_user_id').notNull(),
publishedAt: timestamp('published_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull()
},
(table) => [
foreignKey({
columns: [table.widgetId],
foreignColumns: [widget.id],
name: 'widget_version_widget_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.publishedByUserId],
foreignColumns: [user.id],
name: 'widget_version_published_by_user_id_fkey'
}).onDelete('restrict'),
foreignKey({
columns: [table.rolledBackFromVersionId],
foreignColumns: [table.id],
name: 'widget_version_rolled_back_from_version_id_fkey'
}).onDelete('set null'),
index('idx_widget_version_widget_id').on(table.widgetId),
unique('widget_version_widget_version_key').on(table.widgetId, table.version)
]
);
export const dashOrgStats = pgView('dash_org_stats', {
orgId: uuid('org_id'),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
@@ -2273,3 +2548,107 @@ export const programNewsfeedComment = pgTable(
}).onDelete('cascade')
]
);
// ─── AI Token Usage ──────────────────────────────────────────────────────────
export const aiTokenUsage = pgTable(
'ai_token_usage',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_token_usage_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
orgId: uuid('org_id').notNull(),
userId: uuid('user_id').notNull(),
courseId: uuid('course_id').notNull(),
promptTokens: integer('prompt_tokens').notNull(),
completionTokens: integer('completion_tokens').notNull(),
model: text().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.orgId],
foreignColumns: [organization.id],
name: 'ai_token_usage_org_id_fkey'
}),
index('idx_ai_token_usage_org_month').on(table.orgId, table.createdAt)
]
);
// ─── AI Credit Balance ───────────────────────────────────────────────────────
export const aiCreditBalance = pgTable(
'ai_credit_balance',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_credit_balance_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
orgId: uuid('org_id').notNull(),
balance: integer().notNull().default(0),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.orgId],
foreignColumns: [organization.id],
name: 'ai_credit_balance_org_id_fkey'
}),
unique('ai_credit_balance_org_id_key').on(table.orgId)
]
);
export const aiChatConversation = pgTable(
'ai_chat_conversation',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey(),
courseId: uuid('course_id').notNull(),
userId: uuid('user_id').notNull(),
title: text().default('New conversation'),
messages: jsonb().notNull().default([]),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'ai_chat_conversation_course_id_fkey'
}),
index('idx_ai_chat_conversation_course_user').on(table.courseId, table.userId)
]
);
export const aiChatDocument = pgTable(
'ai_chat_document',
{
id: text().primaryKey(),
conversationId: uuid('conversation_id').notNull(),
courseId: uuid('course_id').notNull(),
userId: uuid('user_id').notNull(),
assetId: uuid('asset_id'),
fileName: text('file_name').notNull(),
mimeType: text('mime_type').notNull(),
text: text().notNull(),
wordCount: integer('word_count').notNull().default(0),
pageCount: integer('page_count'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.conversationId],
foreignColumns: [aiChatConversation.id],
name: 'ai_chat_document_conversation_id_fkey'
}).onDelete('cascade'),
index('idx_ai_chat_document_conversation').on(table.conversationId)
]
);
+30 -7
View File
@@ -10,7 +10,7 @@ import { seedGroupmembers } from '@db/utils/seed/groupmember';
import { type LessonTemplate, seedLessons } from '@db/utils/seed/lesson';
import { seedOrganization } from '@db/utils/seed/organization';
import { seedOrganizationMember } from '@db/utils/seed/organizationmember';
import { seedEnterpriseOrganizationPlan } from '@db/utils/seed/organizationPlan';
import { seedEarlyAdopterOrganizationPlan, seedEnterpriseOrganizationPlan } from '@db/utils/seed/organizationPlan';
import { seedProfile } from '@db/utils/seed/profile';
import { seedQuestionTypes } from '@db/utils/seed/questionType';
import { seedQuestions } from '@db/utils/seed/question';
@@ -32,6 +32,10 @@ const STUDENT_USER_ID = '0c256e75-aa40-4f62-8d30-0217ca1c60d9';
const ENTERPRISE_ORG_ID = '2b8f4a1c-6d3e-4b2a-9f7e-1c4d8a6e9b0f';
const ENTERPRISE_ADMIN_USER_ID = '3c9052de-7e4f-4c3a-8d8e-2d5e9b7f1a3c';
const ENTERPRISE_STUDENT_USER_ID = '4da163ef-8050-4d4b-9c9f-3e6fac802b4d';
const EARLY_ADOPTER_ORG_ID = '5c7d4b2e-8f5a-4c3b-ae8f-2d6e9c1b0f5a';
const EARLY_ADOPTER_ADMIN_USER_ID = '6e0163ff-9161-4d4b-be9f-3e7fbd913c5e';
const EARLY_ADOPTER_STUDENT_USER_ID = '7fb274aa-a272-4e5c-cf0a-4f8cce024d6f';
const EARLY_ADOPTER_GROUP_ID = '8ac385bb-b383-4f6d-d01b-5a9ddf135e7a';
// Parse command line arguments
function parseFlags(): Set<string> {
@@ -66,7 +70,7 @@ Flags:
--accounts Seed accounts
--organizations Seed organizations
--organization-members Seed organization members
--organization-plan Seed enterprise plan for Coursera Test org
--organization-plan Seed enterprise + early adopter plans
--groups Seed groups
--group-members Seed group members
--courses Seed courses
@@ -113,7 +117,11 @@ const seedFunctions = {
},
organizations: async () => {
console.log('📝 Seeding organizations...');
await seedOrganization({ testOrgId: TEST_ORG_ID, enterpriseOrgId: ENTERPRISE_ORG_ID });
await seedOrganization({
testOrgId: TEST_ORG_ID,
enterpriseOrgId: ENTERPRISE_ORG_ID,
earlyAdopterOrgId: EARLY_ADOPTER_ORG_ID
});
},
'organization-members': async () => {
console.log('📝 Seeding organization members...');
@@ -123,12 +131,17 @@ const seedFunctions = {
studentUserId: STUDENT_USER_ID,
enterpriseOrgId: ENTERPRISE_ORG_ID,
enterpriseAdminUserId: ENTERPRISE_ADMIN_USER_ID,
enterpriseStudentUserId: ENTERPRISE_STUDENT_USER_ID
enterpriseStudentUserId: ENTERPRISE_STUDENT_USER_ID,
earlyAdopterOrgId: EARLY_ADOPTER_ORG_ID,
earlyAdopterAdminUserId: EARLY_ADOPTER_ADMIN_USER_ID,
earlyAdopterStudentUserId: EARLY_ADOPTER_STUDENT_USER_ID
});
},
'organization-plan': async () => {
console.log('📝 Seeding enterprise organization plan...');
await seedEnterpriseOrganizationPlan({ enterpriseOrgId: ENTERPRISE_ORG_ID });
console.log('📝 Seeding early adopter organization plan...');
await seedEarlyAdopterOrganizationPlan({ earlyAdopterOrgId: EARLY_ADOPTER_ORG_ID });
},
groups: async () => {
console.log('📝 Seeding groups...');
@@ -136,7 +149,9 @@ const seedFunctions = {
mvcGroupId: MVC_GROUP_ID,
reactGroupId: REACT_GROUP_ID,
pandasGroupId: PANDAS_GROUP_ID,
testOrgId: TEST_ORG_ID
testOrgId: TEST_ORG_ID,
earlyAdopterGroupId: EARLY_ADOPTER_GROUP_ID,
earlyAdopterOrgId: EARLY_ADOPTER_ORG_ID
});
},
'group-members': async () => {
@@ -146,12 +161,20 @@ const seedFunctions = {
reactGroupId: REACT_GROUP_ID,
pandasGroupId: PANDAS_GROUP_ID,
adminUserId: ADMIN_USER_ID,
studentUserId: STUDENT_USER_ID
studentUserId: STUDENT_USER_ID,
earlyAdopterGroupId: EARLY_ADOPTER_GROUP_ID,
earlyAdopterAdminUserId: EARLY_ADOPTER_ADMIN_USER_ID,
earlyAdopterStudentUserId: EARLY_ADOPTER_STUDENT_USER_ID
});
},
courses: async () => {
console.log('📝 Seeding courses...');
await seedCourses({ mvcGroupId: MVC_GROUP_ID, reactGroupId: REACT_GROUP_ID, pandasGroupId: PANDAS_GROUP_ID });
await seedCourses({
mvcGroupId: MVC_GROUP_ID,
reactGroupId: REACT_GROUP_ID,
pandasGroupId: PANDAS_GROUP_ID,
earlyAdopterGroupId: EARLY_ADOPTER_GROUP_ID
});
},
sections: async () => {
console.log('📝 Seeding course sections...');
+15
View File
@@ -60,6 +60,12 @@ export type TNewSubmissionstatus = typeof schema.submissionstatus.$inferInsert;
export type TCourse = typeof schema.course.$inferSelect;
export type TNewCourse = typeof schema.course.$inferInsert;
export type TCourseCompletionRecord = typeof schema.courseCompletionRecord.$inferSelect;
export type TNewCourseCompletionRecord = typeof schema.courseCompletionRecord.$inferInsert;
export type TCourseCompletionNotificationEvent = typeof schema.courseCompletionNotificationEvent.$inferSelect;
export type TNewCourseCompletionNotificationEvent = typeof schema.courseCompletionNotificationEvent.$inferInsert;
export type TCourseCertificateIssue = typeof schema.courseCertificateIssue.$inferSelect;
export type TNewCourseCertificateIssue = typeof schema.courseCertificateIssue.$inferInsert;
export type TAppsPoll = typeof schema.appsPoll.$inferSelect;
export type TNewAppsPoll = typeof schema.appsPoll.$inferInsert;
@@ -156,6 +162,12 @@ export type TNewTag = typeof schema.tag.$inferInsert;
export type TTagAssignment = typeof schema.tagAssignment.$inferSelect;
export type TNewTagAssignment = typeof schema.tagAssignment.$inferInsert;
export type TWidget = typeof schema.widget.$inferSelect;
export type TNewWidget = typeof schema.widget.$inferInsert;
export type TWidgetCourse = typeof schema.widgetCourse.$inferSelect;
export type TNewWidgetCourse = typeof schema.widgetCourse.$inferInsert;
export type TWidgetVersion = typeof schema.widgetVersion.$inferSelect;
export type TNewWidgetVersion = typeof schema.widgetVersion.$inferInsert;
export type TExerciseTemplate = typeof schema.exerciseTemplate.$inferSelect;
export type TNewExerciseTemplate = typeof schema.exerciseTemplate.$inferInsert;
@@ -195,3 +207,6 @@ export type TAutomationUsageCategory = (typeof schema.automationUsageCategory.en
export type TCourseImportSourceType = (typeof schema.courseImportSourceType.enumValues)[number];
export type TCourseImportDraftStatus = (typeof schema.courseImportDraftStatus.enumValues)[number];
export type TWidgetStatus = (typeof schema.widgetStatus.enumValues)[number];
export type TWidgetLayoutType = (typeof schema.widgetLayoutType.enumValues)[number];
export type TWidgetSelectionMode = (typeof schema.widgetSelectionMode.enumValues)[number];
+30 -1
View File
@@ -6,15 +6,17 @@ interface SeedCourse {
mvcGroupId: string;
reactGroupId: string;
pandasGroupId: string;
earlyAdopterGroupId: string;
}
export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId }: SeedCourse) {
export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId, earlyAdopterGroupId }: SeedCourse) {
const existingCourses = await db.select().from(course);
const existingCourseIds = existingCourses.map((c) => c.id);
const mvcCourseId = '98e6e798-f0bd-4f9d-a6f5-ce0816a4f97e';
const reactCourseId = '16e3bc8d-5d1b-4708-988e-93abae288ccf';
const pandasCourseId = 'f0a85d18-aff4-412f-b8e6-3b34ef098dce';
const earlyAdopterCourseId = '9bd496cc-c494-4a7e-e12c-6baeef246f8b';
const coursesToInsert: TNewCourse[] = [
{
@@ -98,6 +100,33 @@ export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId }: S
theme: 'professional'
},
status: 'ACTIVE'
},
{
id: earlyAdopterCourseId,
title: 'Product Management Fundamentals',
description:
'A practical introduction to product management covering roadmaps, prioritization frameworks, user research, and cross-functional collaboration skills needed to ship great products.',
overview: 'Welcome to Product Management Fundamentals 🚀',
groupId: earlyAdopterGroupId,
isTemplate: false,
logo: '',
slug: 'product-management-fundamentals',
metadata: {
goals: '',
grading: false,
description: '',
requirements: '',
lessonDownload: false,
allowNewStudent: true
},
cost: 0,
currency: 'NGN',
isPublished: true,
certificate: {
isDownloadable: false,
theme: 'professional'
},
status: 'ACTIVE'
}
].filter((c) => !existingCourseIds.includes(c.id));
+17 -1
View File
@@ -5,9 +5,18 @@ interface SeedGroup {
reactGroupId: string;
pandasGroupId: string;
testOrgId: string;
earlyAdopterGroupId: string;
earlyAdopterOrgId: string;
}
export async function seedGroup({ mvcGroupId, reactGroupId, pandasGroupId, testOrgId }: SeedGroup) {
export async function seedGroup({
mvcGroupId,
reactGroupId,
pandasGroupId,
testOrgId,
earlyAdopterGroupId,
earlyAdopterOrgId
}: SeedGroup) {
const existingGroups = await db.select().from(group);
const existingGroupIds = existingGroups.map((g) => g.id);
@@ -32,6 +41,13 @@ export async function seedGroup({ mvcGroupId, reactGroupId, pandasGroupId, testO
description:
'Unlock the power of data with our "Data Science with Python and Pandas" course. Dive into Python programming fundamentals and then journey into the world of Pandas for efficient data manipulation and analysis.',
organizationId: testOrgId
},
{
id: earlyAdopterGroupId,
name: 'Product Management Fundamentals',
description:
'A practical introduction to product management covering roadmaps, prioritization frameworks, user research, and cross-functional collaboration skills needed to ship great products.',
organizationId: earlyAdopterOrgId
}
].filter((g) => !existingGroupIds.includes(g.id));
+18 -1
View File
@@ -6,6 +6,9 @@ interface SeedGroupmember {
pandasGroupId: string;
adminUserId: string;
studentUserId: string;
earlyAdopterGroupId: string;
earlyAdopterAdminUserId: string;
earlyAdopterStudentUserId: string;
}
export async function seedGroupmembers({
@@ -13,7 +16,10 @@ export async function seedGroupmembers({
reactGroupId,
pandasGroupId,
adminUserId,
studentUserId
studentUserId,
earlyAdopterGroupId,
earlyAdopterAdminUserId,
earlyAdopterStudentUserId
}: SeedGroupmember) {
const existingGroupMembers = await db.select().from(groupmember);
const existingGroupMemberKeys = existingGroupMembers.map((gm) => `${gm.groupId}-${gm.profileId || gm.email}`);
@@ -41,6 +47,17 @@ export async function seedGroupmembers({
groupId: pandasGroupId,
roleId: 3, // STUDENT
profileId: studentUserId
},
{
groupId: earlyAdopterGroupId,
roleId: 2, // TUTOR
profileId: earlyAdopterAdminUserId,
email: 'early-adopter@test.com'
},
{
groupId: earlyAdopterGroupId,
roleId: 3, // STUDENT
profileId: earlyAdopterStudentUserId
}
].filter((gm) => !existingGroupMemberKeys.includes(`${gm.groupId}-${gm.profileId || gm.email}`));
+22 -1
View File
@@ -1,6 +1,14 @@
import { db, organization } from '@db/drizzle';
export async function seedOrganization({ testOrgId, enterpriseOrgId }: { testOrgId: string; enterpriseOrgId: string }) {
export async function seedOrganization({
testOrgId,
enterpriseOrgId,
earlyAdopterOrgId
}: {
testOrgId: string;
enterpriseOrgId: string;
earlyAdopterOrgId: string;
}) {
const existingOrgs = await db.select().from(organization);
const existingOrgIds = existingOrgs.map((o) => o.id);
@@ -30,6 +38,19 @@ export async function seedOrganization({ testOrgId, enterpriseOrgId }: { testOrg
course: { grading: true, newsfeed: true },
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
}
},
{
id: earlyAdopterOrgId,
name: 'Skillshare Test',
siteName: 'skillshare-test',
settings: {},
landingpage: {},
theme: '',
customization: {
apps: { poll: true, comments: true },
course: { grading: true, newsfeed: true },
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
}
}
].filter((o) => !existingOrgIds.includes(o.id));
@@ -26,3 +26,30 @@ export async function seedEnterpriseOrganizationPlan({ enterpriseOrgId }: { ente
});
console.log(' ✓ Inserted enterprise organization plan');
}
export async function seedEarlyAdopterOrganizationPlan({ earlyAdopterOrgId }: { earlyAdopterOrgId: string }) {
const existing = await db
.select()
.from(organizationPlan)
.where(
and(
eq(organizationPlan.orgId, earlyAdopterOrgId),
eq(organizationPlan.planName, 'EARLY_ADOPTER'),
eq(organizationPlan.isActive, true)
)
);
if (existing.length > 0) {
console.log(' ✓ Early adopter organization plan already exists, skipping');
return;
}
await db.insert(organizationPlan).values({
orgId: earlyAdopterOrgId,
planName: 'EARLY_ADOPTER',
isActive: true,
subscriptionId: 'seed-skillshare-test-early-adopter',
provider: 'seed'
});
console.log(' ✓ Inserted early adopter organization plan');
}
@@ -7,6 +7,9 @@ interface SeedOrganizationMember {
enterpriseOrgId: string;
enterpriseAdminUserId: string;
enterpriseStudentUserId: string;
earlyAdopterOrgId: string;
earlyAdopterAdminUserId: string;
earlyAdopterStudentUserId: string;
}
export async function seedOrganizationMember({
@@ -15,7 +18,10 @@ export async function seedOrganizationMember({
studentUserId,
enterpriseOrgId,
enterpriseAdminUserId,
enterpriseStudentUserId
enterpriseStudentUserId,
earlyAdopterOrgId,
earlyAdopterAdminUserId,
earlyAdopterStudentUserId
}: SeedOrganizationMember) {
const existingOrgMembers = await db.select().from(organizationmember);
const existingOrgMemberKeys = existingOrgMembers.map((om) => `${om.organizationId}-${om.profileId}`);
@@ -44,6 +50,18 @@ export async function seedOrganizationMember({
roleId: 3, // STUDENT
profileId: enterpriseStudentUserId,
verified: false
},
{
organizationId: earlyAdopterOrgId,
roleId: 1, // ADMIN
profileId: earlyAdopterAdminUserId,
verified: false
},
{
organizationId: earlyAdopterOrgId,
roleId: 3, // STUDENT
profileId: earlyAdopterStudentUserId,
verified: false
}
].filter((om) => !existingOrgMemberKeys.includes(`${om.organizationId}-${om.profileId}`));
+102
View File
@@ -253,5 +253,107 @@
}
],
"profile_fullname": "Enterprise Student"
},
{
"instance_id": "00000000-0000-0000-0000-000000000000",
"id": "6e0163ff-9161-4d4b-be9f-3e7fbd913c5e",
"aud": "authenticated",
"role": "authenticated",
"email": "early-adopter@test.com",
"encrypted_password": "$2a$10$n8vBI6.pyE0W/RO9DcJDseLKF/CRwsU4X4Lc2MaQogt8pQgnJavTa",
"email_confirmed_at": "2025-03-23T12:00:00.000Z",
"invited_at": null,
"confirmation_token": "",
"confirmation_sent_at": null,
"recovery_token": "",
"recovery_sent_at": null,
"email_change_token_new": "",
"email_change": "",
"email_change_sent_at": null,
"last_sign_in_at": null,
"raw_app_meta_data": { "provider": "email", "providers": ["email"] },
"raw_user_meta_data": {},
"is_super_admin": null,
"created_at": "2025-03-23T12:00:00.000Z",
"updated_at": "2025-03-23T12:00:00.000Z",
"phone": null,
"phone_confirmed_at": null,
"phone_change": "",
"phone_change_token": "",
"phone_change_sent_at": null,
"confirmed_at": "2025-03-23T12:00:00.000Z",
"email_change_token_current": "",
"email_change_confirm_status": 0,
"banned_until": null,
"reauthentication_token": "",
"reauthentication_sent_at": null,
"is_sso_user": false,
"deleted_at": null,
"is_anonymous": false,
"identities": [
{
"provider_id": "6e0163ff-9161-4d4b-be9f-3e7fbd913c5e",
"user_id": "6e0163ff-9161-4d4b-be9f-3e7fbd913c5e",
"identity_data": { "sub": "6e0163ff-9161-4d4b-be9f-3e7fbd913c5e", "email": "early-adopter@test.com" },
"provider": "email",
"last_sign_in_at": "2025-03-23T12:00:00.000Z",
"created_at": "2025-03-23T12:00:00.000Z",
"updated_at": "2025-03-23T12:00:00.000Z",
"email": "early-adopter@test.com",
"id": "a0e274bb-c3ee-4f5c-d13d-7bccd0257fac"
}
],
"profile_fullname": "Early Adopter Admin"
},
{
"instance_id": "00000000-0000-0000-0000-000000000000",
"id": "7fb274aa-a272-4e5c-cf0a-4f8cce024d6f",
"aud": "authenticated",
"role": "authenticated",
"email": "early-adopter-student@test.com",
"encrypted_password": "$2a$10$n8vBI6.pyE0W/RO9DcJDseLKF/CRwsU4X4Lc2MaQogt8pQgnJavTa",
"email_confirmed_at": "2025-03-23T12:00:00.000Z",
"invited_at": null,
"confirmation_token": "",
"confirmation_sent_at": null,
"recovery_token": "",
"recovery_sent_at": null,
"email_change_token_new": "",
"email_change": "",
"email_change_sent_at": null,
"last_sign_in_at": null,
"raw_app_meta_data": { "provider": "email", "providers": ["email"] },
"raw_user_meta_data": {},
"is_super_admin": null,
"created_at": "2025-03-23T12:00:00.000Z",
"updated_at": "2025-03-23T12:00:00.000Z",
"phone": null,
"phone_confirmed_at": null,
"phone_change": "",
"phone_change_token": "",
"phone_change_sent_at": null,
"confirmed_at": "2025-03-23T12:00:00.000Z",
"email_change_token_current": "",
"email_change_confirm_status": 0,
"banned_until": null,
"reauthentication_token": "",
"reauthentication_sent_at": null,
"is_sso_user": false,
"deleted_at": null,
"is_anonymous": false,
"identities": [
{
"provider_id": "7fb274aa-a272-4e5c-cf0a-4f8cce024d6f",
"user_id": "7fb274aa-a272-4e5c-cf0a-4f8cce024d6f",
"identity_data": { "sub": "7fb274aa-a272-4e5c-cf0a-4f8cce024d6f", "email": "early-adopter-student@test.com" },
"provider": "email",
"last_sign_in_at": "2025-03-23T12:00:00.000Z",
"created_at": "2025-03-23T12:00:00.000Z",
"updated_at": "2025-03-23T12:00:00.000Z",
"email": "early-adopter-student@test.com",
"id": "b1f385cc-d4ff-4a6d-e24e-8cdde1368abd"
}
],
"profile_fullname": "Early Adopter Student"
}
]
+8 -8
View File
@@ -1,3 +1,9 @@
import type {
TAutomationCourseTagAssignment,
TAutomationDraftTagAssignment,
TAutomationDraftTagParam
} from '@cio/utils/validation/tag';
import type { TCourseContentReorder, TCourseLandingPageUpdate, TCourseUpdateParam } from '@cio/utils/validation/course';
import type {
TCourseImportCourseParam,
TCourseImportDraftCreate,
@@ -6,7 +12,6 @@ import type {
TCourseImportDraftPublishToCourse,
TCourseImportDraftUpdate
} from '@cio/utils/validation/course-import';
import type { TCourseContentReorder, TCourseLandingPageUpdate, TCourseUpdateParam } from '@cio/utils/validation/course';
import type {
TExerciseCreate,
TExerciseFromTemplate,
@@ -14,14 +19,9 @@ import type {
TExerciseListQuery,
TExerciseUpdate
} from '@cio/utils/validation/exercise';
import type { TGetOrganizationCoursesQuery } from '@cio/utils/validation/organization';
import type {
TAutomationCourseTagAssignment,
TAutomationDraftTagAssignment,
TAutomationDraftTagParam
} from '@cio/utils/validation/tag';
import type { McpServerConfig } from './config.js';
import type { McpServerConfig } from './config';
import type { TGetOrganizationCoursesQuery } from '@cio/utils/validation/organization';
type ApiSuccess<T> = {
success: true;
+4 -4
View File
@@ -1,9 +1,9 @@
import { ClassroomIoApiClient, ClassroomIoApiError } from './api-client';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { ClassroomIoApiClient, ClassroomIoApiError } from './api-client.js';
import { getConfig } from './config.js';
import { registerCourseDraftTools } from './tools/course-drafts.js';
import { getConfig } from './config';
import { registerCourseDraftTools } from './tools/course-drafts';
async function main() {
const config = getConfig();
+27 -20
View File
@@ -3,7 +3,21 @@ import {
ZAutomationDraftTagAssignment,
ZAutomationDraftTagParam
} from '@cio/utils/validation/tag';
import { ZGetOrganizationCoursesQuery } from '@cio/utils/validation/organization';
import {
ZCourseContentReorder,
ZCourseContentReorderBase,
ZCourseLandingPageUpdate
} from '@cio/utils/validation/course';
import {
ZCourseImportCourseParam,
ZCourseImportDraftCreate,
ZCourseImportDraftCreateFromCourse,
ZCourseImportDraftGetParam,
ZCourseImportDraftPublishBase,
ZCourseImportDraftPublishToCourse,
ZCourseImportDraftPublishToCourseBase,
ZCourseImportDraftUpdate
} from '@cio/utils/validation/course-import';
import {
ZExerciseCreate,
ZExerciseFromTemplate,
@@ -11,24 +25,11 @@ import {
ZExerciseListQuery,
ZExerciseUpdate
} from '@cio/utils/validation/exercise';
import {
ZCourseImportCourseParam,
ZCourseImportDraftCreate,
ZCourseImportDraftCreateFromCourse,
ZCourseImportDraftGetParam,
ZCourseImportDraftPublish,
ZCourseImportDraftPublishToCourse,
ZCourseImportDraftUpdate
} from '@cio/utils/validation/course-import';
import {
ZCourseContentReorder,
ZCourseContentReorderBase,
ZCourseLandingPageUpdate
} from '@cio/utils/validation/course';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
import type { ClassroomIoApiClient } from '../api-client.js';
import type { ClassroomIoApiClient } from '../api-client';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { ZGetOrganizationCoursesQuery } from '@cio/utils/validation/organization';
import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
export const ZUpdateCourseDraftToolInput = ZCourseImportDraftUpdate.extend({
draftId: ZCourseImportDraftGetParam.shape.draftId
@@ -69,12 +70,18 @@ export const ZReorderCourseContentToolInput = ZCourseContentReorderBase.extend({
courseId: ZCourseImportCourseParam.shape.courseId
});
export const ZPublishCourseDraftToolInput = ZCourseImportDraftPublish.extend({
export const ZPublishCourseDraftToolInput = ZCourseImportDraftPublishBase.extend({
draftId: ZCourseImportDraftGetParam.shape.draftId
}).refine((data) => data.type !== 'COMPLIANCE' || data.compliance !== undefined, {
message: 'Compliance settings are required for COMPLIANCE courses',
path: ['compliance']
});
export const ZPublishCourseDraftToExistingCourseToolInput = ZCourseImportDraftPublishToCourse.extend({
export const ZPublishCourseDraftToExistingCourseToolInput = ZCourseImportDraftPublishToCourseBase.extend({
draftId: ZCourseImportDraftGetParam.shape.draftId
}).refine((data) => data.type !== 'COMPLIANCE' || data.compliance !== undefined, {
message: 'Compliance settings are required for COMPLIANCE courses',
path: ['compliance']
});
const createCourseDraftShape = ZCourseImportDraftCreate.shape as unknown as ZodRawShapeCompat;
+1
View File
@@ -14,6 +14,7 @@
},
"dependencies": {
"@cio/ui": "workspace:*",
"@cio/utils": "workspace:*",
"svelte-inview": "^4.0.4",
"svelte-motion": "^0.12.2"
},
@@ -0,0 +1,174 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { ChatTextarea } from '@cio/ui';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/ChatTextarea',
component: ChatTextarea,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
const sampleItems = [
{ id: '1', label: 'Introduction to Programming', type: 'lesson' },
{ id: '2', label: 'Variables and Data Types', type: 'lesson' },
{ id: '3', label: 'Quiz: Variables', type: 'exercise' },
{ id: '4', label: 'Control Flow', type: 'lesson' },
{ id: '5', label: 'Practice: Loops', type: 'exercise' },
{ id: '6', label: 'Functions and Scope', type: 'lesson' },
{ id: '7', label: 'Final Assessment', type: 'exercise' }
];
</script>
<script lang="ts">
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
import { Button } from '@cio/ui/base/button';
import SendIcon from '@lucide/svelte/icons/send';
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
import SquareIcon from '@lucide/svelte/icons/square';
let value = $state('');
let sentMessages: string[] = $state([]);
let isStreaming = $state(false);
const MENTION_REGEX = /@\[([^\]]+)\]\((lesson|exercise):([a-zA-Z0-9_-]+)\)/g;
function renderMentionHtml(text: string): string {
const escaped = text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
return escaped.replace(MENTION_REGEX, (_match, title, type) => {
const emoji = type === 'exercise' ? '📝' : '📖';
return `<span style="background: hsl(var(--primary) / 0.15); padding: 1px 6px; border-radius: 4px; font-weight: 500;">${emoji} ${title}</span>`;
});
}
function handleSend() {
if (!value.trim()) return;
sentMessages = [...sentMessages, value];
value = '';
}
</script>
<Story name="Default">
{#snippet template()}
<div style="width: 360px;">
<ChatTextarea
bind:value
mentionItems={sampleItems}
onSubmit={handleSend}
placeholder="Ask the AI assistant... (type @ to mention)"
typeLabel={(item) => (item.type === 'exercise' ? 'Exercise' : 'Lesson')}
emptyMessage="No matching content"
>
{#snippet icon({ item })}
{#if item.type === 'exercise'}
<ExerciseIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
{#snippet actions()}
<button class="ui:text-muted-foreground hover:ui:bg-muted ui:rounded-md ui:p-1.5 ui:transition-colors">
<PaperclipIcon size={16} />
</button>
<div class="ui:flex-1"></div>
<Button size="icon" variant="default" onclick={handleSend} disabled={!value.trim()} class="ui:size-7">
<SendIcon size={14} />
</Button>
{/snippet}
</ChatTextarea>
<p class="ui:text-muted-foreground ui:mt-2 ui:text-xs">
Press Enter to send. Current value: <code class="ui:text-xs">{value || '(empty)'}</code>
</p>
</div>
{/snippet}
</Story>
<Story name="With Sent Messages">
{#snippet template()}
<div
style="width: 380px; border: 1px solid hsl(var(--border)); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; height: 400px; background: hsl(var(--background));"
>
<div style="flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px;">
{#if sentMessages.length === 0}
<p class="ui:text-muted-foreground" style="text-align: center; font-size: 13px; margin-top: 40px;">
Type <kbd class="ui:bg-muted" style="padding: 1px 5px; border-radius: 4px; font-size: 12px;">@</kbd> to mention
a lesson or exercise, then press Enter to send.
</p>
{/if}
{#each sentMessages as msg, index (index)}
<div
style="align-self: flex-end; max-width: 85%; background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-radius: 8px; padding: 8px 12px; font-size: 14px;"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderMentionHtml(msg)}
</div>
{/each}
</div>
<div style="border-top: 1px solid hsl(var(--border)); padding: 8px;">
<ChatTextarea
bind:value
mentionItems={sampleItems}
onSubmit={handleSend}
placeholder="Ask the AI assistant..."
typeLabel={(item) => (item.type === 'exercise' ? 'Exercise' : 'Lesson')}
emptyMessage="No matching content"
>
{#snippet icon({ item })}
{#if item.type === 'exercise'}
<ExerciseIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
{#snippet actions()}
<button class="ui:text-muted-foreground hover:ui:bg-muted ui:rounded-md ui:p-1.5 ui:transition-colors">
<PaperclipIcon size={16} />
</button>
<div class="ui:flex-1"></div>
<Button size="icon" variant="default" onclick={handleSend} disabled={!value.trim()} class="ui:size-7">
<SendIcon size={14} />
</Button>
{/snippet}
</ChatTextarea>
</div>
</div>
{/snippet}
</Story>
<Story name="Without Mentions">
{#snippet template()}
<div style="width: 360px;">
<ChatTextarea bind:value placeholder="Type a message..." onSubmit={handleSend} />
</div>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template()}
<div style="width: 360px;">
<ChatTextarea value="Streaming response..." disabled placeholder="Ask the AI assistant...">
{#snippet actions()}
<button class="ui:text-muted-foreground ui:rounded-md ui:p-1.5 ui:opacity-50" disabled>
<PaperclipIcon size={16} />
</button>
<div class="ui:flex-1"></div>
<Button size="icon" variant="outline" class="ui:size-7">
<SquareIcon size={12} />
</Button>
{/snippet}
</ChatTextarea>
</div>
{/snippet}
</Story>
@@ -0,0 +1 @@
export const FIELDS = ['class', 'placeholder', 'disabled', 'emptyMessage'] as string[];
@@ -0,0 +1,135 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import * as Field from '@cio/ui/base/field';
import { CheckboxOptionCard, CheckboxOptionCardGroup } from '@cio/ui/custom/checkbox-option-card';
import { Badge } from '@cio/ui/base/badge';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/CheckboxOptionCard',
component: CheckboxOptionCard,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
let selectedCourses = $state<string[]>(['intro-html']);
let selectedFeatures = $state<string[]>(['certificates', 'live-classes']);
let selectedAudiences = $state<string[]>([]);
const courseOptions = [
{
id: 'intro-html',
title: 'Introduction to HTML',
description: '12 lessons · Self-paced',
value: 'intro-html'
},
{
id: 'css-fundamentals',
title: 'CSS Fundamentals',
description: '18 lessons · Self-paced',
value: 'css-fundamentals'
},
{
id: 'js-essentials',
title: 'JavaScript Essentials',
description: '24 lessons · Live class',
value: 'js-essentials'
},
{
id: 'svelte-bootcamp',
title: 'Svelte Bootcamp',
description: 'Coming soon',
value: 'svelte-bootcamp',
disabled: true
}
];
const featureOptions = [
{
id: 'certificates',
title: 'Certificates',
description: 'Issue completion certificates to students.',
value: 'certificates'
},
{
id: 'live-classes',
title: 'Live classes',
description: 'Schedule and host real-time lessons.',
value: 'live-classes'
},
{
id: 'compliance',
title: 'Compliance tracking',
description: 'Track required training across teams.',
value: 'compliance'
}
];
const audienceOptions = [
{ id: 'students', title: 'Students', description: 'Anyone enrolled in a course.', value: 'students' },
{ id: 'tutors', title: 'Tutors', description: 'Course facilitators and graders.', value: 'tutors' },
{ id: 'admins', title: 'Admins', description: 'Organization administrators.', value: 'admins' }
];
</script>
<Story name="Course Picker (stacked horizontally)">
{#snippet template()}
<div class="ui:w-full ui:max-w-xl">
<Field.Description class="ui:mb-4 ui:block">
Pick one or more courses. Each card stacks vertically and lays out title and checkbox horizontally.
</Field.Description>
<CheckboxOptionCardGroup options={courseOptions} bind:value={selectedCourses} class="ui:grid-cols-1" />
<p class="ui:mt-3 ui:text-muted-foreground ui:text-sm">Selected: {selectedCourses.join(', ') || 'none'}</p>
</div>
{/snippet}
</Story>
<Story name="Feature Toggles (Group)">
{#snippet template()}
<div class="ui:w-full ui:max-w-xl">
<Field.Description class="ui:mb-4 ui:block">
Toggle one or more product features. Bound value is an array of selected option values.
</Field.Description>
<CheckboxOptionCardGroup options={featureOptions} bind:value={selectedFeatures} />
<p class="ui:mt-3 ui:text-muted-foreground ui:text-sm">Selected: {selectedFeatures.join(', ') || 'none'}</p>
</div>
{/snippet}
</Story>
<Story name="With Disabled Option and Title Suffix">
{#snippet template()}
<div class="ui:w-full ui:max-w-xl">
<Field.Description class="ui:mb-4 ui:block">
Use the <code>titleSuffix(option)</code> snippet to render per-option content.
</Field.Description>
<CheckboxOptionCardGroup options={courseOptions} bind:value={selectedCourses}>
{#snippet titleSuffix(option)}
{#if option.disabled}
<Badge variant="secondary" class="ui:text-xs">Coming soon</Badge>
{/if}
{/snippet}
</CheckboxOptionCardGroup>
</div>
{/snippet}
</Story>
<Story name="Custom Columns (Group class override)">
{#snippet template()}
<div class="ui:w-full ui:max-w-3xl">
<Field.Description class="ui:mb-4 ui:block">
Override grid with the <code>class</code> prop on <code>CheckboxOptionCardGroup</code>.
</Field.Description>
<CheckboxOptionCardGroup
options={audienceOptions}
bind:value={selectedAudiences}
class="ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3"
/>
<p class="ui:mt-3 ui:text-muted-foreground ui:text-sm">Selected: {selectedAudiences.join(', ') || 'none'}</p>
</div>
{/snippet}
</Story>
@@ -0,0 +1,2 @@
/** Props shown in Storybook controls (class overrides layout/columns per card) */
export const FIELDS = ['class'] as string[];
@@ -0,0 +1 @@
export const FIELDS = ['class'] as string[];
@@ -0,0 +1,94 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { MentionPopover } from '@cio/ui';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/MentionPopover',
component: MentionPopover,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
const sampleItems = [
{ id: '1', label: 'Introduction to Programming', type: 'lesson' },
{ id: '2', label: 'Variables and Data Types', type: 'lesson' },
{ id: '3', label: 'Quiz: Variables', type: 'exercise' },
{ id: '4', label: 'Control Flow', type: 'lesson' },
{ id: '5', label: 'Practice: Loops', type: 'exercise' },
{ id: '6', label: 'Functions and Scope', type: 'lesson' },
{ id: '7', label: 'Final Assessment', type: 'exercise' }
];
</script>
<script lang="ts">
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
let lastSelected = $state('');
</script>
<Story name="Default">
{#snippet template()}
<MentionPopover
items={sampleItems}
query=""
selectedIndex={0}
onSelect={(item) => (lastSelected = item.label)}
typeLabel={(item) => (item.type === 'exercise' ? 'Exercise' : 'Lesson')}
>
{#snippet icon({ item })}
{#if item.type === 'exercise'}
<ExerciseIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
</MentionPopover>
{#if lastSelected}
<p class="ui:mt-3 ui:text-muted-foreground ui:text-sm">Selected: {lastSelected}</p>
{/if}
{/snippet}
</Story>
<Story name="Filtered with Highlight">
{#snippet template()}
<MentionPopover
items={sampleItems}
query="Quiz"
selectedIndex={0}
onSelect={(item) => (lastSelected = item.label)}
typeLabel={(item) => (item.type === 'exercise' ? 'Exercise' : 'Lesson')}
>
{#snippet icon({ item })}
{#if item.type === 'exercise'}
<ExerciseIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
</MentionPopover>
{/snippet}
</Story>
<Story name="No Results">
{#snippet template()}
<MentionPopover
items={sampleItems}
query="zzzzz"
selectedIndex={0}
onSelect={() => {}}
emptyMessage="No matching content"
/>
{/snippet}
</Story>
<Story name="Without Icons or Type Labels">
{#snippet template()}
<MentionPopover items={sampleItems} query="" selectedIndex={2} onSelect={(item) => (lastSelected = item.label)} />
{/snippet}
</Story>
@@ -30,6 +30,7 @@
'Unlimited Q/A',
'20 Students',
'AI Course Builder (no video upload)',
'50K AI credits / month',
'ClassroomIO branding'
],
CTA: {
@@ -0,0 +1 @@
export const FIELDS = ['payload'] as string[];
@@ -0,0 +1,312 @@
import {
getDefaultWidgetConfig,
type TWidgetLayoutType,
type TWidgetPayload,
type TWidgetPayloadCategory,
type TWidgetPayloadCourse,
type TWidgetPayloadCourseTag
} from '@cio/utils/validation/widget';
const TAGS: TWidgetPayloadCourseTag[] = [
{ id: '11111111-1111-4111-8111-111111111111', name: 'Compliance', slug: 'compliance', color: '#16a34a' },
{ id: '22222222-2222-4222-8222-222222222222', name: 'Math', slug: 'math', color: '#ef4444' },
{ id: '33333333-3333-4333-8333-333333333333', name: 'AI', slug: 'ai', color: '#6366f1' },
{ id: '44444444-4444-4444-8444-444444444444', name: 'Certificate', slug: 'certificate', color: '#f59e0b' },
{ id: '55555555-5555-4555-8555-555555555555', name: 'Live', slug: 'live', color: '#0ea5e9' },
{ id: '66666666-6666-4666-8666-666666666666', name: 'Design', slug: 'design', color: '#ec4899' },
{ id: '77777777-7777-4777-8777-777777777777', name: 'Engineering', slug: 'engineering', color: '#14b8a6' },
{ id: '88888888-8888-4888-8888-888888888888', name: 'Leadership', slug: 'leadership', color: '#8b5cf6' }
];
/** Stable Unsplash thumbnails (signed by Unsplash, no API key needed) at 800×500. */
function unsplash(seed: string): string {
return `https://images.unsplash.com/${seed}?auto=format&fit=crop&w=800&h=500&q=70`;
}
function makeCourse(
partial: Partial<TWidgetPayloadCourse> & { id: string; title: string; courseType: string }
): TWidgetPayloadCourse {
return {
id: partial.id,
slug: partial.id,
title: partial.title,
description:
partial.description ??
'A structured course covering core concepts, applied exercises, and a final assessment to lock in mastery.',
imageUrl: partial.imageUrl ?? null,
price: partial.price ?? 'Free',
lessonCount: partial.lessonCount ?? 12,
exerciseCount: partial.exerciseCount ?? 4,
rating: partial.rating ?? 4.4,
ratingCount: partial.ratingCount ?? 38,
courseType: partial.courseType,
createdAt: partial.createdAt ?? new Date().toISOString(),
tags: partial.tags ?? [],
featured: partial.featured ?? false,
url: partial.url ?? `https://demo.classroomio.com/course/${partial.id}`
};
}
const COURSES: TWidgetPayloadCourse[] = [
makeCourse({
id: 'aaaaaaa1-1111-4111-8111-aaaaaaaaaaaa',
title: 'SaaS Security & Compliance Essentials',
description:
'Stand up a SOC 2-ready posture in weeks: scope, controls, evidence collection, vendor reviews, and audit prep — explained for engineers and ops leads.',
courseType: 'COMPLIANCE',
rating: 4.0,
ratingCount: 28,
lessonCount: 14,
exerciseCount: 4,
price: '$199',
imageUrl: unsplash('photo-1518770660439-4636190af475'),
tags: [TAGS[0], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa2-2222-4222-8222-aaaaaaaaaaab',
title: 'Geometry: Shapes, Proofs, and Space',
description:
'Twenty-five hands-on lessons on Euclidean and analytic geometry, with proof-writing drills and visual intuition for transformations and loci.',
courseType: 'SELF_PACED',
rating: 4.8,
ratingCount: 112,
lessonCount: 25,
exerciseCount: 9,
price: 'Free',
imageUrl: unsplash('photo-1509228468518-180dd4864904'),
tags: [TAGS[1], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa3-3333-4333-8333-aaaaaaaaaaac',
title: 'Trigonometry Rebuild: A Detailed Refresher',
description:
'Rebuild your trig instincts from the unit circle up. Identities, graphs, inverse functions, and applied problems with worked solutions.',
courseType: 'SELF_PACED',
rating: 4.2,
ratingCount: 47,
lessonCount: 7,
exerciseCount: 4,
price: '$49',
imageUrl: unsplash('photo-1503676260728-1c00da094a0b'),
tags: [TAGS[1], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa4-4444-4444-8444-aaaaaaaaaaad',
title: 'AI Ethics & Responsible Use in Production',
description:
'A practical, case-based curriculum for product teams shipping LLM features. Bias audits, eval harnesses, escalation paths, and red-team exercises.',
courseType: 'LIVE_CLASS',
rating: 4.9,
ratingCount: 203,
lessonCount: 9,
exerciseCount: 2,
price: '$299',
imageUrl: unsplash('photo-1677442136019-21780ecad995'),
tags: [TAGS[2], TAGS[4], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa5-5555-4555-8555-aaaaaaaaaaae',
title: 'Data Privacy Fundamentals (GDPR & CCPA)',
description:
'Map data flows, write defensible DPIAs, and configure consent so legal sleeps at night. Built for PMs, engineers, and customer-facing teams.',
courseType: 'COMPLIANCE',
rating: 4.3,
ratingCount: 61,
lessonCount: 11,
exerciseCount: 3,
price: '$149',
imageUrl: unsplash('photo-1554224155-6726b3ff858f'),
tags: [TAGS[0]]
}),
makeCourse({
id: 'aaaaaaa6-6666-4666-8666-aaaaaaaaaaaf',
title: 'Business Communication for Hybrid Teams',
description:
'Run sharper standups, write briefs that stick, and present decisions to execs. Six live workshops with peer feedback and rubrics.',
courseType: 'LIVE_CLASS',
rating: 4.1,
ratingCount: 39,
lessonCount: 12,
exerciseCount: 5,
price: '$129',
imageUrl: unsplash('photo-1552664730-d307ca884978'),
tags: [TAGS[4], TAGS[3], TAGS[7]]
}),
makeCourse({
id: 'aaaaaaa7-7777-4777-8777-aaaaaaaaaaa1',
title: 'Design Systems with Tokens & Figma',
description:
'From color ramps to multi-brand theming. Build a token pipeline that survives team changes, with hands-on Figma-to-code workflows.',
courseType: 'SELF_PACED',
rating: 4.7,
ratingCount: 87,
lessonCount: 18,
exerciseCount: 6,
price: '$179',
imageUrl: unsplash('photo-1581291518633-83b4ebd1d83e'),
tags: [TAGS[5], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa8-8888-4888-8888-aaaaaaaaaaa2',
title: 'TypeScript for Production Engineers',
description:
'Stop fighting the inferencer. Generics, type guards, conditional types, and migration patterns for sprawling JavaScript codebases.',
courseType: 'SELF_PACED',
rating: 4.6,
ratingCount: 156,
lessonCount: 22,
exerciseCount: 12,
price: '$229',
imageUrl: unsplash('photo-1517694712202-14dd9538aa97'),
tags: [TAGS[6], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa9-9999-4999-8999-aaaaaaaaaaa3',
title: 'Engineering Management 101',
description:
'The transition from staff engineer to engineering manager: 1:1s, performance feedback, hiring loops, and the calendar discipline nobody warns you about.',
courseType: 'LIVE_CLASS',
rating: 4.5,
ratingCount: 92,
lessonCount: 10,
exerciseCount: 4,
price: '$249',
imageUrl: unsplash('photo-1521737711867-e3b97375f902'),
tags: [TAGS[7], TAGS[4], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaab-aaaa-4aaa-8aaa-aaaaaaaaaaa4',
title: 'Postgres Performance for Application Teams',
description:
'Indexes that earn their keep, EXPLAIN ANALYZE without the panic, and the partitioning strategies that keep your p99 honest.',
courseType: 'SELF_PACED',
rating: 4.8,
ratingCount: 64,
lessonCount: 16,
exerciseCount: 7,
price: '$159',
imageUrl: unsplash('photo-1544383835-bda2bc66a55d'),
tags: [TAGS[6]]
}),
makeCourse({
id: 'aaaaaaac-bbbb-4bbb-8bbb-aaaaaaaaaaa5',
title: 'Practical Calculus for Data Practitioners',
description:
'Limits, derivatives, integrals — taught through gradient descent, ROC curves, and the math that actually shows up in your notebooks.',
courseType: 'SELF_PACED',
rating: 4.4,
ratingCount: 41,
lessonCount: 14,
exerciseCount: 8,
price: '$99',
imageUrl: unsplash('photo-1635070041078-e363dbe005cb'),
tags: [TAGS[1], TAGS[2]]
}),
makeCourse({
id: 'aaaaaaad-cccc-4ccc-8ccc-aaaaaaaaaaa6',
title: 'Brand Visual Identity Workshop',
description:
'A four-week studio: research, moodboards, logo systems, and a packaged brand guideline ready for client handoff.',
courseType: 'LIVE_CLASS',
rating: 4.6,
ratingCount: 73,
lessonCount: 8,
exerciseCount: 3,
price: '$219',
imageUrl: unsplash('photo-1561070791-2526d30994b8'),
tags: [TAGS[5], TAGS[4]]
})
];
const courseIdsByTag = new Map<string, string[]>();
for (const tag of TAGS) {
courseIdsByTag.set(
tag.id,
COURSES.filter((course) => course.tags.some((courseTag) => courseTag.id === tag.id)).map((course) => course.id)
);
}
const CATEGORIES: TWidgetPayloadCategory[] = TAGS.filter((tag) => (courseIdsByTag.get(tag.id)?.length ?? 0) > 0).map(
(tag) => ({
tagId: tag.id,
name: tag.name,
slug: tag.slug,
color: tag.color,
courseIds: courseIdsByTag.get(tag.id) ?? []
})
);
export function makeFixturePayload(layoutType: TWidgetLayoutType): TWidgetPayload {
const config = getDefaultWidgetConfig();
if (layoutType === 'primary_course') {
config.layoutOptions.primaryCourse.featuredCourseId = COURSES[3].id;
config.layoutOptions.primaryCourse.eyebrowLabel = 'Featured course';
config.layoutOptions.primaryCourse.ctaLabel = 'Enroll today';
}
if (layoutType === 'editorial_spotlight') {
config.layoutOptions.editorialSpotlight.mainCourseId = COURSES[3].id;
}
if (layoutType === 'category_shelf') {
config.layoutOptions.categoryShelf.categoryTagIds = CATEGORIES.map((cat) => cat.tagId);
config.layoutOptions.categoryShelf.maxPerCategory = 4;
}
if (layoutType === 'card_grid') {
config.layoutOptions.cardGrid.maxCount = 9;
}
if (layoutType === 'tag_filter') {
config.layoutOptions.tagFilter.maxCount = 12;
}
if (layoutType === 'carousel') {
config.layoutOptions.carousel.maxCount = 8;
}
if (layoutType === 'compact_list') {
config.layoutOptions.compactList.maxCount = 6;
}
const featuredId =
layoutType === 'primary_course'
? config.layoutOptions.primaryCourse.featuredCourseId
: layoutType === 'editorial_spotlight'
? config.layoutOptions.editorialSpotlight.mainCourseId
: null;
const orderedCourses = featuredId
? [
COURSES.find((course) => course.id === featuredId)!,
...COURSES.filter((course) => course.id !== featuredId)
].map((course, index) => ({ ...course, featured: index === 0 }))
: COURSES;
return {
version: 'v1',
widgetId: '99999999-9999-4999-8999-999999999999',
publicKey: 'wgt_storybook_demo_key',
organization: {
id: 'aaaaaaaa-0000-4000-8000-aaaaaaaa0000',
name: 'Acme Academy',
siteName: 'acme',
customDomain: null
},
layoutType,
selectionMode: 'manual',
design: config,
planGatedFields: {
isPaidPlan: true,
canUseCustomColors: true,
canUseCustomCss: true,
canToggleBranding: true,
isBrandingForced: false,
availableThemes: ['classroomio', 'graphite', 'linen', 'spruce'],
selectedTheme: 'classroomio'
},
labels: {
loadMoreLabel: 'Load more',
poweredByLabel: 'Powered by Classroomio'
},
courses: orderedCourses,
tagPool: layoutType === 'tag_filter' ? TAGS : [],
categories: layoutType === 'category_shelf' ? CATEGORIES : [],
timestamp: Date.now()
};
}
@@ -0,0 +1,95 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import { onMount, onDestroy } from 'svelte';
import { CourseWidget, widgetStyles } from '@cio/ui/custom/widget-layouts';
import { FIELDS } from './fields';
import { makeFixturePayload } from './fixtures';
const { Story } = defineMeta({
title: 'Molecules/WidgetLayouts',
component: CourseWidget,
tags: ['autodocs'],
args: {
payload: makeFixturePayload('card_grid')
},
parameters: {
layout: 'fullscreen',
controls: {
include: FIELDS
}
}
});
</script>
<script lang="ts">
let styleEl: HTMLStyleElement | null = null;
onMount(() => {
styleEl = document.createElement('style');
styleEl.setAttribute('data-cio-widget-stories', '');
styleEl.textContent = widgetStyles;
document.head.appendChild(styleEl);
});
onDestroy(() => {
if (styleEl?.parentNode) {
styleEl.parentNode.removeChild(styleEl);
}
});
</script>
<Story name="Card grid" args={{ payload: makeFixturePayload('card_grid') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Tag filter" args={{ payload: makeFixturePayload('tag_filter') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Carousel" args={{ payload: makeFixturePayload('carousel') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Primary course" args={{ payload: makeFixturePayload('primary_course') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Compact list" args={{ payload: makeFixturePayload('compact_list') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8; max-width: 480px;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Editorial spotlight" args={{ payload: makeFixturePayload('editorial_spotlight') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Category shelf" args={{ payload: makeFixturePayload('category_shelf') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
+10 -1
View File
@@ -40,11 +40,13 @@
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonAnimate = 'border' | 'bg-shine';
export type ButtonPropsWithoutHTML = WithChildren<{
ref?: HTMLElement | null;
variant?: ButtonVariant;
size?: ButtonSize;
animate?: ButtonAnimate;
loading?: boolean;
onClickPromise?: (
e: MouseEvent & {
@@ -78,6 +80,7 @@
ref = $bindable(null),
variant = 'default',
size = 'default',
animate = undefined,
href = undefined,
type = 'button',
loading = false,
@@ -89,6 +92,12 @@
children,
...rest
}: ButtonProps = $props();
const animateClasses: Record<ButtonAnimate, string> = {
border: '',
'bg-shine':
'ui:animate-shine ui:border ui:border-neutral-800 ui:bg-[linear-gradient(110deg,#000103,45%,#1e2631,55%,#000103)] ui:bg-[length:200%_100%] ui:text-white'
};
</script>
<!-- This approach to disabled links is inspired by bits-ui see: https://github.com/huntabyte/bits-ui/pull/1055 -->
@@ -102,7 +111,7 @@
aria-disabled={href ? disabled : undefined}
role={href && disabled ? 'link' : undefined}
tabindex={href && disabled ? -1 : tabindex}
class={cn(buttonVariants({ variant, size }), className)}
class={cn(buttonVariants({ variant, size }), animate && animateClasses[animate], className)}
bind:this={ref}
onclick={async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+2
View File
@@ -6,6 +6,7 @@ import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
type ButtonAnimate,
type AnchorElementProps,
type ButtonElementProps,
type ButtonPropsWithoutHTML,
@@ -21,6 +22,7 @@ export {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
type ButtonAnimate,
type AnchorElementProps,
type ButtonElementProps,
type ButtonPropsWithoutHTML
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '../button';
import { UseClipboard } from '../../use-clipboard.svelte';
import { cn } from '../../utils.js';
import { cn } from '../../utils';
import CheckIcon from '@lucide/svelte/icons/check';
import CopyIcon from '@lucide/svelte/icons/copy';
import XIcon from '@lucide/svelte/icons/x';
@@ -1,7 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
import * as Dialog from './index';
import { cn, type WithoutChildrenOrChild } from '../../tools';
import CrossIcon from '@lucide/svelte/icons/x';
@@ -25,7 +25,7 @@
bind:ref
data-slot="dialog-content"
class={cn(
'ui:bg-background ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-95 ui:fixed ui:left-[50%] ui:top-[50%] ui:z-200 ui:grid ui:w-full ui:max-w-[calc(100%-2rem)] ui:translate-x-[-50%] ui:translate-y-[-50%] ui:gap-4 ui:rounded-lg ui:p-6 ui:shadow-lg ui:duration-200 ui:sm:max-w-lg',
'ui:bg-background ui:border ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-95 ui:fixed ui:left-[50%] ui:top-[50%] ui:z-200 ui:grid ui:w-full ui:max-w-[calc(100%-2rem)] ui:translate-x-[-50%] ui:translate-y-[-50%] ui:gap-4 ui:rounded-lg ui:p-6 ui:shadow-lg ui:duration-200 ui:sm:max-w-lg',
className
)}
{...restProps}
@@ -13,6 +13,7 @@
'ui:group/field-label ui:peer/field-label ui:flex ui:w-fit ui:gap-2 ui:leading-snug ui:group-data-[disabled=true]/field:opacity-50',
'ui:has-[>[data-slot=field]]:w-full ui:has-[>[data-slot=field]]:flex-col ui:has-[>[data-slot=field]]:rounded-md ui:has-[>[data-slot=field]]:border ui:*:data-[slot=field]:p-4',
'ui:has-data-[state=checked]:bg-primary/5 ui:has-data-[state=checked]:border-primary ui:dark:has-data-[state=checked]:bg-primary/10',
'ui:hover:bg-primary/5 ui:cursor-pointer ui:transition-colors ui:transition-duration-200',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
import { IsMobile } from '../../hooks/is-mobile.svelte';
import { getContext, setContext } from 'svelte';
import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js';
import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants';
type Getter<T> = () => T;
@@ -16,7 +16,7 @@
const mergedProps = $derived({
class: cn(
'ui:text-sidebar-foreground/70 ui:ring-sidebar-ring ui:outline-hidden ui:flex ui:h-8 ui:shrink-0 ui:items-center ui:rounded-md ui:px-2 ui:text-xs ui:font-medium ui:transition-[margin,opacity] ui:duration-200 ui:ease-linear ui:focus-visible:ring-2 ui:[&>svg]:size-4 ui:[&>svg]:shrink-0',
'ui:group-data-[collapsible=icon]:-mt-8 ui:group-data-[collapsible=icon]:opacity-0',
'ui:group-data-[collapsible=icon]:-mt-8 ui:group-data-[collapsible=icon]:opacity-0 ui:group-data-[collapsible=icon]:pointer-events-none',
className
),
'data-slot': 'sidebar-group-label',
@@ -2,7 +2,7 @@
import * as Tooltip from '../tooltip';
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
import { SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './constants.js';
import { SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './constants';
import { setSidebar } from './context.svelte';
let {
@@ -6,6 +6,8 @@
let {
ref = $bindable(null),
class: className,
onclick,
onpointerdown,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
@@ -19,7 +21,15 @@
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onclick={sidebar.toggle}
onclick={(event) => {
if (onclick) {
onclick(event);
return;
}
sidebar.toggle();
}}
{onpointerdown}
title="Toggle Sidebar"
class={cn(
'ui:hover:after:bg-sidebar-border ui:absolute ui:inset-y-0 ui:z-20 ui:hidden ui:w-4 ui:-translate-x-1/2 ui:transition-all ui:ease-linear ui:after:absolute ui:after:inset-y-0 ui:after:left-[calc(1/2*100%-1px)] ui:after:w-[2px] ui:sm:flex ui:group-data-[side=left]:-right-4 ui:group-data-[side=right]:left-0',
+2 -2
View File
@@ -2,7 +2,7 @@
import * as Sheet from '../sheet';
import { cn, type WithElementRef } from '../../../src/tools';
import type { HTMLAttributes } from 'svelte/elements';
import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
import { SIDEBAR_WIDTH_MOBILE } from './constants';
import { useSidebar } from './context.svelte';
let {
@@ -77,7 +77,7 @@
<div
data-slot="sidebar-container"
class={cn(
'ui:w-(--sidebar-width) ui:fixed ui:inset-y-0 ui:z-10 ui:hidden ui:max-h-svh ui:transition-[left,right,width] ui:duration-200 ui:ease-linear ui:md:flex',
'ui:w-(--sidebar-width) ui:fixed ui:inset-y-0 ui:z-100 ui:hidden ui:max-h-svh ui:transition-[left,right,width] ui:duration-200 ui:ease-linear ui:md:flex',
side === 'left'
? 'ui:left-0 ui:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'ui:right-0 ui:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
@@ -3,9 +3,10 @@
import type { Snippet } from 'svelte';
interface Props {
href: string;
href?: string;
target?: string;
rel?: string;
onclick?: (event: MouseEvent) => void;
shimmerColor?: string;
shimmerSize?: string;
shimmerDuration?: string;
@@ -19,6 +20,7 @@
href,
target = '',
rel = '',
onclick,
shimmerColor = '#ffffff',
shimmerSize = '0.05em',
shimmerDuration = '3s',
@@ -29,39 +31,64 @@
}: Props = $props();
</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 rounded-(--radius) border border-white/10 px-6 py-3 whitespace-nowrap text-white [background:var(--bg)] dark:text-white',
'transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px',
className
)}
{href}
{target}
{rel}
data-rewardful
>
<div class={cn('-z-30 blur-[2px]', '@container-[size] absolute inset-0 overflow-visible')}>
<div class="animate-magicslide absolute inset-0 aspect-[1] h-[100cqh] rounded-none [mask:none]">
{#snippet shimmerContent()}
<div class={cn('ui:-z-30 ui:blur-[2px]', 'ui:@container-[size] ui:absolute ui:inset-0 ui:overflow-visible')}>
<div
class="ui:animate-magicslide ui:absolute ui:inset-0 ui:aspect-[1] ui:h-[100cqh] ui:rounded-none ui:[mask:none]"
>
<div
class="animate-spin-around absolute -inset-full w-auto [translate:0_0] rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]"
class="ui:animate-spin-around ui:absolute ui:-inset-full ui:w-auto ui:[translate:0_0] ui:rotate-0 ui:[background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]"
></div>
</div>
</div>
{@render children?.()}
<div
class={cn(
'absolute inset-0 h-full w-full',
'rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]',
'transform-gpu transition-all duration-300 ease-in-out',
'group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]',
'group-active:shadow-[inset_0_-10px_10px_#ffffff3f]'
'ui:absolute ui:inset-0 ui:h-full ui:w-full',
'ui:rounded-2xl ui:px-4 ui:py-1.5 ui:text-sm ui:font-medium ui:shadow-[inset_0_-8px_10px_#ffffff1f]',
'ui:transform-gpu ui:transition-all ui:duration-300 ui:ease-in-out',
'ui:group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]',
'ui:group-active:shadow-[inset_0_-10px_10px_#ffffff3f]'
)}
></div>
<div class={cn('absolute inset-(--cut) -z-20 rounded-(--radius) [background:var(--bg)]')}></div>
</a>
<div class={cn('ui:absolute ui:inset-(--cut) ui:-z-20 ui:rounded-(--radius) ui:[background:var(--bg)]')}></div>
{/snippet}
{#if href}
<a
style:--spread="90deg"
style:--shimmer-color={shimmerColor}
style:--radius={borderRadius}
style:--speed={shimmerDuration}
style:--cut={shimmerSize}
style:--bg={background}
class={cn(
'ui:group ui:relative ui:z-0 ui:flex ui:cursor-pointer ui:items-center ui:justify-center ui:overflow-hidden ui:rounded-(--radius) ui:border ui:border-white/10 ui:px-6 ui:py-3 ui:whitespace-nowrap ui:text-white ui:[background:var(--bg)] ui:dark:text-white',
'ui:transform-gpu ui:transition-transform ui:duration-300 ui:ease-in-out ui:active:translate-y-px',
className
)}
{href}
{target}
{rel}
data-rewardful
>
{@render shimmerContent()}
</a>
{:else}
<button
style:--spread="90deg"
style:--shimmer-color={shimmerColor}
style:--radius={borderRadius}
style:--speed={shimmerDuration}
style:--cut={shimmerSize}
style:--bg={background}
class={cn(
'ui:group ui:relative ui:z-0 ui:flex ui:cursor-pointer ui:items-center ui:justify-center ui:overflow-hidden ui:rounded-(--radius) ui:border ui:border-white/10 ui:px-6 ui:py-3 ui:whitespace-nowrap ui:text-white ui:[background:var(--bg)] ui:dark:text-white',
'ui:transform-gpu ui:transition-transform ui:duration-300 ui:ease-in-out ui:active:translate-y-px',
className
)}
{onclick}
>
{@render shimmerContent()}
</button>
{/if}
@@ -0,0 +1,179 @@
<script lang="ts">
import { cn } from '../../tools';
import { Textarea } from '../../base/textarea';
import { MentionPopover } from '../mention-popover';
import type { MentionItem } from '../mention-popover/types';
import type { Snippet } from 'svelte';
interface Props {
/** Two-way bound text value (includes raw mention syntax like `@[Title](type:id)`). */
value: string;
/** Forwarded textarea element ref for parent-controlled focus. */
ref?: HTMLTextAreaElement | null;
/** Items available for @ mention. */
mentionItems?: MentionItem[];
/** Fires when Enter is pressed (without Shift). */
onSubmit?: () => void;
/** Custom icon snippet for mention popover items. */
icon?: Snippet<[{ item: MentionItem }]>;
/** Custom type badge label for mention popover items. */
typeLabel?: (item: MentionItem) => string;
/** Action buttons rendered inside the textarea container (e.g. send, attach). */
actions?: Snippet;
/** Empty state text when no mention items match the query. */
emptyMessage?: string;
placeholder?: string;
disabled?: boolean;
/** Initial visible textarea row count. Defaults to 1 (single-line). */
rows?: number;
class?: string;
}
let {
value = $bindable(''),
ref = $bindable(null),
mentionItems = [],
onSubmit,
icon,
typeLabel,
actions,
emptyMessage = 'No results',
placeholder = '',
disabled = false,
rows = 1,
class: className
}: Props = $props();
let showPopover = $state(false);
let mentionQuery = $state('');
let mentionStartIndex = $state(-1);
let selectedIndex = $state(0);
const filteredItems = $derived(
mentionQuery
? mentionItems.filter((item) => item.label.toLowerCase().includes(mentionQuery.toLowerCase()))
: mentionItems
);
function detectMention() {
if (!ref) return;
const cursorPos = ref.selectionStart;
const textBeforeCursor = value.slice(0, cursorPos);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex >= 0) {
const charBeforeAt = lastAtIndex > 0 ? textBeforeCursor[lastAtIndex - 1] : ' ';
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
const hasClosingBracket = textAfterAt.includes(')');
if ((charBeforeAt === ' ' || charBeforeAt === '\n' || lastAtIndex === 0) && !hasClosingBracket) {
mentionStartIndex = lastAtIndex;
mentionQuery = textAfterAt;
selectedIndex = 0;
showPopover = true;
return;
}
}
showPopover = false;
}
function insertMention(item: MentionItem) {
const before = value.slice(0, mentionStartIndex);
const afterCursor = ref ? value.slice(ref.selectionStart) : '';
const mention = `@[${item.label}](${item.type}:${item.id}) `;
value = before + mention + afterCursor;
showPopover = false;
requestAnimationFrame(() => {
if (ref) {
ref.focus();
const newPos = before.length + mention.length;
ref.setSelectionRange(newPos, newPos);
}
});
}
function handleKeydown(event: KeyboardEvent) {
if (showPopover && filteredItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = (selectedIndex + 1) % filteredItems.length;
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = (selectedIndex - 1 + filteredItems.length) % filteredItems.length;
return;
}
if (event.key === 'Enter') {
event.preventDefault();
insertMention(filteredItems[selectedIndex]);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
showPopover = false;
return;
}
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
onSubmit?.();
}
}
</script>
<div data-slot="chat-textarea" class={cn('ui:relative', className)}>
{#if showPopover && mentionItems.length > 0}
<div class="ui:absolute ui:bottom-full ui:left-0 ui:z-50 ui:mb-1">
{#if icon}
<MentionPopover
items={mentionItems}
query={mentionQuery}
{selectedIndex}
onSelect={insertMention}
{icon}
{typeLabel}
{emptyMessage}
/>
{:else}
<MentionPopover
items={mentionItems}
query={mentionQuery}
{selectedIndex}
onSelect={insertMention}
{typeLabel}
{emptyMessage}
/>
{/if}
</div>
{/if}
<div
class="ui:border-input ui:focus-within:border-ring ui:focus-within:ring-ring/50 ui:rounded-lg ui:border ui:shadow-xs ui:transition-[color,box-shadow] ui:focus-within:ring-[3px]"
>
<Textarea
bind:ref
bind:value
oninput={detectMention}
onkeydown={handleKeydown}
{placeholder}
{disabled}
{rows}
class="ui:min-h-0 ui:max-h-32 ui:resize-none ui:border-0 ui:py-2 ui:text-sm ui:shadow-none ui:focus-visible:ring-0"
/>
{#if actions}
<div class="ui:flex ui:items-center ui:gap-1 ui:px-2 ui:pb-2">
{@render actions()}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,5 @@
import ChatTextarea from './chat-textarea.svelte';
export { ChatTextarea };
export { ChatTextarea as default };
export type { MentionItem } from './types';
@@ -0,0 +1 @@
export type { MentionItem } from '../mention-popover/types';
@@ -0,0 +1,70 @@
<script lang="ts">
import { cn } from '../../tools';
import CheckboxOptionCard from './checkbox-option-card.svelte';
export interface CheckboxOptionCardOption {
id: string;
title: string;
description?: string;
value: string;
disabled?: boolean;
}
type SnippetWithOption = import('svelte').Snippet<[option: CheckboxOptionCardOption]>;
interface Props {
options: CheckboxOptionCardOption[];
/** Bound array of selected option values */
value?: string[];
/** Overridable CSS class for the grid container (e.g. grid-cols-2, md:grid-cols-3) */
class?: string;
titleSuffix?: SnippetWithOption;
leading?: SnippetWithOption;
footer?: SnippetWithOption;
}
let {
options,
value = $bindable([]),
class: className,
titleSuffix: parentTitleSuffix,
leading: parentLeading,
footer: parentFooter
}: Props = $props();
function toggle(optionValue: string, isChecked: boolean) {
if (isChecked) {
if (value.includes(optionValue)) {
return;
}
value = [...value, optionValue];
return;
}
value = value.filter((entry) => entry !== optionValue);
}
</script>
<div role="group" data-slot="checkbox-option-card-group" class={cn('ui:grid ui:gap-3', className)}>
{#each options as option (option.id)}
<CheckboxOptionCard
id={option.id}
title={option.title}
description={option.description}
checked={value.includes(option.value)}
disabled={option.disabled}
onCheckedChange={(isChecked) => toggle(option.value, isChecked)}
>
{#snippet titleSuffix()}
{@render parentTitleSuffix?.(option)}
{/snippet}
{#snippet leading()}
{@render parentLeading?.(option)}
{/snippet}
{#snippet footer()}
{@render parentFooter?.(option)}
{/snippet}
</CheckboxOptionCard>
{/each}
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import { Checkbox } from '../../base/checkbox';
import * as Field from '../../base/field';
interface Props {
id: string;
title: string;
description?: string;
checked: boolean;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
titleSuffix?: import('svelte').Snippet;
leading?: import('svelte').Snippet;
footer?: import('svelte').Snippet;
/** Overridable CSS class (e.g. for grid column span or custom layout) */
class?: string;
}
let {
id,
title,
description,
checked,
disabled = false,
onCheckedChange,
titleSuffix,
leading,
footer,
class: className
}: Props = $props();
</script>
<Field.Label for={id} class={className}>
<Field.Field orientation="horizontal">
{#if leading}
{@render leading()}
{/if}
<Field.Content>
<Field.Title class="ui:flex ui:items-center ui:gap-2">
{title}
{#if titleSuffix}
{@render titleSuffix()}
{/if}
</Field.Title>
{#if description}
<Field.Description>{description}</Field.Description>
{/if}
</Field.Content>
<Checkbox
{id}
aria-label={title}
{disabled}
{checked}
onCheckedChange={(value) => onCheckedChange?.(value === true)}
/>
</Field.Field>
{#if footer}
<div class="-mt-1 min-w-0 px-4 pb-3">
{@render footer()}
</div>
{/if}
</Field.Label>
@@ -0,0 +1,6 @@
import CheckboxOptionCard from './checkbox-option-card.svelte';
import CheckboxOptionCardGroup from './checkbox-option-card-group.svelte';
export { CheckboxOptionCard, CheckboxOptionCardGroup };
export type { CheckboxOptionCardOption } from './checkbox-option-card-group.svelte';
export { CheckboxOptionCard as default };
@@ -1,21 +1,31 @@
<script lang="ts">
import { Button, type ButtonProps } from '../../base/button';
import { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from '../../base/button';
import { Tooltip, Provider, Trigger, Content } from '../../base/tooltip';
interface Props extends Omit<ButtonProps, 'variant' | 'size'> {
tooltip?: string;
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
tooltipClass?: string;
variant?: ButtonVariant;
size?: Extract<ButtonSize, 'icon' | 'icon-2xs' | 'icon-xs' | 'icon-sm' | 'icon-lg'>;
}
let { tooltip, tooltipSide = 'top', tooltipClass = '', children, ...buttonProps }: Props = $props();
let {
tooltip,
tooltipSide = 'top',
tooltipClass = '',
variant = 'secondary',
size = 'icon',
children,
...buttonProps
}: Props = $props();
</script>
{#if tooltip}
<Provider>
<Tooltip>
<Trigger class={tooltipClass}>
<Button variant="secondary" size="icon" {...buttonProps}>
<Button {variant} {size} {...buttonProps}>
{@render children?.()}
</Button>
</Trigger>
@@ -25,7 +35,7 @@
</Tooltip>
</Provider>
{:else}
<Button variant="secondary" size="icon" {...buttonProps}>
<Button {variant} {size} {...buttonProps}>
{@render children?.()}
</Button>
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import Button, { type ButtonElementProps } from '../../base/button/button.svelte';
import { useImageCropperCancel } from './image-cropper.svelte.js';
import { useImageCropperCancel } from './image-cropper-context.svelte';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
let { ref = $bindable(null), variant = 'outline', size = 'sm', onclick, ...rest }: ButtonElementProps = $props();
@@ -1,6 +1,6 @@
<script lang="ts">
import Button, { type ButtonElementProps } from '../../base/button/button.svelte';
import { useImageCropperCrop } from './image-cropper.svelte.js';
import { useImageCropperCrop } from './image-cropper-context.svelte';
import CropIcon from '@lucide/svelte/icons/crop';
let { ref = $bindable(null), variant = 'default', size = 'sm', onclick, ...rest }: ButtonElementProps = $props();
@@ -1,7 +1,7 @@
<script lang="ts">
import Cropper from 'svelte-easy-crop';
import { useImageCropperCropper } from './image-cropper.svelte.js';
import type { ImageCropperCropperProps } from './types.js';
import { useImageCropperCropper } from './image-cropper-context.svelte';
import type { ImageCropperCropperProps } from './types';
let { cropShape = 'round', aspect = 1, showGrid = false, ...rest }: ImageCropperCropperProps = $props();
@@ -1,7 +1,7 @@
<script lang="ts">
import * as Dialog from '../../base/dialog';
import { cn } from '../../tools';
import { useImageCropperDialog } from './image-cropper.svelte.js';
import { useImageCropperDialog } from './image-cropper-context.svelte';
import type { ImageCropperDialogProps } from './types';
let { children, class: className, ...rest }: ImageCropperDialogProps = $props();
@@ -1,7 +1,7 @@
<script lang="ts">
import * as Avatar from '../../base/avatar';
import type { ImageCropperPreviewProps } from './types';
import { useImageCropperPreview } from './image-cropper.svelte.js';
import { useImageCropperPreview } from './image-cropper-context.svelte';
import UploadIcon from '@lucide/svelte/icons/upload';
import { cn } from '../../tools';
@@ -1,5 +1,5 @@
<script lang="ts">
import { useImageCropperTrigger } from './image-cropper.svelte.js';
import { useImageCropperTrigger } from './image-cropper-context.svelte';
import type { ImageCropperUploadTriggerProps } from './types';
import { cn } from '../../tools';
@@ -1,6 +1,6 @@
<script lang="ts">
import { box } from 'svelte-toolbelt';
import { useImageCropperRoot } from './image-cropper.svelte.js';
import { useImageCropperRoot } from './image-cropper-context.svelte';
import type { ImageCropperRootProps } from './types';
import { onDestroy } from 'svelte';
import { useId } from 'bits-ui';
@@ -0,0 +1,5 @@
import MentionPopover from './mention-popover.svelte';
export { MentionPopover };
export { MentionPopover as default };
export type { MentionItem } from './types';
@@ -0,0 +1,115 @@
<script lang="ts">
import { cn } from '../../tools';
import type { MentionItem } from './types';
import type { Snippet } from 'svelte';
interface Props {
items: MentionItem[];
query: string;
selectedIndex: number;
onSelect: (item: MentionItem) => void;
/** Custom icon snippet — receives `{ item }` and should render a small icon. */
icon?: Snippet<[{ item: MentionItem }]>;
/** Custom label for each item's type badge. */
typeLabel?: (item: MentionItem) => string;
emptyMessage?: string;
class?: string;
}
let {
items,
query,
selectedIndex,
onSelect,
icon,
typeLabel,
emptyMessage = 'No results',
class: className
}: Props = $props();
const filteredItems = $derived(
query ? items.filter((item) => item.label.toLowerCase().includes(query.toLowerCase())) : items
);
/**
* Split a label into segments: plain text and highlighted matches.
* Returns an array of `{ text, highlighted }` pairs for rendering.
*/
function highlightSegments(label: string, search: string): Array<{ text: string; highlighted: boolean }> {
if (!search) return [{ text: label, highlighted: false }];
const lowerLabel = label.toLowerCase();
const lowerSearch = search.toLowerCase();
const matchIndex = lowerLabel.indexOf(lowerSearch);
if (matchIndex === -1) return [{ text: label, highlighted: false }];
const segments: Array<{ text: string; highlighted: boolean }> = [];
if (matchIndex > 0) {
segments.push({ text: label.slice(0, matchIndex), highlighted: false });
}
segments.push({ text: label.slice(matchIndex, matchIndex + search.length), highlighted: true });
if (matchIndex + search.length < label.length) {
segments.push({ text: label.slice(matchIndex + search.length), highlighted: false });
}
return segments;
}
</script>
{#if filteredItems.length > 0}
<div
data-slot="mention-popover"
class={cn(
'ui:bg-popover ui:text-popover-foreground ui:max-h-52 ui:w-72 ui:overflow-y-auto ui:rounded-md ui:border ui:p-1 ui:shadow-md',
className
)}
role="listbox"
>
{#each filteredItems as item, index (item.id)}
<button
role="option"
aria-selected={index === selectedIndex}
class={cn(
'ui:flex ui:w-full ui:items-center ui:gap-2 ui:rounded-sm ui:px-2 ui:py-1.5 ui:text-left ui:text-sm ui:transition-colors',
index === selectedIndex ? 'ui:bg-accent ui:text-accent-foreground' : 'hover:ui:bg-accent/50'
)}
onmousedown={(e) => {
e.preventDefault();
onSelect(item);
}}
>
{#if icon}
<span class="ui:shrink-0 ui:opacity-60">
{@render icon({ item })}
</span>
{/if}
<span class="ui:min-w-0 ui:flex-1 ui:truncate">
{#each highlightSegments(item.label, query) as segment (segment.text + String(segment.highlighted))}
{#if segment.highlighted}
<mark class="ui:bg-primary/20 ui:text-foreground ui:rounded-sm">{segment.text}</mark>
{:else}
{segment.text}
{/if}
{/each}
</span>
{#if typeLabel}
<span class="ui:text-muted-foreground ui:shrink-0 ui:text-xs">{typeLabel(item)}</span>
{/if}
</button>
{/each}
</div>
{:else if query}
<div
data-slot="mention-popover"
class={cn(
'ui:bg-popover ui:text-popover-foreground ui:w-72 ui:rounded-md ui:border ui:p-3 ui:shadow-md',
className
)}
>
<p class="ui:text-muted-foreground ui:text-sm">{emptyMessage}</p>
</div>
{/if}
@@ -0,0 +1,5 @@
export type MentionItem = {
id: string;
label: string;
type: string;
};
@@ -41,7 +41,7 @@
<main>
<BoldHero {hero} />
<section class="ui:relative ui:bg-muted/30 ui:px-6 ui:overflow-hidden">
<section class="ui:relative ui:bg-muted/30 ui:px-6 ui:overflow-hidden ui:mb-12">
<DotPattern class="ui:opacity-[0.15]" />
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
<div class="ui:flex ui:items-end ui:justify-between ui:mb-12">
@@ -108,7 +108,7 @@
variant="outline"
class="ui:rounded-lg ui:font-bold ui:text-sm {disableCourseLinks
? 'ui:pointer-events-none'
: 'ui:group-hover:bg-primary ui:group-hover:text-primary-foreground ui:transition-colors'}"
: 'ui:hover:bg-primary ui:hover:text-primary-foreground ui:group-hover:bg-primary ui:group-hover:text-primary-foreground ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
@@ -63,7 +63,9 @@
})}
<BlurFade delay={0.1 * index} once={true} class="ui:w-full ui:flex ui:justify-center">
<CourseCard
href={disableCourseLinks ? undefined : course.link || `/course/${course.slug || course.id}`}
href={disableCourseLinks
? undefined
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
bannerImage={course.logo || ''}
bannerAlt={course.title}
title={course.title}
@@ -49,7 +49,9 @@
{@const primaryTag = getPrimaryCourseTag(course)}
<BlurFade delay={0.1 * index} once={true}>
<a
href={disableCourseLinks ? undefined : course.link || `/course/${course.slug || course.id}`}
href={disableCourseLinks
? undefined
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
class="ui:block ui:h-full ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:transition-colors'}"
@@ -1,6 +1,5 @@
<script lang="ts">
import * as Avatar from '../../base/avatar';
import UserIcon from '@lucide/svelte/icons/user';
interface Props {
src?: string | null;
@@ -17,6 +16,6 @@
<Avatar.Image {src} {alt} />
{/if}
<Avatar.Fallback>
<UserIcon class="ui:text-muted-foreground {iconClass || 'h-1/2 w-1/2'}" />
<img src="/images/avatar.svg" alt="User avatar" class="size-10 rounded-md {iconClass}" />
</Avatar.Fallback>
</Avatar.Root>
@@ -0,0 +1,27 @@
<script lang="ts">
import { getStarSegments } from '../utils';
let {
rating = null,
ratingCount,
showCount = true
}: { rating?: number | null; ratingCount?: number | null; showCount?: boolean } = $props();
const segments = $derived(getStarSegments(rating));
const showStars = $derived(rating != null);
</script>
<span class="cio-stars">
{#if showStars}
{#each Array(segments.filled) as _, i (`f-${i}`)}
<span class="cio-star">★</span>
{/each}
{#each Array(segments.empty) as _, i (`e-${i}`)}
<span class="cio-star cio-star-empty">★</span>
{/each}
<span class="cio-rating-val">{rating?.toFixed(1)}</span>
{#if showCount && ratingCount}
<span class="cio-rating-count">({ratingCount})</span>
{/if}
{/if}
</span>
@@ -0,0 +1,9 @@
<script lang="ts">
let { src = null, alt = '', className = '' }: { src?: string | null; alt?: string; className?: string } = $props();
</script>
{#if src}
<img {src} {alt} class={`cio-img-cover ${className}`} loading="lazy" />
{:else}
<span class={`cio-thumb-fallback ${className}`} aria-hidden="true"></span>
{/if}

Some files were not shown because too many files have changed in this diff Show More