diff --git a/.gitignore b/.gitignore index 33844035..892c0eec 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,11 @@ company/ # Claude Code local artifacts .claude/skills -.claude/scheduled_tasks.lock \ No newline at end of file +.claude/scheduled_tasks.lock + +# Local db-agent playground (not committed) +apps/db-agent + +# Generated htpasswd files (hashes are safe to store in secrets/CI, plaintext never committed) +.htpasswd +*.htpasswd \ No newline at end of file diff --git a/.slim.yaml b/.slim.yaml deleted file mode 100644 index b3f9cda2..00000000 --- a/.slim.yaml +++ /dev/null @@ -1,6 +0,0 @@ -services: - - domain: cio-fe - port: 5173 - - domain: cio-be - port: 3002 -log_mode: minimal diff --git a/PRD-TRACKER.md b/PRD-TRACKER.md index c0fa5065..b0c3a2cb 100644 --- a/PRD-TRACKER.md +++ b/PRD-TRACKER.md @@ -6,6 +6,8 @@ Use this file to keep a single prioritized view of all PRDs. | Rank | List # | PRD | Status | | --- | --- | --- | --- | +| - | - | `prd/super-admin` | draft | +| - | - | `prd/embed-link-platforms` | draft | | 1 | - | `prd/notification-system` | todo | | 2 | 14 | `prd/course-templates` | todo | | 3 | 5 | `prd/course-import` | todo | @@ -21,7 +23,9 @@ Use this file to keep a single prioritized view of all PRDs. | - | - | `prd/customer-course-documentation` | todo | | - | - | `prd/walkthrough-widget` | draft | | - | - | `prd/copy-page-as-markdown` | todo | +| - | - | `prd/reset-student-course-progress` | todo | | - | 15 | `prd/webhooks` | todo | +| - | - | `prd/zapier` | draft | ## DONE (Completed PRDs) diff --git a/README.md b/README.md index 8509e3c9..9ee8966c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ - ClassroomIO is a no-code tool that allows you build and scale your online bootcamp with ease. + ClassroomIO is a no-code tool that allows you build and scale your online bootcamp with ease.

ClassroomIO.com

The Open Source Learning Management System for Companies diff --git a/apps/api/src/routes/course/course.ts b/apps/api/src/routes/course/course.ts index 548ca958..efdd7c90 100644 --- a/apps/api/src/routes/course/course.ts +++ b/apps/api/src/routes/course/course.ts @@ -35,6 +35,7 @@ import { courseAiTutorRouter } from '@api/routes/course/ai-tutor'; import { authMiddleware } from '@api/middlewares/auth'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { cloneCourse } from '@api/services/course/clone'; +import { sanitizeHtml } from '@cio/core/utils/sanitize-html'; import { complianceRouter } from '@api/routes/course/compliance'; import { contentRouter } from '@api/routes/course/content'; import { courseMemberMiddleware } from '@api/middlewares/course-member'; @@ -329,6 +330,13 @@ export const courseRouter = new Hono() const validatedData = c.req.valid('json'); const { tagIds, ...courseData } = validatedData; + if (courseData.metadata?.welcomeEmailMessage) { + courseData.metadata = { + ...courseData.metadata, + welcomeEmailMessage: sanitizeHtml(courseData.metadata.welcomeEmailMessage) + }; + } + const result = await updateCourse(courseId, courseData); if (tagIds !== undefined) { diff --git a/apps/api/src/routes/course/exercise.ts b/apps/api/src/routes/course/exercise.ts index 4533a1e8..11dd10a9 100644 --- a/apps/api/src/routes/course/exercise.ts +++ b/apps/api/src/routes/course/exercise.ts @@ -29,8 +29,14 @@ import { initVideoRecordingUpload } from '@api/services/exercise/video-recording'; +import { + getNotifyCourseExerciseStatusService, + notifyCourseExerciseService +} from '@api/services/course/notify-exercise'; + import { Hono } from '@api/utils/hono'; import { authMiddleware } from '@api/middlewares/auth'; +import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { courseMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-member-or-automation-key'; @@ -82,6 +88,28 @@ export const exerciseRouter = new Hono() return handleError(c, error, 'Failed to fetch exercise submissions overview'); } }) + .post('/:exerciseId/notify', authMiddleware, courseTeamMemberMiddleware, async (c) => { + try { + const courseId = c.req.param('courseId')!; + const exerciseId = c.req.param('exerciseId')!; + + const result = await notifyCourseExerciseService(courseId, exerciseId); + return c.json({ success: true, data: result }, 202); + } catch (error) { + return handleError(c, error, 'Failed to notify students'); + } + }) + .get('/:exerciseId/notify/:jobId', authMiddleware, courseTeamMemberMiddleware, async (c) => { + try { + const jobId = c.req.param('jobId')!; + const pollCount = Number.parseInt(c.req.query('pollCount') ?? '0', 10) || 0; + + const envelope = await getNotifyCourseExerciseStatusService(jobId, pollCount); + return c.json({ success: true, data: envelope }, 200); + } catch (error) { + return handleError(c, error, 'Failed to fetch notification status'); + } + }) .get( '/:exerciseId', authOrAutomationKeyMiddleware, diff --git a/apps/api/src/routes/course/lesson.ts b/apps/api/src/routes/course/lesson.ts index b2278924..fc33ed03 100644 --- a/apps/api/src/routes/course/lesson.ts +++ b/apps/api/src/routes/course/lesson.ts @@ -38,6 +38,8 @@ import { Hono } from '@api/utils/hono'; import { ZLessonDownloadContent } from '@cio/utils/validation/course'; import { authMiddleware } from '@api/middlewares/auth'; import { courseMemberMiddleware } from '@api/middlewares/course-member'; +import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member'; +import { notifyCourseSessionUpdateService } from '@api/services/course/notify-session'; import { generateLessonPdf } from '@api/utils/lesson'; import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group'; import { handleError } from '@api/utils/errors'; @@ -121,6 +123,23 @@ export const lessonRouter = new Hono() } } ) + .post( + '/:lessonId/notify-session-update', + authMiddleware, + courseTeamMemberMiddleware, + zValidator('param', ZLessonGetParam), + async (c) => { + try { + const courseId = c.req.param('courseId')!; + const { lessonId } = c.req.valid('param'); + + const result = await notifyCourseSessionUpdateService(courseId, lessonId); + return c.json({ success: true, data: result }, 202); + } catch (error) { + return handleError(c, error, 'Failed to notify session update'); + } + } + ) .delete('/:lessonId', authMiddleware, courseMemberMiddleware, zValidator('param', ZLessonGetParam), async (c) => { try { const { lessonId } = c.req.valid('param'); diff --git a/apps/api/src/routes/internal/compliance.ts b/apps/api/src/routes/internal/compliance.ts index 86774517..59d42a41 100644 --- a/apps/api/src/routes/internal/compliance.ts +++ b/apps/api/src/routes/internal/compliance.ts @@ -3,9 +3,19 @@ import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/ import { Hono } from '@api/utils/hono'; import { apiKeyMiddleware } from '@api/middlewares/api-key'; +import { enqueueSessionReminderScan } from '@cio/jobs'; import { handleError } from '@api/utils/errors'; export const internalComplianceRouter = new Hono() + .post('/send-session-reminders', apiKeyMiddleware, async (c) => { + try { + const jobId = await enqueueSessionReminderScan(); + + return c.json({ success: true, data: { jobId: jobId ?? null } }, 200); + } catch (error) { + return handleError(c, error, 'Failed to enqueue session reminder scan'); + } + }) .post('/check-expiry', apiKeyMiddleware, async (c) => { try { const data = await runComplianceExpiryCheck(); diff --git a/apps/api/src/services/course/invite.ts b/apps/api/src/services/course/invite.ts index 400ed6e3..b25a1b35 100644 --- a/apps/api/src/services/course/invite.ts +++ b/apps/api/src/services/course/invite.ts @@ -29,11 +29,12 @@ import crypto from 'node:crypto'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { getCourseTeachers } from '@cio/db/queries/course/people'; import { getProfileById } from '@cio/db/queries/auth'; -import { buildEmailFromName } from '@cio/email'; +import { buildEmailFromName, buildEmailBranding, type EmailBranding } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs'; import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth'; import { generateSlug } from '@cio/utils/functions'; import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance'; +import { getWelcomeSessionIcs } from './session-invite'; import { db } from '@cio/db/drizzle'; import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; @@ -358,20 +359,26 @@ async function sendStudentJoinEmails(input: { courseName: string; orgName: string; org: OrgUrlInfo; + branding: EmailBranding; studentId: string; studentEmail: string; + welcomeEmailMessage?: string | null; }) { try { const loginUrl = getDashboardBaseUrl(input.org); + const ics = await getWelcomeSessionIcs(input.courseId); await enqueueTransactionalEmail('studentCourseWelcome', { to: input.studentEmail, fields: { orgName: input.orgName, courseName: input.courseName, - loginUrl + loginUrl, + customMessage: input.welcomeEmailMessage ?? undefined, + branding: input.branding }, from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`), - idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}` + idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`, + ics }); } catch (error) { console.error('Failed to enqueue student welcome email:', error); @@ -395,7 +402,8 @@ async function sendStudentJoinEmails(input: { fields: { courseName: input.courseName, studentName, - studentEmail: input.studentEmail + studentEmail: input.studentEmail, + branding: input.branding }, from: buildEmailFromName('ClassroomIO'), idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}` @@ -414,6 +422,7 @@ async function createEmailInviteAndSend(input: { courseName: string; courseSlug: string; org: OrgUrlInfo; + branding: EmailBranding; metadata?: Record; shouldSend: boolean; }) { @@ -450,7 +459,8 @@ async function createEmailInviteAndSend(input: { orgName: input.orgName, courseName: input.courseName, inviteLink: createdInvite.inviteLink, - expiresAt: getExpiryLabel(createdInvite.expiresAt) + expiresAt: getExpiryLabel(createdInvite.expiresAt), + branding: input.branding }, from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`), idempotencyKey: `course-invite-email:${createdInvite.id}` @@ -549,6 +559,11 @@ export async function createStudentInvite(courseId: string, createdByProfileId: const orgName = courseOrgData.orgName || 'ClassroomIO'; const courseName = courseOrgData.courseTitle || course[0].title || 'Course'; + const orgBranding = buildEmailBranding({ + name: courseOrgData.orgName, + avatarUrl: courseOrgData.orgAvatarUrl, + theme: courseOrgData.orgTheme + }); const inviteResults = await Promise.all( recipients.valid.map((recipientEmail) => @@ -561,6 +576,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId: courseName, courseSlug, org: orgUrlInfo, + branding: orgBranding, metadata: data.metadata, shouldSend: data.sendEmail }) @@ -630,7 +646,8 @@ export async function enrollInCourse( throw new AppError('This course is not available for enrollment', ErrorCodes.VALIDATION_ERROR, 400); } - const courseMetadata = (courseWithRelations.metadata as { allowNewStudent?: boolean } | null) ?? null; + const courseMetadata = + (courseWithRelations.metadata as { allowNewStudent?: boolean; welcomeEmailMessage?: string | null } | null) ?? null; const isInternalEnrollmentOnly = org.settings?.internalEnrollmentOnly ?? false; const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(org.id, user.id); @@ -677,8 +694,10 @@ export async function enrollInCourse( courseName: title, orgName: org.name, org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified }, + branding: buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme }), studentId: user.id, - studentEmail: normalizedEmail + studentEmail: normalizedEmail, + welcomeEmailMessage: courseMetadata?.welcomeEmailMessage }); trackServerEvent({ @@ -944,7 +963,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex orgName: organization.name, orgSiteName: organization.siteName, orgCustomDomain: organization.customDomain, - orgIsCustomDomainVerified: organization.isCustomDomainVerified + orgIsCustomDomainVerified: organization.isCustomDomainVerified, + orgAvatarUrl: organization.avatarUrl, + orgTheme: organization.theme }; } @@ -1025,7 +1046,11 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex orgName: organization.name, orgSiteName: organization.siteName, orgCustomDomain: organization.customDomain, - orgIsCustomDomainVerified: organization.isCustomDomainVerified + orgIsCustomDomainVerified: organization.isCustomDomainVerified, + orgAvatarUrl: organization.avatarUrl, + orgTheme: organization.theme, + welcomeEmailMessage: + (course.metadata as { welcomeEmailMessage?: string | null } | null)?.welcomeEmailMessage ?? null }; }); @@ -1041,8 +1066,14 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex customDomain: result.orgCustomDomain, isCustomDomainVerified: result.orgIsCustomDomainVerified }, + branding: buildEmailBranding({ + name: result.orgName, + avatarUrl: result.orgAvatarUrl, + theme: result.orgTheme + }), studentId: user.id, - studentEmail: normalizedEmail + studentEmail: normalizedEmail, + welcomeEmailMessage: result.welcomeEmailMessage }); trackServerEvent({ diff --git a/apps/api/src/services/course/notify-exercise.ts b/apps/api/src/services/course/notify-exercise.ts new file mode 100644 index 00000000..e52b223d --- /dev/null +++ b/apps/api/src/services/course/notify-exercise.ts @@ -0,0 +1,90 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { getCourseById, getCourseWithOrgData } from '@cio/db/queries/course'; +import { getExerciseById } from '@cio/db/queries/exercise'; +import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; +import { buildEmailFromName, buildEmailBranding } from '@cio/email'; +import { QUEUE_NAMES, enqueueNotifyCourseExercise, getQueueJobEnvelope, type JobEnvelope } from '@cio/jobs'; + +const NOTIFY_DOMAIN = 'notify-course-exercise'; + +/** + * Enqueue a background job that emails every course member a "quiz assigned" + * nudge with a deep link to take the exercise. Returns the job id the + * dashboard polls for completion. + */ +export async function notifyCourseExerciseService(courseId: string, exerciseId: string): Promise<{ jobId: string }> { + const [courseRows, orgData, exercise] = await Promise.all([ + getCourseById(courseId), + getCourseWithOrgData(courseId), + getExerciseById(exerciseId) + ]); + + const course = courseRows?.[0]; + + if (!course) { + throw new AppError('Course not found', ErrorCodes.NOT_FOUND, 404); + } + + if (!orgData) { + throw new AppError('Course organization not found', ErrorCodes.NOT_FOUND, 404); + } + + if (!exercise || exercise.courseId !== courseId) { + throw new AppError('Exercise not found for this course', ErrorCodes.NOT_FOUND, 404); + } + + const baseUrl = getDashboardBaseUrl({ + siteName: orgData.orgSiteName, + customDomain: orgData.orgCustomDomain, + isCustomDomainVerified: orgData.orgIsCustomDomainVerified + }); + + // PUBLIC courses are taken on the anonymous slug-based org-site route; + // every other type uses the authenticated id-based LMS route. + let quizUrl: string; + if (course.type === 'PUBLIC') { + if (!course.slug || !exercise.slug) { + throw new AppError('Public course/exercise is missing a slug to link to', ErrorCodes.VALIDATION_ERROR, 400); + } + + quizUrl = `${baseUrl}/course/${course.slug}/lesson/${exercise.slug}`; + } else { + quizUrl = `${baseUrl}/courses/${courseId}/exercises/${exerciseId}`; + } + + const orgName = orgData.orgName || 'ClassroomIO'; + + const jobId = await enqueueNotifyCourseExercise({ + courseId, + exerciseTitle: exercise.title, + courseName: course.title, + orgName, + fromName: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), + quizUrl, + branding: buildEmailBranding({ + name: orgData.orgName, + avatarUrl: orgData.orgAvatarUrl, + theme: orgData.orgTheme + }) + }); + + if (!jobId) { + throw new AppError('Failed to enqueue notification job', ErrorCodes.INTERNAL_ERROR, 500); + } + + return { jobId }; +} + +/** + * Read the status of a notify job (BullMQ-backed envelope). Throws 404 if the + * job is gone (retention swept it). + */ +export async function getNotifyCourseExerciseStatusService(jobId: string, pollCount = 0): Promise { + const envelope = await getQueueJobEnvelope(QUEUE_NAMES.notifications, jobId, NOTIFY_DOMAIN, pollCount); + + if (!envelope) { + throw new AppError('Notification job not found', ErrorCodes.NOT_FOUND, 404); + } + + return envelope; +} diff --git a/apps/api/src/services/course/notify-session.ts b/apps/api/src/services/course/notify-session.ts new file mode 100644 index 00000000..abe0ce36 --- /dev/null +++ b/apps/api/src/services/course/notify-session.ts @@ -0,0 +1,16 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { enqueueNotifyCourseSessionUpdate } from '@cio/jobs'; + +/** + * Enqueue a background job that emails every course member an updated calendar + * invite after a teacher changes a live session. Returns the job id. + */ +export async function notifyCourseSessionUpdateService(courseId: string, lessonId: string): Promise<{ jobId: string }> { + const jobId = await enqueueNotifyCourseSessionUpdate({ courseId, lessonId }); + + if (!jobId) { + throw new AppError('Failed to enqueue session update notification', ErrorCodes.INTERNAL_ERROR, 500); + } + + return { jobId }; +} diff --git a/apps/api/src/services/course/payment-request.ts b/apps/api/src/services/course/payment-request.ts index cad8120b..828a642d 100644 --- a/apps/api/src/services/course/payment-request.ts +++ b/apps/api/src/services/course/payment-request.ts @@ -2,7 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; import { getCourseTeachers } from '@cio/db/queries/course/people'; import { getCourseWithOrgData } from '@cio/db/queries/course'; -import { buildEmailFromName } from '@cio/email'; +import { buildEmailFromName, buildEmailBranding } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs'; import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; @@ -27,6 +27,11 @@ export async function createPaymentRequest(data: PaymentRequestData) { const courseName = course.courseTitle || ''; const orgName = course.orgName || 'ClassroomIO'; + const branding = buildEmailBranding({ + name: course.orgName, + avatarUrl: course.orgAvatarUrl, + theme: course.orgTheme + }); const groupId = course.groupId; if (!groupId) { @@ -48,7 +53,8 @@ export async function createPaymentRequest(data: PaymentRequestData) { fields: { courseName, studentEmail: data.studentEmail, - studentFullname: data.studentFullname + studentFullname: data.studentFullname, + branding }, from: buildEmailFromName('ClassroomIO'), idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}` @@ -64,7 +70,8 @@ export async function createPaymentRequest(data: PaymentRequestData) { courseName, teacherEmail, studentFullname: data.studentFullname, - orgName + orgName, + branding }, from: buildEmailFromName(`${orgName} - ClassroomIO`), replyTo: teacherEmail, diff --git a/apps/api/src/services/course/people.ts b/apps/api/src/services/course/people.ts index 98b81cab..8c725788 100644 --- a/apps/api/src/services/course/people.ts +++ b/apps/api/src/services/course/people.ts @@ -14,9 +14,10 @@ import type { TGroupmember } from '@cio/db/types'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { getCourseWithOrgData } from '@cio/db/queries/course'; import { getProfileById } from '@cio/db/queries/auth'; -import { buildEmailFromName } from '@cio/email'; +import { buildEmailFromName, buildEmailBranding } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs'; import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance'; +import { getWelcomeSessionIcs } from './session-invite'; /** * Gets all course members (people) for a course @@ -68,6 +69,11 @@ export async function addMember( if (courseOrgData) { const courseName = courseOrgData.courseTitle || ''; const orgName = courseOrgData.orgName || 'ClassroomIO'; + const branding = buildEmailBranding({ + name: courseOrgData.orgName, + avatarUrl: courseOrgData.orgAvatarUrl, + theme: courseOrgData.orgTheme + }); const loginUrl = getDashboardBaseUrl({ siteName: courseOrgData.orgSiteName, customDomain: courseOrgData.orgCustomDomain, @@ -97,10 +103,13 @@ export async function addMember( fields: { orgName, courseName, - loginUrl + loginUrl, + customMessage: courseOrgData.welcomeEmailMessage ?? undefined, + branding }, from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), - idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}` + idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`, + ics: await getWelcomeSessionIcs(courseId) }); } catch (emailError) { console.error('Failed to enqueue student welcome email:', emailError); @@ -119,7 +128,8 @@ export async function addMember( fields: { courseName, studentName, - studentEmail + studentEmail, + branding }, from: buildEmailFromName('ClassroomIO'), idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}` @@ -182,6 +192,11 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) { const courseName = courseOrgData.courseTitle || ''; const orgName = courseOrgData.orgName || 'ClassroomIO'; const orgSiteName = courseOrgData.orgSiteName || ''; + const branding = buildEmailBranding({ + name: courseOrgData.orgName, + avatarUrl: courseOrgData.orgAvatarUrl, + theme: courseOrgData.orgTheme + }); // Add all members const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member))); @@ -217,7 +232,8 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) { name, orgName, courseName, - inviteLink + inviteLink, + branding }, from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), idempotencyKey: `teacher-course-welcome:${courseId}:${email}` diff --git a/apps/api/src/services/course/session-invite.ts b/apps/api/src/services/course/session-invite.ts new file mode 100644 index 00000000..2a752b65 --- /dev/null +++ b/apps/api/src/services/course/session-invite.ts @@ -0,0 +1,37 @@ +import { getCourseById, getUpcomingSessionsForCourseIds } from '@cio/db/queries/course'; +import { buildSessionIcs } from '@cio/email'; + +/** + * Build the calendar invite (.ics) for a course's next upcoming live session, + * for attaching to the welcome email on enrollment. Returns undefined when the + * course is not a LIVE_CLASS or has no upcoming session. The invite reuses the + * same UID as the session reminders, so calendars treat them as one event. + */ +export async function getWelcomeSessionIcs(courseId: string): Promise { + try { + const courseRows = await getCourseById(courseId); + const course = courseRows?.[0]; + if (!course || course.type !== 'LIVE_CLASS') { + return undefined; + } + + const upcoming = (await getUpcomingSessionsForCourseIds([courseId])).get(courseId); + if (!upcoming) { + return undefined; + } + + return buildSessionIcs({ + uid: `session-${upcoming.lessonId}@classroomio`, + sequence: 0, + method: 'PUBLISH', + start: upcoming.lessonAt, + title: upcoming.lessonTitle, + description: `Join your live session: ${upcoming.callUrl}`, + url: upcoming.callUrl, + alarmsBeforeMinutes: [1440, 60] + }); + } catch (error) { + console.error('getWelcomeSessionIcs error:', error); + return undefined; + } +} diff --git a/apps/api/src/services/jobs/email-jobs.ts b/apps/api/src/services/jobs/email-jobs.ts index 1a138ecc..c1345c59 100644 --- a/apps/api/src/services/jobs/email-jobs.ts +++ b/apps/api/src/services/jobs/email-jobs.ts @@ -20,6 +20,8 @@ export interface EnqueueTemplateEmailInput extends CommonOp fields: z.infer>; from?: string; replyTo?: string; + /** Optional iCalendar (.ics) body attached as a text/calendar part. */ + ics?: string; } export interface EnqueueRawEmailInput extends CommonOptions { @@ -81,7 +83,8 @@ export async function enqueueTransactionalEmail( to: recipient, fields: validatedFields, from: input.from, - replyTo: input.replyTo + replyTo: input.replyTo, + ics: input.ics }, { idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) } ); diff --git a/apps/api/src/services/newsfeed/newsfeed.ts b/apps/api/src/services/newsfeed/newsfeed.ts index 9a17fb7c..61bc7618 100644 --- a/apps/api/src/services/newsfeed/newsfeed.ts +++ b/apps/api/src/services/newsfeed/newsfeed.ts @@ -19,7 +19,7 @@ import { } from '@cio/db/queries/newsfeed'; import { env } from '@cio/core/config/env'; -import { buildEmailFromName } from '@cio/email'; +import { buildEmailFromName, buildEmailBranding } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs'; /** @@ -359,6 +359,11 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) { const orgName = feedData.organization?.name || 'ClassroomIO'; const orgSiteName = feedData.organization?.siteName || 'app'; + const branding = buildEmailBranding({ + name: feedData.organization?.name, + avatarUrl: feedData.organization?.avatarUrl, + theme: feedData.organization?.theme + }); const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`; const recipients = feedData.courseMembers @@ -374,7 +379,8 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) { teacherName: feedData.author?.fullname || 'A teacher', content: feedData.content || '', postLink, - orgName + orgName, + branding }, from: buildEmailFromName(`${orgName} - ClassroomIO`), replyTo: feedData.author?.email || 'noreply@classroomio.com', @@ -403,6 +409,11 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string) const orgName = feedData.organization?.name || 'ClassroomIO'; const orgSiteName = feedData.organization?.siteName || 'app'; + const branding = buildEmailBranding({ + name: feedData.organization?.name, + avatarUrl: feedData.organization?.avatarUrl, + theme: feedData.organization?.theme + }); const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`; await enqueueTransactionalEmail('newsfeedComment', { @@ -411,7 +422,8 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string) courseTitle: feedData.courseTitle, comment: commentContent, postLink, - orgName + orgName, + branding }, from: buildEmailFromName(`${orgName} - ClassroomIO`), replyTo: 'noreply@classroomio.com' diff --git a/apps/api/src/services/organization/audience.ts b/apps/api/src/services/organization/audience.ts index 4c1cdf31..4d079f77 100644 --- a/apps/api/src/services/organization/audience.ts +++ b/apps/api/src/services/organization/audience.ts @@ -5,7 +5,7 @@ import type { TImportAudienceMembers } from '@cio/utils/validation/organization'; import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group'; -import { buildEmailFromName } from '@cio/email'; +import { buildEmailFromName, buildEmailBranding } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs'; import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program'; import { @@ -30,6 +30,7 @@ import crypto from 'node:crypto'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { getProfilesByEmails } from '@cio/db/queries/auth'; import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance'; +import { getWelcomeSessionIcs } from '../course/session-invite'; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; @@ -62,8 +63,11 @@ function generateToken(): string { return crypto.randomBytes(32).toString('base64url'); } -function buildInviteLink(token: string): string { - return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`; +function buildInviteLink( + token: string, + org?: { siteName?: string | null; customDomain?: string | null; isCustomDomainVerified?: boolean | null } +): string { + return `${getDashboardBaseUrl(org)}/invite/${encodeURIComponent(token)}`; } function getExpiryLabel(expiresAtIso: string): string { @@ -170,6 +174,7 @@ async function enrollAudienceStudentProfilesInCourses( const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[]; const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle])); + const welcomeMessageByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.welcomeEmailMessage])); const validProfiles = uniqueProfileIds.filter((id) => validProfileIds.has(id)); const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId }))); @@ -198,6 +203,10 @@ async function enrollAudienceStudentProfilesInCourses( const loginUrl = getDashboardBaseUrl(organization); if (shouldSendEmail && toInsert.length > 0) { + const icsByGroupId = new Map( + await Promise.all(courseGroups.map(async (cg) => [cg.groupId, await getWelcomeSessionIcs(cg.courseId)] as const)) + ); + const emailPromises = toInsert .filter((p) => profileEmailMap.get(p.profileId)) .map(async (p) => { @@ -208,10 +217,13 @@ async function enrollAudienceStudentProfilesInCourses( fields: { orgName: organization.name, courseName: courseTitleByGroupId.get(p.groupId) || 'Course', - loginUrl + loginUrl, + customMessage: welcomeMessageByGroupId.get(p.groupId) ?? undefined, + branding: buildEmailBranding(organization) }, from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), - idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}` + idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}`, + ics: icsByGroupId.get(p.groupId) }); emailsSent++; } catch (emailError) { @@ -290,7 +302,8 @@ async function enrollAudienceStudentProfilesInPrograms( fields: { orgName: organization.name, programName: programNameById.get(pair.programId) || 'Program', - loginUrl + loginUrl, + branding: buildEmailBranding(organization) }, from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}` @@ -387,7 +400,7 @@ async function createStudentOrgInvitesAndSendEmails(input: { const invite = inviteByEmail.get(email)!; const token = tokenByEmail.get(email)!; try { - const inviteLink = buildInviteLink(token); + const inviteLink = buildInviteLink(token, organization); await enqueueTransactionalEmail('studentOrgInvite', { to: email, fields: { @@ -395,7 +408,8 @@ async function createStudentOrgInvitesAndSendEmails(input: { orgName: organization.name, inviteLink, expiresAt: getExpiryLabel(expiresAt), - courseNames: accessNamesLabel + courseNames: accessNamesLabel, + branding: buildEmailBranding(organization) }, from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), idempotencyKey: `student-org-invite:${invite.id}` @@ -677,7 +691,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB let emailSent = false; try { - const inviteLink = buildInviteLink(token); + const inviteLink = buildInviteLink(token, organization); await enqueueTransactionalEmail('studentOrgInvite', { to: emailToUse, fields: { @@ -685,7 +699,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB orgName: organization.name, inviteLink, expiresAt: getExpiryLabel(expiresAt), - courseNames: accessNamesLabel + courseNames: accessNamesLabel, + branding: buildEmailBranding(organization) }, from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), idempotencyKey: `student-org-invite:${invite.id}` diff --git a/apps/api/src/services/organization/invite.ts b/apps/api/src/services/organization/invite.ts index 4c2553a0..41646284 100644 --- a/apps/api/src/services/organization/invite.ts +++ b/apps/api/src/services/organization/invite.ts @@ -32,6 +32,7 @@ import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org'; import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; import { enqueueTransactionalEmail } from '@api/services/jobs'; +import { buildEmailBranding } from '@cio/email'; import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance'; type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED'; @@ -60,8 +61,11 @@ function normalizeEmails(emails: string[]): string[] { return [...new Set(emails.map((email) => email.toLowerCase().trim()).filter(Boolean))]; } -function buildInviteLink(token: string): string { - return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`; +function buildInviteLink( + token: string, + org?: { siteName?: string | null; customDomain?: string | null; isCustomDomainVerified?: boolean | null } +): string { + return `${getDashboardBaseUrl(org)}/invite/${encodeURIComponent(token)}`; } function getRoleLabel(roleId: number): string { @@ -245,7 +249,7 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId: } }); - const inviteLink = buildInviteLink(token); + const inviteLink = buildInviteLink(token, organization); try { await enqueueTransactionalEmail('inviteTeacher', { @@ -256,7 +260,8 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId: orgSiteName: organization.siteName, roleName, expiresAt: getExpiryLabel(expiresAt), - inviteLink + inviteLink, + branding: buildEmailBranding(organization) }, idempotencyKey: `org-invite-teacher:${invite.id}` }); diff --git a/apps/api/src/services/program/goal.ts b/apps/api/src/services/program/goal.ts index 2ace890e..12a5326e 100644 --- a/apps/api/src/services/program/goal.ts +++ b/apps/api/src/services/program/goal.ts @@ -1,6 +1,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; import { enqueueTransactionalEmail } from '@api/services/jobs'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; +import { buildEmailBranding } from '@cio/email'; import { countAssignmentsByStatus, createProgramGoal, @@ -425,7 +426,12 @@ export async function runProgramGoalReminderScan(): Promise<{ daysUntilDue, completedCount: row.assignment.completedCount ?? 0, requiredCount: row.assignment.requiredCount ?? 0, - loginUrl: `${loginUrl}/lms/programs/${row.programId}` + loginUrl: `${loginUrl}/lms/programs/${row.programId}`, + branding: buildEmailBranding({ + name: row.organizationName, + avatarUrl: row.organizationAvatarUrl, + theme: row.organizationTheme + }) }, idempotencyKey: `program-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}` }); diff --git a/apps/api/src/services/submission/submission.ts b/apps/api/src/services/submission/submission.ts index d44cd8ee..ef2aa6ad 100644 --- a/apps/api/src/services/submission/submission.ts +++ b/apps/api/src/services/submission/submission.ts @@ -12,8 +12,8 @@ import type { TSubmissionGradesUpdate, TSubmissionUpdate } from '@cio/utils/validation/submission'; -import { buildEmailFromName } from '@cio/email'; -import { enqueueRawEmail } from '@api/services/jobs'; +import { buildEmailBranding, buildEmailFromName } from '@cio/email'; +import { enqueueTransactionalEmail } from '@api/services/jobs'; import { createSubmission, deleteSubmission, @@ -34,7 +34,7 @@ import { getQuestionTypeByTypename, requiresManualGrading } from '@cio/question-types'; -import { getCourseById, getOrganizationByCourseId } from '@cio/db/queries/course'; +import { getCourseById, getCourseWithOrgData } from '@cio/db/queries/course'; import { getCourseTeachers, getProfileByGroupMemberId } from '@cio/db/queries/course/people'; import { getExerciseById, getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise'; import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group'; @@ -578,7 +578,8 @@ export async function createSubmissionService( .map((question) => question.questionType?.typename ?? '') .filter((typename) => typename.length > 0) ); - const gradingState: SubmissionGradingState = 'queued'; + const gradingState: SubmissionGradingState = + overallStatus === 'manual_required' || overallStatus === 'hybrid' ? 'awaiting_manual' : 'queued'; const submissionData: TNewSubmission = { courseId, @@ -869,7 +870,6 @@ export async function updateSubmissionGradesBatch( const currentGradingState = resolveSubmissionGradingState(submission); const targetGradingState: SubmissionGradingState = 'completed'; const targetStatusId = projectLegacyStatusId(targetGradingState); - if (!isAllowedGradingTransition(currentGradingState, targetGradingState)) { throw new AppError('Invalid submission workflow transition', ErrorCodes.VALIDATION_ERROR, 400); } @@ -965,13 +965,17 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb return; } - // Get organization name - const orgResult = await getOrganizationByCourseId(fullSubmission.courseId || ''); + // Get organization name and URL info + const orgResult = await getCourseWithOrgData(fullSubmission.courseId || ''); const orgName = orgResult?.orgName || 'ClassroomIO'; const statusText = LEGACY_BOARD_STATUS_LABELS[newStatusId] || 'Updated'; - const baseUrl = getDashboardBaseUrl(); + const baseUrl = getDashboardBaseUrl({ + siteName: orgResult?.orgSiteName, + customDomain: orgResult?.orgCustomDomain, + isCustomDomainVerified: orgResult?.orgIsCustomDomainVerified + }); const exerciseLink = `${baseUrl}/courses/${fullSubmission.courseId}/exercises/${fullSubmission.exercise.id}`; // Calculate total and max points @@ -979,37 +983,33 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb const totalMark = answers.reduce((sum: number, a: any) => sum + (a.point || 0), 0); const maxMark = (fullSubmission.exercise.questions || []).reduce((sum: number, q: any) => sum + (q.points || 0), 0); - let content = ` -

Hello ${fullSubmission.groupmember.profile.fullname || 'Student'},

-

The status of your submitted exercise on ${fullSubmission.exercise.title} has been updated to ${statusText}

- `; + const isGraded = newStatusId === 3; + const score = isGraded ? `${totalMark}/${maxMark}` : undefined; - if (newStatusId === 3) { - content += ` -

Your score was ${totalMark}/${maxMark}

-
View your Result - `; - } else { - content += `Open Exercise`; + try { + await enqueueTransactionalEmail('submissionGraded', { + to: fullSubmission.groupmember.profile.email, + fields: { + orgName, + studentName: fullSubmission.groupmember.profile.fullname || 'Student', + exerciseTitle: fullSubmission.exercise.title, + courseName: course.title, + statusText, + exerciseLink, + score, + lessonTitle: fullSubmission.lesson?.title, + branding: buildEmailBranding({ + name: orgResult?.orgName, + avatarUrl: orgResult?.orgAvatarUrl, + theme: orgResult?.orgTheme + }) + }, + from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), + idempotencyKey: `submission-update:${submissionId}:${newStatusId}` + }); + } catch (error) { + console.error('Failed to enqueue submission graded email:', error); } - - if (fullSubmission.lesson?.title) { - content += ` -

This exercise is for ${fullSubmission.lesson.title} in a course you are taking titled ${course.title}

- `; - } else { - content += ` -

This exercise is in a course you are taking titled ${course.title}

- `; - } - - await enqueueRawEmail({ - from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), - to: fullSubmission.groupmember.profile.email, - subject: 'Submission Update', - content, - idempotencyKey: `submission-update:${submissionId}:${newStatusId}` - }); } /** @@ -1042,32 +1042,43 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s return; } - // Get organization name - const orgResult = await getOrganizationByCourseId(courseId); + // Get organization name and URL info + const orgResult = await getCourseWithOrgData(courseId); const orgName = orgResult?.orgName || 'ClassroomIO'; - const baseUrl = getDashboardBaseUrl(); + const baseUrl = getDashboardBaseUrl({ + siteName: orgResult?.orgSiteName, + customDomain: orgResult?.orgCustomDomain, + isCustomDomainVerified: orgResult?.orgIsCustomDomainVerified + }); const exerciseLink = `${baseUrl}/courses/${courseId}/exercises/${exerciseId}`; const submissionLink = `${baseUrl}/courses/${courseId}/submissions`; - const content = ` -

Hello,

-

A student ${student.fullname || student.username} just submitted an exercise ${exercise.title}

-

You can get started grading by clicking "Open Submissions"

-
- Open Submissions -
- `; + const studentName = student.fullname || student.username || 'A student'; const tutorEmails = tutorsResult.map((tutor) => tutor.email).filter((email): email is string => Boolean(email)); if (tutorEmails.length === 0) return; - await enqueueRawEmail({ - from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), - to: tutorEmails, - subject: `[Submitted]: ${exercise.title}`, - content, - idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}` - }); + try { + await enqueueTransactionalEmail('submissionReceived', { + to: tutorEmails, + fields: { + orgName, + studentName, + exerciseTitle: exercise.title, + exerciseLink, + submissionLink, + branding: buildEmailBranding({ + name: orgResult?.orgName, + avatarUrl: orgResult?.orgAvatarUrl, + theme: orgResult?.orgTheme + }) + }, + from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), + idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}` + }); + } catch (error) { + console.error('Failed to enqueue submission received email:', error); + } } diff --git a/apps/api/src/utils/course-completion.ts b/apps/api/src/utils/course-completion.ts index 7dcca63a..eccd579b 100644 --- a/apps/api/src/utils/course-completion.ts +++ b/apps/api/src/utils/course-completion.ts @@ -5,7 +5,7 @@ import { } from '@cio/db/queries/course/course'; import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people'; import { getProfileById } from '@cio/db/queries/auth'; -import { buildEmailFromName } from '@cio/email'; +import { buildEmailFromName, buildEmailBranding } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs'; export function calcCourseProgressPercent(params: { @@ -81,7 +81,12 @@ export function scheduleCertificationCompletionWork(params: { courseName: courseRow.title, studentName: profile?.fullname || studentEmail, certificateUrl, - customMessage: courseRow.certificate?.emailMessage ?? null + customMessage: courseRow.certificate?.emailMessage ?? null, + branding: buildEmailBranding({ + name: courseRow.orgName, + avatarUrl: courseRow.orgAvatarUrl, + theme: courseRow.orgTheme + }) }, from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`), idempotencyKey: `course-completion:${groupMemberId}` diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 8545863b..f3a1b0d8 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -1,5 +1,5 @@ - A flexible, user-friendly platform for creating, managing, and delivering courses for companies and training organisations + A flexible, user-friendly platform for creating, managing, and delivering trainings for companies and training organisations

ClassroomIO

The Open Source Teaching Platform for Bootcamps diff --git a/apps/dashboard/src/lib/features/app/init.svelte.ts b/apps/dashboard/src/lib/features/app/init.svelte.ts index 7113802d..892feac2 100644 --- a/apps/dashboard/src/lib/features/app/init.svelte.ts +++ b/apps/dashboard/src/lib/features/app/init.svelte.ts @@ -202,9 +202,14 @@ class AppInitApi extends BaseApi { if (!shouldRedirectOnAuth(page.url.pathname)) return; + // Students with existing org memberships who open /onboarding may intend to create their own academy. + if (path.includes('/onboarding') && isStudent && userHasOrganizations) { + return; + } + const shouldGoToLMS = isCloud ? isOrgSite || !!isStudent : !!isStudent; console.log('redirecting to', shouldGoToLMS ? 'lms' : 'org'); - return shouldGoToLMS ? this.goToLMS() : this.goToOrg(); + return shouldGoToLMS ? this.goToLMS(isOrgSite) : this.goToOrg(); } goToLMS(isOrgSite: boolean) { diff --git a/apps/dashboard/src/lib/features/course/api/exercise.svelte.ts b/apps/dashboard/src/lib/features/course/api/exercise.svelte.ts index ee01054c..a0723aa1 100644 --- a/apps/dashboard/src/lib/features/course/api/exercise.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/exercise.svelte.ts @@ -28,6 +28,8 @@ import { import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { snackbar } from '$features/ui/snackbar/store'; +import { toast } from '@cio/ui/base/sonner'; +import { t } from '$lib/utils/functions/translations'; /** * API class for exercise operations @@ -60,6 +62,74 @@ export class ExerciseApi extends BaseApiWithErrors { }); } + /** + * Notifies all course members that a quiz has been assigned. Runs as a + * background job; shows a sonner spinner that polls until the job is done. + */ + async notifyStudents(courseId: string, exerciseId: string) { + const toastId = toast.loading(t.get('course.navItem.lessons.exercises.all_exercises.notify.sending'), { + position: 'bottom-right' + }); + + try { + const res = await classroomio.course[':courseId'].exercise[':exerciseId'].notify.$post({ + param: { courseId, exerciseId } + }); + const body = await res.json(); + + if (!body.success) { + throw new Error('Failed to start notification'); + } + + const jobId = body.data.jobId; + const maxPolls = 60; + + for (let pollCount = 0; pollCount < maxPolls; pollCount++) { + const statusRes = await classroomio.course[':courseId'].exercise[':exerciseId'].notify[':jobId'].$get({ + param: { courseId, exerciseId, jobId }, + query: { pollCount: String(pollCount) } + }); + const statusBody = await statusRes.json(); + + if (!statusBody.success) { + throw new Error('Failed to read notification status'); + } + + const { job, nextPollMs } = statusBody.data; + + if (job.status === 'completed') { + const notified = (job.result as { notified?: number } | null)?.notified ?? 0; + toast.success(t.get('course.navItem.lessons.exercises.all_exercises.notify.sent', { count: notified }), { + id: toastId, + position: 'bottom-right' + }); + return; + } + + if (job.status === 'failed' || job.status === 'canceled') { + toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), { + id: toastId, + position: 'bottom-right' + }); + return; + } + + await new Promise((resolve) => setTimeout(resolve, nextPollMs || 2000)); + } + + toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), { + id: toastId, + position: 'bottom-right' + }); + } catch (error) { + console.error('notifyStudents error:', error); + toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), { + id: toastId, + position: 'bottom-right' + }); + } + } + /** * Creates a new exercise */ diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte index 6ed92be7..0b3742e7 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte @@ -140,6 +140,7 @@ {/each} {$t('course.certification.final_exercise_helper')} + {$t('course.certification.final_exercise_multiple_attempts_note')} {#if courseApi.course?.certificate?.requiredExerciseId} diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte index 14f4e7bd..71e86948 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/course-completion-modal.svelte @@ -4,21 +4,34 @@ import { resolve } from '$app/paths'; import * as Dialog from '@cio/ui/base/dialog'; import { Button } from '@cio/ui/base/button'; + import { Spinner } from '@cio/ui/base/spinner'; import { Confetti } from '$features/ui'; import { setConfetti } from '$features/ui/confetti/store'; + import { ContentType } from '@cio/utils/constants/content'; import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal'; + import { getContentRoute } from '$features/course/utils/content'; + import { formatBlockerMessage } from '$features/course/utils/certificate-utils'; import { t } from '$lib/utils/functions/translations'; let open = $state(false); let activeCourseId = $state(''); + let activeStep = $state<'checking' | 'eligible' | 'not-eligible'>('checking'); + let activeEvaluation = $state | null>(null); + let activeExerciseId = $state(undefined); onMount(() => { return courseCompletionModal.subscribe((v) => { if (v?.open && v.courseId) { activeCourseId = v.courseId; + activeStep = v.step; + activeEvaluation = v.evaluation as Record | null; + activeExerciseId = v.exerciseId; open = true; - setConfetti(true); - setTimeout(() => setConfetti(false), 5500); + + if (v.step === 'eligible' && !v.evaluation?.certificateEarnedAt) { + setConfetti(true); + setTimeout(() => setConfetti(false), 5500); + } } else { open = false; } @@ -37,6 +50,24 @@ onOpenChange(false); goto(resolve(`/courses/${activeCourseId}/certificates`, {})); } + + function goToExercise() { + if (!activeCourseId || !activeExerciseId) return; + onOpenChange(false); + const path = getContentRoute(activeCourseId, { id: activeExerciseId, type: ContentType.Exercise }); + if (path) { + goto(resolve(path, {})); + } + } + + function formatSingleBlocker() { + if (!activeEvaluation) return ''; + const blockers = (activeEvaluation as Record).blockers as + | Array> + | undefined; + if (!blockers || blockers.length === 0) return ''; + return formatBlockerMessage(blockers[0] as Parameters[0]); + }

@@ -45,13 +76,33 @@ - - {$t('course.completion.modal.title')} - {$t('course.completion.modal.subtitle')} - - - - - + {#if activeStep === 'checking'} +
+ +

{$t('course.completion.modal.checking')}

+
+ {:else if activeStep === 'eligible'} + + {$t('course.completion.modal.title')} + {$t('course.completion.modal.subtitle')} + + + + + + {:else if activeStep === 'not-eligible'} + + {$t('course.completion.modal.not_eligible_title')} + +
+

{formatSingleBlocker()}

+
+ + + {#if activeExerciseId} + + {/if} + + {/if}
diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte index d8eb5c20..7ce4c7d8 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/student-certificate/unlocked-certificate.svelte @@ -11,7 +11,7 @@ import { snackbar } from '$features/ui/snackbar/store'; import { classroomio } from '$lib/utils/services/api'; import type { CertificationEvaluationData } from '$features/course/utils/types'; - import { normalizeCertificateIssuedAt } from '$features/course/utils/certificate-utils'; + import { normalizeCertificateIssuedAt, formatBlockerMessage } from '$features/course/utils/certificate-utils'; import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; let isLoading = $state(false); @@ -21,41 +21,6 @@ let courseId = $derived(courseApi.course?.id ?? ''); - function formatBlockerMessage(blocker: CertificationEvaluationData['blockers'][number]): string { - const p = blocker.params ?? {}; - switch (blocker.code) { - case 'CERT_PROGRESS': - return t.get('course.certification.blocker_progress', { - current: Number(p.current ?? 0), - required: Number(p.required ?? 0) - }); - case 'CERT_DEADLINE_PASSED': - return t.get('course.certification.blocker_deadline'); - case 'CERT_NO_CONTENT': - return t.get('course.certification.blocker_no_content'); - case 'CERT_FINAL_EXERCISE_NOT_SUBMITTED': - return t.get('course.certification.blocker_final_not_submitted', { - exerciseTitle: String(p.exerciseTitle ?? '') - }); - case 'CERT_FINAL_EXERCISE_PENDING_GRADE': - return t.get('course.certification.blocker_final_pending', { - exerciseTitle: String(p.exerciseTitle ?? '') - }); - case 'CERT_FINAL_EXERCISE_SCORE': - return t.get('course.certification.blocker_final_score', { - exerciseTitle: String(p.exerciseTitle ?? ''), - bestPercent: Number(p.bestPercent ?? 0), - requiredPercent: Number(p.requiredPercent ?? 0) - }); - case 'CERT_FINAL_EXERCISE_MISCONFIGURED': - return t.get('course.certification.blocker_final_misconfigured', { - exerciseTitle: String(p.exerciseTitle ?? '') - }); - default: - return blocker.code; - } - } - function buildBody() { return { studentName: $profile.fullname || 'Recipient', diff --git a/apps/dashboard/src/lib/features/course/components/course-header.svelte b/apps/dashboard/src/lib/features/course/components/course-header.svelte index 09561423..652af5da 100644 --- a/apps/dashboard/src/lib/features/course/components/course-header.svelte +++ b/apps/dashboard/src/lib/features/course/components/course-header.svelte @@ -3,6 +3,7 @@ import * as Sidebar from '@cio/ui/base/sidebar'; import EyeIcon from '@lucide/svelte/icons/eye'; import { Button } from '@cio/ui/base/button'; + import { Waves } from '@cio/ui/custom/animation'; import { page } from '$app/state'; import { currentOrg, currentOrgDomain } from '$lib/utils/store/org'; import { isStudentExperience } from '$lib/utils/store/app'; @@ -58,9 +59,22 @@ - {#if !$isStudentExperience} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte index 37850ccc..4811116a 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte @@ -53,7 +53,7 @@ shouldDeleteExercise?: boolean; exerciseId: string; goBack?: () => void; - /** Self-paced + all auto-gradable question types: enforce points > 0 */ + /** All auto-gradable question types (any course): enforce points > 0 for scoring */ requiresPositivePointsForAutoGrade?: boolean; selfPacedCourse?: boolean; /** Public course restricts picker to auto-gradable question types only. */ diff --git a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte index 06d5c396..cfe4762b 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte @@ -40,7 +40,11 @@ import { ContentType } from '@cio/utils/constants/content'; import { getOrderedNavigableContent } from '$features/course/utils/content'; import { isOrgStudent } from '$lib/utils/store/app'; - import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; + import { + openCourseCompletionModal, + updateCourseCompletionModal, + closeCourseCompletionModal + } from '$features/course/store/course-completion-modal'; import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils'; import { toExerciseQuestionModel } from './question-type-utils'; import { getExerciseQuestionLabels } from './question-labels'; @@ -152,7 +156,19 @@ $isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete); if (allComplete && courseApi.course?.id) { + const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined; openCourseCompletionModal(courseApi.course.id); + + const certRes = await courseApi.getCertificationEvaluation(courseApi.course.id); + if (certRes?.data) { + if (certRes.data.isNewCompletion) { + updateCourseCompletionModal(courseApi.course.id, 'eligible', certRes.data, requiredExerciseId); + } else { + updateCourseCompletionModal(courseApi.course.id, 'not-eligible', certRes.data, requiredExerciseId); + } + } else { + closeCourseCompletionModal(); + } } } diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte index f1291646..b982f4b0 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte @@ -286,7 +286,7 @@ ? $t('course.navItem.lessons.add_lesson.lock') : $t('course.navItem.lessons.add_lesson.unlock')} {@const canRenderJoinButton = - item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked} + item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked && !isEditingItem} {@const canRenderContinueButton = isStudentView && !itemLocked} {@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])} {@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte index ff5f0d6d..c6f451b9 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte @@ -14,7 +14,11 @@ import { ContentType } from '@cio/utils/constants/content'; import { snackbar } from '$features/ui/snackbar/store'; import type { CourseContentItem } from '$features/course/utils/types'; - import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; + import { + openCourseCompletionModal, + updateCourseCompletionModal, + closeCourseCompletionModal + } from '$features/course/store/course-completion-modal'; import { updateLessonCompletionInCourseContent } from '$features/course/utils/content-completion'; interface Props { @@ -135,7 +139,19 @@ navigableContentItems.every((item) => item.isComplete); if (allComplete) { + const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined; openCourseCompletionModal(courseId); + + const certRes = await courseApi.getCertificationEvaluation(courseId); + if (certRes?.data) { + if (certRes.data.isNewCompletion) { + updateCourseCompletionModal(courseId, 'eligible', certRes.data, requiredExerciseId); + } else { + updateCourseCompletionModal(courseId, 'not-eligible', certRes.data, requiredExerciseId); + } + } else { + closeCourseCompletionModal(); + } } } else { snackbar.error('snackbar.lessons.error.try_later'); diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-row.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-row.svelte index cac5c89e..d968c773 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/content-row.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/content-row.svelte @@ -2,10 +2,13 @@ import { resolve } from '$app/paths'; import LockIcon from '@lucide/svelte/icons/lock'; import { InputField } from '@cio/ui/custom/input-field'; + import { RadioIcon } from '@cio/ui/custom/moving-icons'; import { CircleCheckIcon } from '$features/ui/icons'; import type { Snippet } from 'svelte'; import CourseContentIcon from '$features/course/components/course-content-icon.svelte'; import type { CourseContentItem } from '$features/course/utils/types'; + import { ContentType } from '@cio/utils/constants/content'; + import { t } from '$lib/utils/functions/translations'; import { cn } from '@cio/ui/tools'; interface Props { @@ -62,6 +65,16 @@ {/if} + {#if item.type === ContentType.Lesson && item.callUrl} + + + + {/if} +
- import { lessonApi } from '$features/course/api'; + import { lessonApi, courseApi } from '$features/course/api'; import { InputField } from '@cio/ui/custom/input-field'; import { Checkbox } from '@cio/ui/base/checkbox'; + import { Button } from '@cio/ui/base/button'; + import * as Dialog from '@cio/ui/base/dialog'; import * as Field from '@cio/ui/base/field'; import * as Select from '@cio/ui/base/select'; + import * as Tooltip from '@cio/ui/base/tooltip'; + import InfoIcon from '@lucide/svelte/icons/info'; + import { toast } from '@cio/ui/base/sonner'; + import { classroomio } from '$lib/utils/services/api'; import { t } from '$lib/utils/functions/translations'; + import { getBrowserTimezone, instantToZonedWallClock, zonedWallClockToInstant } from '$lib/utils/functions/date'; import { getVideoTitle, isHlsUploadVideo, type LessonVideo } from './video/video-card-utils'; type LessonCompletionPolicy = 'manual' | 'video_watch' | 'none'; + // ── Live session (Zoom link + scheduled time + timezone) ────────────────── + const isLiveClass = $derived(courseApi.course?.type === 'LIVE_CLASS'); + const timezoneOptions = (() => { + try { + return (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.('timeZone') ?? []; + } catch { + return []; + } + })(); + + let sessionCallUrl = $state(''); + let sessionWallClock = $state(''); + let sessionTimezone = $state(getBrowserTimezone()); + let sessionInitializedFor = $state(null); + let isSavingSession = $state(false); + let confirmOpen = $state(false); + + $effect(() => { + const lesson = lessonApi.lesson; + if (!lesson || sessionInitializedFor === lesson.id) return; + + const tz = courseApi.course?.metadata?.sessionTimezone || getBrowserTimezone(); + sessionTimezone = tz; + sessionCallUrl = lesson.callUrl ?? ''; + sessionWallClock = instantToZonedWallClock(lesson.lessonAt, tz); + sessionInitializedFor = lesson.id; + }); + + const hadPriorSession = $derived(Boolean(lessonApi.lesson?.lessonAt && lessonApi.lesson?.callUrl)); + + function computeNewInstant(): string { + return sessionWallClock ? zonedWallClockToInstant(sessionWallClock, sessionTimezone) : ''; + } + + function sessionChanged(): boolean { + const currentTz = courseApi.course?.metadata?.sessionTimezone || getBrowserTimezone(); + + return ( + sessionCallUrl !== (lessonApi.lesson?.callUrl ?? '') || + computeNewInstant() !== (lessonApi.lesson?.lessonAt ?? '') || + sessionTimezone !== currentTz + ); + } + + async function persistSession() { + const courseId = courseApi.course?.id; + const lessonId = lessonApi.lesson?.id; + if (!courseId || !lessonId) return; + + isSavingSession = true; + try { + await lessonApi.update(courseId, lessonId, { + callUrl: sessionCallUrl || undefined, + lessonAt: computeNewInstant() || undefined + }); + + const currentTz = courseApi.course?.metadata?.sessionTimezone || ''; + if (sessionTimezone && sessionTimezone !== currentTz) { + await courseApi.update(courseId, { + metadata: { ...(courseApi.course?.metadata ?? {}), sessionTimezone } + }); + } + + sessionInitializedFor = null; // re-sync baselines from refreshed lesson/course + } finally { + isSavingSession = false; + } + } + + async function notifySessionUpdate() { + const courseId = courseApi.course?.id; + const lessonId = lessonApi.lesson?.id; + if (!courseId || !lessonId) return; + + try { + await classroomio.course[':courseId'].lesson[':lessonId']['notify-session-update'].$post({ + param: { courseId, lessonId } + }); + toast.success(t.get('course.navItem.lessons.session.notified')); + } catch { + toast.error(t.get('course.navItem.lessons.session.notify_failed')); + } + } + + async function handleSaveSession() { + if (!sessionChanged()) return; + + if (hadPriorSession) { + confirmOpen = true; + return; + } + + await persistSession(); + toast.success(t.get('course.navItem.lessons.session.saved')); + } + + async function confirmSaveAndNotify() { + confirmOpen = false; + await persistSession(); + await notifySessionUpdate(); + } + const completionPolicy = $derived(lessonApi.lesson?.completionPolicy ?? 'manual'); const videoWatchThreshold = $derived(lessonApi.lesson?.videoWatchThreshold ?? 95); const videos = $derived(lessonApi.lesson?.videos ?? []); @@ -63,6 +172,59 @@
+ {#if isLiveClass} + + {$t('course.navItem.lessons.session.heading')} + {$t('course.navItem.lessons.session.intro')} + + + (sessionCallUrl = (e.currentTarget as HTMLInputElement).value)} + /> + + +
+ + {$t('course.navItem.lessons.session.datetime_label')} + (sessionWallClock = (e.currentTarget as HTMLInputElement).value)} + /> + + + + {$t('course.navItem.lessons.session.timezone_label')} + + {$t('course.navItem.lessons.session.timezone_helper')} + +
+ +
+ +
+
+ + + {/if} + {$t('course.navItem.lessons.settings.progression.heading')} {$t('course.navItem.lessons.settings.progression.intro')} @@ -72,9 +234,36 @@ - - {$t('course.navItem.lessons.completion_policy.label')} - +
+ + {$t('course.navItem.lessons.completion_policy.label')} + + + + + + + +
    +
  • + {$t('course.navItem.lessons.completion_policy.manual_label')}: + {$t('course.navItem.lessons.completion_policy.manual_description')} +
  • +
  • + {$t('course.navItem.lessons.completion_policy.video_watch_label')}: + {$t('course.navItem.lessons.completion_policy.video_watch_description')} +
  • +
  • + {$t('course.navItem.lessons.completion_policy.none_label')}: + {$t('course.navItem.lessons.completion_policy.none_description')} +
  • +
+
+
+
+
{policyTriggerLabel(completionPolicy)} @@ -108,30 +297,6 @@
-
-

{$t('course.navItem.lessons.settings.progression.option_guide_heading')}

-
    -
  • - - {$t('course.navItem.lessons.completion_policy.manual_label')}: - - {$t('course.navItem.lessons.completion_policy.manual_description')} -
  • -
  • - - {$t('course.navItem.lessons.completion_policy.video_watch_label')}: - - {$t('course.navItem.lessons.completion_policy.video_watch_description')} -
  • -
  • - - {$t('course.navItem.lessons.completion_policy.none_label')}: - - {$t('course.navItem.lessons.completion_policy.none_description')} -
  • -
-
- {#if completionPolicy === 'video_watch'}
+ + + + + {$t('course.navItem.lessons.session.confirm_title')} + {$t('course.navItem.lessons.session.confirm_body')} + + + + + + + diff --git a/apps/dashboard/src/lib/features/course/components/lesson/live-session-card.svelte b/apps/dashboard/src/lib/features/course/components/lesson/live-session-card.svelte new file mode 100644 index 00000000..499b34a0 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/live-session-card.svelte @@ -0,0 +1,35 @@ + + + diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte b/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte index a91a3f87..27ff5dfd 100644 --- a/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte +++ b/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte @@ -12,6 +12,7 @@ import { CircleCheckIcon } from '$features/ui/icons'; import { t } from '$lib/utils/functions/translations'; import { IconButton } from '@cio/ui/custom/icon-button'; + import { RadioIcon } from '@cio/ui/custom/moving-icons'; import CourseContentIcon from '$features/course/components/course-content-icon.svelte'; interface Props { @@ -29,6 +30,16 @@ const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent); +{#snippet liveSessionDot()} + + + +{/snippet} + {#if contentData.grouped} {#each contentData.sections as section (section.id)} @@ -74,7 +85,7 @@ {#snippet child({ props })} {@const isContentLocked = (contentItem.isUnlocked ?? true) === false} - {@const isLockedForStudent = isStudent && isContentLocked} + {@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
{/if} - {#if isContentLocked} + {#if contentItem.type === ContentType.Lesson && contentItem.callUrl} + {@render liveSessionDot()} + {:else if isContentLocked || isLockedForStudent} {#snippet child({ props })} {@const isContentLocked = (contentItem.isUnlocked ?? true) === false} - {@const isLockedForStudent = isStudent && isContentLocked} + {@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)} {/if} - {#if isContentLocked} + {#if contentItem.type === ContentType.Lesson && contentItem.callUrl} + {@render liveSessionDot()} + {:else if isContentLocked || isLockedForStudent} 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id))); } - /** When true, every question must have points > 0 before save */ - const requiresPositivePointsForAutoGrade = $derived( - isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds) - ); + const isFullyAutoGradable = $derived(isExerciseFullyAutoGradable(activeQuestionTypeIds)); + + /** When true, every question must have points > 0 before save (for meaningful auto-grading) */ + const requiresPositivePointsForAutoGrade = $derived(isFullyAutoGradable); + const teacherAutoGradeBadge = $derived.by(() => { - if ($isOrgStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null; - return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const); + if ($isOrgStudent || isPublicCourse || activeQuestionTypeIds.length === 0) return null; + return isFullyAutoGradable ? ('auto' as const) : ('manual' as const); }); async function scrollToExerciseElement(elementId: string) { @@ -498,6 +499,16 @@ {$t('course.navItem.lessons.exercises.all_exercises.reorder')} {/if} + { + if (courseApi.course?.id && exerciseId) { + exerciseApi.notifyStudents(courseApi.course.id, exerciseId); + } + }} + > + {$t('course.navItem.lessons.exercises.all_exercises.notify.action')} + {#snippet child()} -
+
+ {#if isLiveSessionLesson && mode === MODES.view && !($isOrgStudent && !isLessonUnlocked)} +
+ +
+ {/if} + {#if $isOrgStudent && !isLessonUnlocked} ; const updatedCourse = { @@ -224,7 +226,8 @@ allowNewStudents: !!course.metadata?.allowNewStudent, isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true, progressionMode: course.metadata?.progressionMode ?? 'free', - callout: normalizeCallout(course.callout) + callout: normalizeCallout(course.callout), + welcomeEmailMessage: course.metadata?.welcomeEmailMessage ?? '' }); }); } @@ -350,7 +353,7 @@
- + + + {/each} + + +{/if} diff --git a/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte b/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte index af06e710..ff0a3aae 100644 --- a/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte +++ b/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte @@ -19,12 +19,14 @@ import { coursesApi } from '$features/course/api'; import * as ResourceListRow from '@cio/ui/custom/resource-list-row'; import { CourseListRow } from '$features/course/components'; + import UpcomingSessionsCard from '$features/lms/components/upcoming-sessions-card.svelte'; import CoursePublicBadge from '$features/course/components/course-public-badge.svelte'; import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte'; import type { RecommendedCourses } from '$features/course/types'; import { IconButton } from '@cio/ui/custom/icon-button'; import ExternalLinkIcon from '@lucide/svelte/icons/external-link'; import CopyIcon from '@lucide/svelte/icons/copy'; + import VideoIcon from '@lucide/svelte/icons/video'; import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview'; import { getStudentCourseComplianceDate, @@ -59,6 +61,27 @@ let inProgressCourses = $derived(coursesApi.enrolledCourses.filter((course) => !isStudentCourseComplete(course))); let highlightedCourses = $derived.by(() => getHighlightedCourses(inProgressCourses)); let currentCourse = $derived(highlightedCourses[0] ?? coursesApi.enrolledCourses[0] ?? null); + let shouldShowCertificateHero = $derived.by(() => { + if (!currentCourse || coursesApi.enrolledCourses.length !== 1) { + return false; + } + + return isStudentCourseComplete(currentCourse) && currentCourse.certificateEarnedAt != null; + }); + + let upcomingSessions = $derived( + coursesApi.enrolledCourses + .filter((course) => course.type === 'LIVE_CLASS' && course.upcomingSession) + .map((course) => ({ + lessonId: course.upcomingSession!.lessonId, + courseTitle: course.title, + lessonTitle: course.upcomingSession!.lessonTitle, + callUrl: course.upcomingSession!.callUrl, + lessonAt: course.upcomingSession!.lessonAt, + timezone: course.upcomingSession!.sessionTimezone + })) + .sort((a, b) => new Date(a.lessonAt).getTime() - new Date(b.lessonAt).getTime()) + ); $effect(() => { if (!$profile.id || !$currentOrg.id) return; @@ -127,6 +150,12 @@ goto(`/courses/${id}/lessons?next=true`); } + function gotoCourseCertificates(id: string | undefined) { + if (!id) return; + + goto(`/courses/${id}/certificates`); + } + function showPublishedPublicCourseLinks(course: EnrolledCourse | null) { if (!course || course.type !== 'PUBLIC') { return false; @@ -167,16 +196,28 @@
+ {#if upcomingSessions.length} + + {/if} +

- {$t('dashboard.pick_up_where_you_left_off')} + {#if shouldShowCertificateHero} + {$t('dashboard.get_your_certificate')} + {:else} + {$t('dashboard.pick_up_where_you_left_off')} + {/if}

{#if currentCourse} - {$t('dashboard.course_awaits_you', { title: currentCourse.title })} + {#if shouldShowCertificateHero} + {currentCourse.title} + {:else} + {$t('dashboard.course_awaits_you', { title: currentCourse.title })} + {/if} {:else} {$t('dashboard.learning_awaits_you')} {/if} @@ -205,10 +246,19 @@ + + {/if} diff --git a/apps/dashboard/src/lib/features/settings/components/index.ts b/apps/dashboard/src/lib/features/settings/components/index.ts index 7530a366..b92eee91 100644 --- a/apps/dashboard/src/lib/features/settings/components/index.ts +++ b/apps/dashboard/src/lib/features/settings/components/index.ts @@ -2,3 +2,4 @@ export { default as LanguagePicker } from './language-picker.svelte'; export { default as AuthGeneral } from './auth-general.svelte'; export { default as AuthSso } from './auth-sso.svelte'; export { default as AuthTokenAuth } from './auth-token-auth.svelte'; +export { default as OrgSettingsInlineTabs } from './org-settings-inline-tabs.svelte'; diff --git a/apps/dashboard/src/lib/features/settings/components/org-settings-inline-tabs.svelte b/apps/dashboard/src/lib/features/settings/components/org-settings-inline-tabs.svelte new file mode 100644 index 00000000..a6d03d4c --- /dev/null +++ b/apps/dashboard/src/lib/features/settings/components/org-settings-inline-tabs.svelte @@ -0,0 +1,52 @@ + + +{#if currentTab} +
+ handleTabChange(event)}> + + {#each orgSettingsTabs as tab} + + {$t(tab.label)} + + {/each} + + +
+{/if} diff --git a/apps/dashboard/src/lib/features/settings/pages/org.svelte b/apps/dashboard/src/lib/features/settings/pages/org.svelte index 0a4202e0..61d414bd 100644 --- a/apps/dashboard/src/lib/features/settings/pages/org.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/org.svelte @@ -1,17 +1,14 @@ @@ -171,51 +165,6 @@

- - - - - {$t('settings.organization.organization_profile.customize_lms.heading')} - {$t('settings.organization.organization_profile.customize_lms.sub_heading')} - {$t('settings.organization.organization_profile.customize_lms.body')} - - - - - - - - - {$t('settings.organization.organization_profile.custom_domain.heading')} - {$t('settings.organization.organization_profile.custom_domain.sub_heading')} - {$t('settings.organization.organization_profile.custom_domain.body')} - - - - - - - - - {$t('settings.organization.organization_profile.team.heading')} - {$t('settings.organization.organization_profile.team.sub_heading')} - {$t('settings.organization.organization_profile.team.body')} - - - - diff --git a/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte b/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte index fdb1e08d..8c520bcc 100644 --- a/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte +++ b/packages/ui/src/custom/simple-logo-nav/simple-logo-nav.svelte @@ -11,7 +11,5 @@ ClassroomIO -

- The Most Customizable Platform for Customer, Partner, and Employee Education -

+

One Platform for Customer, Partner, and Employee Training

diff --git a/packages/utils/src/validation/course/course.ts b/packages/utils/src/validation/course/course.ts index 822c771d..73831da6 100644 --- a/packages/utils/src/validation/course/course.ts +++ b/packages/utils/src/validation/course/course.ts @@ -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() diff --git a/packages/utils/src/validation/mail/mail.ts b/packages/utils/src/validation/mail/mail.ts index db087f23..be037d95 100644 --- a/packages/utils/src/validation/mail/mail.ts +++ b/packages/utils/src/validation/mail/mail.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edcb50ab..585e8d83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4099,155 +4099,183 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -4792,48 +4820,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.112.0': resolution: {integrity: sha512-3R0iqjM3xYOZCnwgcxOQXH7hrz64/USDIuLbNTM1kZqQzRqaR4w7SwoWKU934zABo8d0op2oSwOp+CV3hZnM7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.112.0': resolution: {integrity: sha512-lAQf8PQxfgy7h0bmcfSVE3hg3qMueshPYULFsCrHM+8KefGZ9W+ZMvRyU33gLrB4w1O3Fz1orR0hmKMCRxXNrQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.112.0': resolution: {integrity: sha512-2QlvQBUhHuAE3ezD4X3CAEKMXdfgInggQ5Bj/7gb5NcYP3GyfLTj7c+mMu+BRwfC9B3AXBNyqHWbqEuuUvZyRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.112.0': resolution: {integrity: sha512-v06iu0osHszgqJ1dLQRb6leWFU1sjG/UQk4MoVBtE6ZPewgfTkby6G9II1SpEAf2onnAuQceVYxQH9iuU3NJqw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.112.0': resolution: {integrity: sha512-+5HhNHtxsdcd7+ljXFnn9FOoCNXJX3UPgIfIE6vdwS1HqdGNH6eAcVobuqGOp54l8pvcxDQA6F4cPswCgLrQfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.112.0': resolution: {integrity: sha512-jKwO7ZLNkjxwg7FoCLw+fJszooL9yXRZsDN0AQ1AQUTWq1l8GH/2e44k68N3fcP19jl8O8jGpqLAZcQTYk6skA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.112.0': resolution: {integrity: sha512-TYqnuKV/p3eOc+N61E0961nA7DC+gaCeJ3+V2LcjJdTwFMdikqWL6uVk1jlrpUCBrozHDATVUKDZYH7r4FQYjQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.112.0': resolution: {integrity: sha512-ZhrVmWFifVEFQX4XPwLoVFDHw9tAWH9p9vHsHFH+5uCKdfVR+jje4WxVo6YrokWCboGckoOzHq5KKMOcPZfkRg==} @@ -4896,36 +4932,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -5562,56 +5604,67 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -6631,72 +6684,84 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -10802,72 +10867,84 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.31.1: resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2056cddf..77576d55 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - apps/* + - '!apps/db-agent' - packages/* - packages/course-app/src/* onlyBuiltDependencies: diff --git a/prd/embed-link-platforms.md b/prd/embed-link-platforms.md new file mode 100644 index 00000000..f6d06e88 --- /dev/null +++ b/prd/embed-link-platforms.md @@ -0,0 +1,341 @@ +# Embed Link Platforms (Twitter/X + Vimeo) + +## Purpose + +Extend the lesson "Embed Link" video source to support platform-specific embeds (Twitter/X videos, Vimeo, and future platforms) instead of treating every URL as a raw HTML5 video source. The user selects the target platform from a dropdown; the system validates the URL, fetches metadata, and renders the appropriate embed player. + +## Goals + +- Redesign the Embed Link tab with a platform source dropdown + URL input. +- Support Twitter/X video/post embedding via oEmbed + widgets.js. +- Support Vimeo embedding via iframe player URL. +- Keep the `generic` video type for raw HTML5 video URLs (Mp4, WebM). +- Lay a pattern for adding future platforms (Loom, Wistia, etc.) without schema changes per platform. +- Rename the tab from "Embed Link" to "Embed" to reflect the broader scope. + +## Non-Goals + +- Adding a dedicated tab per platform in the add-video modal. +- Implementing watch enforcement or transcript support for embed platforms (they render as iframes, not HTML5 video). +- Supporting Twitter/X as a downloadable video source. + +## Constraints + +- Existing `generic` type behavior must be preserved (HTML5 `