All files / web/src/lib/notifications types.ts

0% Statements 0/135
0% Branches 0/1
0% Functions 0/1
0% Lines 0/135

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,
      }
  }
}