feat(notifications): email notification preferences (#747)

* feat(notifications): add email notification preferences

Implement the email notification preferences PRD with org-level defaults
and personal opt-outs enforced at the email enqueue chokepoint.

- Add toggle map, profile.settings jsonb, and shouldSendEmail resolver
- Extend profile and organization update APIs with emailNotifications
- Pass preference context through all toggleable email send call sites
- Add Settings > Notifications page with personal and org admin sections

Co-authored-by: Rotimi Best <[email protected]>

* feat(notifications): group email toggles into course, cohort, and enrollment sections

Organize personal and org notification settings into scannable sections
so users can find course- or cohort-related emails quickly.

Co-authored-by: Rotimi Best <[email protected]>

* fix(notifications): use Exercise instead of Quiz in toggle copy

Co-authored-by: Rotimi Best <[email protected]>

* fix(notifications): only mark certification email sent when enqueued

Skip setMemberCertificationEmailSent when notification preferences or
missing Redis prevent the completion email from being queued, so the
email can still send if preferences change later.

Co-authored-by: Rotimi Best <[email protected]>

* fix(notifications): address PR review feedback

- Add EmailPreferenceLookupCache to avoid N+1 settings lookups in batch
  processors and multi-recipient email enqueue paths
- Replace $effect store hydration in notifications settings with onMount
  and explicit sync after save
- Remove thin api email-preferences re-export; import from db queries
- Hide tutor-only personal toggles (newStudent, newSubmission) for students

Co-authored-by: Rotimi Best <[email protected]>

* fix(notifications): hydrate toggles after store load and add profile settings migration

- One-time store hydration via onMount plus guarded effect when profile/org
  load after mount, without clobbering in-progress edits
- Add drizzle migration for profile.settings jsonb column

Co-authored-by: Rotimi Best <[email protected]>

---------
This commit is contained in:
Rotimi Best
2026-07-13 10:33:19 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 14db5363e7
commit fa060f5f6d
47 changed files with 14622 additions and 46 deletions
+12 -5
View File
@@ -17,6 +17,7 @@ import {
import type { OrganizationWithMemberAndPlans } from '@cio/db/queries/organization/types';
import { ROLE } from '@cio/utils/constants';
import type { TProfile } from '@cio/db/types';
import type { TUpdateProfile } from '@cio/utils/validation/account';
import { env } from '@cio/core/config/env';
import { getLicenseStatus } from '@api/services/license';
@@ -97,12 +98,18 @@ export async function getAccountData(userId: string): Promise<GetAccountDataResu
* @param data - Partial profile data to update (excluding email, id, createdAt, updatedAt)
* @returns Updated profile data
*/
export async function updateUser(
userId: string,
data: Partial<Omit<TProfile, 'id' | 'email' | 'createdAt' | 'updatedAt'>>
) {
export async function updateUser(userId: string, data: TUpdateProfile) {
try {
const updatedProfile = await updateProfile(userId, data);
const { emailNotifications, ...profileFields } = data;
const updatePayload: Partial<Omit<TProfile, 'id' | 'email' | 'createdAt' | 'updatedAt'>> = {
...profileFields
};
if (emailNotifications !== undefined) {
updatePayload.settings = { emailNotifications };
}
const updatedProfile = await updateProfile(userId, updatePayload);
if (!updatedProfile) {
throw new AppError(`Profile not found for user ID: ${userId}`, ErrorCodes.PROFILE_NOT_FOUND, 404);
+2 -1
View File
@@ -429,7 +429,8 @@ export async function runCohortGoalReminderScan(): Promise<{
theme: row.organizationTheme
})
},
idempotencyKey: `cohort-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
idempotencyKey: `cohort-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`,
preference: { organizationId: row.organizationId, recipientProfileId: row.profileId }
});
remindersEnqueued += 1;
} catch (error) {
+9 -2
View File
@@ -359,6 +359,7 @@ async function sendStudentJoinEmails(input: {
courseId: string;
courseName: string;
orgName: string;
organizationId: string;
org: OrgUrlInfo;
branding: EmailBranding;
studentId: string;
@@ -379,7 +380,8 @@ async function sendStudentJoinEmails(input: {
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`,
ics
ics,
preference: { organizationId: input.organizationId, recipientProfileId: input.studentId }
});
} catch (error) {
console.error('Failed to enqueue student welcome email:', error);
@@ -407,7 +409,8 @@ async function sendStudentJoinEmails(input: {
branding: input.branding
},
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`,
preference: { organizationId: input.organizationId }
});
} catch (error) {
console.error('Failed to enqueue teacher join notification emails:', error);
@@ -696,6 +699,7 @@ export async function enrollInCourse(
courseId,
courseName: title,
orgName: org.name,
organizationId: org.id,
org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified },
branding: buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme }),
studentId: user.id,
@@ -964,6 +968,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: course.id,
courseName: course.title,
orgName: organization.name,
organizationId: organization.id,
orgSiteName: organization.siteName,
orgCustomDomain: organization.customDomain,
orgIsCustomDomainVerified: organization.isCustomDomainVerified,
@@ -1049,6 +1054,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: course.id,
courseName: course.title,
orgName: organization.name,
organizationId: organization.id,
orgSiteName: organization.siteName,
orgCustomDomain: organization.customDomain,
orgIsCustomDomainVerified: organization.isCustomDomainVerified,
@@ -1066,6 +1072,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: result.courseId,
courseName: result.courseName,
orgName: result.orgName,
organizationId: result.organizationId,
org: {
siteName: result.orgSiteName,
customDomain: result.orgCustomDomain,
@@ -59,6 +59,7 @@ export async function notifyCourseExerciseService(courseId: string, exerciseId:
exerciseTitle: exercise.title,
courseName: course.title,
orgName,
organizationId: orgData.orgId,
fromName: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
quizUrl,
branding: buildEmailBranding({
+6 -3
View File
@@ -110,7 +110,8 @@ export async function addMember(
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`,
ics: await getWelcomeSessionIcs(courseId)
ics: await getWelcomeSessionIcs(courseId),
preference: { organizationId: courseOrgData.orgId, recipientProfileId: data.profileId }
});
} catch (emailError) {
console.error('Failed to enqueue student welcome email:', emailError);
@@ -133,7 +134,8 @@ export async function addMember(
branding
},
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`,
preference: { organizationId: courseOrgData.orgId }
});
} catch (emailError) {
console.error('Failed to enqueue teacher notification email:', emailError);
@@ -237,7 +239,8 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
branding
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `teacher-course-welcome:${courseId}:${email}`
idempotencyKey: `teacher-course-welcome:${courseId}:${email}`,
preference: { organizationId: courseOrgData.orgId }
}).catch((emailError) => {
console.error(`Failed to enqueue welcome email to ${email}:`, emailError);
return [];
+19
View File
@@ -1,5 +1,6 @@
import { enqueueEmailSend, isRedisConfigured } from '@cio/jobs';
import { EmailRegistry, type EmailId, type EmailSchemaFor } from '@cio/email';
import { EmailPreferenceLookupCache } from '@cio/db/queries/notifications';
import * as z from 'zod';
import { logRedisUnavailableOnce } from '@cio/core/utils/redis/redis';
@@ -24,6 +25,10 @@ export interface EnqueueTemplateEmailInput<TId extends EmailId> extends CommonOp
subject?: string;
/** Optional iCalendar (.ics) body attached as a text/calendar part. */
ics?: string;
preference?: {
organizationId?: string;
recipientProfileId?: string;
};
}
export interface EnqueueRawEmailInput extends CommonOptions {
@@ -76,8 +81,22 @@ export async function enqueueTransactionalEmail<TId extends EmailId>(
const recipients = toRecipientArray(input.to);
const jobIds: string[] = [];
const preferenceCache = input.preference ? new EmailPreferenceLookupCache() : null;
for (const recipient of recipients) {
if (preferenceCache && input.preference) {
const allowed = await preferenceCache.shouldSend({
emailId: template,
organizationId: input.preference.organizationId,
recipientEmail: recipient,
recipientProfileId: input.preference.recipientProfileId
});
if (!allowed) {
continue;
}
}
const jobId = await enqueueEmailSend(
{
kind: 'template',
+4 -2
View File
@@ -384,7 +384,8 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: feedData.author?.email || '[email protected]',
idempotencyKey: `newsfeed:post:${feedId}`
idempotencyKey: `newsfeed:post:${feedId}`,
preference: { organizationId: feedData.organization.id }
});
} catch (error) {
console.error('Error sending newsfeed post email:', error);
@@ -426,7 +427,8 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
branding
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: '[email protected]'
replyTo: '[email protected]',
preference: { organizationId: feedData.organization.id }
});
} catch (error) {
console.error('Error sending newsfeed comment email:', error);
@@ -224,7 +224,8 @@ async function enrollAudienceStudentProfilesInCourses(
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}`,
ics: icsByGroupId.get(p.groupId)
ics: icsByGroupId.get(p.groupId),
preference: { organizationId: orgId, recipientProfileId: p.profileId }
});
emailsSent++;
} catch (emailError) {
@@ -307,7 +308,8 @@ async function enrollAudienceStudentProfilesInCohorts(
branding: buildEmailBranding(organization)
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-cohort-welcome:${pair.cohortId}:${pair.profileId}`
idempotencyKey: `audience-cohort-welcome:${pair.cohortId}:${pair.profileId}`,
preference: { organizationId: orgId, recipientProfileId: pair.profileId }
});
emailsSent++;
} catch (emailError) {
@@ -1005,7 +1005,11 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
})
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `submission-update:${submissionId}:${newStatusId}`
idempotencyKey: `submission-update:${submissionId}:${newStatusId}`,
preference: {
organizationId: orgResult?.orgId,
recipientProfileId: fullSubmission.groupmember.profile.id
}
});
} catch (error) {
console.error('Failed to enqueue submission graded email:', error);
@@ -1076,7 +1080,8 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s
})
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`,
preference: { organizationId: orgResult?.orgId }
});
} catch (error) {
console.error('Failed to enqueue submission received email:', error);
+7 -2
View File
@@ -74,7 +74,7 @@ export function scheduleCertificationCompletionWork(params: {
});
const certificateUrl = `${base}/courses/${courseId}/certificates`;
await enqueueTransactionalEmail('studentCourseCompletion', {
const enqueueResult = await enqueueTransactionalEmail('studentCourseCompletion', {
to: studentEmail,
fields: {
orgName: courseRow.orgName,
@@ -89,9 +89,14 @@ export function scheduleCertificationCompletionWork(params: {
})
},
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-completion:${groupMemberId}`
idempotencyKey: `course-completion:${groupMemberId}`,
preference: { organizationId: courseRow.orgId, recipientProfileId: profileId }
});
if (enqueueResult.jobIds.length === 0) {
return;
}
await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString());
} catch (error) {
console.error('scheduleCertificationCompletionWork: failed:', error);