diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/certificate-form.svelte b/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/certificate-form.svelte index 22740234..4b63128b 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/certificate-form.svelte +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/certificate-form.svelte @@ -53,7 +53,7 @@

{$t('course.navItem.landing_page.editor.certificate_form.upload_template')}

- +
diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/instructor-form.svelte b/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/instructor-form.svelte index 4c58de29..05146f40 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/instructor-form.svelte +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/instructor-form.svelte @@ -1,4 +1,5 @@
- + void; } @@ -33,6 +35,7 @@ flexDirection = 'flex-col', isUploading = $bindable(false), previewVariant = 'avatar', + elevatedDialog = false, change }: Props = $props(); @@ -147,7 +150,7 @@ {/if}
- + {#if isSignaturePreview} diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 539ee9dd..2475b380 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -256,7 +256,8 @@ "file_size": "Filstørrelsen må ikke være større end 500 kb", "try_again": "Prøv igen", "label": "Upload billede", - "fetch_error": "Fejl ved hentning af billeder fra Unsplash" + "fetch_error": "Fejl ved hentning af billeder fra Unsplash", + "upload_failed": "Kunne ikke uploade billede. Prøv venligst igen." } }, "team_members": { diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index a04e5e16..5a9106eb 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -256,7 +256,8 @@ "file_size": "Die Dateigröße darf 500 KB nicht überschreiten", "try_again": "Versuchen Sie es erneut", "label": "Bild hochladen", - "fetch_error": "Fehler beim Abrufen von Bildern von Unsplash" + "fetch_error": "Fehler beim Abrufen von Bildern von Unsplash", + "upload_failed": "Bild konnte nicht hochgeladen werden. Bitte versuchen Sie es erneut." } }, "team_members": { diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 805cce77..fe96d0c9 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -4117,7 +4117,8 @@ "file_size": "File Size must not be greater than 500kb", "try_again": "Try Again", "label": "Upload Image", - "fetch_error": "Error fetching images from Unsplash" + "fetch_error": "Error fetching images from Unsplash", + "upload_failed": "Failed to upload image. Please try again." }, "success": { "complete": "Complete :)" diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index c27fccaf..5a63a8eb 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -256,7 +256,8 @@ "file_size": "El tamaño del archivo no debe ser superior a 500 kb.", "try_again": "Inténtalo de nuevo", "label": "Subir imagen", - "fetch_error": "Error al recuperar imágenes de Unsplash" + "fetch_error": "Error al recuperar imágenes de Unsplash", + "upload_failed": "No se pudo cargar la imagen. Por favor inténtalo de nuevo." } }, "team_members": { diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 9d2babb6..436885be 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -256,7 +256,8 @@ "file_size": "La taille du fichier ne doit pas dépasser 500 Ko", "try_again": "Réessayez", "label": "Télécharger une image", - "fetch_error": "Erreur lors de la récupération des images depuis Unsplash" + "fetch_error": "Erreur lors de la récupération des images depuis Unsplash", + "upload_failed": "Échec du téléchargement de l'image. Veuillez réessayer." } }, "team_members": { diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 14e779af..8a803ee3 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -256,7 +256,8 @@ "file_size": "फ़ाइल का आकार 500kb से अधिक नहीं होना चाहिए", "try_again": "पुनः प्रयास करें", "label": "छवि अपलोड करें", - "fetch_error": "अनस्प्लैश से छवियाँ लाने में त्रुटि" + "fetch_error": "अनस्प्लैश से छवियाँ लाने में त्रुटि", + "upload_failed": "छवि अपलोड करने में विफल. कृपया पुन: प्रयास करें।" } }, "team_members": { diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 6b78aafe..49a3171b 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -256,7 +256,8 @@ "file_size": "Rozmiar pliku nie może przekraczać 500 KB", "try_again": "Spróbuj ponownie", "label": "Prześlij obraz", - "fetch_error": "Błąd podczas pobierania obrazów z Unsplash" + "fetch_error": "Błąd podczas pobierania obrazów z Unsplash", + "upload_failed": "Nie udało się przesłać obrazu. Spróbuj ponownie." } }, "team_members": { diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index 64f65fbf..a4c8ebc7 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -256,7 +256,8 @@ "file_size": "O tamanho do arquivo não deve ser superior a 500kb", "try_again": "Tente novamente", "label": "Carregar imagem", - "fetch_error": "Erro ao buscar imagens do Unsplash" + "fetch_error": "Erro ao buscar imagens do Unsplash", + "upload_failed": "Falha ao carregar a imagem. Por favor, tente novamente." } }, "team_members": { diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 21e88d57..76ccdc0e 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -256,7 +256,8 @@ "file_size": "Размер файла не должен превышать 500 КБ.", "try_again": "Попробуйте еще раз", "label": "Загрузить изображение", - "fetch_error": "Ошибка при получении изображений из Unsplash." + "fetch_error": "Ошибка при получении изображений из Unsplash.", + "upload_failed": "Не удалось загрузить изображение. Пожалуйста, попробуйте еще раз." } }, "team_members": { diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 70443e4c..c5c643bd 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -256,7 +256,8 @@ "file_size": "Kích thước tệp không được lớn hơn 500kb", "try_again": "Thử lại", "label": "Tải hình ảnh lên", - "fetch_error": "Lỗi tìm nạp hình ảnh từ Unsplash" + "fetch_error": "Lỗi tìm nạp hình ảnh từ Unsplash", + "upload_failed": "Không thể tải lên hình ảnh. Vui lòng thử lại." } }, "team_members": { diff --git a/prd/email-notification-preferences/README.md b/prd/email-notification-preferences/README.md new file mode 100644 index 00000000..56dbf525 --- /dev/null +++ b/prd/email-notification-preferences/README.md @@ -0,0 +1,391 @@ +# Email Notification Preferences PRD + +## Purpose + +Let people control which emails ClassroomIO sends them. Today every email is +**fire-and-forget per event** — there is no preference table, no opt-out, and no +settings UI anywhere in the product. This PRD adds notification preferences in +**Settings**, at two scopes: + +1. **Personal** — each user enables/disables the notification emails *they* + receive, from their own settings. +2. **Organization** — admins set org-wide defaults for the notification emails + the org sends to its members. + +An email sends only when **both** the org default and the recipient's personal +preference allow it. Transactional emails (auth, invites, payment steps, account +lifecycle) are always sent and never appear as toggles. + +## Confirmed decisions + +1. **Two scopes:** org-level defaults (admin) **and** personal per-user overrides. +2. **Opt-out defaults:** every toggle ships **on**. Nothing changes for existing + orgs until someone turns an email off — behavior is identical to today until + a user acts. +3. **Personal preferences are profile-global** (one setting per user, not per + org) for v1. Per-org personal preferences are a possible future extension + (see Out of Scope). +4. **Storage reuses `jsonb` settings columns** — `organization.settings` + (exists) and a new `profile.settings` column. No new tables. +5. **Enforcement at a single chokepoint:** the preference check lives in the + email enqueue layer so individual call sites stay unchanged. +6. **Transactional emails are never toggleable.** + +## The governing rule + +> Emails the recipient is **expecting** or **must act on** stay mandatory. +> Ambient "FYI" emails become opt-out. + +## Current state audit + +| Capability | Status | Location | +| --- | --- | --- | +| Email templates | 24 templates | `packages/email/src/emails/` | +| Email dispatch (API) | queued | `enqueueTransactionalEmail()` in `apps/api/src/services/jobs/email-jobs.ts` → `apps/jobs/src/processors/emails/send.ts` | +| Email dispatch (auth) | direct | `sendEmail()` in `packages/db/src/auth/*` | +| Email preferences (any) | **not implemented** | — | +| Unsubscribe / opt-out | **not implemented** | — | +| `organization.settings` jsonb | exists, deep-merged on update | `packages/db/src/schema.ts:2007`; merge in `updateOrganization()` `packages/db/src/queries/organization/organization.ts:1065` | +| `profile.settings` jsonb | **does not exist** (profile has only untyped `metadata`) | `packages/db/src/schema.ts:392` | +| Personal settings UI | exists | settings index renders `ProfilePage` — `apps/dashboard/src/lib/features/settings/pages/profile.svelte`, route `apps/dashboard/src/routes/(app)/org/[slug]/settings/+page.svelte` | +| Org settings UI + tabs | exists | tabs in `apps/dashboard/src/lib/features/settings/components/org-settings-inline-tabs.svelte`; sub-routes under `apps/dashboard/src/routes/(app)/org/[slug]/settings/` | +| Profile update endpoint | exists | `PUT /account/profile` (`ZUpdateProfile`) → `updateUser()` | +| Org update endpoint | exists (admin) | `PUT /organization/` (`ZUpdateOrganization`, `orgAdminMiddleware`) | + +## Email classification + +Recipient legend: 🎓 student · 🧑‍🏫 tutor/admin · 👤 account owner. + +### Never toggleable (mandatory) + +| Email id | Recipient | Trigger | Why mandatory | +|---|---|---|---| +| `verifyEmail` | 👤 | Signup / email-change verification | Security | +| `forgotPassword` | 👤 | Password reset requested | Security | +| `onPasswordReset` | 👤 | Password changed | Security | +| `welcome` | 👤 | Account created | Account lifecycle | +| `studentLimitReached` | 🧑‍🏫 admin | Free-plan student cap hit | Must know students are turned away | +| `inviteTeacher` | 🧑‍🏫 | Invited to org as staff | The invite *is* the email | +| `studentOrgInvite` | 🎓 | Invited to the org | The invite *is* the email | +| `studentCourseInvite` | 🎓 | Invited to a course | The invite *is* the email | +| `studentProvePayment` | 🎓 | Must send proof of payment | Required next step | +| `teacherStudentBuyRequest` | 🧑‍🏫 | Student requests to buy a paid course | Requires admin action | + +### Toggleable + +Each toggle maps to one or more template ids. `key` is the stable identifier +stored in the settings JSON. + +| Toggle key | UI label | Email id(s) | Recipient | Org default | Personal | +|---|---|---|---|---|---| +| `newStudent` | New student joins | `teacherStudentJoined` | 🧑‍🏫 | ✅ | ✅ | +| `newSubmission` | New submissions to grade | `submissionReceived` | 🧑‍🏫 | ✅ | ✅ | +| `gradingResult` | Grading results | `submissionGraded` | 🎓 | ✅ | ✅ | +| `newsfeed` | Course announcements & comments | `newsfeedPost`, `newsfeedComment` | 🎓/🧑‍🏫 | ✅ | ✅ | +| `quizAssigned` | Quiz / exercise assigned | `quizAssigned` | 🎓 | ✅ | ✅ | +| `cohortReminder` | Cohort goal reminders | `cohortGoalReminder` | 🎓 | ✅ | ✅ | +| `session` | Live session reminders & updates | `sessionReminder`, `sessionUpdated` | 🎓 | ✅ | ✅ | +| `enrollmentWelcome` | Enrollment welcome emails | `studentCourseWelcome`, `studentCohortWelcome`, `teacherCourseWelcome` | 🎓/🧑‍🏫 | ✅ | — (org only) | +| `courseCompletion` | Course completion / certificate | `studentCourseCompletion` | 🎓 | ✅ | ✅ | + +Notes: +- **`enrollmentWelcome` is org-scoped only.** It fires when *staff* add/enroll + someone, so the org owns the choice, not the recipient. +- **`session` groups reminder + update.** Muting it also stops "the time + changed" alerts. Intentional grouping for v1; split later if needed. + +## Architecture + +```mermaid +flowchart TB + subgraph ui [Settings UI] + PersonalPage["Personal → Notifications section\n(profile settings page)"] + OrgPage["Org → Notifications tab\n(admin only)"] + end + subgraph api [apps/api] + AccountRoute["PUT /account/profile\n(ZUpdateProfile + emailNotifications)"] + OrgRoute["PUT /organization/\n(ZUpdateOrganization + settings.emailNotifications)"] + Enqueue["enqueueTransactionalEmail()\n+ preference guard"] + Resolver["resolveEmailDelivery(emailId, orgId, recipient)"] + end + subgraph db [packages/db] + Profile[(profile.settings jsonb)] + Org[(organization.settings jsonb)] + end + + PersonalPage --> AccountRoute --> Profile + OrgPage --> OrgRoute --> Org + Enqueue --> Resolver + Resolver --> Org + Resolver --> Profile +``` + +Send decision: + +``` +send(emailId, orgId, recipient) = + isMandatory(emailId) + OR ( orgAllows(toggleFor(emailId), orgId) + AND personalAllows(toggleFor(emailId), recipient) ) +``` + +Absent keys resolve to `true` (opt-out default). + +## Data model + +### `profile.settings` (new column) + +Add a typed `settings` jsonb column to `profile` +(`packages/db/src/schema.ts:392`), mirroring `organization.settings`: + +```ts +settings: jsonb().default({}).$type<{ + emailNotifications?: { + newStudent?: boolean; + newSubmission?: boolean; + gradingResult?: boolean; + newsfeed?: boolean; + quizAssigned?: boolean; + cohortReminder?: boolean; + session?: boolean; + courseCompletion?: boolean; + }; +}>(), +``` + +> Migrations are handled outside this workflow — the schema edit stops at a +> passing `@cio/db` build (no hand-authored migration). + +### `organization.settings.emailNotifications` (extend existing type) + +Extend the existing `organization.settings` `$type<>()` at +`packages/db/src/schema.ts:2007` (which already carries `signup`, +`internalEnrollmentOnly`, and `studentLimitNotified` — this addition is purely +additive): + +```ts +emailNotifications?: { + newStudent?: boolean; + newSubmission?: boolean; + gradingResult?: boolean; + newsfeed?: boolean; + quizAssigned?: boolean; + cohortReminder?: boolean; + session?: boolean; + enrollmentWelcome?: boolean; + courseCompletion?: boolean; +}; +``` + +`updateOrganization()` already deep-merges `settings`, so partial updates work +without clobbering other keys. + +## Phase 1 — Preference resolution (backend core) + +### 1a. Toggle map + +New `packages/utils/src/notifications/email-toggles.ts` (or colocated in +`@cio/email`): a static, exhaustive map from template id → toggle key, plus the +set of mandatory ids. This is the single source of truth for "which toggle +governs which email". + +```ts +export const EMAIL_TOGGLE_MAP: Partial> = { + 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' +}; +// ids absent from the map are mandatory → always send. +// 'enrollmentWelcome' is org-only (skip the personal check). +``` + +### 1b. Queries + +- `getOrganizationEmailNotificationSettings(orgId)` in + `packages/db/src/queries/organization/` — returns the org's + `settings.emailNotifications` object. +- `getProfileEmailNotificationSettingsByEmail(email)` (and/or `...ById`) in + `packages/db/src/queries/auth/profile.ts` — returns a profile's + `settings.emailNotifications`. + +### 1c. Resolver service + +`apps/api/src/services/notification/email-preferences.ts`: + +```ts +export async function shouldSendEmail(input: { + emailId: EmailId; + organizationId?: string; + recipientEmail?: string; + recipientProfileId?: string; +}): Promise +``` + +- Mandatory id (not in map) → `true`. +- Look up the org toggle; `false` (explicitly disabled) → `false`. +- If the toggle is personal-capable and a recipient is known, look up the + personal toggle; explicitly `false` → `false`. +- Missing settings / unknown recipient → `true` (opt-out default; never + silently drop mail we can't reason about). + +### 1d. Enqueue guard + +Extend `EnqueueTemplateEmailInput` in +`apps/api/src/services/jobs/email-jobs.ts` with an optional preference context: + +```ts +preference?: { + organizationId?: string; + recipientProfileId?: string; // recipient resolved from `to` when omitted +}; +``` + +In `enqueueTransactionalEmail()`, before enqueueing each recipient, call +`shouldSendEmail()` and skip recipients whose preference is off. When +`preference` is omitted the email is treated as mandatory and always sends +(backward-compatible). Toggleable call sites (§Phase 3) pass the context. + +## Phase 2 — Settings API + +### 2a. Personal + +Extend `ZUpdateProfile` (`packages/utils/src/validation/account/account.ts`) +with an optional `emailNotifications` object (all keys optional booleans). The +existing `PUT /account/profile` route and `updateUser()` service carry it +through to a `profile.settings` merge in the profile update query. + +- The profile update query must **deep-merge** `settings.emailNotifications` + (like `updateOrganization` does) so a partial toggle update doesn't wipe + other keys. +- Extend the `GET /account/profile` response to include + `settings.emailNotifications` so the UI can hydrate current values. + +### 2b. Org + +Extend `ZUpdateOrganization.settings` +(`packages/utils/src/validation/organization/organization.ts:110`) with +`emailNotifications`. No route changes — `PUT /organization/` already validates +`settings` and deep-merges. Admin-only via `orgAdminMiddleware` (already +applied). + +## Phase 3 — Wire preference context into toggleable sends + +For each toggleable send call site, pass `preference: { organizationId, +recipientProfileId? }`. These are the only call sites that change: + +| File | Email id(s) | +|---|---| +| `apps/api/src/services/course/people.ts` | `teacherStudentJoined`, `teacherCourseWelcome`, `studentCourseWelcome` | +| `apps/api/src/services/course/invite.ts` | `teacherStudentJoined`, `studentCourseWelcome` | +| `apps/api/src/services/organization/audience.ts` | `studentCourseWelcome`, `studentCohortWelcome` | +| `apps/api/src/services/submission/submission.ts` | `submissionReceived`, `submissionGraded` | +| `apps/api/src/services/newsfeed/newsfeed.ts` | `newsfeedPost`, `newsfeedComment` | +| `apps/api/src/services/cohort/goal.ts` | `cohortGoalReminder` | +| `apps/api/src/utils/course-completion.ts` | `studentCourseCompletion` | +| `apps/jobs/src/processors/notifications/notify-course-exercise.ts` | `quizAssigned` | +| `apps/jobs/src/processors/notifications/session-reminder-scan.ts` | `sessionReminder` | +| `apps/jobs/src/processors/notifications/notify-session-update.ts` | `sessionUpdated` | + +`apps/jobs` scans already load org + member context, so they can resolve +preferences directly (or call `shouldSendEmail()` before enqueuing). + +Mandatory-email call sites (onboarding, invites, payment-request, org invite, +student-limit) are **not** touched. + +## Phase 4 — Frontend + +### 4a. Personal — Notifications section on the settings page + +Add a **Notifications** section to the personal settings page +(`apps/dashboard/src/lib/features/settings/pages/profile.svelte`, rendered by the +`/org/[slug]/settings` index) — or a sibling personal page if the profile page +is too crowded. Use the `Field.Group` / `Field.Set` + `Switch` pattern from +CLAUDE.md ("Form Structure"). Each personal toggle is a horizontal switch row. + +Reuse the profile API flow: the toggles bind to a `$state` object and save +through the existing profile-update API class +(`apps/dashboard/src/lib/features/auth/api/profile.svelte.ts` → `updateProfile()` +→ `classroomio.account.profile.$put`), sending `emailNotifications` in the +payload. Server side, `PUT /account/profile` → `updateUser()` +(`apps/api/src/services/account/profile.ts`) → `updateProfile()` query. + +**UI precedent:** `apps/dashboard/src/lib/features/settings/components/auth-general.svelte` +is the closest existing panel — multiple `Switch` toggles in +`Field.Group` / `Field.Set` / `Field.Field orientation="horizontal"` that +read from and merge-write a `settings` jsonb. Mirror its client-side +merge-then-save convention. + +### 4b. Org — Notifications tab (admin) + +Add a `notifications` tab to `org-settings-inline-tabs.svelte` and a route +`apps/dashboard/src/routes/(app)/org/[slug]/settings/notifications/+page.svelte` +rendering a new `features/settings/pages/notifications.svelte`. Admin-only +(match how other org tabs are gated). Toggles save via the org API class +(`apps/dashboard/src/lib/features/org/api/org.svelte.ts`) using the existing +`PUT /organization/` update with `settings.emailNotifications`. + +### 4c. Types & translations + +- Request/response types inferred in + `apps/dashboard/src/lib/features/settings/utils/types.ts` (never import from + `@cio/db/queries`). +- All labels/descriptions in + `apps/dashboard/src/lib/utils/translations/en.json` under a + `settings.notifications.*` namespace; run `pnpm --filter @cio/dashboard translate` + (via `cd apps/dashboard && pnpm translate`) and verify `{}` placeholders. + +## Relationship to `prd/notification-system` + +The unbuilt `prd/notification-system` PRD introduces in-app notifications and a +`notify()` service. This preferences layer is complementary: when that lands, +`shouldSendEmail()` / the toggle map should also gate whether an in-app +notification is created, so users control both channels from one setting. Build +the resolver so `notify()` can reuse it. + +## Files to create + +| File | Purpose | +|---|---| +| `packages/utils/src/notifications/email-toggles.ts` | Template id → toggle key map + mandatory set | +| `packages/db/src/queries/organization/*` (fn) | `getOrganizationEmailNotificationSettings()` | +| `packages/db/src/queries/auth/profile.ts` (fn) | `getProfileEmailNotificationSettingsByEmail()` / `...ById` | +| `apps/api/src/services/notification/email-preferences.ts` | `shouldSendEmail()` resolver | +| `apps/dashboard/src/lib/features/settings/pages/notifications.svelte` | Org notifications tab UI | +| `apps/dashboard/src/routes/(app)/org/[slug]/settings/notifications/+page.svelte` | Org tab route | + +## Files to modify + +| File | Change | +|---|---| +| `packages/db/src/schema.ts` | Add typed `profile.settings` jsonb; extend `organization.settings` type with `emailNotifications` | +| `packages/db/src/queries/auth/profile.ts` | Deep-merge `settings.emailNotifications` in profile update | +| `packages/utils/src/validation/account/account.ts` | Add `emailNotifications` to `ZUpdateProfile` | +| `packages/utils/src/validation/organization/organization.ts` | Add `emailNotifications` to `ZUpdateOrganization.settings` | +| `apps/api/src/services/jobs/email-jobs.ts` | Add `preference` context + guard in `enqueueTransactionalEmail()` | +| `apps/api/src/routes/account/account.ts` | Include `settings.emailNotifications` in `GET /account/profile` response | +| Phase 3 send call sites (table above) | Pass `preference` context | +| `apps/dashboard/src/lib/features/settings/pages/profile.svelte` | Add personal Notifications section | +| `apps/dashboard/src/lib/features/settings/components/org-settings-inline-tabs.svelte` | Add `notifications` tab | +| `apps/dashboard/src/lib/features/auth/api/profile.svelte.ts` | Send `emailNotifications` on save | +| `apps/dashboard/src/lib/features/org/api/org.svelte.ts` | Send `settings.emailNotifications` on save | +| `apps/dashboard/src/lib/features/settings/utils/types.ts` | Inferred request/response types | +| `apps/dashboard/src/lib/utils/translations/en.json` | `settings.notifications.*` strings (+ translate) | + +## Out of scope (v1) + +- **Per-org personal preferences** — v1 personal prefs are profile-global. If a + user is staff in one org and a student in another, one setting governs both. +- **One-click unsubscribe** links in email footers (CAN-SPAM / RFC 8058) — + would later map an unsubscribe token to the personal toggle. +- **Digest / batching** (one daily summary instead of per-event emails). +- **Per-course granularity** — toggles are org-wide and account-wide.