* 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.
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:
- Personal — each user enables/disables the notification emails they receive, from their own settings.
- 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
- Two scopes: org-level defaults (admin) and personal per-user overrides.
- 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.
- 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).
- Storage reuses
jsonbsettings columns —organization.settings(exists) and a newprofile.settingscolumn. No new tables. - Enforcement at a single chokepoint: the preference check lives in the email enqueue layer so individual call sites stay unchanged.
- 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:
enrollmentWelcomeis org-scoped only. It fires when staff add/enroll someone, so the org owns the choice, not the recipient.sessiongroups 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/dbbuild (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)inpackages/db/src/queries/organization/— returns the org'ssettings.emailNotificationsobject.getProfileEmailNotificationSettingsByEmail(email)(and/or...ById) inpackages/db/src/queries/auth/profile.ts— returns a profile'ssettings.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(likeupdateOrganizationdoes) so a partial toggle update doesn't wipe other keys. - Extend the
GET /account/profileresponse to includesettings.emailNotificationsso 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.jsonunder asettings.notifications.*namespace; runpnpm --filter @cio/dashboard translate(viacd 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.