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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

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

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

---------
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
@@ -0,0 +1,117 @@
export const EMAIL_NOTIFICATION_TOGGLE_KEYS = [
'newStudent',
'newSubmission',
'gradingResult',
'newsfeed',
'quizAssigned',
'cohortReminder',
'session',
'enrollmentWelcome',
'courseCompletion'
] as const;
export type EmailNotificationToggleKey = (typeof EMAIL_NOTIFICATION_TOGGLE_KEYS)[number];
export type PersonalEmailNotificationToggleKey = Exclude<EmailNotificationToggleKey, 'enrollmentWelcome'>;
export const PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS = EMAIL_NOTIFICATION_TOGGLE_KEYS.filter(
(key): key is PersonalEmailNotificationToggleKey => key !== 'enrollmentWelcome'
);
/** Personal toggles that only apply to tutors/admins — hidden from pure students in the UI. */
export const TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS = ['newStudent', 'newSubmission'] as const;
export type TutorOnlyPersonalEmailNotificationToggleKey =
(typeof TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS)[number];
export type EmailNotificationSectionId = 'course' | 'cohort' | 'enrollment';
export type EmailNotificationSection<TKey extends string = EmailNotificationToggleKey> = {
id: EmailNotificationSectionId;
keys: readonly TKey[];
};
export const PERSONAL_EMAIL_NOTIFICATION_SECTIONS: EmailNotificationSection<PersonalEmailNotificationToggleKey>[] = [
{
id: 'course',
keys: ['newStudent', 'newSubmission', 'gradingResult', 'newsfeed', 'quizAssigned', 'session', 'courseCompletion']
},
{
id: 'cohort',
keys: ['cohortReminder']
}
];
export const ORG_EMAIL_NOTIFICATION_SECTIONS: EmailNotificationSection[] = [
{
id: 'course',
keys: ['newStudent', 'newSubmission', 'gradingResult', 'newsfeed', 'quizAssigned', 'session', 'courseCompletion']
},
{
id: 'cohort',
keys: ['cohortReminder']
},
{
id: 'enrollment',
keys: ['enrollmentWelcome']
}
];
export type EmailNotificationSettings = Partial<Record<EmailNotificationToggleKey, boolean>>;
export type PersonalEmailNotificationSettings = Partial<Record<PersonalEmailNotificationToggleKey, boolean>>;
export const EMAIL_TOGGLE_MAP = {
teacherStudentJoined: 'newStudent',
submissionReceived: 'newSubmission',
submissionGraded: 'gradingResult',
newsfeedPost: 'newsfeed',
newsfeedComment: 'newsfeed',
quizAssigned: 'quizAssigned',
cohortGoalReminder: 'cohortReminder',
sessionReminder: 'session',
sessionUpdated: 'session',
studentCourseWelcome: 'enrollmentWelcome',
studentCohortWelcome: 'enrollmentWelcome',
teacherCourseWelcome: 'enrollmentWelcome',
studentCourseCompletion: 'courseCompletion'
} as const satisfies Record<string, EmailNotificationToggleKey>;
export type ToggleableEmailId = keyof typeof EMAIL_TOGGLE_MAP;
export function getToggleKeyForEmail(emailId: string): EmailNotificationToggleKey | undefined {
return EMAIL_TOGGLE_MAP[emailId as ToggleableEmailId];
}
export function isMandatoryEmail(emailId: string): boolean {
return getToggleKeyForEmail(emailId) === undefined;
}
/**
* Pure preference resolution. Absent keys resolve to allowed (opt-out default).
*/
export function resolveEmailDelivery(
emailId: string,
orgSettings?: EmailNotificationSettings | null,
personalSettings?: PersonalEmailNotificationSettings | null
): boolean {
const toggleKey = getToggleKeyForEmail(emailId);
if (!toggleKey) {
return true;
}
if (orgSettings?.[toggleKey] === false) {
return false;
}
if (toggleKey === 'enrollmentWelcome') {
return true;
}
if (personalSettings?.[toggleKey] === false) {
return false;
}
return true;
}
@@ -0,0 +1 @@
export * from './email-toggles';
@@ -1,10 +1,22 @@
import * as z from 'zod';
const ZEmailNotificationToggleFields = {
newStudent: z.boolean().optional(),
newSubmission: z.boolean().optional(),
gradingResult: z.boolean().optional(),
newsfeed: z.boolean().optional(),
quizAssigned: z.boolean().optional(),
cohortReminder: z.boolean().optional(),
session: z.boolean().optional(),
courseCompletion: z.boolean().optional()
};
export const ZUpdateProfile = z.object({
fullname: z.string().min(3).optional(),
username: z.string().min(3).optional(),
locale: z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']).optional(),
avatarUrl: z.url().optional()
avatarUrl: z.url().optional(),
emailNotifications: z.object(ZEmailNotificationToggleFields).optional()
});
export type TUpdateProfile = z.infer<typeof ZUpdateProfile>;
@@ -115,7 +115,20 @@ export const ZUpdateOrganization = z.object({
inviteOnly: z.boolean().optional()
})
.optional(),
internalEnrollmentOnly: z.boolean().optional()
internalEnrollmentOnly: z.boolean().optional(),
emailNotifications: z
.object({
newStudent: z.boolean().optional(),
newSubmission: z.boolean().optional(),
gradingResult: z.boolean().optional(),
newsfeed: z.boolean().optional(),
quizAssigned: z.boolean().optional(),
cohortReminder: z.boolean().optional(),
session: z.boolean().optional(),
enrollmentWelcome: z.boolean().optional(),
courseCompletion: z.boolean().optional()
})
.optional()
})
.optional()
});