story-research-zapwall/lib/notificationDetector.ts
2026-01-07 01:59:05 +01:00

289 lines
9.6 KiB
TypeScript

/**
* Notification detector - scans IndexedDB for new events and creates notifications
* Runs in a service worker or main thread
*/
import { objectCache } from './objectCache'
import { notificationService, type NotificationType } from './notificationService'
import type { CachedObject } from './objectCache'
interface ObjectChange {
objectType: string
objectId: string
eventId: string
oldPublished: false | string[]
newPublished: false | string[]
}
class NotificationDetector {
private lastScanTime: number = 0
private scanInterval: number | null = null
private isScanning = false
private userPubkey: string | null = null
/**
* Start scanning for notifications
*/
start(userPubkey: string): void {
if (this.scanInterval) {
return // Already started
}
this.userPubkey = userPubkey
this.lastScanTime = Date.now()
// Scan immediately
void this.scan()
// Then scan periodically (every 30 seconds)
this.scanInterval = window.setInterval(() => {
void this.scan()
}, 30000)
}
/**
* Stop scanning
*/
stop(): void {
if (this.scanInterval) {
clearInterval(this.scanInterval)
this.scanInterval = null
}
this.userPubkey = null
}
/**
* Scan IndexedDB for new events that should trigger notifications
*/
async scan(): Promise<void> {
if (this.isScanning || !this.userPubkey) {
return
}
this.isScanning = true
try {
// Scan for user-related objects
await this.scanUserObjects()
// Scan for published status changes
await this.scanPublishedStatusChanges()
this.lastScanTime = Date.now()
} catch (error) {
console.error('[NotificationDetector] Error scanning:', error)
} finally {
this.isScanning = false
}
}
/**
* Scan for user-related objects (purchases, reviews, sponsoring, review_tips, payment_notes)
*/
private async scanUserObjects(): Promise<void> {
if (!this.userPubkey) {
return
}
const objectTypes: Array<{ type: string; notificationType: NotificationType }> = [
{ type: 'purchase', notificationType: 'purchase' },
{ type: 'review', notificationType: 'review' },
{ type: 'sponsoring', notificationType: 'sponsoring' },
{ type: 'review_tip', notificationType: 'review_tip' },
{ type: 'payment_note', notificationType: 'payment_note' },
]
for (const { type, notificationType } of objectTypes) {
try {
const allObjects = await objectCache.getAll(type as Parameters<typeof objectCache.getAll>[0])
const userObjects = (allObjects as CachedObject[]).filter((obj: CachedObject) => {
// Check if object is related to the user
// For purchases: targetPubkey === userPubkey
// For reviews: targetEventId points to user's article
// For sponsoring: targetPubkey === userPubkey
// For review_tips: targetEventId points to user's review
// For payment_notes: targetPubkey === userPubkey
if (type === 'purchase' || type === 'sponsoring' || type === 'payment_note') {
return (obj as { targetPubkey?: string }).targetPubkey === this.userPubkey
}
if (type === 'review' || type === 'review_tip') {
// Need to check if the target event belongs to the user
// This is more complex and may require checking the article/review
// For now, we'll create notifications for all reviews/tips
// The UI can filter them if needed
return true
}
return false
})
// Create notifications for objects created after last scan
for (const obj of userObjects) {
const cachedObj = obj
if (cachedObj.createdAt * 1000 > this.lastScanTime) {
const eventId = cachedObj.id.split(':')[1] ?? cachedObj.id
await notificationService.createNotification({
type: notificationType,
objectType: type,
objectId: cachedObj.id,
eventId,
data: { object: obj },
})
}
}
} catch (error) {
console.error(`[NotificationDetector] Error scanning ${type}:`, error)
}
}
}
/**
* Scan for published status changes (published: false -> list of relays)
*/
private async scanPublishedStatusChanges(): Promise<void> {
if (!this.userPubkey) {
return
}
try {
// Get all object types that can be published
const objectTypes = ['author', 'series', 'publication', 'review', 'purchase', 'sponsoring', 'review_tip', 'payment_note']
for (const objectType of objectTypes) {
try {
const allObjects = await objectCache.getAll(objectType as Parameters<typeof objectCache.getAll>[0])
const userObjects = (allObjects as CachedObject[]).filter((obj: CachedObject) => {
// Check if object belongs to user - need to check parsed object for pubkey
const parsed = obj.parsed as { pubkey?: string } | undefined
return parsed?.pubkey === this.userPubkey
})
for (const obj of userObjects) {
// Check if object was recently published (published changed from false to array)
if (Array.isArray(obj.published) && obj.published.length > 0) {
// Check if we already created a notification for this
const eventId = obj.id.split(':')[1] ?? obj.id
const existing = await notificationService.getNotificationByEventId(eventId)
if (existing?.type !== 'published') {
// Check if this is a recent change (within last 5 minutes)
// We can't track old/new state easily, so we'll create notification
// if object was published recently (created in last hour and published)
const oneHourAgo = Date.now() - 60 * 60 * 1000
const cachedObj = obj
if (cachedObj.createdAt * 1000 > oneHourAgo) {
const relays = cachedObj.published as string[]
await notificationService.createNotification({
type: 'published',
objectType,
objectId: cachedObj.id,
eventId,
data: {
relays,
object: obj,
title: 'Publication réussie',
message: `Votre contenu a été publié sur ${relays.length} relais`,
},
})
}
}
}
}
} catch (error) {
console.error(`[NotificationDetector] Error scanning published status for ${objectType}:`, error)
}
}
} catch (error) {
console.error('[NotificationDetector] Error scanning published status changes:', error)
}
}
/**
* Manually check for a specific object change
*/
async checkObjectChange(change: ObjectChange): Promise<void> {
if (!this.userPubkey) {
return
}
try {
// Check if published status changed from false to array
if (change.oldPublished === false && Array.isArray(change.newPublished) && change.newPublished.length > 0) {
// Get the object to check if it belongs to user
const obj = await objectCache.get(change.objectType as Parameters<typeof objectCache.get>[0], change.objectId)
if (obj) {
const cachedObj = obj as CachedObject
const parsed = cachedObj.parsed as { pubkey?: string } | undefined
if (parsed?.pubkey === this.userPubkey) {
const relays = change.newPublished
await notificationService.createNotification({
type: 'published',
objectType: change.objectType,
objectId: change.objectId,
eventId: change.eventId,
data: {
relays,
object: obj,
title: 'Publication réussie',
message: `Votre contenu a été publié sur ${relays.length} relais`,
},
})
}
}
}
} catch (error) {
console.error('[NotificationDetector] Error checking object change:', error)
}
}
/**
* Get notification title based on type
*/
private getNotificationTitle(type: NotificationType, _obj: CachedObject): string {
switch (type) {
case 'purchase':
return 'Nouvel achat'
case 'review':
return 'Nouvel avis'
case 'sponsoring':
return 'Nouveau sponsoring'
case 'review_tip':
return 'Nouveau remerciement'
case 'payment_note':
return 'Nouvelle note de paiement'
case 'published':
return 'Publication réussie'
default:
return 'Nouvelle notification'
}
}
/**
* Get notification message based on type
*/
private getNotificationMessage(type: NotificationType, _obj: CachedObject): string {
switch (type) {
case 'purchase':
return `Vous avez acheté un article`
case 'review':
return `Un nouvel avis a été publié`
case 'sponsoring':
return `Vous avez reçu un sponsoring`
case 'review_tip':
return `Vous avez reçu un remerciement`
case 'payment_note':
return `Une note de paiement a été ajoutée`
case 'published':
const cachedObj = _obj
const relays = Array.isArray(cachedObj.published) ? cachedObj.published : []
return `Votre contenu a été publié sur ${relays.length} relais`
default:
return 'Nouvelle notification'
}
}
}
export const notificationDetector = new NotificationDetector()