Files
Rotimi Best 2815a61437 fix(dashboard): fix instructor avatar upload on course landing page (#745)
* fix(dashboard): fix instructor avatar upload on course landing page

The crop dialog was hidden behind the full-screen (z-250) landing-page
editor because bits-ui portals it to <body> at z-modal (200), and every
crop triggered an unbounded loop of POST /media/image requests.

- Add opt-in `elevatedDialog` prop to UploadImage that raises the crop
  dialog above the editor takeover; enable it in the instructor,
  certificate, and review landing-page editor forms.
- Break the upload effect loop in instructor-form by reading `course.id`
  through `untrack`, so the effect no longer re-runs when the setter
  reassigns `course` after each unique upload URL.
- Handle upload failures in instructor-form with a snackbar and always
  reset `isUploading`, so a failed upload no longer leaves the widget
  stuck and silent.

* docs(email): add email notification preferences PRD

Specify a Settings feature letting users enable/disable notification emails at two scopes: personal per-user preferences and org-wide admin defaults. Both gates must allow a send; transactional emails stay mandatory. Grounds storage in organization.settings and a new profile.settings jsonb, with a single preference guard at the email enqueue chokepoint.
2026-07-13 00:01:11 +01:00

18 KiB

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

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:

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):

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".

export const EMAIL_TOGGLE_MAP: Partial<Record<EmailId, EmailToggleKey>> = {
  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:

export async function shouldSendEmail(input: {
  emailId: EmailId;
  organizationId?: string;
  recipientEmail?: string;
  recipientProfileId?: string;
}): Promise<boolean>
  • 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:

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.