Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | import type { NotificationType } from '@/db/schema/user-notification-settings' // --------------------------------------------------------------------------- // Event payloads — one per notification type // --------------------------------------------------------------------------- export interface SessionStartedPayload { sessionId: string playerId: string playerName: string playerEmoji: string observeUrl: string } export interface PostcardReadyPayload { postcardId: string callerNumber: number imageUrl: string | null thumbnailUrl: string | null postcardUrl: string } export interface AdminSongFailedPayload { songId: string sessionPlanId: string playerId: string playerName: string failureKind: string adminUrl: string } // --------------------------------------------------------------------------- // Discriminated union of all notification events // --------------------------------------------------------------------------- export type NotificationEvent = | { type: 'session-started'; data: SessionStartedPayload } | { type: 'postcard-ready'; data: PostcardReadyPayload } | { type: 'admin-song-failed'; data: AdminSongFailedPayload } /** * Extract the payload type for a given notification type. * Useful for channel implementations that switch on event.type. */ export type PayloadFor<T extends NotificationType> = Extract<NotificationEvent, { type: T }>['data'] // --------------------------------------------------------------------------- // Delivery target — who to notify and how to reach them // --------------------------------------------------------------------------- /** * Resolved delivery target — represents one user who should receive * a notification, with their resolved channel preferences and endpoints. */ export interface DeliveryTarget { userId: string email: string | null channels: { inApp: boolean push: boolean email: boolean } /** * Push endpoint from a practice notification subscription record. * Used for anonymous subscribers who don't have user-level push registrations. * When present, the web push channel sends directly to this endpoint * instead of looking up user_push_subscriptions. */ subscriptionPushEndpoint?: { endpoint: string; keys: { p256dh: string; auth: string } } } // --------------------------------------------------------------------------- // Channel interface — delivery implementations // --------------------------------------------------------------------------- export interface NotificationChannel { /** Must match a key in NotificationChannelsConfig: 'webPush' | 'email' | 'inApp' */ name: string /** Check whether this target has the data needed for delivery via this channel */ canDeliver(target: DeliveryTarget): boolean /** Attempt to deliver a notification to a target */ deliver(target: DeliveryTarget, event: NotificationEvent): Promise<DeliveryResult> } export interface DeliveryResult { success: boolean error?: string /** When true, the push endpoint should be removed (e.g. 410 Gone) */ shouldDisable?: boolean } export interface NotifyResult { targetCount: number attempted: number succeeded: number errors: string[] } // --------------------------------------------------------------------------- // Helpers for formatting notification content per event type // --------------------------------------------------------------------------- export interface NotificationContent { title: string body: string icon: string url: string } /** Format a notification event into display-ready content */ export function formatNotificationContent(event: NotificationEvent): NotificationContent { switch (event.type) { case 'session-started': return { title: `${event.data.playerName} started practicing!`, body: 'Tap to watch live', icon: '/icon-192x192.png', url: event.data.observeUrl, } case 'postcard-ready': return { title: `Your postcard from #${Number.isInteger(event.data.callerNumber) ? event.data.callerNumber : event.data.callerNumber.toPrecision(4)} is ready!`, body: 'Tap to view your postcard', icon: event.data.thumbnailUrl ?? '/icon-192x192.png', url: event.data.postcardUrl, } case 'admin-song-failed': return { title: `Session song failed for ${event.data.playerName}`, body: `${event.data.failureKind.replace('_', ' ')}. Open the songs dashboard to inspect and retry.`, icon: '/icon-192x192.png', url: event.data.adminUrl, } } } |