Spaces:
Running
Running
Download src/services/pacerNotificationService.ts from caustino/CUSIPstudio: direct link, hf CLI and curl.
- Browser
- Download file 13.5 kB
-
https://huggingface.co/spaces/caustino/CUSIPstudio/resolve/main/src/services/pacerNotificationService.ts
- Command line
-
hf download hf://spaces/caustino/CUSIPstudio/src/services/pacerNotificationService.ts
-
curl -L -o pacerNotificationService.ts https://huggingface.co/spaces/caustino/CUSIPstudio/resolve/main/src/services/pacerNotificationService.ts
13.5 kB
| /** | |
| * 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(); | |