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.