feat: live class sessions, CE workflow, and docs (#683)
Adds live-class session scheduling on lessons (join link, date/time, timezone) with automatic 24h and 1h reminder emails plus a calendar invite (.ics), a time-change notification with an updated invite, a teacher-authored welcome email and session invite on enrollment, and a "Notify students" quiz nudge. LMS gets an upcoming live-sessions card and Join buttons, plus a reusable live-session card component (3 states) in @cio/ui with a Storybook story. The sidebar content tree now reflects sequential progression locking like the content list, and the course-completion confetti regression is fixed. Org-based emails no longer reference ClassroomIO by name. Docs add live-class, welcome-email, and course-progression guides, expand the certificates guide (design, signatures, corrected labels), move plan-required callouts to the top, flatten how-to-guides to the docs root with redirects, and add screenshots. Includes an add-docs-image skill.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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({
|
||||
|
||||
@@ -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<JobEnvelope> {
|
||||
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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export interface EnqueueTemplateEmailInput<TId extends EmailId> extends CommonOp
|
||||
fields: z.infer<EmailSchemaFor<TId>>;
|
||||
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<TId extends EmailId>(
|
||||
to: recipient,
|
||||
fields: validatedFields,
|
||||
from: input.from,
|
||||
replyTo: input.replyTo
|
||||
replyTo: input.replyTo,
|
||||
ics: input.ics
|
||||
},
|
||||
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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}`
|
||||
});
|
||||
|
||||
@@ -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}`
|
||||
});
|
||||
|
||||
@@ -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 = `
|
||||
<p>Hello ${fullSubmission.groupmember.profile.fullname || 'Student'},</p>
|
||||
<p>The status of your submitted exercise on <strong>${fullSubmission.exercise.title}</strong> has been updated to ${statusText}</p>
|
||||
`;
|
||||
const isGraded = newStatusId === 3;
|
||||
const score = isGraded ? `${totalMark}/${maxMark}` : undefined;
|
||||
|
||||
if (newStatusId === 3) {
|
||||
content += `
|
||||
<p>Your score was ${totalMark}/${maxMark}</p>
|
||||
<a class="button" href="${exerciseLink}">View your Result</a>
|
||||
`;
|
||||
} else {
|
||||
content += `<a class="button" href="${exerciseLink}">Open Exercise</a>`;
|
||||
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 += `
|
||||
<p>This exercise is for <strong>${fullSubmission.lesson.title}</strong> in a course you are taking titled <strong>${course.title}</strong></p>
|
||||
`;
|
||||
} else {
|
||||
content += `
|
||||
<p>This exercise is in a course you are taking titled <strong>${course.title}</strong></p>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<p>Hello,</p>
|
||||
<p>A student ${student.fullname || student.username} just submitted an exercise <a href="${exerciseLink}">${exercise.title}</a></p>
|
||||
<p>You can get started grading by clicking "Open Submissions"</p>
|
||||
<div>
|
||||
<a class="button" href="${submissionLink}">Open Submissions</a>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user