diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 6ed999b..7203a63 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -151,6 +151,56 @@ async function notifyAvailability(titleId: string, titleName: string): Promise { + const from = new Date(Date.now() - windowDays * 86_400_000); + const to = new Date(); + + const titles = await prisma.title.findMany({ + where: { + type: "MOVIE", + releaseDate: { gte: from, lte: to }, + entries: { some: { deletedAt: null, status: "WATCHLIST" } }, + }, + select: { id: true, title: true, releaseDate: true }, + }); + + let sent = 0; + for (const title of titles) { + const watchers = await prisma.libraryEntry.findMany({ + where: { + titleId: title.id, + deletedAt: null, + status: "WATCHLIST", + user: { notifyRelease: true, isActive: true }, + }, + select: { userId: true }, + }); + + for (const watcher of watchers) { + const result = await notifyUser(watcher.userId, { + subject: `Jetzt erschienen: ${title.title}`, + body: `„${title.title}" von deiner Watchlist ist seit dem ${title.releaseDate?.toLocaleDateString( + "de-DE", + { day: "2-digit", month: "2-digit", year: "numeric" }, + )} draußen.`, + path: `/titel/${title.id}`, + dedupeKey: `release:${title.id}`, + }); + sent += result.sent; + } + } + + return sent; +} + /** Räumt den Papierkorb und abgelaufene Sitzungen. */ export async function purgeExpired(): Promise<{ entries: number; sessions: number }> { const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 86_400_000); @@ -223,6 +273,16 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise< stats.errors.push("Kein TMDB-Schlüssel hinterlegt — der Abgleich wurde übersprungen."); } + // Erscheinungsmeldungen hängen nicht am TMDB-Abruf: sie stützen sich auf + // die Erscheinungsdaten, die schon im Katalog stehen. + try { + stats.notificationsSent += await notifyReleases(); + } catch (error) { + stats.errors.push( + `Erscheinungsmeldungen: ${error instanceof Error ? error.message : "unbekannter Fehler"}`, + ); + } + const purged = await purgeExpired(); stats.entriesPurged = purged.entries; stats.sessionsPurged = purged.sessions; diff --git a/tests/transfer.test.ts b/tests/transfer.test.ts index 0357b0d..db0968d 100644 --- a/tests/transfer.test.ts +++ b/tests/transfer.test.ts @@ -8,7 +8,7 @@ import { prisma } from "../src/lib/db"; import { importMovie, importSeries } from "../src/lib/catalog"; import { addToLibrary, markMovieWatched, moveToTrash } from "../src/lib/library"; import { exportCsv, exportJson, importCsv, importTrakt, parseCsv } from "../src/lib/transfer"; -import { purgeExpired, TRASH_RETENTION_DAYS } from "../src/lib/sync"; +import { purgeExpired, runSync, TRASH_RETENTION_DAYS } from "../src/lib/sync"; import { notifyUser } from "../src/lib/notify"; /** Prüft Import, Export, das Aufräumen des Papierkorbs und den Doppelversandschutz. */ @@ -239,3 +239,39 @@ test("dieselbe Nachricht wird nicht zweimal verschickt", async () => { await prisma.notificationChannel.deleteMany({ where: { userId } }); }); + +test("erschienene Watchlist-Filme werden gemeldet — und nur einmal", async () => { + // Der Film liegt auf der Watchlist und ist gestern erschienen. + const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST"); + await prisma.title.update({ + where: { id: movieTitleId }, + data: { releaseDate: new Date(Date.now() - 86_400_000) }, + }); + await prisma.user.update({ where: { id: userId }, data: { notifyRelease: true } }); + + await prisma.notificationChannel.create({ + data: { + userId, + kind: "WEBHOOK", + name: "Test", + config: { webhookUrl: "http://127.0.0.1:9/nicht-erreichbar" }, + }, + }); + + await runSync({ limit: 0 }); + const first = await prisma.notificationLog.findMany({ + where: { userId, dedupeKey: `release:${movieTitleId}` }, + }); + assert.equal(first.length, 1, "die Meldung wird angelegt"); + assert.match(first[0]?.subject ?? "", /Jetzt erschienen/); + + // Ein zweiter Lauf meldet dasselbe nicht noch einmal. + await runSync({ limit: 0 }); + const second = await prisma.notificationLog.count({ + where: { userId, dedupeKey: `release:${movieTitleId}` }, + }); + assert.equal(second, 1); + + await prisma.notificationChannel.deleteMany({ where: { userId } }); + await prisma.libraryEntry.delete({ where: { id: entry.id } }); +});