story-research-zapwall/lib/notificationDetector.ts
2026-01-10 09:41:57 +01:00

259 lines
7.9 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'
const USER_OBJECT_NOTIFICATION_TYPES: 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' },
]
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
}
for (const cfg of USER_OBJECT_NOTIFICATION_TYPES) {
await this.scanUserObjectsOfType(cfg)
}
}
private async scanUserObjectsOfType(params: { type: string; notificationType: NotificationType }): Promise<void> {
try {
const {userPubkey} = this
if (!userPubkey) {
return
}
const allObjects = await objectCache.getAll(params.type as Parameters<typeof objectCache.getAll>[0])
const userObjects = filterUserRelatedObjects({ type: params.type, allObjects: allObjects as CachedObject[], userPubkey })
await this.createNotificationsForNewObjects({ type: params.type, notificationType: params.notificationType, objects: userObjects })
} catch (error) {
console.error(`[NotificationDetector] Error scanning ${params.type}:`, error)
}
}
private async createNotificationsForNewObjects(params: {
type: string
notificationType: NotificationType
objects: CachedObject[]
}): Promise<void> {
for (const obj of params.objects) {
if (obj.createdAt * 1000 > this.lastScanTime) {
const eventId = obj.id.split(':')[1] ?? obj.id
await notificationService.createNotification({
type: params.notificationType,
objectType: params.type,
objectId: obj.id,
eventId,
data: { object: obj },
})
}
}
}
/**
* 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']
const oneHourAgo = Date.now() - 60 * 60 * 1000
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) {
await this.maybeCreatePublishedNotification({
obj,
objectType,
oneHourAgo,
})
}
} catch (error) {
console.error(`[NotificationDetector] Error scanning published status for ${objectType}:`, error)
}
}
} catch (error) {
console.error('[NotificationDetector] Error scanning published status changes:', error)
}
}
private async maybeCreatePublishedNotification(params: {
obj: CachedObject
objectType: string
oneHourAgo: number
}): Promise<void> {
if (!Array.isArray(params.obj.published) || params.obj.published.length === 0) {
return
}
if (params.obj.createdAt * 1000 <= params.oneHourAgo) {
return
}
const eventId = params.obj.id.split(':')[1] ?? params.obj.id
const existing = await notificationService.getNotificationByEventId(eventId)
if (existing?.type === 'published') {
return
}
const relays = params.obj.published
await notificationService.createNotification({
type: 'published',
objectType: params.objectType,
objectId: params.obj.id,
eventId,
data: {
relays,
object: params.obj,
title: 'Publication réussie',
message: `Votre contenu a été publié sur ${relays.length} relais`,
},
})
}
/**
* 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)
}
}
}
function filterUserRelatedObjects(params: { type: string; allObjects: CachedObject[]; userPubkey: string }): CachedObject[] {
return params.allObjects.filter((obj: CachedObject) => {
if (params.type === 'purchase' || params.type === 'sponsoring' || params.type === 'payment_note') {
return (obj as { targetPubkey?: string }).targetPubkey === params.userPubkey
}
if (params.type === 'review' || params.type === 'review_tip') {
return true
}
return false
})
}
export const notificationDetector = new NotificationDetector()