feat: live class sessions, CE workflow, and docs (#683)

Adds live-class session scheduling on lessons (join link, date/time,
timezone) with automatic 24h and 1h reminder emails plus a calendar
invite (.ics), a time-change notification with an updated invite, a
teacher-authored welcome email and session invite on enrollment, and a
"Notify students" quiz nudge.

LMS gets an upcoming live-sessions card and Join buttons, plus a
reusable live-session card component (3 states) in @cio/ui with a
Storybook story. The sidebar content tree now reflects sequential
progression locking like the content list, and the course-completion
confetti regression is fixed.

Org-based emails no longer reference ClassroomIO by name. Docs add
live-class, welcome-email, and course-progression guides, expand the
certificates guide (design, signatures, corrected labels), move
plan-required callouts to the top, flatten how-to-guides to the docs
root with redirects, and add screenshots. Includes an add-docs-image
skill.
This commit is contained in:
Rotimi Best
2026-06-22 05:43:48 +01:00
committed by GitHub
parent 3547901de6
commit 81294a47f9
193 changed files with 5477 additions and 805 deletions
+5 -4
View File
@@ -160,9 +160,10 @@ Mentally verify, then return only if all are true:
**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:
- **The plan card contains NO database IDs — its sections, lessons, and exercises are titles only.** You cannot write content or quizzes from the plan alone. Every lesson and exercise must first be created with \`create_lesson\` / \`create_exercise\`, which return the real \`id\`. NEVER pass an ID to \`update_lesson_content\`, \`add_questions\`, or any quiz tool unless that ID was returned by a \`create_*\` call in this run or fetched via \`get_course_structure\`. Do not invent, guess, or derive IDs from the 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 "lesson": call create_lesson FIRST to mint the lesson id, THEN update_lesson_content (using that returned id) 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
- **Comprehensive final exam (last plan section):** Use \`create_exercise\` with \`questions: []\` if you need an empty shell, then for **each prior course section** (every course outline section except the final exam section) call \`create_exercise_section\` with a title that reflects that section's topic, then \`add_questions\` **3–5** questions into that block (\`exerciseSectionId\` from \`get_exercise_details\`). Mix \`questionTypeId\` values across the whole exam. If you already added questions in \`create_exercise\`, assign them to the correct block or recreate structure as needed. If step limits interrupt, resume with \`get_exercise_details\` and continue until every prior section has a block with 3–5 questions.
@@ -170,11 +171,11 @@ Mentally verify, then return only if all are true:
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.
**Tools that require an approved plan.** The following BULK-CREATION tools are not bound to the chat endpoint and will only become available once the teacher approves a plan (which spawns a background Agent-mode run): \`create_lesson\`, \`create_exercise\`, \`add_questions\`. They are intentionally absent from the chat tool list. If the teacher asks you to create NEW lessons or exercises or add a batch of questions, you MUST call \`generate_course_plan\` and wait for approval — **do not say these tools are unavailable**; the plan-then-run path is required for bulk creation. Reads (\`get_*\`, \`check_course_go_live_readiness\`, \`fetch_documentation_url\`), single-lesson content edits (\`update_lesson_content\`), small metadata edits (\`update_lesson\`, \`update_exercise\`, \`update_section\`, \`update_exercise_section\`, \`update_questions\`), landing-page mutations, and \`go_live_course\` all remain available in chat.
**One-off creation runs inline in chat.** \`create_lesson\`, \`create_exercise\`, and \`add_questions\` are all available directly in chat. When a teacher asks to create a single lesson or exercise — including requests that reference a specific section via @mention like \`@[Section Name](SECTION:uuid)\` — call the relevant tool immediately without going through \`generate_course_plan\`. Use the section ID from the @mention as the \`sectionId\` argument.
**Single-lesson edits run inline.** When the teacher asks to revise the lesson they currently have open ("make this more detailed", "shorten this", "rewrite the intro", "translate to French", "add an example about X"), call \`update_lesson_content\` directly on that lesson. Do NOT call \`generate_course_plan\` — a one-lesson revision is not a plannable bulk-creation operation and the plan schema (which requires a comprehensive final exam in the last section) does not fit. Plan + run is for creating multiple new lessons/exercises from scratch, not for editing one existing lesson.
**Single-lesson edits run inline.** When the teacher asks to revise the lesson they currently have open ("make this more detailed", "shorten this", "rewrite the intro", "translate to French", "add an example about X"), call \`update_lesson_content\` directly on that lesson. Do NOT call \`generate_course_plan\` — a one-lesson revision is not a plannable bulk-creation operation.
**IMPORTANT — never block on missing run-only tools.** When a teacher asks to create NEW lessons or exercises or add a batch of questions and you notice \`create_lesson\` / \`create_exercise\` / \`add_questions\` are not in your tool list, do NOT respond with "those tools are unavailable" or "I cannot perform this action." Instead, treat it as a Plan Mode trigger: call \`generate_course_plan\` to propose a plan and wait for the teacher's approval. The background run that follows the approval is what unlocks those tools. The plan-then-approve flow IS the way to create lessons and exercises — it is not a workaround.
**When to use \`generate_course_plan\`.** Only call \`generate_course_plan\` when the teacher asks you to design or structure a whole course (or a large multi-section chunk of one) from scratch. The plan → approve → background-run path is for bulk content generation, not for individual one-off requests. Never force a single lesson or exercise creation through a plan.
**Driving an approved plan to completion — do not voluntarily pause.** Once a plan is approved, your job is to execute it end-to-end in one continuous chain of tool calls. Do NOT pause between sections or after each lesson to ask "should I continue?", "ready for the next section?", "shall I proceed?", or any equivalent confirmation. The teacher's approval already covered the entire plan. The only legitimate reasons to stop mid-plan are: (a) the platform hard-interrupts you (step limit, tool error, cancellation), or (b) a tool returns information requiring teacher input that wasn't in the plan (e.g. a missing required field you genuinely cannot infer). Asking the teacher to type "continue" is a regression — drive forward.
@@ -19,8 +19,10 @@ function buildResourceOwnershipError(params: {
const { resourceType, resourceId, resource } = params;
if (!resource) {
const createTool = resourceType === 'Lesson' ? 'create_lesson' : 'create_exercise';
return new AppError(
`${resourceType} ${resourceId} does not exist in this course. The ID may have been hallucinated — call get_course_structure to fetch real IDs and retry.`,
`${resourceType} ${resourceId} does not exist in this course. Do NOT take IDs from the plan card — it contains no IDs. If you have not yet created this ${resourceType.toLowerCase()} in this run, call ${createTool} first and use the id it returns. Otherwise call get_course_structure to fetch the real id, then retry. Never invent or guess IDs.`,
'RESOURCE_NOT_IN_COURSE',
403
);
+6 -24
View File
@@ -1004,30 +1004,12 @@ export function buildAgentTools(
}
/**
* Tools that can only run inside a durable `agent_run` (BullMQ worker), never
* inline on `/agent/chat`. These are bulk-creation tools — the kind triggered
* by a teacher-approved course plan. Each one writes a significant amount of
* content per call and the plan-then-run path gives them retry, cancel, a
* progress card, and resumability.
*
* Note that `update_lesson_content` is intentionally NOT in this set: a teacher
* asking the chat assistant to revise one existing lesson ("make this more
* detailed", "shorten this", "rewrite the intro") is a single targeted edit,
* not bulk content creation. Routing it through `generate_course_plan` forced
* the model into a planning shape that doesn't match the request and tripped
* the final-exam schema. Single-lesson revisions run inline in chat.
* All tools are available in chat. The plan → approve → durable-run path is
* only triggered when the teacher explicitly approves a `generate_course_plan`
* result; it is not required for one-off creation requests.
*/
export const RUN_ONLY_TOOL_NAMES = new Set(['add_questions', 'create_lesson', 'create_exercise'] as const);
export const RUN_ONLY_TOOL_NAMES = new Set<string>();
/**
* Drop run-only tools from the chat-mode tool set so the model never sees their
* schemas in `/agent/chat` and routes heavy work through the plan-then-run path.
*/
export function filterToolsForChatMode<T extends Record<string, unknown>>(tools: T): Partial<T> {
const filtered: Record<string, unknown> = {};
for (const [name, value] of Object.entries(tools)) {
if (RUN_ONLY_TOOL_NAMES.has(name as never)) continue;
filtered[name] = value;
}
return filtered as Partial<T>;
export function filterToolsForChatMode<T extends Record<string, unknown>>(tools: T): T {
return tools;
}
+3 -1
View File
@@ -29,7 +29,7 @@ import type { TCourse } from '@cio/db/types';
import type { TCourseCreate } from '@cio/utils/validation/course';
import { db } from '@cio/db/drizzle';
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { updateExercise, updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { getProfileById } from '@cio/db/queries/auth';
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
import { annotateCourseContentWithProgression } from './progression';
@@ -311,6 +311,8 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
if (!ok) {
throw new AppError('Certification exercise must belong to this course', ErrorCodes.VALIDATION_ERROR, 400);
}
await updateExercise(data.certificate.requiredExerciseId, { allowMultipleAttempts: true });
}
const updated = await updateCourseQuery(courseId, sanitizeUnknownStrings(sanitizedData));
+37 -6
View File
@@ -16,6 +16,7 @@ import { and, count, desc, eq, ilike, inArray, isNotNull, isNull, or, sql } from
import { ROLE } from '@cio/utils/constants';
import { db, type DbOrTxClient } from '@db/drizzle';
import { getCourseContentItems, type CourseContentItemRow } from './content';
import { getUpcomingSessionsForCourseIds, type CourseUpcomingSession } from './session';
/**
* Base course type - extends TCourse with lessonCount
@@ -42,10 +43,12 @@ export interface TAdminCourse extends TBaseCourse {
export interface TStudentCourse extends TBaseCourse {
progressRate: number;
exercisesCompleted: number;
certificateEarnedAt: string | null;
complianceStatus: string | null;
complianceCycleNumber: number | null;
complianceDueDate: string | null;
complianceValidUntil: string | null;
upcomingSession: CourseUpcomingSession | null;
}
/**
@@ -305,6 +308,7 @@ export type CourseWithRelations = TCourse & {
customDomain: string | null;
isCustomDomainVerified: boolean | null;
theme: string | null;
avatarUrl: string | null;
settings: (typeof schema.organization.$inferSelect)['settings'];
} | null;
};
@@ -350,6 +354,7 @@ export async function getCourseWithRelations(
customDomain: string | null;
isCustomDomainVerified: boolean | null;
theme: string | null;
avatarUrl: string | null;
settings: (typeof schema.organization.$inferSelect)['settings'];
} | null;
};
@@ -373,6 +378,7 @@ export async function getCourseWithRelations(
customDomain: schema.organization.customDomain,
isCustomDomainVerified: schema.organization.isCustomDomainVerified,
theme: schema.organization.theme,
avatarUrl: schema.organization.avatarUrl,
settings: schema.organization.settings
}
})
@@ -416,6 +422,7 @@ export async function getCourseWithRelations(
customDomain: groupData[0].organization.customDomain ?? null,
isCustomDomainVerified: groupData[0].organization.isCustomDomainVerified ?? null,
theme: groupData[0].organization.theme ?? null,
avatarUrl: groupData[0].organization.avatarUrl ?? null,
settings: groupData[0].organization.settings ?? null
}
: null;
@@ -633,6 +640,8 @@ export type TCourseCertificationRow = {
orgCustomDomain: string | null;
orgIsCustomDomainVerified: boolean | null;
orgName: string;
orgAvatarUrl: string | null;
orgTheme: string | null;
};
export async function getCourseCertificationRow(courseId: string): Promise<TCourseCertificationRow | null> {
@@ -646,7 +655,9 @@ export async function getCourseCertificationRow(courseId: string): Promise<TCour
orgSiteName: schema.organization.siteName,
orgCustomDomain: schema.organization.customDomain,
orgIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
orgName: schema.organization.name
orgName: schema.organization.name,
orgAvatarUrl: schema.organization.avatarUrl,
orgTheme: schema.organization.theme
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
@@ -955,6 +966,7 @@ export const getEnrolledCourses = async ({
sql`gm.profile_id = ${sql.raw(`'${profileId}'::uuid`)}`
)}
)`.as('exercises_completed'),
certificateEarnedAt: schema.groupmember.certificateEarnedAt,
complianceStatus: schema.courseCompletionRecord.status,
complianceCycleNumber: schema.courseCompletionRecord.cycleNumber,
complianceDueDate: schema.courseCompletionRecord.dueDate,
@@ -1002,16 +1014,21 @@ export const getEnrolledCourses = async ({
.groupBy(schema.course.id, schema.courseCompletionRecord.id)
.orderBy(desc(schema.course.createdAt));
const liveCourseIds = result.filter((row) => row.course.type === 'LIVE_CLASS').map((row) => row.course.id);
const upcomingByCourse = await getUpcomingSessionsForCourseIds(liveCourseIds);
return result.map((row) => ({
...row.course,
lessonCount: Number(row.lessonCount),
progressRate: Number(row.progressRate),
exerciseCount: Number(row.exerciseCount),
exercisesCompleted: Number(row.exercisesCompleted),
certificateEarnedAt: row.certificateEarnedAt ?? null,
complianceStatus: row.complianceStatus ?? null,
complianceCycleNumber: row.complianceCycleNumber ?? null,
complianceDueDate: row.complianceDueDate ?? null,
complianceValidUntil: row.complianceValidUntil ?? null
complianceValidUntil: row.complianceValidUntil ?? null,
upcomingSession: upcomingByCourse.get(row.course.id) ?? null
}));
} catch (error) {
console.error('getEnrolledCourses error:', error);
@@ -1122,7 +1139,10 @@ export async function getCourseWithOrgData(courseId: string): Promise<{
orgSiteName: string | null;
orgCustomDomain: string | null;
orgIsCustomDomainVerified: boolean | null;
orgAvatarUrl: string | null;
orgTheme: string | null;
groupId: string | null;
welcomeEmailMessage: string | null;
} | null> {
try {
const result = await db
@@ -1132,7 +1152,10 @@ export async function getCourseWithOrgData(courseId: string): Promise<{
orgSiteName: schema.organization.siteName,
orgCustomDomain: schema.organization.customDomain,
orgIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
groupId: schema.course.groupId
orgAvatarUrl: schema.organization.avatarUrl,
orgTheme: schema.organization.theme,
groupId: schema.course.groupId,
metadata: schema.course.metadata
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
@@ -1144,7 +1167,9 @@ export async function getCourseWithOrgData(courseId: string): Promise<{
return null;
}
return result[0];
const { metadata, ...rest } = result[0];
return { ...rest, welcomeEmailMessage: metadata?.welcomeEmailMessage ?? null };
} catch (error) {
console.error('getCourseWithOrgData error:', error);
throw new Error(
@@ -1332,15 +1357,21 @@ export async function getOrgCourseGroups(orgId: string, courseIds: string[]) {
try {
if (courseIds.length === 0) return [];
return db
const rows = await db
.select({
courseId: schema.course.id,
courseTitle: schema.course.title,
groupId: schema.course.groupId
groupId: schema.course.groupId,
metadata: schema.course.metadata
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(and(eq(schema.group.organizationId, orgId), inArray(schema.course.id, courseIds)));
return rows.map(({ metadata, ...rest }) => ({
...rest,
welcomeEmailMessage: metadata?.welcomeEmailMessage ?? null
}));
} catch (error) {
console.error('getOrgCourseGroups error:', error);
throw new Error(`Failed to get org course groups: ${error instanceof Error ? error.message : 'Unknown error'}`);
+1
View File
@@ -6,3 +6,4 @@ export * from './content-batch';
export * from './people';
export * from './invite';
export * from './public-course';
export * from './session';
+5 -1
View File
@@ -350,6 +350,8 @@ export type TCourseInviteAcceptBundle = {
siteName: string;
customDomain: string | null;
isCustomDomainVerified: boolean | null;
avatarUrl: string | null;
theme: string | null;
};
};
@@ -374,7 +376,9 @@ export async function selectCourseInviteAcceptBundleByTokenHash(
name: schema.organization.name,
siteName: schema.organization.siteName,
customDomain: schema.organization.customDomain,
isCustomDomainVerified: schema.organization.isCustomDomainVerified
isCustomDomainVerified: schema.organization.isCustomDomainVerified,
avatarUrl: schema.organization.avatarUrl,
theme: schema.organization.theme
}
})
.from(schema.courseInvite)
+161
View File
@@ -0,0 +1,161 @@
import * as schema from '@db/schema';
import { and, eq, gt, inArray, isNotNull, lt } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
export type UpcomingSessionReminderRow = {
courseId: string;
courseName: string;
sessionTimezone: string | null;
lessonId: string;
lessonTitle: string;
lessonAt: string;
callUrl: string;
profileId: string;
email: string | null;
organizationName: string;
organizationSiteName: string | null;
organizationCustomDomain: string | null;
organizationIsCustomDomainVerified: boolean | null;
organizationAvatarUrl: string | null;
organizationTheme: string | null;
};
/**
* Lessons that are live sessions (have a `callUrl`) starting within the next
* ~25h, on a published/active LIVE_CLASS course, joined to each enrolled
* student + org branding. One row per (session × student) for the reminder scan.
*/
export async function listUpcomingSessionsForReminderScan(): Promise<UpcomingSessionReminderRow[]> {
try {
const now = new Date();
const horizon = new Date(now.getTime() + 25 * 60 * 60 * 1000);
const result = await db
.select({
courseId: schema.course.id,
courseName: schema.course.title,
courseMetadata: schema.course.metadata,
lessonId: schema.lesson.id,
lessonTitle: schema.lesson.title,
lessonAt: schema.lesson.lessonAt,
callUrl: schema.lesson.callUrl,
profileId: schema.groupmember.profileId,
email: schema.profile.email,
organizationName: schema.organization.name,
organizationSiteName: schema.organization.siteName,
organizationCustomDomain: schema.organization.customDomain,
organizationIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
organizationAvatarUrl: schema.organization.avatarUrl,
organizationTheme: schema.organization.theme
})
.from(schema.lesson)
.innerJoin(schema.course, eq(schema.lesson.courseId, schema.course.id))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.innerJoin(schema.groupmember, eq(schema.groupmember.groupId, schema.group.id))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(
and(
eq(schema.course.type, 'LIVE_CLASS'),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true),
eq(schema.groupmember.roleId, ROLE.STUDENT),
isNotNull(schema.lesson.callUrl),
isNotNull(schema.lesson.lessonAt),
gt(schema.lesson.lessonAt, now.toISOString()),
lt(schema.lesson.lessonAt, horizon.toISOString())
)
);
return result
.filter((row) => row.profileId && row.lessonAt && row.callUrl)
.map((row) => ({
courseId: row.courseId,
courseName: row.courseName,
sessionTimezone: row.courseMetadata?.sessionTimezone ?? null,
lessonId: row.lessonId,
lessonTitle: row.lessonTitle,
lessonAt: row.lessonAt!,
callUrl: row.callUrl!,
profileId: row.profileId!,
email: row.email,
organizationName: row.organizationName,
organizationSiteName: row.organizationSiteName,
organizationCustomDomain: row.organizationCustomDomain,
organizationIsCustomDomainVerified: row.organizationIsCustomDomainVerified,
organizationAvatarUrl: row.organizationAvatarUrl,
organizationTheme: row.organizationTheme
}));
} catch (error) {
console.error('listUpcomingSessionsForReminderScan error:', error);
throw new Error(
`Failed to list upcoming sessions for reminder scan: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export type CourseUpcomingSession = {
lessonId: string;
lessonTitle: string;
callUrl: string;
lessonAt: string;
sessionTimezone: string | null;
};
/**
* The next upcoming live session (earliest future lesson with a `callUrl`) for
* each of the given course ids. Used to show a "Join session" button on /lms.
*/
export async function getUpcomingSessionsForCourseIds(
courseIds: string[]
): Promise<Map<string, CourseUpcomingSession>> {
try {
if (courseIds.length === 0) return new Map();
const now = new Date().toISOString();
const rows = await db
.select({
courseId: schema.lesson.courseId,
courseMetadata: schema.course.metadata,
lessonId: schema.lesson.id,
lessonTitle: schema.lesson.title,
callUrl: schema.lesson.callUrl,
lessonAt: schema.lesson.lessonAt
})
.from(schema.lesson)
.innerJoin(schema.course, eq(schema.lesson.courseId, schema.course.id))
.where(
and(
inArray(schema.lesson.courseId, courseIds),
isNotNull(schema.lesson.callUrl),
isNotNull(schema.lesson.lessonAt),
gt(schema.lesson.lessonAt, now)
)
)
.orderBy(schema.lesson.lessonAt);
const byCourse = new Map<string, CourseUpcomingSession>();
for (const row of rows) {
if (!row.callUrl || !row.lessonAt) continue;
// rows are ascending by lessonAt; keep the first (earliest) per course.
if (byCourse.has(row.courseId)) continue;
byCourse.set(row.courseId, {
lessonId: row.lessonId,
lessonTitle: row.lessonTitle,
callUrl: row.callUrl,
lessonAt: row.lessonAt,
sessionTimezone: row.courseMetadata?.sessionTimezone ?? null
});
}
return byCourse;
} catch (error) {
console.error('getUpcomingSessionsForCourseIds error:', error);
throw new Error(`Failed to get upcoming sessions: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
+7 -2
View File
@@ -2,6 +2,7 @@ import * as schema from '@db/schema';
import type { TCourseNewsfeed, TCourseNewsfeedComment, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@db/types';
import { and, db, desc, eq, lt, ne, sql } from '@db/drizzle';
import { ROLE } from '@cio/utils/constants';
/**
* Gets newsfeed items by course ID
@@ -400,6 +401,8 @@ export async function getNewsfeedForEmail(
organization: {
name: string | null;
siteName: string | null;
avatarUrl: string | null;
theme: string | null;
} | null;
courseMembers: Array<{
email: string | null;
@@ -431,7 +434,7 @@ export async function getNewsfeedForEmail(
const row = result[0];
// Get all course members (excluding author if specified)
const memberConditions = [eq(schema.course.id, row.course.id)];
const memberConditions = [eq(schema.course.id, row.course.id), eq(schema.groupmember.roleId, ROLE.STUDENT)];
if (excludeAuthorId) {
memberConditions.push(ne(schema.groupmember.id, excludeAuthorId));
}
@@ -460,7 +463,9 @@ export async function getNewsfeedForEmail(
: null,
organization: {
name: row.organization.name,
siteName: row.organization.siteName
siteName: row.organization.siteName,
avatarUrl: row.organization.avatarUrl,
theme: row.organization.theme
},
courseMembers: membersResult
.filter((m) => m.profile?.email) // Only include members with email
+8 -2
View File
@@ -106,6 +106,8 @@ export async function listAssignmentsForReminderScan(): Promise<
organizationSiteName: string | null;
organizationCustomDomain: string | null;
organizationIsCustomDomainVerified: boolean | null;
organizationAvatarUrl: string | null;
organizationTheme: string | null;
}>
> {
try {
@@ -120,7 +122,9 @@ export async function listAssignmentsForReminderScan(): Promise<
organizationName: schema.organization.name,
organizationSiteName: schema.organization.siteName,
organizationCustomDomain: schema.organization.customDomain,
organizationIsCustomDomainVerified: schema.organization.isCustomDomainVerified
organizationIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
organizationAvatarUrl: schema.organization.avatarUrl,
organizationTheme: schema.organization.theme
})
.from(schema.programGoalAssignment)
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
@@ -146,7 +150,9 @@ export async function listAssignmentsForReminderScan(): Promise<
organizationName: row.organizationName,
organizationSiteName: row.organizationSiteName,
organizationCustomDomain: row.organizationCustomDomain,
organizationIsCustomDomainVerified: row.organizationIsCustomDomainVerified
organizationIsCustomDomainVerified: row.organizationIsCustomDomainVerified,
organizationAvatarUrl: row.organizationAvatarUrl,
organizationTheme: row.organizationTheme
}));
} catch (error) {
console.error('listAssignmentsForReminderScan error:', error);
+4
View File
@@ -687,6 +687,10 @@ export const course = pgTable(
grading?: boolean;
lessonDownload?: boolean;
allowNewStudent: boolean;
/** Teacher-authored HTML sent in the welcome email after a student enrolls. */
welcomeEmailMessage?: string | null;
/** IANA timezone for this course's live sessions (display + scheduling). */
sessionTimezone?: string | null;
sectionDisplay?: Record<string, boolean>;
isContentGroupingEnabled?: boolean;
/** `free` = all unlocked content is accessible; `sequential` = prior items must be complete. */
+2 -1
View File
@@ -19,7 +19,8 @@ export async function sendTemplateEmail<TSchema extends z.ZodType>(
subject: config.template.subject,
content,
from: config.from ?? config.template.from,
replyTo: config.replyTo ?? config.template.replyTo
replyTo: config.replyTo ?? config.template.replyTo,
ics: config.ics
}
]);
}
+86
View File
@@ -0,0 +1,86 @@
import * as z from 'zod';
/**
* Branding passed into org-scoped emails so the masthead, logo, and primary
* button reflect the sending organization rather than ClassroomIO defaults.
*
* `themeColor` is always a resolved hex (e.g. `#1d4ed8`) — named org themes are
* resolved to a hex by `buildEmailBranding` so the template never has to know
* about ClassroomIO's theme tokens.
*/
export const ZEmailBranding = z
.object({
orgName: z.string().optional(),
logoUrl: z.string().url().optional(),
themeColor: z
.string()
.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)
.optional()
})
.optional();
export type EmailBranding = z.infer<typeof ZEmailBranding>;
const HEX_REGEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
/**
* sRGB hex for each named org theme's primary button color. Mirrors the
* `--primary` token each theme sets in the dashboard (see `app.css`), pinned to
* plain hex because email clients can't resolve CSS vars or oklch reliably.
*/
const NAMED_THEME_HEX: Record<string, string> = {
default: '#1d4ed8',
blue: '#1d4ed8',
green: '#65a30d',
amber: '#d97706',
rose: '#e11d48',
purple: '#9333ea',
orange: '#ea580c',
teal: '#0d9488',
mono: '#57534e',
red: '#dc2626',
yellow: '#facc15',
violet: '#7c3aed'
};
/**
* Resolves an org `theme` value to an email-safe hex. Accepts either a named
* theme token (`blue`, `rose`, …) or a raw hex; returns undefined when the
* value is empty or unrecognized so callers fall back to the default color.
*/
export function resolveThemeColor(theme?: string | null): string | undefined {
if (!theme) {
return undefined;
}
const trimmed = theme.trim();
if (trimmed.startsWith('#')) {
return HEX_REGEX.test(trimmed) ? trimmed.toLowerCase() : undefined;
}
return NAMED_THEME_HEX[trimmed.toLowerCase()];
}
/**
* Builds the email branding payload from an organization's raw fields. Pass the
* result as the `branding` field of any org-scoped transactional email.
*/
export function buildEmailBranding(org?: {
name?: string | null;
avatarUrl?: string | null;
theme?: string | null;
}): EmailBranding {
if (!org) {
return undefined;
}
const orgName = org.name?.trim() || undefined;
const logoUrl = org.avatarUrl?.trim() || undefined;
const themeColor = resolveThemeColor(org.theme);
if (!orgName && !logoUrl && !themeColor) {
return undefined;
}
return { orgName, logoUrl, themeColor };
}
+2
View File
@@ -29,6 +29,8 @@ export interface SendConfig<TSchema extends z.ZodType = z.ZodType> {
fields: z.infer<TSchema>;
from?: string;
replyTo?: string;
/** Optional iCalendar (.ics) body delivered as a text/calendar part. */
ics?: string;
}
/**
+13
View File
@@ -22,6 +22,10 @@ export * from './teacher-course-welcome';
export * from './student-org-invite';
export * from './student-program-welcome';
export * from './program-goal-reminder';
export * from './quiz-assigned';
export * from './session';
export * from './submission-received';
export * from './submission-graded';
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
@@ -36,6 +40,10 @@ import type { studentProvePaymentEmail } from './student-prove-payment';
import type { studentOrgInviteEmail } from './student-org-invite';
import type { studentProgramWelcomeEmail } from './student-program-welcome';
import type { programGoalReminderEmail } from './program-goal-reminder';
import type { quizAssignedEmail } from './quiz-assigned';
import type { sessionReminderEmail, sessionUpdatedEmail } from './session';
import type { submissionReceivedEmail } from './submission-received';
import type { submissionGradedEmail } from './submission-graded';
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
@@ -66,4 +74,9 @@ export type EmailSchemas = {
studentOrgInvite: typeof studentOrgInviteEmail.template.schema;
studentProgramWelcome: typeof studentProgramWelcomeEmail.template.schema;
programGoalReminder: typeof programGoalReminderEmail.template.schema;
quizAssigned: typeof quizAssignedEmail.template.schema;
sessionReminder: typeof sessionReminderEmail.template.schema;
sessionUpdated: typeof sessionUpdatedEmail.template.schema;
submissionReceived: typeof submissionReceivedEmail.template.schema;
submissionGraded: typeof submissionGradedEmail.template.schema;
};
+4 -2
View File
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const inviteTeacherEmail = defineEmail({
id: 'inviteTeacher',
@@ -12,7 +13,8 @@ export const inviteTeacherEmail = defineEmail({
orgSiteName: z.string().min(1),
roleName: z.string().min(1),
expiresAt: z.string().min(1),
inviteLink: z.url()
inviteLink: z.url(),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -24,6 +26,6 @@ export const inviteTeacherEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
+7 -4
View File
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const newsfeedPostEmail = defineEmail({
id: 'newsfeedPost',
@@ -11,7 +12,8 @@ export const newsfeedPostEmail = defineEmail({
teacherName: z.string().min(1),
content: z.string().min(1),
postLink: z.url(),
orgName: z.string().min(1)
orgName: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -22,7 +24,7 @@ export const newsfeedPostEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -33,7 +35,8 @@ export const newsfeedCommentEmail = defineEmail({
courseTitle: z.string().min(1),
comment: z.string().min(1),
postLink: z.url(),
orgName: z.string().min(1)
orgName: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -44,6 +47,6 @@ export const newsfeedCommentEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const programGoalReminderEmail = defineEmail({
id: 'programGoalReminder',
@@ -13,7 +14,8 @@ export const programGoalReminderEmail = defineEmail({
daysUntilDue: z.number().int(),
completedCount: z.number().int(),
requiredCount: z.number().int(),
loginUrl: z.string().min(1)
loginUrl: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const dueLine =
@@ -30,11 +32,11 @@ export const programGoalReminderEmail = defineEmail({
<p>This is a reminder that the goal <strong>${fields.goalTitle}</strong> in your program <strong>${fields.programName}</strong> at ${fields.orgName} needs your attention.</p>
${dueLine}
<p>Your progress so far: <strong>${progress}</strong>.</p>
<p><a href="${fields.loginUrl}">Open ClassroomIO</a> to keep going.</p>
<p><a href="${fields.loginUrl}">Open the LMS</a> to keep going.</p>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -0,0 +1,30 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const quizAssignedEmail = defineEmail({
id: 'quizAssigned',
subject: 'You have a quiz to complete',
schema: z.object({
orgName: z.string().min(1),
courseName: z.string().min(1),
exerciseTitle: z.string().min(1),
quizUrl: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p>A quiz — <strong>${fields.exerciseTitle}</strong> — has been assigned to you in <strong>${fields.courseName}</strong> at <strong>${fields.orgName}</strong>.</p>
<div>
<a class="button" href="${fields.quizUrl}">Take the quiz</a>
</div>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content, fields.branding);
}
});
+61
View File
@@ -0,0 +1,61 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const sessionReminderEmail = defineEmail({
id: 'sessionReminder',
subject: 'Reminder: your live session is coming up',
schema: z.object({
orgName: z.string().min(1),
courseName: z.string().min(1),
sessionTitle: z.string().min(1),
sessionTimeLabel: z.string().min(1),
whenLabel: z.string().min(1),
joinUrl: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p>Your live session <strong>${fields.sessionTitle}</strong> in <strong>${fields.courseName}</strong> starts <strong>${fields.whenLabel}</strong>.</p>
<p><strong>When:</strong> ${fields.sessionTimeLabel}</p>
<div>
<a class="button" href="${fields.joinUrl}">Join the session</a>
</div>
<p>See you there,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content, fields.branding);
}
});
export const sessionUpdatedEmail = defineEmail({
id: 'sessionUpdated',
subject: 'Updated: your live session details changed',
schema: z.object({
orgName: z.string().min(1),
courseName: z.string().min(1),
sessionTitle: z.string().min(1),
sessionTimeLabel: z.string().min(1),
joinUrl: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p>The live session <strong>${fields.sessionTitle}</strong> in <strong>${fields.courseName}</strong> has been updated.</p>
<p><strong>New time:</strong> ${fields.sessionTimeLabel}</p>
<p>The attached calendar invite will update the event already on your calendar.</p>
<div>
<a class="button" href="${fields.joinUrl}">Join the session</a>
</div>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentCourseCompletionEmail = defineEmail({
id: 'studentCourseCompletion',
@@ -11,7 +12,8 @@ export const studentCourseCompletionEmail = defineEmail({
courseName: z.string().min(1),
studentName: z.string().min(1),
certificateUrl: z.string().url(),
customMessage: z.string().nullable().optional()
customMessage: z.string().nullable().optional(),
branding: ZEmailBranding
}),
render: (fields) => {
const customBlock =
@@ -28,6 +30,6 @@ export const studentCourseCompletionEmail = defineEmail({
<p>Cheers,<br/>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentCourseInviteEmail = defineEmail({
id: 'studentCourseInvite',
@@ -10,7 +11,8 @@ export const studentCourseInviteEmail = defineEmail({
orgName: z.string().min(1),
courseName: z.string().min(1),
inviteLink: z.string().url(),
expiresAt: z.string().min(1)
expiresAt: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -22,6 +24,6 @@ export const studentCourseInviteEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentCourseWelcomeEmail = defineEmail({
id: 'studentCourseWelcome',
@@ -9,18 +10,28 @@ export const studentCourseWelcomeEmail = defineEmail({
schema: z.object({
orgName: z.string().min(1),
courseName: z.string().min(1),
loginUrl: z.string().min(1)
loginUrl: z.string().min(1),
customMessage: z.string().optional(),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
const hasCustomMessage = !!fields.customMessage && fields.customMessage.trim().length > 0;
const intro = hasCustomMessage
? fields.customMessage
: `
<p>Hi there,</p>
<p>You now have access to <strong>${fields.courseName}</strong> in <strong>${fields.orgName}</strong>.</p>
<p><a href="${fields.loginUrl}">Sign in to ClassroomIO</a> to open the course and get started.</p>
<p>If you run into any issues, reach out to your instructor(s).</p>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
const content = `
${intro}
<p><a href="${fields.loginUrl}">Sign in to the LMS</a> to open the course and start learning.</p>
`;
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentOrgInviteEmail = defineEmail({
id: 'studentOrgInvite',
@@ -11,7 +12,8 @@ export const studentOrgInviteEmail = defineEmail({
orgName: z.string().min(1),
inviteLink: z.url(),
expiresAt: z.string().min(1),
courseNames: z.string().optional()
courseNames: z.string().optional(),
branding: ZEmailBranding
}),
render: (fields) => {
const courseLine = fields.courseNames
@@ -28,6 +30,6 @@ export const studentOrgInviteEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentProgramWelcomeEmail = defineEmail({
id: 'studentProgramWelcome',
@@ -9,18 +10,19 @@ export const studentProgramWelcomeEmail = defineEmail({
schema: z.object({
orgName: z.string().min(1),
programName: z.string().min(1),
loginUrl: z.string().min(1)
loginUrl: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p>You now have access to <strong>${fields.programName}</strong> in <strong>${fields.orgName}</strong>.</p>
<p><a href="${fields.loginUrl}">Sign in to ClassroomIO</a> to open the program and get started.</p>
<p><a href="${fields.loginUrl}">Sign in to the LMS</a> to open the program and start learning.</p>
<p>If you run into any issues, reach out to your instructor(s).</p>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentProvePaymentEmail = defineEmail({
id: 'studentProvePayment',
@@ -10,7 +11,8 @@ export const studentProvePaymentEmail = defineEmail({
courseName: z.string().min(1),
teacherEmail: z.email(),
studentFullname: z.string().min(1),
orgName: z.string().min(1)
orgName: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -21,6 +23,6 @@ export const studentProvePaymentEmail = defineEmail({
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -0,0 +1,54 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const submissionGradedEmail = defineEmail({
id: 'submissionGraded',
subject: 'Your exercise submission has been updated',
schema: z.object({
orgName: z.string().min(1),
studentName: z.string().min(1),
exerciseTitle: z.string().min(1),
courseName: z.string().min(1),
statusText: z.string().min(1),
exerciseLink: z.string().min(1),
score: z.string().optional(),
lessonTitle: z.string().optional(),
branding: ZEmailBranding
}),
render: (fields) => {
let content = `
<p>Hello ${fields.studentName},</p>
<p>The status of your submitted exercise on <strong>${fields.exerciseTitle}</strong> has been updated to <strong>${fields.statusText}</strong>.</p>
`;
if (fields.score) {
content += `
<p>Your score was <strong>${fields.score}</strong>.</p>
<div>
<a class="button" href="${fields.exerciseLink}">View your result</a>
</div>
`;
} else {
content += `
<div>
<a class="button" href="${fields.exerciseLink}">Open exercise</a>
</div>
`;
}
if (fields.lessonTitle) {
content += `
<p>This exercise is for <strong>${fields.lessonTitle}</strong> in a course you are taking titled <strong>${fields.courseName}</strong>.</p>
`;
} else {
content += `
<p>This exercise is in a course you are taking titled <strong>${fields.courseName}</strong>.</p>
`;
}
return getDefaultTemplate(content, fields.branding);
}
});
@@ -0,0 +1,30 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const submissionReceivedEmail = defineEmail({
id: 'submissionReceived',
subject: 'New exercise submission',
schema: z.object({
orgName: z.string().min(1),
studentName: z.string().min(1),
exerciseTitle: z.string().min(1),
exerciseLink: z.string().min(1),
submissionLink: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
<p>Hello,</p>
<p>A student <strong>${fields.studentName}</strong> just submitted an exercise <a href="${fields.exerciseLink}">${fields.exerciseTitle}</a>.</p>
<p>You can get started grading by clicking "Open Submissions".</p>
<div>
<a class="button" href="${fields.submissionLink}">Open Submissions</a>
</div>
`;
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const teacherCourseWelcomeEmail = defineEmail({
id: 'teacherCourseWelcome',
@@ -10,7 +11,8 @@ export const teacherCourseWelcomeEmail = defineEmail({
name: z.string().min(1),
orgName: z.string().min(1),
courseName: z.string().min(1),
inviteLink: z.url()
inviteLink: z.url(),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -22,6 +24,6 @@ export const teacherCourseWelcomeEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const teacherStudentBuyRequestEmail = defineEmail({
id: 'teacherStudentBuyRequest',
@@ -9,7 +10,8 @@ export const teacherStudentBuyRequestEmail = defineEmail({
schema: z.object({
courseName: z.string().min(1),
studentEmail: z.string().email(),
studentFullname: z.string().min(1)
studentFullname: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -22,6 +24,6 @@ export const teacherStudentBuyRequestEmail = defineEmail({
</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const teacherStudentJoinedEmail = defineEmail({
id: 'teacherStudentJoined',
@@ -9,7 +10,8 @@ export const teacherStudentJoinedEmail = defineEmail({
schema: z.object({
courseName: z.string().min(1),
studentName: z.string().min(1),
studentEmail: z.email()
studentEmail: z.email(),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -19,6 +21,6 @@ export const teacherStudentJoinedEmail = defineEmail({
<p>If you run into any issues, please don't fail to reach out to us, we'd love to make your teaching experience as easy as possible.</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Minimal hand-rolled iCalendar (.ics) builder for live-session invites.
* No dependency — emits a single VEVENT (times in UTC; calendars localize for
* the viewer) with optional DISPLAY alarms. A stable `uid` + incrementing
* `sequence` lets a later send (METHOD:REQUEST) update the same calendar event
* instead of creating a duplicate.
*/
export interface SessionIcsInput {
uid: string;
/** Increment on each update so calendars supersede the prior version. */
sequence?: number;
method?: 'PUBLISH' | 'REQUEST' | 'CANCEL';
/** Event start as an ISO string / absolute instant. */
start: string;
durationMinutes?: number;
title: string;
description?: string;
url?: string;
/** Minutes before start for DISPLAY alarms (e.g. [1440, 60]). */
alarmsBeforeMinutes?: number[];
}
function toIcsUtc(date: Date): string {
// 2026-06-20T18:00:00.000Z -> 20260620T180000Z
return date
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
}
function escapeText(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
}
function triggerFromMinutes(minutes: number): string {
if (minutes % 1440 === 0) return `-P${minutes / 1440}D`;
if (minutes % 60 === 0) return `-PT${minutes / 60}H`;
return `-PT${minutes}M`;
}
export function buildSessionIcs(input: SessionIcsInput): string {
const {
uid,
sequence = 0,
method = 'PUBLISH',
start,
durationMinutes = 60,
title,
description,
url,
alarmsBeforeMinutes = []
} = input;
const startDate = new Date(start);
const endDate = new Date(startDate.getTime() + durationMinutes * 60 * 1000);
const stamp = toIcsUtc(startDate);
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//ClassroomIO//Live Session//EN',
'CALSCALE:GREGORIAN',
`METHOD:${method}`,
'BEGIN:VEVENT',
`UID:${uid}`,
`SEQUENCE:${sequence}`,
`DTSTAMP:${stamp}`,
`DTSTART:${toIcsUtc(startDate)}`,
`DTEND:${toIcsUtc(endDate)}`,
`SUMMARY:${escapeText(title)}`
];
if (description) lines.push(`DESCRIPTION:${escapeText(description)}`);
if (url) lines.push(`URL:${escapeText(url)}`);
if (method === 'CANCEL') lines.push('STATUS:CANCELLED');
for (const minutes of alarmsBeforeMinutes) {
lines.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`DESCRIPTION:${escapeText(title)}`,
`TRIGGER:${triggerFromMinutes(minutes)}`,
'END:VALARM'
);
}
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines.join('\r\n');
}
+3
View File
@@ -32,6 +32,9 @@ export type { EmailId, EmailSchemaFor } from './utils/types';
export { deliverEmail } from './send';
export { buildEmailFromName } from './utils/functions/email-helpers';
export { getDefaultTemplate } from './templates/default';
export { buildEmailBranding, resolveThemeColor, ZEmailBranding } from './core/branding';
export { buildSessionIcs, type SessionIcsInput } from './ics';
export type { EmailBranding } from './core/branding';
// Export email registry for introspection (optional)
export { EmailRegistry } from './core/registry';
+2 -1
View File
@@ -94,7 +94,8 @@ export async function sendEmail<TEmailId extends EmailId>(
template,
fields: validatedFields,
from: config.from ?? template.from,
replyTo: config.replyTo ?? template.replyTo
replyTo: config.replyTo ?? template.replyTo,
ics: config.ics
});
} catch (error) {
if (error instanceof ZodError) {
+37 -6
View File
@@ -1,5 +1,37 @@
export const getDefaultTemplate = (content: string): string =>
`<!DOCTYPE html>
import type { EmailBranding } from '../core/branding';
const DEFAULT_BUTTON_COLOR = '#1D4EE2';
const CLASSROOMIO_LOGO = 'https://brand.cdn.clsrio.com/cio-bg-transparent.png';
function escapeHtmlAttr(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function buildMasthead(branding: EmailBranding): string {
const logoUrl = branding?.logoUrl;
const orgName = branding?.orgName ? escapeHtmlAttr(branding.orgName) : '';
if (logoUrl) {
const nameHtml = orgName
? `<span style="margin-left:10px;font-size:16px;font-weight:600;color:#111827;vertical-align:middle;">${orgName}</span>`
: '';
return `<img src="${logoUrl}" alt="${orgName || 'Logo'}" style="max-height:40px;width:auto;display:inline-block;vertical-align:middle;" />${nameHtml}`;
}
if (orgName) {
return `<span style="font-size:18px;font-weight:600;color:#111827;">${orgName}</span>`;
}
return `<a href="https://classroomio.com" target="_blank">
<img src="${CLASSROOMIO_LOGO}" alt="ClassroomIO" width="130" style="width:130px;height:auto;display:block;" />
</a>`;
}
export const getDefaultTemplate = (content: string, branding?: EmailBranding): string => {
const buttonColor = branding?.themeColor || DEFAULT_BUTTON_COLOR;
const masthead = buildMasthead(branding);
return `<!DOCTYPE html>
<html>
<head>
@@ -57,7 +89,7 @@ export const getDefaultTemplate = (content: string): string =>
.button {
margin-top: 12px;
background: #1D4EE2;
background: ${buttonColor};
border-radius: 6px;
text-decoration: none !important;
color: #fff !important;
@@ -105,9 +137,7 @@ export const getDefaultTemplate = (content: string): string =>
">
<div style="background-color:#fff;max-width:525px;margin:0 auto;border-radius:4px;">
<div style="padding:10px;border-bottom:1px solid #f0f0f0;">
<a href="https://classroomio.com" target="_blank">
<img src="https://brand.cdn.clsrio.com/cio-bg-transparent.png" alt="ClassroomIO" width="130" style="width:130px;height:auto;display:block;" />
</a>
${masthead}
</div>
<div style="padding:10px 15px;">
${content}
@@ -131,3 +161,4 @@ export const getDefaultTemplate = (content: string): string =>
</html>
`;
};
+5
View File
@@ -7,6 +7,11 @@ export const EMAIL_IDS = [
'newsfeedPost',
'onPasswordReset',
'programGoalReminder',
'quizAssigned',
'sessionReminder',
'sessionUpdated',
'submissionGraded',
'submissionReceived',
'studentCourseInvite',
'studentCourseCompletion',
'studentCourseWelcome',
@@ -38,7 +38,7 @@ const setupTransporter = async () => {
};
export async function sendWithNodemailer(emailData: TEmailData): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
const { from, to, subject, content, replyTo, ics } = emailData;
if (!transporter) {
transporter = await setupTransporter();
@@ -57,7 +57,8 @@ export async function sendWithNodemailer(emailData: TEmailData): Promise<EmailRe
to,
subject,
replyTo,
html: content
html: content,
...(ics ? { icalEvent: { filename: 'session.ics', method: 'PUBLISH', content: ics } } : {})
});
return {
+13 -2
View File
@@ -10,7 +10,7 @@ export const zohoClient = new SendMailClient({
});
export async function sendWithZoho(emailData: TEmailData): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
const { from, to, subject, content, replyTo, ics } = emailData;
const fromData = extractNameAndEmail(from || '');
@@ -36,7 +36,18 @@ export async function sendWithZoho(emailData: TEmailData): Promise<EmailResponse
],
subject,
htmlbody: content,
reply_to: getReplyTo(replyTo)
reply_to: getReplyTo(replyTo),
...(ics
? {
attachments: [
{
content: Buffer.from(ics).toString('base64'),
mime_type: 'text/calendar',
name: 'session.ics'
}
]
}
: {})
});
return {
+1
View File
@@ -1,3 +1,4 @@
export * from './media';
export * from './emails';
export * from './agent-course-generation';
export * from './notifications';
@@ -0,0 +1,51 @@
import type { JobsOptions } from 'bullmq';
import { JOB_NAMES, QUEUE_NAMES } from '../queues/names';
import { QUEUE_DEFAULTS } from '../queues/defaults';
import { getQueue } from '../queues/factories';
import type { TNotifyCourseExercisePayload, TNotifyCourseSessionUpdatePayload } from '../payloads/notifications';
/**
* Enqueue a `notifications:notify-course-exercise` job. Returns the BullMQ
* job id so the dashboard can poll its status. Each click is a fresh nudge,
* so we do NOT set a stable jobId (no dedupe across clicks).
*/
export async function enqueueNotifyCourseExercise(
payload: TNotifyCourseExercisePayload,
options: JobsOptions = {}
): Promise<string | undefined> {
const job = await getQueue(QUEUE_NAMES.notifications).add(JOB_NAMES.notifications.notifyCourseExercise, payload, {
...QUEUE_DEFAULTS[QUEUE_NAMES.notifications],
...options
});
return job.id;
}
/** Enqueue a one-off run of the session-reminder scan (manual trigger / on-demand). */
export async function enqueueSessionReminderScan(options: JobsOptions = {}): Promise<string | undefined> {
const job = await getQueue(QUEUE_NAMES.notifications).add(
JOB_NAMES.notifications.sessionReminderScan,
{},
{ ...QUEUE_DEFAULTS[QUEUE_NAMES.notifications], ...options }
);
return job.id;
}
/** Enqueue a "session updated" notification that emails members an updated calendar invite. */
export async function enqueueNotifyCourseSessionUpdate(
payload: TNotifyCourseSessionUpdatePayload,
options: JobsOptions = {}
): Promise<string | undefined> {
const job = await getQueue(QUEUE_NAMES.notifications).add(
JOB_NAMES.notifications.notifyCourseSessionUpdate,
payload,
{
...QUEUE_DEFAULTS[QUEUE_NAMES.notifications],
...options
}
);
return job.id;
}
+3 -1
View File
@@ -11,7 +11,9 @@ export const ZSendTemplateEmailPayload = z.object({
to: z.string().email(),
fields: z.record(z.string(), z.unknown()).default({}),
from: z.string().optional(),
replyTo: z.string().optional()
replyTo: z.string().optional(),
/** Optional iCalendar (.ics) body attached as a text/calendar part. */
ics: z.string().optional()
});
export type TSendTemplateEmailPayload = z.infer<typeof ZSendTemplateEmailPayload>;
+1
View File
@@ -2,3 +2,4 @@ export * from './media';
export * from './emails';
export * from './maintenance';
export * from './agent-course-generation';
export * from './notifications';
@@ -0,0 +1,49 @@
import * as z from 'zod';
/**
* Org branding carried through to the `quizAssigned` email. Mirrors
* `@cio/email`'s `ZEmailBranding`; defined inline so this lean queue package
* doesn't take a dependency on the email package. Re-validated against the
* email template schema at send time.
*/
const ZNotifyBranding = z
.object({
orgName: z.string().optional(),
logoUrl: z.string().url().optional(),
themeColor: z
.string()
.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)
.optional()
})
.optional();
/**
* Payload for a `notifications:notify-course-exercise` job. Fired when a
* teacher clicks "Notify students" on an exercise — the worker fans out one
* `quizAssigned` email per course member with a deep link to take the quiz.
*/
export const ZNotifyCourseExercisePayload = z.object({
courseId: z.string().min(1),
exerciseTitle: z.string().min(1),
courseName: z.string().min(1),
orgName: z.string().min(1),
fromName: z.string().min(1),
quizUrl: z.string().min(1),
branding: ZNotifyBranding
});
export type TNotifyCourseExercisePayload = z.infer<typeof ZNotifyCourseExercisePayload>;
/** Periodic scan that emails 24h/1h reminders for upcoming live sessions. No payload. */
export const ZSessionReminderScanPayload = z.object({}).default({});
export type TSessionReminderScanPayload = z.infer<typeof ZSessionReminderScanPayload>;
/**
* Payload for a `notifications:notify-course-session-update` job. Fired when a
* teacher changes a live lesson's time/link — emails every member an updated
* calendar invite (same UID, higher SEQUENCE).
*/
export const ZNotifyCourseSessionUpdatePayload = z.object({
courseId: z.string().min(1),
lessonId: z.string().min(1)
});
export type TNotifyCourseSessionUpdatePayload = z.infer<typeof ZNotifyCourseSessionUpdatePayload>;
+1
View File
@@ -1,3 +1,4 @@
export * from './names';
export * from './defaults';
export * from './factories';
export * from './job-state';
+56
View File
@@ -0,0 +1,56 @@
import { getQueue } from './factories';
import type { QueueName } from './names';
import { JOB_STATUS, type JobEnvelope, type JobStatus, suggestNextPollMs } from '../status';
function mapBullState(state: string): JobStatus {
switch (state) {
case 'completed':
return JOB_STATUS.completed;
case 'failed':
return JOB_STATUS.failed;
case 'active':
return JOB_STATUS.running;
default:
// waiting | waiting-children | delayed | prioritized | paused
return JOB_STATUS.queued;
}
}
/**
* Read a queued job's status as a `JobEnvelope` directly from BullMQ — no DB
* ledger. Used by domains (e.g. notify-course-exercise) that don't need a
* durable run history, just "is it done yet?" polling. Returns null if the
* job no longer exists (e.g. retention swept it).
*/
export async function getQueueJobEnvelope(
queueName: QueueName,
jobId: string,
domain: string,
pollCount = 0
): Promise<JobEnvelope | null> {
const job = await getQueue(queueName).getJob(jobId);
if (!job) {
return null;
}
const status = mapBullState(await job.getState());
const finishedOn = job.finishedOn ?? job.processedOn ?? job.timestamp;
return {
job: {
id: String(job.id),
domain,
status,
stage: null,
progressPercent: typeof job.progress === 'number' ? job.progress : null,
createdAt: new Date(job.timestamp).toISOString(),
updatedAt: new Date(finishedOn).toISOString(),
result: (job.returnvalue as Record<string, unknown> | null) ?? null,
error: job.failedReason ? { code: 'JOB_FAILED', message: job.failedReason } : null,
attempt: job.attemptsMade,
maxAttempts: job.opts.attempts ?? 1
},
events: [],
nextPollMs: suggestNextPollMs(status, pollCount)
};
}
+5
View File
@@ -33,6 +33,11 @@ export const JOB_NAMES = {
emails: {
send: 'send'
},
notifications: {
notifyCourseExercise: 'notify-course-exercise',
sessionReminderScan: 'session-reminder-scan',
notifyCourseSessionUpdate: 'notify-course-session-update'
},
webhooks: {
deliver: 'deliver'
},
@@ -0,0 +1,87 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { LiveSessionCard } from '@cio/ui/custom/live-session-card';
const { Story } = defineMeta({
title: 'Molecules/LiveSessionCard',
component: LiveSessionCard,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<script>
const labels = {
liveNow: 'Live now',
liveSession: 'Live Session',
providerFallback: 'Live session',
started: 'Started',
minAgo: 'min ago',
joinNow: 'Join now',
copyLink: 'Copy link',
copy: 'Copy',
addToCalendar: 'Add to calendar',
addGoogle: 'Google Calendar',
addOutlook: 'Outlook.com',
addOffice365: 'Office 365',
addYahoo: 'Yahoo Calendar',
addApple: 'Apple Calendar (.ics)',
startsIn: 'Starts in',
sessionEnded: 'Session ended'
};
const callUrl = 'https://zoom.us/j/1234567890';
const day = 24 * 60 * 60 * 1000;
const minute = 60 * 1000;
const upcomingAt = new Date(Date.now() + 2 * day).toISOString();
const liveAt = new Date(Date.now() - 12 * minute).toISOString();
const endedAt = new Date(Date.now() - 3 * day).toISOString();
</script>
<Story name="Upcoming">
{#snippet template()}
<div class="w-[440px] p-8">
<LiveSessionCard
title="Compliance Essentials Webinar"
{callUrl}
lessonAt={upcomingAt}
timezone="America/New_York"
{labels}
status="upcoming"
/>
</div>
{/snippet}
</Story>
<Story name="Live now">
{#snippet template()}
<div class="w-[440px] p-8">
<LiveSessionCard
title="Compliance Essentials Webinar"
{callUrl}
lessonAt={liveAt}
timezone="America/New_York"
{labels}
status="live"
/>
</div>
{/snippet}
</Story>
<Story name="Ended">
{#snippet template()}
<div class="w-[440px] p-8">
<LiveSessionCard
title="Compliance Essentials Webinar"
{callUrl}
lessonAt={endedAt}
timezone="America/New_York"
{labels}
status="ended"
/>
</div>
{/snippet}
</Story>
+4
View File
@@ -96,6 +96,10 @@ Learner and author UIs for exercise questions (take, preview, review, submission
Marketing / demo widget: left-hand list of question types and a live **take**-mode preview using `ExerciseQuestion.QuestionRenderer`. Copy is English-only (no dashboard i18n). Also consumed by the **`@cio/embeds`** app as a CDN bundle (`apps/embeds`).
### Live session card (`src/custom/live-session-card/`)
Presentational card for a live-class lesson with three states (`live`, `upcoming`, `ended`) derived from `lessonAt` + `durationMinutes`, or forced via the `status` prop (used by Storybook). Shows a join/copy action set when live, an "Add to calendar" combo button (Google, Outlook.com, Office 365, Yahoo, plus an `.ics` download for Apple) and a countdown when upcoming. All copy is passed in via the `labels: LiveSessionLabels` prop, so the dashboard wrapper supplies translated strings; `onCopyLink` fires after the link is copied (e.g. for a snackbar). See `Molecules/LiveSessionCard` in Storybook.
### Hooks (`src/hooks/`)
Reusable Svelte hooks are located in the `src/hooks/` directory. These are Svelte 5 runes-based utilities that can be used across components.
@@ -24,6 +24,7 @@
size: {
default: 'ui:h-9 ui:px-4 ui:py-2 ui:has-[>svg]:px-3',
sm: 'ui:h-8 ui:gap-1.5 ui:rounded-md ui:px-3 ui:has-[>svg]:px-2.5',
xs: 'ui:h-7 ui:gap-1 ui:rounded-md ui:px-2.5 ui:text-xs ui:has-[>svg]:px-2',
lg: 'ui:h-10 ui:rounded-md ui:px-6 ui:has-[>svg]:px-4',
icon: 'ui:size-9',
'icon-2xs': 'ui:size-4',
@@ -0,0 +1,2 @@
export { default as LiveSessionCard } from './live-session-card.svelte';
export type { LiveSessionLabels } from './live-session-card.svelte';
@@ -0,0 +1,293 @@
<script lang="ts" module>
export interface LiveSessionLabels {
liveNow: string;
liveSession: string;
providerFallback: string;
started: string;
minAgo: string;
joinNow: string;
copyLink: string;
copy: string;
addToCalendar: string;
addGoogle: string;
addOutlook: string;
addOffice365: string;
addYahoo: string;
addApple: string;
startsIn: string;
sessionEnded: string;
}
</script>
<script lang="ts">
import { onDestroy } from 'svelte';
import { Button } from '../../base/button';
import { CopyButton } from '../../base/copy-button';
import * as ButtonGroup from '../../base/button-group';
import * as DropdownMenu from '../../base/dropdown-menu';
import { RadioIcon } from '../moving-icons';
import VideoIcon from '@lucide/svelte/icons/video';
import CalendarPlusIcon from '@lucide/svelte/icons/calendar-plus';
import ClockIcon from '@lucide/svelte/icons/clock';
import HourglassIcon from '@lucide/svelte/icons/hourglass';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
interface Props {
title: string;
callUrl: string;
lessonAt: string;
labels: LiveSessionLabels;
timezone?: string | null;
durationMinutes?: number;
status?: 'live' | 'upcoming' | 'ended';
}
let {
title,
callUrl,
lessonAt,
labels,
timezone = null,
durationMinutes = 60,
status: statusOverride = undefined
}: Props = $props();
let now = $state(Date.now());
const ticker = setInterval(() => (now = Date.now()), 30_000);
onDestroy(() => clearInterval(ticker));
const startMs = $derived(new Date(lessonAt).getTime());
const endMs = $derived(startMs + durationMinutes * 60_000);
const status = $derived(statusOverride ?? (now < startMs ? 'upcoming' : now < endMs ? 'live' : 'ended'));
const tz = $derived(timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
const dateLabel = $derived(
new Intl.DateTimeFormat('en-US', {
timeZone: tz,
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
}).format(new Date(lessonAt))
);
const provider = $derived.by(() => {
try {
const host = new URL(callUrl).hostname;
if (host.includes('zoom')) return 'Zoom';
if (host.includes('meet.google')) return 'Google Meet';
if (host.includes('teams')) return 'Microsoft Teams';
if (host.includes('webex')) return 'Webex';
return labels.providerFallback;
} catch {
return labels.providerFallback;
}
});
function duration(ms: number): string {
let secs = Math.max(0, Math.floor(ms / 1000));
const days = Math.floor(secs / 86400);
secs -= days * 86400;
const hours = Math.floor(secs / 3600);
secs -= hours * 3600;
const mins = Math.floor(secs / 60);
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours || days) parts.push(`${hours}h`);
parts.push(`${mins}m`);
return parts.join(' ');
}
const startsIn = $derived(duration(startMs - now));
const startedAgo = $derived(`${Math.max(1, Math.floor((now - startMs) / 60_000))} ${labels.minAgo}`);
function pad(value: number): string {
return String(value).padStart(2, '0');
}
function toIcsUtc(ms: number): string {
const d = new Date(ms);
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
);
}
function icsEscape(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
}
function addToCalendar() {
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//ClassroomIO//Live Session//EN',
'BEGIN:VEVENT',
`UID:session-${startMs}@classroomio`,
`DTSTAMP:${toIcsUtc(now)}`,
`DTSTART:${toIcsUtc(startMs)}`,
`DTEND:${toIcsUtc(endMs)}`,
`SUMMARY:${icsEscape(title)}`,
`DESCRIPTION:${icsEscape(`Join your live session: ${callUrl}`)}`,
`URL:${icsEscape(callUrl)}`,
'BEGIN:VALARM',
'ACTION:DISPLAY',
'DESCRIPTION:Reminder',
'TRIGGER:-PT1H',
'END:VALARM',
'END:VEVENT',
'END:VCALENDAR'
].join('\r\n');
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'session.ics';
link.click();
URL.revokeObjectURL(url);
}
const calendarDetails = $derived(`Join your live session: ${callUrl}`);
const googleCalendarUrl = $derived(
'https://calendar.google.com/calendar/render?action=TEMPLATE' +
`&text=${encodeURIComponent(title)}` +
`&dates=${toIcsUtc(startMs)}/${toIcsUtc(endMs)}` +
`&details=${encodeURIComponent(calendarDetails)}` +
`&location=${encodeURIComponent(callUrl)}`
);
const outlookCalendarUrl = $derived(
'https://outlook.live.com/calendar/0/deeplink/compose?path=/calendar/action/compose&rru=addevent' +
`&subject=${encodeURIComponent(title)}` +
`&body=${encodeURIComponent(calendarDetails)}` +
`&startdt=${new Date(startMs).toISOString()}` +
`&enddt=${new Date(endMs).toISOString()}` +
`&location=${encodeURIComponent(callUrl)}`
);
const office365CalendarUrl = $derived(
'https://outlook.office.com/calendar/0/deeplink/compose?path=/calendar/action/compose&rru=addevent' +
`&subject=${encodeURIComponent(title)}` +
`&body=${encodeURIComponent(calendarDetails)}` +
`&startdt=${new Date(startMs).toISOString()}` +
`&enddt=${new Date(endMs).toISOString()}` +
`&location=${encodeURIComponent(callUrl)}`
);
const yahooCalendarUrl = $derived(
'https://calendar.yahoo.com/?v=60&view=d&type=20' +
`&title=${encodeURIComponent(title)}` +
`&st=${toIcsUtc(startMs)}&et=${toIcsUtc(endMs)}` +
`&desc=${encodeURIComponent(calendarDetails)}` +
`&in_loc=${encodeURIComponent(callUrl)}`
);
function openCalendar(url: string) {
window.open(url, '_blank', 'noreferrer');
}
</script>
{#snippet heading()}
<h3 class="ui:mt-0.5 ui:flex ui:items-center ui:gap-1.5 ui:text-sm ui:font-semibold">
{labels.liveSession}
<RadioIcon size={13} color="#ef4444" />
</h3>
{/snippet}
{#snippet addToCalendarGroup()}
<ButtonGroup.Root>
<Button variant="outline" size="xs" onclick={() => openCalendar(googleCalendarUrl)}>
<CalendarPlusIcon size={13} />
{labels.addToCalendar}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} type="button" variant="outline" size="xs" aria-label={labels.addToCalendar}>
<ChevronDownIcon size={13} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
<DropdownMenu.Item onclick={() => openCalendar(googleCalendarUrl)}>{labels.addGoogle}</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openCalendar(outlookCalendarUrl)}>{labels.addOutlook}</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openCalendar(office365CalendarUrl)}>{labels.addOffice365}</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openCalendar(yahooCalendarUrl)}>{labels.addYahoo}</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={addToCalendar}>{labels.addApple}</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
{/snippet}
<div class="ui:bg-card ui:text-card-foreground ui:border-primary/30 ui:rounded-md ui:border ui:p-3">
{#if status === 'live'}
<div
class="ui:text-red-600 ui:flex ui:items-center ui:gap-1.5 ui:text-[11px] ui:font-semibold ui:tracking-wide ui:uppercase"
>
<span class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full ui:bg-red-500"></span>
{labels.liveNow}
</div>
{@render heading()}
<div
class="ui:text-muted-foreground ui:mt-1 ui:flex ui:flex-wrap ui:items-center ui:gap-x-3 ui:gap-y-0.5 ui:text-xs"
>
<span class="ui:flex ui:items-center ui:gap-1"><ClockIcon size={12} /> {labels.started} {startedAgo}</span>
<span class="ui:flex ui:items-center ui:gap-1"><VideoIcon size={12} /> {provider}</span>
</div>
<div class="ui:mt-3 ui:flex ui:flex-wrap ui:items-center ui:gap-2">
<a href={callUrl} target="_blank" rel="noreferrer">
<Button size="xs"><VideoIcon size={13} />{labels.joinNow}</Button>
</a>
<CopyButton text={callUrl} variant="outline" size="xs">
{labels.copyLink}
</CopyButton>
</div>
<div class="ui:mt-2.5 ui:flex ui:items-center ui:gap-2">
<p class="ui:bg-muted ui:text-muted-foreground ui:flex-1 ui:truncate ui:rounded ui:px-2.5 ui:py-1.5 ui:text-xs">
{callUrl}
</p>
<CopyButton text={callUrl} variant="outline" size="xs">
{labels.copy}
</CopyButton>
</div>
{:else if status === 'upcoming'}
{@render heading()}
<div
class="ui:text-muted-foreground ui:mt-1 ui:flex ui:flex-wrap ui:items-center ui:gap-x-3 ui:gap-y-0.5 ui:text-xs"
>
<span class="ui:flex ui:items-center ui:gap-1"><ClockIcon size={12} /> {dateLabel}</span>
<span class="ui:flex ui:items-center ui:gap-1"><VideoIcon size={12} /> {provider}</span>
</div>
<div class="ui:mt-3 ui:flex ui:flex-wrap ui:items-center ui:gap-2">
{@render addToCalendarGroup()}
<CopyButton text={callUrl} variant="outline" size="xs">
{labels.copyLink}
</CopyButton>
</div>
<div
class="ui:text-muted-foreground ui:border-primary/30 ui:mt-3 ui:flex ui:items-center ui:gap-1.5 ui:border-t ui:pt-2.5 ui:text-xs"
>
<HourglassIcon size={12} />
{labels.startsIn}
<span class="ui:text-foreground ui:font-semibold">{startsIn}</span>
</div>
{:else}
<div class="ui:text-muted-foreground ui:text-[11px] ui:font-semibold ui:tracking-wide ui:uppercase">
{labels.sessionEnded}
</div>
{@render heading()}
<div class="ui:text-muted-foreground ui:mt-1 ui:flex ui:items-center ui:gap-1 ui:text-xs">
<ClockIcon size={12} />
{dateLabel}
</div>
{/if}
</div>
@@ -27,6 +27,7 @@ export { default as PeopleIcon } from './people.svelte';
export { default as PersonIcon } from './person.svelte';
export { default as PremiumIcon } from './premium.svelte';
export { default as PreviewIcon } from './preview.svelte';
export { default as RadioIcon } from './radio.svelte';
export { default as ReviewIcon } from './review.svelte';
export { default as SettingsIcon } from './settings.svelte';
export { default as SetupIcon } from './setup.svelte';
@@ -0,0 +1,91 @@
<script>
/**
* Broadcast / "live" indicator. Standard moving-icon behavior: static by
* default, plays a brief signal animation on hover via `isHovered`. The
* center is filled so it reads as a recording dot.
*
* @typedef {Object} Props
* @property {string} [color]
* @property {number} [size]
* @property {number} [strokeWidth]
* @property {boolean} [isHovered]
* @property {string} [class]
*/
/** @type {Props} */
let { color = 'currentColor', size = 24, strokeWidth = 2, isHovered = false, class: className = '' } = $props();
function handleMouseEnter() {
if (isHovered) return;
isHovered = true;
setTimeout(() => {
isHovered = false;
}, 700);
}
</script>
<div class={className} aria-label="live session" role="img" onmouseenter={handleMouseEnter}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="radio-icon"
class:animate={isHovered}
>
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" class="radio-level radio-line-3" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" class="radio-level radio-line-2" />
<circle cx="12" cy="12" r="2" fill={color} stroke="none" class="radio-level radio-line-1" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" class="radio-level radio-line-2" />
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" class="radio-level radio-line-3" />
</svg>
</div>
<style>
div {
display: inline-block;
line-height: 0;
}
.radio-icon {
overflow: visible;
}
.radio-level {
opacity: 1;
transition: opacity 0.2s ease;
}
.radio-icon.animate .radio-level {
animation: fadeInSequence 0.6s ease forwards;
}
.radio-icon.animate .radio-line-1 {
opacity: 0;
animation-delay: 0.15s;
}
.radio-icon.animate .radio-line-2 {
opacity: 0;
animation-delay: 0.25s;
}
.radio-icon.animate .radio-line-3 {
opacity: 0;
animation-delay: 0.35s;
}
@keyframes fadeInSequence {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>
@@ -11,7 +11,5 @@
<span class="ui:text-foreground ui:text-md">ClassroomIO</span>
</a>
<p class="ui:text-muted-foreground ui:text-sm">
The Most Customizable Platform for Customer, Partner, and Employee Education
</p>
<p class="ui:text-muted-foreground ui:text-sm">One Platform for Customer, Partner, and Employee Training</p>
</div>
File diff suppressed because one or more lines are too long
@@ -364,6 +364,8 @@ const ZCourseMetadataFields = z.object({
grading: z.boolean().optional(),
lessonDownload: z.boolean().optional(),
allowNewStudent: z.boolean(),
welcomeEmailMessage: z.string().max(20000).nullish(),
sessionTimezone: z.string().max(64).nullish(),
sectionDisplay: z.record(z.string(), z.boolean()).optional(),
isContentGroupingEnabled: z.boolean().optional(),
progressionMode: z.enum(['free', 'sequential']).optional()
+3 -1
View File
@@ -5,7 +5,9 @@ export const ZEmailData = z.object({
to: z.email(),
subject: z.string().min(1),
content: z.string().min(1),
replyTo: z.string().optional()
replyTo: z.string().optional(),
/** Optional iCalendar (.ics) body attached as a text/calendar part. */
ics: z.string().optional()
});
export type TEmailData = z.infer<typeof ZEmailData>;