diff --git a/app/api/notifications/preferences/route.ts b/app/api/notifications/preferences/route.ts new file mode 100644 index 0000000..afba8e7 --- /dev/null +++ b/app/api/notifications/preferences/route.ts @@ -0,0 +1,237 @@ +export const dynamic = "force-dynamic"; + +/** + * GET /api/notifications/preferences + * Returns the authenticated user's current notification preferences. + * If no row exists yet, the platform defaults are returned. + * + * PUT /api/notifications/preferences + * Merges the supplied fields with existing preferences and persists them. + * All request body fields are optional; only supplied fields are updated. + * + * DELETE /api/notifications/preferences + * Resets the authenticated user's preferences back to platform defaults. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + withAuth, + AuthContext, + resolveUserIdByWallet, +} from "@/lib/auth/middleware"; +import { + enforceRateLimit, + buildRateLimitKey, +} from "@/lib/security/rateLimit"; +import { + getNotificationPreferences, + updateNotificationPreferences, + resetNotificationPreferences, + NotificationPreferencesError, + DIGEST_VALUES, +} from "@/lib/notification-preferences"; + +// ─── Validation schema ──────────────────────────────────────────────────────── + +const UpdatePreferencesSchema = z + .object({ + // Per-category — in-app + milestonesInApp: z.boolean().optional(), + paymentsInApp: z.boolean().optional(), + disputesInApp: z.boolean().optional(), + deadlinesInApp: z.boolean().optional(), + + // Per-category — email + milestonesEmail: z.boolean().optional(), + paymentsEmail: z.boolean().optional(), + disputesEmail: z.boolean().optional(), + deadlinesEmail: z.boolean().optional(), + + // Global channel switches + emailEnabled: z.boolean().optional(), + pushEnabled: z.boolean().optional(), + inAppEnabled: z.boolean().optional(), + + // Delivery cadence + digest: z.enum(["instant", "daily", "weekly"] as [ + (typeof DIGEST_VALUES)[number], + ...(typeof DIGEST_VALUES)[number][], + ]).optional(), + }) + .strict(); // reject unknown fields + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function resolveUser( + request: NextRequest, + auth: AuthContext, + namespace: string, + rateLimitCount: number, +): Promise<{ userId: number } | NextResponse> { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey(request, namespace, auth.walletAddress), + limit: rateLimitCount, + windowMs: 60_000, + }); + if (limited) return limited; + + const userId = await resolveUserIdByWallet(auth.walletAddress); + if (userId === null) { + return NextResponse.json( + { error: "Platform user not found for this wallet", code: "USER_NOT_FOUND" }, + { status: 404 }, + ); + } + + return { userId }; +} + +// ─── GET /api/notifications/preferences ────────────────────────────────────── + +export const GET = withAuth( + async (request: NextRequest, auth: AuthContext): Promise => { + const resolved = await resolveUser( + request, + auth, + "notifications:preferences:get", + 60, + ); + if (resolved instanceof NextResponse) return resolved; + const { userId } = resolved; + + try { + const preferences = await getNotificationPreferences(userId); + return NextResponse.json( + { preferences }, + { status: 200, headers: { "Cache-Control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof NotificationPreferencesError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 400 }, + ); + } + console.error("[GET /api/notifications/preferences]", err); + return NextResponse.json( + { + error: "Failed to fetch notification preferences", + code: "PREFERENCES_FETCH_FAILED", + }, + { status: 503 }, + ); + } + }, +); + +// ─── PUT /api/notifications/preferences ────────────────────────────────────── + +export const PUT = withAuth( + async (request: NextRequest, auth: AuthContext): Promise => { + const resolved = await resolveUser( + request, + auth, + "notifications:preferences:put", + 30, + ); + if (resolved instanceof NextResponse) return resolved; + const { userId } = resolved; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Request body must be valid JSON", code: "INVALID_JSON" }, + { status: 400 }, + ); + } + + const parsed = UpdatePreferencesSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "Validation failed", + code: "VALIDATION_FAILED", + details: parsed.error.flatten().fieldErrors, + }, + { status: 422 }, + ); + } + + if (Object.keys(parsed.data).length === 0) { + return NextResponse.json( + { + error: "Request body must include at least one preference field", + code: "EMPTY_UPDATE", + }, + { status: 400 }, + ); + } + + try { + const preferences = await updateNotificationPreferences( + userId, + parsed.data, + ); + return NextResponse.json({ preferences }, { status: 200 }); + } catch (err) { + if (err instanceof NotificationPreferencesError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 400 }, + ); + } + console.error("[PUT /api/notifications/preferences]", err); + return NextResponse.json( + { + error: "Failed to update notification preferences", + code: "PREFERENCES_UPDATE_FAILED", + }, + { status: 503 }, + ); + } + }, +); + +// ─── DELETE /api/notifications/preferences ──────────────────────────────────── + +export const DELETE = withAuth( + async (request: NextRequest, auth: AuthContext): Promise => { + const resolved = await resolveUser( + request, + auth, + "notifications:preferences:delete", + 10, + ); + if (resolved instanceof NextResponse) return resolved; + const { userId } = resolved; + + try { + const preferences = await resetNotificationPreferences(userId); + return NextResponse.json( + { + message: "Notification preferences reset to defaults", + preferences, + }, + { status: 200 }, + ); + } catch (err) { + if (err instanceof NotificationPreferencesError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 400 }, + ); + } + console.error("[DELETE /api/notifications/preferences]", err); + return NextResponse.json( + { + error: "Failed to reset notification preferences", + code: "PREFERENCES_RESET_FAILED", + }, + { status: 503 }, + ); + } + }, +); diff --git a/app/settings/settings-client.tsx b/app/settings/settings-client.tsx index d419525..d06f833 100644 --- a/app/settings/settings-client.tsx +++ b/app/settings/settings-client.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useTheme } from 'next-themes' @@ -19,6 +19,13 @@ import { Monitor, LogOut, KeyRound, + AlertCircle, + RotateCcw, + Shield, + Mail, + Smartphone, + MonitorSmartphone, + Clock, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' @@ -28,16 +35,13 @@ import { Textarea } from '@/components/ui/textarea' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' import { ThemeToggle } from '@/components/ui/ThemeToggle' import { useStellarWallet, truncateStellarAddress, networkLabel, REQUIRED_NETWORK } from '@/components/wallet-provider' import { cn } from '@/lib/utils' +import type { NotificationPreferences } from '@/lib/notification-preferences' -type NotificationPrefs = { - email: boolean - push: boolean - inApp: boolean - digest: 'instant' | 'daily' | 'weekly' -} +// ─── Local types ───────────────────────────────────────────────────────────── type ProfileDraft = { displayName: string @@ -47,8 +51,26 @@ type ProfileDraft = { bio: string } +type NotifSaveStatus = 'idle' | 'saving' | 'saved' | 'error' + +// ─── Constants ──────────────────────────────────────────────────────────────── + const PROFILE_STORAGE_KEY = 'taskchain_settings_profile' -const NOTIFICATION_STORAGE_KEY = 'taskchain_settings_notifications' + +const DEFAULT_PREFERENCES: NotificationPreferences = { + milestonesInApp: true, + paymentsInApp: true, + disputesInApp: true, + deadlinesInApp: true, + milestonesEmail: true, + paymentsEmail: true, + disputesEmail: true, + deadlinesEmail: false, + emailEnabled: true, + pushEnabled: true, + inAppEnabled: true, + digest: 'daily', +} const defaultProfile: ProfileDraft = { displayName: '', @@ -58,13 +80,6 @@ const defaultProfile: ProfileDraft = { bio: '', } -const defaultNotifications: NotificationPrefs = { - email: true, - push: true, - inApp: true, - digest: 'daily', -} - function readStoredJSON(key: string, fallback: T): T { if (typeof window === 'undefined') return fallback try { @@ -102,10 +117,23 @@ export function SettingsClient() { const router = useRouter() const { theme, setTheme } = useTheme() const { address, isConnected, network } = useStellarWallet() - const [profile, setProfile] = useState(() => readStoredJSON(PROFILE_STORAGE_KEY, defaultProfile)) - const [notifications, setNotifications] = useState(() => readStoredJSON(NOTIFICATION_STORAGE_KEY, defaultNotifications)) + + // ── Profile state ────────────────────────────────────────────────────────── + const [profile, setProfile] = useState(() => + readStoredJSON(PROFILE_STORAGE_KEY, defaultProfile), + ) const [profileSaved, setProfileSaved] = useState(false) - const [notificationsSaved, setNotificationsSaved] = useState(false) + + // ── Notification preferences state ──────────────────────────────────────── + const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES) + const [prefsLoading, setPrefsLoading] = useState(true) + const [prefsLoadError, setPrefsLoadError] = useState(null) + const [notifSaveStatus, setNotifSaveStatus] = useState('idle') + const [notifSaveError, setNotifSaveError] = useState(null) + const [resetDialogOpen, setResetDialogOpen] = useState(false) + const [resetting, setResetting] = useState(false) + + // ── Other UI state ───────────────────────────────────────────────────────── const [copyState, setCopyState] = useState<'idle' | 'copied'>('idle') const [securityDialogOpen, setSecurityDialogOpen] = useState(false) @@ -114,17 +142,110 @@ export function SettingsClient() { return truncateStellarAddress(address) }, [address]) + // ── Load preferences from API on mount ──────────────────────────────────── + useEffect(() => { + let cancelled = false + setPrefsLoading(true) + setPrefsLoadError(null) + + fetch('/api/notifications/preferences', { + headers: { 'Cache-Control': 'no-store' }, + }) + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`) + } + return res.json() as Promise<{ preferences: NotificationPreferences }> + }) + .then((data) => { + if (!cancelled) setPreferences(data.preferences) + }) + .catch((err: unknown) => { + if (!cancelled) { + setPrefsLoadError( + err instanceof Error ? err.message : 'Failed to load preferences', + ) + // Fall back to platform defaults so the UI is still usable offline + setPreferences(DEFAULT_PREFERENCES) + } + }) + .finally(() => { + if (!cancelled) setPrefsLoading(false) + }) + + return () => { cancelled = true } + }, []) + + // ── Handlers ─────────────────────────────────────────────────────────────── + const handleProfileSave = () => { localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(profile)) setProfileSaved(true) window.setTimeout(() => setProfileSaved(false), 1800) } - const handleNotificationsSave = () => { - localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(notifications)) - setNotificationsSaved(true) - window.setTimeout(() => setNotificationsSaved(false), 1800) - } + const handlePrefsChange = useCallback( + (patch: Partial) => { + setPreferences((prev) => ({ ...prev, ...patch })) + // Clear previous error / saved feedback when user makes a new change + setNotifSaveStatus('idle') + setNotifSaveError(null) + }, + [], + ) + + const handleNotificationsSave = useCallback(async () => { + setNotifSaveStatus('saving') + setNotifSaveError(null) + try { + const res = await fetch('/api/notifications/preferences', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(preferences), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error( + (data as { error?: string }).error ?? `HTTP ${res.status}`, + ) + } + setPreferences((data as { preferences: NotificationPreferences }).preferences) + setNotifSaveStatus('saved') + window.setTimeout(() => setNotifSaveStatus('idle'), 2200) + } catch (err) { + setNotifSaveStatus('error') + setNotifSaveError( + err instanceof Error ? err.message : 'Failed to save preferences', + ) + } + }, [preferences]) + + const handlePrefsReset = useCallback(async () => { + setResetting(true) + try { + const res = await fetch('/api/notifications/preferences', { + method: 'DELETE', + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error( + (data as { error?: string }).error ?? `HTTP ${res.status}`, + ) + } + setPreferences((data as { preferences: NotificationPreferences }).preferences) + setNotifSaveStatus('saved') + window.setTimeout(() => setNotifSaveStatus('idle'), 2200) + } catch (err) { + setNotifSaveStatus('error') + setNotifSaveError( + err instanceof Error ? err.message : 'Failed to reset preferences', + ) + } finally { + setResetting(false) + setResetDialogOpen(false) + } + }, []) const handleCopyWallet = async () => { if (!address) return @@ -248,56 +369,237 @@ export function SettingsClient() { - - setNotifications((prev) => ({ ...prev, email: !prev.email }))} - /> - setNotifications((prev) => ({ ...prev, push: !prev.push }))} - /> - setNotifications((prev) => ({ ...prev, inApp: !prev.inApp }))} - /> -
- - + handlePrefsChange({ digest: value as NotificationPreferences['digest'] }) + } + > + + + + + Instant — send immediately + Daily digest — once per day + Weekly digest — once per week + + +
+ + + + {/* ── Security alerts (essential, non-dismissible) ───── */} +
+
+ +
+

Security & account alerts

+

+ Critical security notifications (sign-in alerts, wallet activity, account changes) are always + delivered and cannot be disabled. These protect your account and comply with platform policy. +

+
+ + Always on + +
+
+ + )} +
+ + + {/* Save status feedback */} +
+ {notifSaveStatus === 'saving' && ( + <> + + Saving… + + )} + {notifSaveStatus === 'saved' && ( + <> + + Preferences saved + + )} + {notifSaveStatus === 'error' && notifSaveError && ( + <> + + {notifSaveError} + + )} + {notifSaveStatus === 'idle' && ( + + Preferences sync to your account across all sessions. + + )} +
+ +
+ +
- - -

- Notification preferences are saved locally in this session. -

-
@@ -459,36 +761,146 @@ export function SettingsClient() { + + {/* Reset preferences confirmation dialog */} + + + + Reset notification preferences? + + This will restore all notification settings to the platform defaults and cannot be undone. + + + + + + + + ) } -function PreferenceRow({ +// ─── Sub-components ─────────────────────────────────────────────────────────── + +/** Compact card for a global channel master switch (Email / Push / In-app). */ +function ChannelToggle({ + icon: Icon, label, - description, enabled, onToggle, }: { + icon: React.ComponentType<{ className?: string }> label: string - description: string enabled: boolean onToggle: () => void }) { return ( -
-
+ + ) +} + +/** Table row for a per-event-category in-app + email pair of toggles. */ +function EventRow({ + label, + description, + inApp, + email, + inAppDisabled, + emailDisabled, + onInAppToggle, + onEmailToggle, +}: { + label: string + description: string + inApp: boolean + email: boolean + inAppDisabled: boolean + emailDisabled: boolean + onInAppToggle: () => void + onEmailToggle: () => void +}) { + return ( + +

{label}

-

{description}

-
- -
+

{description}

+ + + + + + + + + ) +} + +/** Small on/off toggle button shared by EventRow cells. */ +function ToggleButton({ + enabled, + disabled, + onToggle, + 'aria-label': ariaLabel, +}: { + enabled: boolean + disabled: boolean + onToggle: () => void + 'aria-label': string +}) { + return ( + ) } diff --git a/lib/db/migrations/011_notification_preferences.sql b/lib/db/migrations/011_notification_preferences.sql new file mode 100644 index 0000000..8aab7e5 --- /dev/null +++ b/lib/db/migrations/011_notification_preferences.sql @@ -0,0 +1,39 @@ +-- Migration: 011_notification_preferences +-- Creates the notification_preferences table so users can persist +-- their per-channel and delivery-digest settings server-side. +-- +-- Each user has at most one row (UNIQUE constraint on user_id). +-- Missing rows are treated as default preferences by the service layer. + +CREATE TABLE IF NOT EXISTS notification_preferences ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + + -- Per-event-category toggles (in-app channel) + milestones_in_app BOOLEAN NOT NULL DEFAULT TRUE, + payments_in_app BOOLEAN NOT NULL DEFAULT TRUE, + disputes_in_app BOOLEAN NOT NULL DEFAULT TRUE, + deadlines_in_app BOOLEAN NOT NULL DEFAULT TRUE, + + -- Per-event-category toggles (email channel) + milestones_email BOOLEAN NOT NULL DEFAULT TRUE, + payments_email BOOLEAN NOT NULL DEFAULT TRUE, + disputes_email BOOLEAN NOT NULL DEFAULT TRUE, + deadlines_email BOOLEAN NOT NULL DEFAULT FALSE, + + -- Global channel switches + email_enabled BOOLEAN NOT NULL DEFAULT TRUE, + push_enabled BOOLEAN NOT NULL DEFAULT TRUE, + in_app_enabled BOOLEAN NOT NULL DEFAULT TRUE, + + -- Delivery cadence: 'instant' | 'daily' | 'weekly' + digest TEXT NOT NULL DEFAULT 'daily' + CHECK (digest IN ('instant', 'daily', 'weekly')), + + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_notification_prefs_user UNIQUE (user_id) +); + +CREATE INDEX IF NOT EXISTS idx_notification_prefs_user + ON notification_preferences (user_id); diff --git a/lib/notification-preferences.ts b/lib/notification-preferences.ts new file mode 100644 index 0000000..af9c0b9 --- /dev/null +++ b/lib/notification-preferences.ts @@ -0,0 +1,244 @@ +// lib/notification-preferences.ts +// +// Service layer for notification-preference persistence. +// All DB access is centralised here so route handlers remain thin +// and the logic is independently testable. +// +// Column mapping (DB snake_case ↔ JS camelCase — no ORM): +// milestones_in_app ↔ milestonesInApp +// payments_in_app ↔ paymentsInApp +// disputes_in_app ↔ disputesInApp +// deadlines_in_app ↔ deadlinesInApp +// milestones_email ↔ milestonesEmail +// payments_email ↔ paymentsEmail +// disputes_email ↔ disputesEmail +// deadlines_email ↔ deadlinesEmail +// email_enabled ↔ emailEnabled +// push_enabled ↔ pushEnabled +// in_app_enabled ↔ inAppEnabled +// digest ↔ digest + +import { sql } from "@/lib/db"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type DigestCadence = "instant" | "daily" | "weekly"; + +/** + * Full set of notification preferences for a single user. + * + * Event-category toggles let users silence a whole event group on a + * per-channel basis (e.g. disable deadline emails while keeping in-app + * deadline alerts on). + * + * The global `emailEnabled` / `pushEnabled` / `inAppEnabled` switches act + * as master overrides — if `emailEnabled` is false, no emails are sent + * regardless of the per-category settings. + */ +export interface NotificationPreferences { + // Per-category — in-app channel + milestonesInApp: boolean; + paymentsInApp: boolean; + disputesInApp: boolean; + deadlinesInApp: boolean; + + // Per-category — email channel + milestonesEmail: boolean; + paymentsEmail: boolean; + disputesEmail: boolean; + deadlinesEmail: boolean; + + // Global channel switches + emailEnabled: boolean; + pushEnabled: boolean; + inAppEnabled: boolean; + + // Delivery cadence + digest: DigestCadence; +} + +/** Subset of fields that a PUT request may update. All keys are optional. */ +export type UpdateNotificationPreferencesInput = + Partial; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +export const DIGEST_VALUES: readonly DigestCadence[] = [ + "instant", + "daily", + "weekly", +] as const; + +export const DEFAULT_PREFERENCES: NotificationPreferences = { + milestonesInApp: true, + paymentsInApp: true, + disputesInApp: true, + deadlinesInApp: true, + + milestonesEmail: true, + paymentsEmail: true, + disputesEmail: true, + deadlinesEmail: false, + + emailEnabled: true, + pushEnabled: true, + inAppEnabled: true, + + digest: "daily", +}; + +// ─── Errors ────────────────────────────────────────────────────────────────── + +export class NotificationPreferencesError extends Error { + public code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "NotificationPreferencesError"; + this.code = code; + } +} + +// ─── Row mapper ────────────────────────────────────────────────────────────── + +type PrefsRow = Record; + +function mapRow(row: PrefsRow): NotificationPreferences { + const bool = (v: unknown, fallback: boolean): boolean => + typeof v === "boolean" ? v : fallback; + + return { + milestonesInApp: bool(row.milestones_in_app, DEFAULT_PREFERENCES.milestonesInApp), + paymentsInApp: bool(row.payments_in_app, DEFAULT_PREFERENCES.paymentsInApp), + disputesInApp: bool(row.disputes_in_app, DEFAULT_PREFERENCES.disputesInApp), + deadlinesInApp: bool(row.deadlines_in_app, DEFAULT_PREFERENCES.deadlinesInApp), + + milestonesEmail: bool(row.milestones_email, DEFAULT_PREFERENCES.milestonesEmail), + paymentsEmail: bool(row.payments_email, DEFAULT_PREFERENCES.paymentsEmail), + disputesEmail: bool(row.disputes_email, DEFAULT_PREFERENCES.disputesEmail), + deadlinesEmail: bool(row.deadlines_email, DEFAULT_PREFERENCES.deadlinesEmail), + + emailEnabled: bool(row.email_enabled, DEFAULT_PREFERENCES.emailEnabled), + pushEnabled: bool(row.push_enabled, DEFAULT_PREFERENCES.pushEnabled), + inAppEnabled: bool(row.in_app_enabled, DEFAULT_PREFERENCES.inAppEnabled), + + digest: DIGEST_VALUES.includes(row.digest as DigestCadence) + ? (row.digest as DigestCadence) + : DEFAULT_PREFERENCES.digest, + }; +} + +// ─── Read ───────────────────────────────────────────────────────────────────── + +/** + * Returns the stored preferences for `userId`, or `DEFAULT_PREFERENCES` + * when no row exists yet (first-visit / first-call). + */ +export async function getNotificationPreferences( + userId: number, +): Promise { + if (!Number.isFinite(userId) || userId <= 0) { + throw new NotificationPreferencesError( + "INVALID_USER", + "userId must be a positive integer", + ); + } + + const rows = (await sql` + SELECT + milestones_in_app, payments_in_app, disputes_in_app, deadlines_in_app, + milestones_email, payments_email, disputes_email, deadlines_email, + email_enabled, push_enabled, in_app_enabled, + digest + FROM notification_preferences + WHERE user_id = ${userId} + LIMIT 1 + `) as PrefsRow[]; + + if (rows.length === 0) { + return { ...DEFAULT_PREFERENCES }; + } + + return mapRow(rows[0]); +} + +// ─── Upsert ─────────────────────────────────────────────────────────────────── + +/** + * Merges `input` with existing preferences and persists via INSERT … ON CONFLICT. + * Returns the final stored preferences. + */ +export async function updateNotificationPreferences( + userId: number, + input: UpdateNotificationPreferencesInput, +): Promise { + if (!Number.isFinite(userId) || userId <= 0) { + throw new NotificationPreferencesError( + "INVALID_USER", + "userId must be a positive integer", + ); + } + + // Merge new values onto existing (or defaults for first-time save). + const current = await getNotificationPreferences(userId); + const merged: NotificationPreferences = { ...current, ...input }; + + await sql` + INSERT INTO notification_preferences ( + user_id, + milestones_in_app, payments_in_app, disputes_in_app, deadlines_in_app, + milestones_email, payments_email, disputes_email, deadlines_email, + email_enabled, push_enabled, in_app_enabled, + digest, + updated_at + ) + VALUES ( + ${userId}, + ${merged.milestonesInApp}, ${merged.paymentsInApp}, + ${merged.disputesInApp}, ${merged.deadlinesInApp}, + ${merged.milestonesEmail}, ${merged.paymentsEmail}, + ${merged.disputesEmail}, ${merged.deadlinesEmail}, + ${merged.emailEnabled}, ${merged.pushEnabled}, ${merged.inAppEnabled}, + ${merged.digest}, + NOW() + ) + ON CONFLICT (user_id) DO UPDATE SET + milestones_in_app = EXCLUDED.milestones_in_app, + payments_in_app = EXCLUDED.payments_in_app, + disputes_in_app = EXCLUDED.disputes_in_app, + deadlines_in_app = EXCLUDED.deadlines_in_app, + milestones_email = EXCLUDED.milestones_email, + payments_email = EXCLUDED.payments_email, + disputes_email = EXCLUDED.disputes_email, + deadlines_email = EXCLUDED.deadlines_email, + email_enabled = EXCLUDED.email_enabled, + push_enabled = EXCLUDED.push_enabled, + in_app_enabled = EXCLUDED.in_app_enabled, + digest = EXCLUDED.digest, + updated_at = NOW() + `; + + return merged; +} + +/** + * Resets a user's preferences back to the platform defaults by deleting + * their row. The next GET will return DEFAULT_PREFERENCES. + */ +export async function resetNotificationPreferences( + userId: number, +): Promise { + if (!Number.isFinite(userId) || userId <= 0) { + throw new NotificationPreferencesError( + "INVALID_USER", + "userId must be a positive integer", + ); + } + + await sql` + DELETE FROM notification_preferences + WHERE user_id = ${userId} + `; + + return { ...DEFAULT_PREFERENCES }; +}