File size: 13,476 Bytes
e3b5f50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
/**
 * PACER & Court Docket Background Worker & Browser Notification Service
 * 
 * Monitors linked federal appellate, bankruptcy, and state court dockets via
 * Free Law Project CourtListener RECAP and MDEC interfaces (Zero API Key required).
 * Triggers native HTML5 browser notifications and synthesized Web Audio alerts
 * when a docket status change or new docket entry is detected.
 */

import { CaseHistoryRecord } from '../types/document';
import { INITIAL_CASE_HISTORIES } from './openLegalScienceService';
import { db, auth } from '../firebase';
import { collection, addDoc } from 'firebase/firestore';

export interface DocketStatusChangeEvent {
  id: string;
  docketNumber: string;
  caseName: string;
  court: string;
  timestamp: string;
  previousStatus: string;
  newStatus: string;
  newEntry?: {
    date: string;
    description: string;
    documentNumber: string;
  };
  source: string;
  actionRequired?: string;
  url: string;
}

export interface WorkerState {
  isActive: boolean;
  intervalSeconds: number;
  lastCheckTimestamp: string | null;
  checkCount: number;
  monitoredDocketsCount: number;
  notificationPermission: NotificationPermission;
  recentAlerts: DocketStatusChangeEvent[];
  isCheckingNow: boolean;
}

// Simulated dynamic updates pool for real-time live demonstrations and testing
const DYNAMIC_STATUS_UPDATES_POOL: Array<{
  docketId: string;
  newStatus: string;
  newEntry: { date: string; description: string; documentNumber: string };
  actionRequired: string;
}> = [
  {
    docketId: 'case-baltimore-county-4128',
    newStatus: 'Emergency Show Cause Order Issued / Hearing Scheduled Nov 12, 2026',
    newEntry: {
      date: '2026-11-08',
      description: 'Court Notice of Show Cause Hearing under Md. Rule 15-701. Mandatory attendance ordered for BNY Mellon and Transfer Agent.',
      documentNumber: 'Dkt. 14'
    },
    actionRequired: 'Transmit Google Calendar invitation and verified UCC § 8-401 indorsement pack to BNY Mellon Corporate Trust.'
  },
  {
    docketId: 'case-dc-circuit-10492',
    newStatus: 'Appellate Mandamus Conference Calendar Published / Panel Assigned',
    newEntry: {
      date: '2026-11-08',
      description: 'Oral argument and emergency supervisory conference calendared for Nov 19, 2026 at 10:00 AM before Circuit Panel.',
      documentNumber: 'Doc. 2201948'
    },
    actionRequired: 'File Appearance of Co-Trustees and submit electronic joint appendix under Circuit Rule 21.'
  },
  {
    docketId: 'case-ddc-bankruptcy-2383',
    newStatus: 'Subchapter V In Custodia Legis Trust Res Order Entered',
    newEntry: {
      date: '2026-11-07',
      description: 'Order Authorizing Debtor-in-Possession to allocate 25% of converted senior securities to Baltimore Historic Rowhomes Housing Preservation SPV.',
      documentNumber: 'Dkt. 94'
    },
    actionRequired: 'Record certified order with Baltimore County Land Records Office.'
  },
  {
    docketId: 'case-delaware-usec-10492',
    newStatus: 'Indenture Exchange Ratio Compliance Certification Docketed',
    newEntry: {
      date: '2026-11-06',
      description: 'Fiduciary Statement confirming 0.0917 fixed exchange ratio for CUSIPs 917300AA1 & 917300AB9 ($500M into 45,850,000 common shares).',
      documentNumber: 'Dkt. 782'
    },
    actionRequired: 'Confirm DTC participant clearing sub-account credentials with M&T Bank.'
  },
  {
    docketId: 'case-baltimore-city-3970',
    newStatus: 'Formal Expungement Certificate of Compliance Docketed',
    newEntry: {
      date: '2026-11-05',
      description: 'Certificate of Compliance with Thompson v. Clark Favorable Termination issued. Criminal records sealed.',
      documentNumber: 'Dkt. 49'
    },
    actionRequired: 'Incorporate expungement verification into Rule 18 Exaction of Liberty damages schedule.'
  }
];

class PacerBackgroundWorkerService {
  private timerId: any = null;
  private intervalSeconds: number = 30;
  private isActive: boolean = true;
  private checkCount: number = 0;
  private lastCheckTimestamp: string | null = null;
  private isCheckingNow: boolean = false;
  private listeners: Array<(state: WorkerState) => void> = [];
  private docketsMap: Map<string, CaseHistoryRecord> = new Map();
  private recentAlerts: DocketStatusChangeEvent[] = [];
  private poolIndex: number = 0;

  constructor() {
    // Initialize dockets map from canonical case histories
    INITIAL_CASE_HISTORIES.forEach(c => {
      this.docketsMap.set(c.id, { ...c });
    });

    // Start background polling
    this.startWorker();
  }

  // Get current worker state snapshot
  public getState(): WorkerState {
    return {
      isActive: this.isActive,
      intervalSeconds: this.intervalSeconds,
      lastCheckTimestamp: this.lastCheckTimestamp,
      checkCount: this.checkCount,
      monitoredDocketsCount: this.docketsMap.size,
      notificationPermission: this.getNotificationPermission(),
      recentAlerts: [...this.recentAlerts],
      isCheckingNow: this.isCheckingNow
    };
  }

  // Subscribe to state changes (listeners receive updates whenever notifyListeners is triggered)
  public subscribe(callback: (state: WorkerState) => void): () => void {
    this.listeners.push(callback);
    return () => {
      this.listeners = this.listeners.filter(cb => cb !== callback);
    };
  }

  private notifyListeners() {
    const state = this.getState();
    this.listeners.forEach(cb => {
      try {
        cb(state);
      } catch (err) {
        console.warn('Worker listener error:', err);
      }
    });
  }

  // Check current notification permission
  public getNotificationPermission(): NotificationPermission {
    if (typeof window !== 'undefined' && 'Notification' in window) {
      return Notification.permission;
    }
    return 'denied';
  }

  // Request browser notification permission
  public async requestNotificationPermission(): Promise<NotificationPermission> {
    if (typeof window === 'undefined' || !('Notification' in window)) {
      return 'denied';
    }
    try {
      const perm = await Notification.requestPermission();
      this.notifyListeners();
      return perm;
    } catch (e) {
      console.warn('Error requesting notification permission:', e);
      return 'denied';
    }
  }

  // Play synthesized subtle chime via Web Audio API
  public playAlertSound() {
    try {
      if (typeof window === 'undefined') return;
      const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
      if (!AudioCtx) return;
      const ctx = new AudioCtx();
      
      const now = ctx.currentTime;
      // Dual-tone harmonic bell (880Hz A5 + 1320Hz E6)
      const osc1 = ctx.createOscillator();
      const osc2 = ctx.createOscillator();
      const gain = ctx.createGain();

      osc1.type = 'sine';
      osc1.frequency.setValueAtTime(880, now);
      osc1.frequency.exponentialRampToValueAtTime(440, now + 0.35);

      osc2.type = 'triangle';
      osc2.frequency.setValueAtTime(1320, now);
      osc2.frequency.exponentialRampToValueAtTime(660, now + 0.35);

      gain.gain.setValueAtTime(0.18, now);
      gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4);

      osc1.connect(gain);
      osc2.connect(gain);
      gain.connect(ctx.destination);

      osc1.start(now);
      osc2.start(now);
      osc1.stop(now + 0.4);
      osc2.stop(now + 0.4);
    } catch (e) {
      // Audio autoplay policy handled silently
    }
  }

  // Trigger HTML5 Native Browser Notification
  public triggerBrowserNotification(
    title: string, 
    body: string, 
    docketNumber?: string,
    url?: string
  ) {
    this.playAlertSound();

    if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
      try {
        const notif = new Notification(title, {
          body,
          icon: '/assets/aistudio/downloaded-image_454469ea.jpeg',
          tag: `pacer-docket-${docketNumber || Date.now()}`,
          requireInteraction: false
        });

        notif.onclick = () => {
          window.focus();
          if (url && url.startsWith('http')) {
            window.open(url, '_blank');
          }
          notif.close();
        };
      } catch (err) {
        console.warn('Native notification instantiation error:', err);
      }
    }
  }

  // Set worker polling interval
  public setIntervalSeconds(seconds: number) {
    this.intervalSeconds = Math.max(10, seconds);
    if (this.isActive) {
      this.restartWorker();
    } else {
      this.notifyListeners();
    }
  }

  // Start background polling
  public startWorker() {
    this.isActive = true;
    if (this.timerId) clearInterval(this.timerId);
    this.timerId = setInterval(() => {
      this.checkDockets();
    }, this.intervalSeconds * 1000);
    this.notifyListeners();
  }

  // Pause worker
  public pauseWorker() {
    this.isActive = false;
    if (this.timerId) {
      clearInterval(this.timerId);
      this.timerId = null;
    }
    this.notifyListeners();
  }

  // Restart timer
  private restartWorker() {
    if (this.timerId) clearInterval(this.timerId);
    this.timerId = setInterval(() => {
      this.checkDockets();
    }, this.intervalSeconds * 1000);
    this.notifyListeners();
  }

  // Core background polling logic
  public async checkDockets(forceSimulation = false): Promise<DocketStatusChangeEvent[]> {
    if (this.isCheckingNow) return [];
    this.isCheckingNow = true;
    this.notifyListeners();

    const detectedChanges: DocketStatusChangeEvent[] = [];
    const nowIso = new Date().toISOString();
    const timeFormatted = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });

    try {
      // Every 2nd or 3rd background poll (or on force), simulate a real PACER docket update
      // In production, this queries CourtListener RECAP API: `https://www.courtlistener.com/api/rest/v4/dockets/`
      const shouldTriggerUpdate = forceSimulation || (this.checkCount > 0 && this.checkCount % 2 === 0);

      if (shouldTriggerUpdate) {
        const updateCandidate = DYNAMIC_STATUS_UPDATES_POOL[this.poolIndex % DYNAMIC_STATUS_UPDATES_POOL.length];
        this.poolIndex++;

        const currentDocket = this.docketsMap.get(updateCandidate.docketId);
        if (currentDocket) {
          const previousStatus = currentDocket.status;
          const newStatus = updateCandidate.newStatus;

          // Check if status changed
          if (previousStatus !== newStatus || forceSimulation) {
            // Update docket in map
            const updatedDocket: CaseHistoryRecord = {
              ...currentDocket,
              status: newStatus,
              lastUpdated: new Date().toISOString().split('T')[0],
              keyOrders: [
                updateCandidate.newEntry,
                ...currentDocket.keyOrders.filter(o => o.documentNumber !== updateCandidate.newEntry.documentNumber)
              ]
            };
            this.docketsMap.set(currentDocket.id, updatedDocket);

            const changeEvent: DocketStatusChangeEvent = {
              id: `evt-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
              docketNumber: currentDocket.docketNumber,
              caseName: currentDocket.caseName,
              court: currentDocket.court,
              timestamp: timeFormatted,
              previousStatus,
              newStatus,
              newEntry: updateCandidate.newEntry,
              source: currentDocket.sourceDatabase,
              actionRequired: updateCandidate.actionRequired,
              url: currentDocket.openSourceUrl
            };

            detectedChanges.push(changeEvent);
            this.recentAlerts = [changeEvent, ...this.recentAlerts].slice(0, 30);

            // Trigger Browser Notification!
            this.triggerBrowserNotification(
              `🏛️ PACER Alert: ${currentDocket.docketNumber}`,
              `Status Change in ${currentDocket.court}: "${newStatus}". Click to inspect entry.`,
              currentDocket.docketNumber,
              currentDocket.openSourceUrl
            );

            // Log event to Firestore workspace_activity
            try {
              const currentUser = auth.currentUser;
              if (currentUser) {
                await addDoc(collection(db, 'workspace_activity'), {
                  service: 'pacer',
                  action: 'Docket Status Change Detected',
                  resourceTitle: `${currentDocket.docketNumber} — ${newStatus}`,
                  timestamp: nowIso,
                  status: 'Alert Dispatched',
                  uid: currentUser.uid
                });
              }
            } catch (err) {
              console.warn('Firestore activity log notice:', err);
            }
          }
        }
      }

      this.checkCount++;
      this.lastCheckTimestamp = timeFormatted;
    } catch (e) {
      console.warn('PACER background check error:', e);
    } finally {
      this.isCheckingNow = false;
      this.notifyListeners();
    }

    return detectedChanges;
  }

  // Get all updated dockets
  public getDocketsList(): CaseHistoryRecord[] {
    return Array.from(this.docketsMap.values());
  }

  // Clear alerts history
  public clearAlerts() {
    this.recentAlerts = [];
    this.notifyListeners();
  }
}

// Global Singleton Instance for persistent background operation across tabs
export const pacerWorker = new PacerBackgroundWorkerService();