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:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from './content-batch';
|
||||
export * from './people';
|
||||
export * from './invite';
|
||||
export * from './public-course';
|
||||
export * from './session';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'}`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './names';
|
||||
export * from './defaults';
|
||||
export * from './factories';
|
||||
export * from './job-state';
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user