CUSIPstudio / src /services /pacerNotificationService.ts
caustino's picture
Upload 91 files
e3b5f50 verified
Raw History Blame Contribute Delete
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();