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
@@ -137,6 +137,30 @@ export class ProfileApi extends BaseApiWithErrors {
}
}
async updateEmailNotifications(emailNotifications: Record<string, boolean>) {
try {
this.isLoading = true;
this.errors = {};
this.success = false;
await this.execute<typeof classroomio.account.profile.$put>({
requestFn: () => classroomio.account.profile.$put({ json: { emailNotifications } }),
logContext: 'updating email notification preferences',
onSuccess: (response) => {
profileStore.update((_profile) => ({
..._profile,
...response.profile
}));
this.success = true;
}
});
} catch (error) {
this.handleError(error);
} finally {
this.isLoading = false;
}
}
async changeEmail(fields: TChangeEmailForm) {
// Validate email format
const result = ZChangeEmail.safeParse({ newEmail: fields.newEmail });
@@ -59,7 +59,7 @@ export interface TOrgUpdateForm {
disableSignupMessage?: string;
disableEmailPassword?: boolean;
disableGoogleAuth?: boolean;
settings?: { signup?: { inviteOnly?: boolean } };
settings?: { signup?: { inviteOnly?: boolean }; emailNotifications?: Record<string, boolean> };
}
/**
@@ -9,6 +9,7 @@ export { default as BillingPage } from './billing.svelte';
export { default as AiCreditsPage } from './ai-credits.svelte';
export { default as IntegrationsPage } from './integrations.svelte';
export { default as AuthPage } from './auth.svelte';
export { default as NotificationsPage } from './notifications.svelte';
// Keep old exports for backward compatibility
export { default as SsoPage } from './sso.svelte';
export { default as TokenAuthPage } from './token-auth.svelte';
@@ -0,0 +1,264 @@
<script lang="ts">
import {
EMAIL_NOTIFICATION_TOGGLE_KEYS,
ORG_EMAIL_NOTIFICATION_SECTIONS,
PERSONAL_EMAIL_NOTIFICATION_SECTIONS,
PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS,
TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS
} from '@cio/utils/notifications';
import { profile } from '$lib/utils/store/user';
import { currentOrg, isOrgAdmin } from '$lib/utils/store/org';
import { isOrgStudent } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { profileApi } from '$features/auth/api/profile.svelte';
import { orgApi } from '$features/org/api/org.svelte';
import * as Field from '@cio/ui/base/field';
import { Switch } from '@cio/ui/base/switch';
import { Button } from '@cio/ui/base/button';
import BellIcon from '@lucide/svelte/icons/bell';
import Building2Icon from '@lucide/svelte/icons/building-2';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
function buildToggleState<T extends string>(keys: readonly T[], source?: Partial<Record<T, boolean>> | null) {
return Object.fromEntries(keys.map((key) => [key, source?.[key] !== false])) as Record<T, boolean>;
}
let personalToggles = $state(buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS));
let orgToggles = $state(buildToggleState(EMAIL_NOTIFICATION_TOGGLE_KEYS));
let isSavingPersonal = $state(false);
let isSavingOrg = $state(false);
let hasHydratedFromStores = $state(false);
const tutorOnlyToggleKeys = new Set<string>(TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS);
const personalNotificationSections = $derived(
$isOrgStudent
? PERSONAL_EMAIL_NOTIFICATION_SECTIONS.map((section) => ({
...section,
keys: section.keys.filter((key) => !tutorOnlyToggleKeys.has(key))
})).filter((section) => section.keys.length > 0)
: PERSONAL_EMAIL_NOTIFICATION_SECTIONS
);
function syncPersonalTogglesFromProfile() {
const profileState = get(profile);
const next = buildToggleState(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, profileState.settings?.emailNotifications);
for (const key of PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS) {
personalToggles[key] = next[key];
}
}
function syncOrgTogglesFromCurrentOrg() {
const org = get(currentOrg);
if (!org) return;
const next = buildToggleState(EMAIL_NOTIFICATION_TOGGLE_KEYS, org.settings?.emailNotifications);
for (const key of EMAIL_NOTIFICATION_TOGGLE_KEYS) {
orgToggles[key] = next[key];
}
}
function tryHydrateFromStores() {
if (hasHydratedFromStores) return;
const profileState = get(profile);
if (!profileState.id) return;
syncPersonalTogglesFromProfile();
if (get(isOrgAdmin)) {
if (!get(currentOrg)?.id) return;
syncOrgTogglesFromCurrentOrg();
}
hasHydratedFromStores = true;
}
onMount(() => {
tryHydrateFromStores();
});
$effect(() => {
if (hasHydratedFromStores) return;
const profileId = $profile.id;
const orgId = $isOrgAdmin ? $currentOrg?.id : 'ready';
if (!profileId || ($isOrgAdmin && !orgId)) return;
tryHydrateFromStores();
});
const personalHasChanges = $derived(
PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS.some(
(key) => personalToggles[key] !== ($profile.settings?.emailNotifications?.[key] !== false)
)
);
const orgHasChanges = $derived(
EMAIL_NOTIFICATION_TOGGLE_KEYS.some(
(key) => orgToggles[key] !== ($currentOrg?.settings?.emailNotifications?.[key] !== false)
)
);
function toStoredPreferences<T extends string>(keys: readonly T[], toggles: Record<T, boolean>) {
const preferences: Partial<Record<T, boolean>> = {};
for (const key of keys) {
preferences[key] = toggles[key];
}
return preferences;
}
async function handleSavePersonal() {
isSavingPersonal = true;
await profileApi.updateEmailNotifications(
toStoredPreferences(PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS, personalToggles)
);
if (profileApi.success) {
syncPersonalTogglesFromProfile();
snackbar.success(t.get('settings.notifications.personal.save_success'));
}
isSavingPersonal = false;
}
async function handleSaveOrg() {
if (!$currentOrg) return;
isSavingOrg = true;
const existingSettings = ($currentOrg.settings as Record<string, unknown>) || {};
await orgApi.update(
$currentOrg.id,
{
settings: {
...existingSettings,
emailNotifications: toStoredPreferences(EMAIL_NOTIFICATION_TOGGLE_KEYS, orgToggles)
}
},
{
onSuccess: () => {
syncOrgTogglesFromCurrentOrg();
snackbar.success(t.get('settings.notifications.org.save_success'));
}
}
);
isSavingOrg = false;
}
</script>
<Field.Group class="w-full max-w-2xl! px-2">
<Field.Set>
<Field.Legend class="flex items-center gap-2">
<BellIcon class="size-5" />
{$t('settings.notifications.personal.heading')}
</Field.Legend>
<Field.Description>
{$t('settings.notifications.personal.description')}
</Field.Description>
<div class="mt-4 space-y-6">
{#each personalNotificationSections as section, sectionIndex (section.id)}
{#if sectionIndex > 0}
<Field.Separator />
{/if}
<Field.Set>
<Field.Legend>{$t(`settings.notifications.sections.${section.id}.heading`)}</Field.Legend>
<Field.Description>
{$t(`settings.notifications.sections.${section.id}.description`)}
</Field.Description>
<div class="mt-4 space-y-4">
{#each section.keys as key (key)}
<Field.Field orientation="horizontal">
<Switch bind:checked={personalToggles[key]} disabled={isSavingPersonal} />
<div class="space-y-0.5">
<Field.Label>{$t(`settings.notifications.toggles.${key}.label`)}</Field.Label>
<Field.Description>
{$t(`settings.notifications.toggles.${key}.description`)}
</Field.Description>
</div>
</Field.Field>
{/each}
</div>
</Field.Set>
{/each}
</div>
<div class="mt-6">
<Button
variant="secondary"
loading={isSavingPersonal}
disabled={!personalHasChanges || isSavingPersonal}
onclick={handleSavePersonal}
>
{$t('settings.notifications.personal.save')}
</Button>
</div>
</Field.Set>
{#if $isOrgAdmin}
<Field.Separator />
<Field.Set>
<Field.Legend class="flex items-center gap-2">
<Building2Icon class="size-5" />
{$t('settings.notifications.org.heading')}
</Field.Legend>
<Field.Description>
{$t('settings.notifications.org.description')}
</Field.Description>
<div class="mt-4 space-y-6">
{#each ORG_EMAIL_NOTIFICATION_SECTIONS as section, sectionIndex (section.id)}
{#if sectionIndex > 0}
<Field.Separator />
{/if}
<Field.Set>
<Field.Legend>{$t(`settings.notifications.sections.${section.id}.heading`)}</Field.Legend>
<Field.Description>
{$t(`settings.notifications.sections.${section.id}.description`)}
</Field.Description>
<div class="mt-4 space-y-4">
{#each section.keys as key (key)}
<Field.Field orientation="horizontal">
<Switch bind:checked={orgToggles[key]} disabled={isSavingOrg} />
<div class="space-y-0.5">
<Field.Label>{$t(`settings.notifications.toggles.${key}.label`)}</Field.Label>
<Field.Description>
{$t(`settings.notifications.toggles.${key}.description`)}
</Field.Description>
</div>
</Field.Field>
{/each}
</div>
</Field.Set>
{/each}
</div>
<div class="mt-6">
<Button
variant="secondary"
loading={isSavingOrg}
disabled={!orgHasChanges || isSavingOrg}
onclick={handleSaveOrg}
>
{$t('settings.notifications.org.save')}
</Button>
</div>
</Field.Set>
{/if}
</Field.Group>
@@ -1,5 +1,9 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type UpdateProfileRequest = typeof classroomio.account.profile.$put;
export type UpdateProfileSuccess = Extract<InferResponseType<UpdateProfileRequest>, { success: true }>;
export type UpdateProfileResponseProfile = UpdateProfileSuccess['profile'];
export type GetUsageRequest = typeof classroomio.agent.usage.$get;
export type GetPurchasedRequest = (typeof classroomio.agent.usage)['purchased']['$get'];
export type GetLeaderboardRequest = (typeof classroomio.agent.usage)['leaderboard']['$get'];
@@ -210,6 +210,11 @@ export const baseNavConfig: NavItemConfig[] = [
path: '/settings',
matchPattern: '^/org/[^/]+/settings/?$'
},
{
titleKey: 'settings.tabs.notifications_tab',
path: '/settings/notifications',
matchPattern: '^/org/[^/]+/settings/notifications/?$'
},
{
titleKey: 'settings.tabs.organization_tab',
path: '/settings/org',
@@ -253,6 +258,10 @@ export const baseNavConfig: NavItemConfig[] = [
}
],
nestedRoutes: [
{
path: 'notifications',
titleKey: 'settings.tabs.notifications_tab'
},
{
path: 'billing',
titleKey: 'settings.tabs.billing_tab'