* 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.
392 lines
18 KiB
Markdown
392 lines
18 KiB
Markdown
# 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<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`:
|
|
|
|
```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:
|
|
|
|
```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.
|