From a6fd79a197bb1722a8278fc5713b5ca75caa9d33 Mon Sep 17 00:00:00 2001
From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 09:44:20 +0300
Subject: [PATCH] fix: close the Phase 1-4 audit defects
- Password reset, password change and email confirmation cancel pending
email changes and reset links; a requested email change warns the old
address; account emails that fail are logged without the address and
forgot-password answers the same either way.
- SMTP errors keep code, status and command but drop the server response.
- Digests count only refused recipients and rejected messages against a
reader, treat sender and connection failures as outages, send only the
releases a run claimed and requeue sends interrupted by a crash; new
release markers, inbox rows and digest events are written together.
- Notifications pause instead of following another source when a title's
preferred source is switched off; unsubscribing a deleted account works.
- Imports attach matches without overwriting stored titles, and saved
titles are refreshed from their source weekly.
- Progress fields accept values past stored totals; removing a game's last
platform turns its notifications off; a concurrent first add retries.
- Private ratings stay off public profiles; the review editor uses a
Private switch that starts off.
- Open reports stay in the admin queue when review text is cleared.
- Import matches carry the adult flag, imports cannot be deleted while
running and leftover uploads are removed at start-up.
- IGDB release dates repeated per region are merged.
- Security headers, including HSTS behind HTTPS; X-Powered-By is off.
---
.../src/components/LibraryEntryEditor.tsx | 17 +-
app/frontend/src/components/ReviewEditor.tsx | 24 +-
app/frontend/src/components/TitleReviews.tsx | 9 -
app/frontend/src/components/Toggle.tsx | 4 +-
app/frontend/src/pages/SettingsPage.tsx | 2 +-
app/frontend/test/review-editor.spec.tsx | 103 +++++++++
app/server/src/admin/admin.service.ts | 1 -
app/server/src/auth/auth.service.ts | 60 ++++-
.../src/catalog/catalog-items.service.ts | 5 +
.../src/catalog/catalog-refresh.service.ts | 59 +++++
app/server/src/catalog/catalog.module.ts | 5 +-
app/server/src/imports/imports.controller.ts | 5 +-
app/server/src/imports/imports.service.ts | 22 +-
app/server/src/library/effective-source.ts | 11 +
app/server/src/library/library.service.ts | 19 +-
app/server/src/mail/mail.service.ts | 32 ++-
app/server/src/main.ts | 12 +-
.../src/notifications/digest.service.ts | 57 +++--
.../src/notifications/notifications.module.ts | 3 +-
.../notifications/notifications.scheduler.ts | 3 +
.../notifications/notifications.service.ts | 26 ++-
.../notifications/release-monitor.service.ts | 66 +++---
app/server/src/notifications/subscriptions.ts | 14 +-
app/server/src/sources/igdb/igdb.service.ts | 21 +-
app/server/src/users/profiles.service.ts | 20 +-
app/server/test/account.spec.ts | 112 +++++++++-
app/server/test/admin.spec.ts | 4 +-
app/server/test/catalog-items.service.spec.ts | 100 +++++++++
app/server/test/connectors.spec.ts | 3 +-
app/server/test/imports.spec.ts | 101 ++++++++-
app/server/test/library.service.spec.ts | 94 +++++++-
app/server/test/notifications.spec.ts | 211 +++++++++++++++---
app/server/test/profiles.spec.ts | 51 ++++-
33 files changed, 1079 insertions(+), 197 deletions(-)
create mode 100644 app/frontend/test/review-editor.spec.tsx
create mode 100644 app/server/src/catalog/catalog-refresh.service.ts
diff --git a/app/frontend/src/components/LibraryEntryEditor.tsx b/app/frontend/src/components/LibraryEntryEditor.tsx
index e91ac04..96b1c27 100644
--- a/app/frontend/src/components/LibraryEntryEditor.tsx
+++ b/app/frontend/src/components/LibraryEntryEditor.tsx
@@ -88,16 +88,16 @@ function ProgressFields({
return (
<>
{units.includes('season') && (
-
+
)}
{units.includes('episode') && (
-
+
)}
{units.includes('chapter') && (
-
+
)}
{units.includes('volume') && (
-
+
)}
{units.includes('hours') && (
@@ -151,6 +151,9 @@ export function LibraryEntryEditor({
const [error, setError] = useState('');
const [removing, setRemoving] = useState(false);
const unavailableSources = entry.item.sources.filter((source) => !source.active);
+ const unavailablePreferredSource = unavailableSources.find(
+ (source) => source.key === entry.preferredSource,
+ );
const finished = entry.state === 'completed' || entry.state === 'dropped';
const needsPlatform =
entry.item.category === 'game' && entry.progress.platforms.length === 0;
@@ -273,6 +276,12 @@ export function LibraryEntryEditor({
)
)}
+ {entry.notificationsEnabled && unavailablePreferredSource && (
+
+ Paused while {unavailablePreferredSource.name} is unavailable. Choose another preferred
+ source or Automatic to get notifications from it instead.
+
+ )}
{entry.notificationsEnabled && emailsOff && (
Emails for {categoryLabels[entry.item.category].toLowerCase()} are off in{' '}
diff --git a/app/frontend/src/components/ReviewEditor.tsx b/app/frontend/src/components/ReviewEditor.tsx
index 6265242..e4e9a00 100644
--- a/app/frontend/src/components/ReviewEditor.tsx
+++ b/app/frontend/src/components/ReviewEditor.tsx
@@ -4,15 +4,14 @@ import { useAuth } from '../auth';
import { reviewBodyLimit, type OwnReview } from '../reviews';
import { ConfirmDialog } from './ConfirmDialog';
import { ReviewCard } from './ReviewCard';
+import { Toggle } from './Toggle';
export function ReviewEditor({
- defaultVisibility,
itemId,
onClose,
onSaved,
review,
}: {
- defaultVisibility: OwnReview['visibility'];
itemId: string;
onClose: () => void;
onSaved: (review: OwnReview | null) => void;
@@ -23,7 +22,7 @@ export function ReviewEditor({
const [title, setTitle] = useState(review?.title ?? '');
const [body, setBody] = useState(review?.body ?? '');
const [containsSpoilers, setContainsSpoilers] = useState(review?.containsSpoilers ?? false);
- const [visibility, setVisibility] = useState(review?.visibility ?? defaultVisibility);
+ const [visibility, setVisibility] = useState(review?.visibility ?? 'public');
const [preview, setPreview] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
@@ -98,18 +97,13 @@ export function ReviewEditor({
)}
-
-
- Visibility
- setVisibility(event.target.value as OwnReview['visibility'])}
- value={visibility}
- >
- Public
- Private, only you and administrators
-
-
-
+
+
setVisibility(checked ? 'private' : 'public')}
+ />
+
setContainsSpoilers(event.target.checked)}
diff --git a/app/frontend/src/components/TitleReviews.tsx b/app/frontend/src/components/TitleReviews.tsx
index 12dec09..5fbf2e4 100644
--- a/app/frontend/src/components/TitleReviews.tsx
+++ b/app/frontend/src/components/TitleReviews.tsx
@@ -12,10 +12,6 @@ import { LinesSkeleton, ListSkeleton, Skeleton } from './Skeleton';
const notInLibrary =
'Add this title to your library and finish or drop it to rate and review it.';
-interface Privacy {
- privacy: { isPublic: boolean; showReviews: boolean };
-}
-
function YourReview({
entry,
itemId,
@@ -27,7 +23,6 @@ function YourReview({
}) {
const own = useResource(`/items/${itemId}/review`, true);
const review = own.data;
- const me = useResource(review === null ? '/me' : null, true);
const [editing, setEditing] = useState(false);
const eligible = entry?.state === 'completed' || entry?.state === 'dropped';
@@ -46,9 +41,6 @@ function YourReview({
return (
setEditing(false)}
onSaved={(saved) => {
@@ -75,7 +67,6 @@ function YourReview({
)}
setEditing(true)}
type="button"
>
diff --git a/app/frontend/src/components/Toggle.tsx b/app/frontend/src/components/Toggle.tsx
index 7c73aa8..4d810ac 100644
--- a/app/frontend/src/components/Toggle.tsx
+++ b/app/frontend/src/components/Toggle.tsx
@@ -8,7 +8,7 @@ export function Toggle({
onChange,
}: {
checked: boolean;
- description: string;
+ description?: string;
disabled?: boolean;
label: string;
onChange: (checked: boolean) => void;
@@ -22,7 +22,7 @@ export function Toggle({
{label}
- {description}
+ {description && {description} }
, string, string]> = [
['showLibrary', 'Library', 'Every title with its list and progress.'],
['showActivity', 'Activity', 'Recent additions, list changes, ratings and reviews from the sections you show.'],
['showRatings', 'Ratings', 'Your scores, including in the library and statistics.'],
- ['showReviews', 'Reviews', 'Your public reviews. New reviews start public when this is on.'],
+ ['showReviews', 'Reviews', 'Your public reviews.'],
];
const settingsSections = [
diff --git a/app/frontend/test/review-editor.spec.tsx b/app/frontend/test/review-editor.spec.tsx
new file mode 100644
index 0000000..f358270
--- /dev/null
+++ b/app/frontend/test/review-editor.spec.tsx
@@ -0,0 +1,103 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { MemoryRouter } from 'react-router';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { AuthProvider } from '../src/auth';
+import { ReviewEditor } from '../src/components/ReviewEditor';
+import type { OwnReview } from '../src/reviews';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const json = (body: unknown) =>
+ new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
+
+const session = {
+ accessToken: 'token',
+ user: {
+ id: 'user-1',
+ email: 'reader@example.com',
+ handle: 'reader',
+ displayName: 'Reader',
+ role: 'member',
+ showAdultContent: false,
+ blurAdultContent: true,
+ },
+};
+
+describe('ReviewEditor', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+ let saved: Array>;
+
+ beforeEach(() => {
+ saved = [];
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((input: string, init?: RequestInit) => {
+ if (String(input).endsWith('/auth/refresh')) return Promise.resolve(json(session));
+ const body = JSON.parse(String(init?.body ?? '{}')) as Record;
+ saved.push(body);
+ return Promise.resolve(json({ id: 'review-id', ...body }));
+ }),
+ );
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ });
+
+ async function render(review: OwnReview | null) {
+ await act(async () =>
+ root.render(
+
+
+ undefined} onSaved={() => undefined} review={review} />
+
+ ,
+ ),
+ );
+ }
+
+ const privateSwitch = () => container.querySelector('button[role="switch"]');
+ const save = () =>
+ act(async () => {
+ container.querySelector('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ });
+
+ it('starts public with a Private switch and saves the choice', async () => {
+ await render(null);
+
+ expect(container.querySelector('select')).toBeNull();
+ expect(privateSwitch()?.getAttribute('aria-checked')).toBe('false');
+ expect(container.textContent).toContain('Private');
+ expect(container.textContent).not.toMatch(/administrator/i);
+
+ await save();
+ expect(saved.at(-1)).toMatchObject({ visibility: 'public' });
+
+ await act(async () => privateSwitch()?.click());
+ expect(privateSwitch()?.getAttribute('aria-checked')).toBe('true');
+ await save();
+ expect(saved.at(-1)).toMatchObject({ visibility: 'private' });
+ });
+
+ it('keeps an existing private review private', async () => {
+ await render({
+ id: 'review-id',
+ rating: 6,
+ title: null,
+ body: null,
+ containsSpoilers: false,
+ visibility: 'private',
+ hidden: false,
+ } as OwnReview);
+
+ expect(privateSwitch()?.getAttribute('aria-checked')).toBe('true');
+ });
+});
diff --git a/app/server/src/admin/admin.service.ts b/app/server/src/admin/admin.service.ts
index 9d11ac2..149e46e 100644
--- a/app/server/src/admin/admin.service.ts
+++ b/app/server/src/admin/admin.service.ts
@@ -63,7 +63,6 @@ export class AdminService {
async reports(status: 'open' | 'resolved', page: number) {
const where = {
resolution: status === 'open' ? null : { not: null },
- review: moderatedReviewWhere,
};
const [total, reports] = await this.prisma.$transaction([
this.prisma.reviewReport.count({ where }),
diff --git a/app/server/src/auth/auth.service.ts b/app/server/src/auth/auth.service.ts
index a86a018..48bf29d 100644
--- a/app/server/src/auth/auth.service.ts
+++ b/app/server/src/auth/auth.service.ts
@@ -3,6 +3,8 @@ import {
ConflictException,
ForbiddenException,
Injectable,
+ Logger,
+ ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@@ -10,7 +12,7 @@ import { JwtService } from '@nestjs/jwt';
import { Prisma, TokenPurpose, UserRole } from '@prisma/client';
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
-import { MailService } from '../mail/mail.service';
+import { MailMessage, MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
import { SiteSettingsService } from '../site/site-settings.service';
import { LoginDto } from './dto/login.dto';
@@ -25,14 +27,22 @@ const tokenLifetimesMs: Record = {
RESET_PASSWORD: 60 * 60 * 1000,
};
+const accountTokens = [TokenPurpose.CHANGE_EMAIL, TokenPurpose.RESET_PASSWORD];
+
function alreadyRegistered(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'
? new ConflictException('Email or handle is already registered')
: error;
}
+function emailUnavailable() {
+ return new ServiceUnavailableException('The email could not be sent. Try again later.');
+}
+
@Injectable()
export class AuthService {
+ private readonly logger = new Logger(AuthService.name);
+
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
@@ -56,7 +66,11 @@ export class AuthService {
},
select: { id: true, email: true, handle: true, displayName: true },
});
- await this.sendVerification(transaction, user, TokenPurpose.VERIFY_EMAIL, user.email);
+ if (
+ !(await this.sendVerification(transaction, user, TokenPurpose.VERIFY_EMAIL, user.email))
+ ) {
+ throw emailUnavailable();
+ }
return { user };
},
{ timeout: 30_000 },
@@ -91,7 +105,10 @@ export class AuthService {
data: { verifiedAt: new Date(), ...(record.email ? { email: record.email } : {}) },
}),
this.prisma.verificationToken.deleteMany({
- where: { userId: record.userId, purpose: record.purpose },
+ where: {
+ userId: record.userId,
+ purpose: { in: record.email ? accountTokens : [record.purpose] },
+ },
}),
]);
} catch (error) {
@@ -167,7 +184,7 @@ export class AuthService {
return;
}
const token = await this.createToken(this.prisma, user.id, TokenPurpose.RESET_PASSWORD);
- await this.mail.send({
+ await this.deliver({
to: user.email,
subject: 'Reset your Graphite Tracker password',
text: [
@@ -204,7 +221,7 @@ export class AuthService {
},
}),
this.prisma.verificationToken.deleteMany({
- where: { userId: record.userId, purpose: TokenPurpose.RESET_PASSWORD },
+ where: { userId: record.userId, purpose: { in: accountTokens } },
}),
this.prisma.refreshSession.updateMany({
where: { userId: record.userId, revokedAt: null },
@@ -221,7 +238,7 @@ export class AuthService {
data: { passwordHash: await this.hashPassword(newPassword) },
}),
this.prisma.verificationToken.deleteMany({
- where: { userId, purpose: TokenPurpose.RESET_PASSWORD },
+ where: { userId, purpose: { in: accountTokens } },
}),
this.prisma.refreshSession.updateMany({
where: { userId, revokedAt: null },
@@ -239,7 +256,22 @@ export class AuthService {
if (await this.prisma.user.findUnique({ where: { email }, select: { id: true } })) {
throw new ConflictException('Email or handle is already registered');
}
- await this.sendVerification(this.prisma, user, TokenPurpose.CHANGE_EMAIL, email);
+ if (!(await this.sendVerification(this.prisma, user, TokenPurpose.CHANGE_EMAIL, email))) {
+ throw emailUnavailable();
+ }
+ await this.deliver({
+ to: user.email,
+ subject: 'Your Graphite Tracker email is being changed',
+ text: [
+ `Hi ${user.displayName},`,
+ '',
+ 'Someone asked to move your Graphite Tracker account to another email address. Nothing changes until that address is confirmed.',
+ '',
+ 'If this was not you, reset your password now. That cancels the change:',
+ '',
+ this.mail.link('/forgot-password'),
+ ].join('\n'),
+ });
}
async confirmPassword(userId: string, password: string) {
@@ -306,7 +338,7 @@ export class AuthService {
) {
const changing = purpose === TokenPurpose.CHANGE_EMAIL;
const token = await this.createToken(client, user.id, purpose, changing ? to : null);
- await this.mail.send({
+ return this.deliver({
to,
subject: changing
? 'Confirm your new Graphite Tracker email'
@@ -325,6 +357,18 @@ export class AuthService {
});
}
+ private async deliver(message: MailMessage) {
+ try {
+ await this.mail.send(message);
+ return true;
+ } catch (error) {
+ this.logger.warn(
+ `"${message.subject}" was not sent: ${error instanceof Error ? error.message : 'unknown error'}`,
+ );
+ return false;
+ }
+ }
+
private async createToken(
client: Prisma.TransactionClient,
userId: string,
diff --git a/app/server/src/catalog/catalog-items.service.ts b/app/server/src/catalog/catalog-items.service.ts
index 677d9f5..a6bf1ab 100644
--- a/app/server/src/catalog/catalog-items.service.ts
+++ b/app/server/src/catalog/catalog-items.service.ts
@@ -20,10 +20,14 @@ export class CatalogItemsService {
transaction: Prisma.TransactionClient,
candidate: CatalogCandidate,
sourceId: string,
+ { partial = false }: { partial?: boolean } = {},
) {
const existing = await transaction.sourceEntry.findUnique({
where: { sourceId_externalId: { sourceId, externalId: candidate.externalId } },
});
+ if (existing && partial) {
+ return transaction.catalogItem.findUniqueOrThrow({ where: { id: existing.catalogItemId } });
+ }
if (existing) {
return transaction.catalogItem.update({
where: { id: existing.catalogItemId },
@@ -56,6 +60,7 @@ export class CatalogItemsService {
sourceId,
externalId: candidate.externalId,
...this.sourceData(candidate),
+ ...(partial ? { lastRefreshedAt: null } : {}),
};
if (sameWork.length === 1 && sameWork[0]) {
return transaction.catalogItem.update({
diff --git a/app/server/src/catalog/catalog-refresh.service.ts b/app/server/src/catalog/catalog-refresh.service.ts
new file mode 100644
index 0000000..b7d3bd2
--- /dev/null
+++ b/app/server/src/catalog/catalog-refresh.service.ts
@@ -0,0 +1,59 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.service';
+import { withLowPriority } from '../sources/connector-http.service';
+import { ConnectorRegistryService } from '../sources/connector-registry.service';
+import { CatalogCategory } from '../sources/source.types';
+import { CatalogItemsService } from './catalog-items.service';
+
+const refreshAfterMs = 7 * 24 * 60 * 60 * 1000;
+const batchSize = 25;
+
+@Injectable()
+export class CatalogRefreshService {
+ private readonly logger = new Logger(CatalogRefreshService.name);
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly connectors: ConnectorRegistryService,
+ private readonly catalogItems: CatalogItemsService,
+ ) {}
+
+ async refreshDue(now = new Date()) {
+ const due = await this.prisma.sourceEntry.findMany({
+ where: {
+ source: { enabled: true },
+ catalogItem: { libraryEntries: { some: {} } },
+ OR: [
+ { lastRefreshedAt: null },
+ { lastRefreshedAt: { lt: new Date(now.getTime() - refreshAfterMs) } },
+ ],
+ },
+ include: { source: true, catalogItem: { select: { category: true } } },
+ orderBy: { lastRefreshedAt: { sort: 'asc', nulls: 'first' } },
+ take: batchSize,
+ });
+ for (const entry of due) {
+ try {
+ const details = await withLowPriority(() =>
+ this.connectors.details(
+ entry.catalogItem.category.toLowerCase() as CatalogCategory,
+ entry.externalId,
+ entry.source.key,
+ true,
+ ),
+ );
+ await this.prisma.$transaction((transaction) =>
+ this.catalogItems.upsert(transaction, details, entry.sourceId),
+ );
+ } catch (error) {
+ this.logger.warn(
+ `Refreshing ${entry.source.key}:${entry.externalId} failed: ${error instanceof Error ? error.message : 'unknown error'}`,
+ );
+ await this.prisma.sourceEntry.update({
+ where: { id: entry.id },
+ data: { lastRefreshedAt: now },
+ });
+ }
+ }
+ }
+}
diff --git a/app/server/src/catalog/catalog.module.ts b/app/server/src/catalog/catalog.module.ts
index 85b82d1..5182ca8 100644
--- a/app/server/src/catalog/catalog.module.ts
+++ b/app/server/src/catalog/catalog.module.ts
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SourcesModule } from '../sources/sources.module';
import { CatalogItemsService } from './catalog-items.service';
+import { CatalogRefreshService } from './catalog-refresh.service';
import { CatalogController } from './catalog.controller';
@Module({
imports: [AuthModule, SourcesModule],
controllers: [CatalogController],
- providers: [CatalogItemsService],
- exports: [CatalogItemsService],
+ providers: [CatalogItemsService, CatalogRefreshService],
+ exports: [CatalogItemsService, CatalogRefreshService],
})
export class CatalogModule {}
diff --git a/app/server/src/imports/imports.controller.ts b/app/server/src/imports/imports.controller.ts
index 93f4e9b..8415610 100644
--- a/app/server/src/imports/imports.controller.ts
+++ b/app/server/src/imports/imports.controller.ts
@@ -14,14 +14,13 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
-import { tmpdir } from 'node:os';
import { AuthenticatedUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RateLimit } from '../redis/rate-limit.guard';
import { maxBackupBytes } from './backup-parser';
import { ApplyImportDto, DecideCandidateDto, ListCandidatesDto } from './dto/import.dto';
-import { ImportsService } from './imports.service';
+import { importUploadDirectory, ImportsService } from './imports.service';
import { UuidPipe } from '../validation/uuid.pipe';
@Controller('imports')
@@ -38,7 +37,7 @@ export class ImportsController {
@RateLimit('imports', 10, 3_600)
@UseInterceptors(
FileInterceptor('file', {
- dest: tmpdir(),
+ dest: importUploadDirectory,
limits: { fileSize: maxBackupBytes, files: 1 },
}),
)
diff --git a/app/server/src/imports/imports.service.ts b/app/server/src/imports/imports.service.ts
index ed10b12..c299c52 100644
--- a/app/server/src/imports/imports.service.ts
+++ b/app/server/src/imports/imports.service.ts
@@ -16,7 +16,8 @@ import {
} from '@prisma/client';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
-import { rm } from 'node:fs/promises';
+import { mkdir, readdir, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { CatalogItemsService } from '../catalog/catalog-items.service';
@@ -33,6 +34,9 @@ const retentionMs = 7 * 24 * 60 * 60 * 1000;
const parseTimeoutMs = 60_000;
const candidatePageSize = 50;
const primaryCategories: Record = { manga: 'manga', anime: 'anime' };
+const workingStates: ImportState[] = [ImportState.PARSING, ImportState.MATCHING, ImportState.APPLYING];
+
+export const importUploadDirectory = join(tmpdir(), 'graphite-tracker-imports');
type Candidate = Prisma.ImportCandidateGetPayload;
@@ -53,6 +57,10 @@ export class ImportsService implements OnModuleInit, OnModuleDestroy {
) {}
async onModuleInit() {
+ await mkdir(importUploadDirectory, { recursive: true });
+ for (const name of await readdir(importUploadDirectory)) {
+ await rm(join(importUploadDirectory, name), { force: true, recursive: true });
+ }
await this.prisma.importBatch.updateMany({
where: { state: ImportState.PARSING },
data: {
@@ -210,6 +218,7 @@ export class ImportsService implements OnModuleInit, OnModuleDestroy {
title: item.title,
releaseDate: item.releaseDate,
posterUrl: item.posterUrl,
+ adult: item.adult,
})),
existing:
(option &&
@@ -283,7 +292,10 @@ export class ImportsService implements OnModuleInit, OnModuleDestroy {
}
async remove(userId: string, id: string) {
- await this.owned(userId, id);
+ const batch = await this.owned(userId, id);
+ if (workingStates.includes(batch.state)) {
+ throw new BadRequestException('Wait until this import finishes before deleting it');
+ }
await this.prisma.importBatch.delete({ where: { id } });
}
@@ -479,7 +491,9 @@ export class ImportsService implements OnModuleInit, OnModuleDestroy {
sourceId = (await this.catalogItems.sourceRecord(transaction, descriptor)).id;
sourceIds.set(descriptor.key, sourceId);
}
- const item = await this.catalogItems.upsert(transaction, option.item, sourceId);
+ const item = await this.catalogItems.upsert(transaction, option.item, sourceId, {
+ partial: true,
+ });
const outcome = applied.has(item.id)
? 'duplicate'
: await this.applyCandidate(transaction, {
@@ -652,7 +666,7 @@ export class ImportsService implements OnModuleInit, OnModuleDestroy {
await this.prisma.importBatch.deleteMany({
where: {
expiresAt: { lt: new Date() },
- state: { notIn: [ImportState.PARSING, ImportState.MATCHING, ImportState.APPLYING] },
+ state: { notIn: workingStates },
},
});
}
diff --git a/app/server/src/library/effective-source.ts b/app/server/src/library/effective-source.ts
index 921bc0a..c5f7157 100644
--- a/app/server/src/library/effective-source.ts
+++ b/app/server/src/library/effective-source.ts
@@ -40,3 +40,14 @@ export function effectiveSourceEntry entry !== undefined) ?? active[0]
);
}
+
+export function releaseSourceEntry(
+ sourceEntries: T[],
+ preferredSourceId: string | null,
+ category: MediaCategory,
+ preferences: SourcePreferences,
+) {
+ return preferredSourceId
+ ? sourceEntries.find((entry) => entry.sourceId === preferredSourceId && entry.source.enabled)
+ : effectiveSourceEntry(sourceEntries, null, category, preferences);
+}
diff --git a/app/server/src/library/library.service.ts b/app/server/src/library/library.service.ts
index 57fd349..0a6e672 100644
--- a/app/server/src/library/library.service.ts
+++ b/app/server/src/library/library.service.ts
@@ -133,7 +133,7 @@ export class LibraryService {
adult,
);
const state = libraryStates[input.state];
- const entry = await this.prisma.$transaction(async (transaction) => {
+ const add = () => this.prisma.$transaction(async (transaction) => {
const source = await this.catalogItems.sourceRecord(transaction, connector.descriptor);
const item = await this.catalogItems.upsert(transaction, details, source.id);
const existing = await transaction.libraryEntry.findUnique({
@@ -159,6 +159,12 @@ export class LibraryService {
})
);
});
+ const entry = await add().catch((error: unknown) => {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
+ return add();
+ }
+ throw error;
+ });
return this.present(entry, await loadSourcePreferences(this.prisma, userId));
}
@@ -196,6 +202,9 @@ export class LibraryService {
: undefined;
const finished =
nextState === LibraryState.COMPLETED || nextState === LibraryState.DROPPED;
+ const withoutPlatforms =
+ current.catalogItem.category === MediaCategory.GAME &&
+ (input.platforms ?? current.platforms).length === 0;
if (input.notificationsEnabled && finished) {
throw new BadRequestException(
'Notifications require a planned or in-progress state',
@@ -214,11 +223,7 @@ export class LibraryService {
'Release notifications are only for titles that are still coming out',
);
}
- if (
- input.notificationsEnabled &&
- current.catalogItem.category === MediaCategory.GAME &&
- (input.platforms ?? current.platforms).length === 0
- ) {
+ if (input.notificationsEnabled && withoutPlatforms) {
throw new BadRequestException(
'Select at least one game platform before enabling notifications',
);
@@ -234,7 +239,7 @@ export class LibraryService {
hoursPlayed: input.hoursPlayed,
completionPercentage: input.completionPercentage,
platforms: input.platforms,
- notificationsEnabled: finished ? false : input.notificationsEnabled,
+ notificationsEnabled: finished || withoutPlatforms ? false : input.notificationsEnabled,
isPrivate: input.isPrivate,
preferredSourceId,
startedAt:
diff --git a/app/server/src/mail/mail.service.ts b/app/server/src/mail/mail.service.ts
index 838bb14..4fc64ff 100644
--- a/app/server/src/mail/mail.service.ts
+++ b/app/server/src/mail/mail.service.ts
@@ -9,6 +9,26 @@ export interface MailMessage {
headers?: Record;
}
+export class MailDeliveryError extends Error {
+ readonly code?: string;
+ readonly responseCode?: number;
+ readonly command?: string;
+
+ constructor(cause: unknown) {
+ const { code, responseCode, command } = (cause ?? {}) as Record;
+ const details = [
+ typeof code === 'string' ? code : undefined,
+ typeof responseCode === 'number' ? `SMTP ${responseCode}` : undefined,
+ typeof command === 'string' ? command : undefined,
+ ].filter((detail) => detail !== undefined);
+ super(`Email delivery failed (${details.join(', ') || 'unknown error'})`);
+ this.name = 'MailDeliveryError';
+ this.code = typeof code === 'string' ? code : undefined;
+ this.responseCode = typeof responseCode === 'number' ? responseCode : undefined;
+ this.command = typeof command === 'string' ? command : undefined;
+ }
+}
+
@Injectable()
export class MailService {
private transport?: Transporter;
@@ -28,10 +48,14 @@ export class MailService {
throw new ServiceUnavailableException('Email delivery is not configured');
}
this.transport ??= this.createTransport();
- await this.transport.sendMail({
- from: this.config.getOrThrow('DEFAULT_FROM_EMAIL'),
- ...message,
- });
+ try {
+ await this.transport.sendMail({
+ from: this.config.getOrThrow('DEFAULT_FROM_EMAIL'),
+ ...message,
+ });
+ } catch (error) {
+ throw new MailDeliveryError(error);
+ }
}
private createTransport() {
diff --git a/app/server/src/main.ts b/app/server/src/main.ts
index b62babf..1cf678a 100644
--- a/app/server/src/main.ts
+++ b/app/server/src/main.ts
@@ -3,13 +3,23 @@ import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
-import type { Response } from 'express';
+import type { NextFunction, Request, Response } from 'express';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.set('trust proxy', 1);
+ app.disable('x-powered-by');
app.setGlobalPrefix('api/v1');
+ app.use((request: Request, response: Response, next: NextFunction) => {
+ response.set({
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Frame-Options': 'DENY',
+ ...(request.secure ? { 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains' } : {}),
+ });
+ next();
+ });
app.use(cookieParser());
app.enableShutdownHooks();
app.useGlobalPipes(
diff --git a/app/server/src/notifications/digest.service.ts b/app/server/src/notifications/digest.service.ts
index 3c858ac..f1a1c6e 100644
--- a/app/server/src/notifications/digest.service.ts
+++ b/app/server/src/notifications/digest.service.ts
@@ -1,13 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import { MediaCategory, NotificationState, Prisma } from '@prisma/client';
import { loadSourcePreferences } from '../library/effective-source';
-import { MailService } from '../mail/mail.service';
+import { MailDeliveryError, MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
import { categoryNames, digestDue, subscriptionInclude, wantsRelease } from './subscriptions';
import { UnsubscribeTokensService } from './unsubscribe-tokens.service';
const maxDeliveryFailures = 3;
const retentionMs = 90 * 24 * 60 * 60 * 1000;
+const interruptedSendMs = 60 * 60 * 1000;
const digestEventInclude = Prisma.validator()({
releaseMarker: { include: { sourceEntry: { include: { source: true } } } },
@@ -16,14 +17,17 @@ const digestEventInclude = Prisma.validator()({
type DigestEvent = Prisma.NotificationEventGetPayload<{ include: typeof digestEventInclude }>;
-function recipientFailure(error: unknown) {
- const { code, responseCode } = (error ?? {}) as { code?: unknown; responseCode?: unknown };
- if (code !== 'EENVELOPE') {
+function readerFailure(error: unknown) {
+ if (!(error instanceof MailDeliveryError)) {
return null;
}
- return typeof responseCode === 'number'
- ? { permanent: responseCode >= 500, reason: `SMTP ${responseCode}` }
- : { permanent: true, reason: 'Recipient address refused' };
+ const refusedRecipient = error.code === 'EENVELOPE' && error.command === 'RCPT TO';
+ if (!refusedRecipient && error.code !== 'EMESSAGE') {
+ return null;
+ }
+ return error.responseCode === undefined
+ ? { permanent: true, reason: refusedRecipient ? 'Recipient address refused' : 'Message refused' }
+ : { permanent: error.responseCode >= 500, reason: `SMTP ${error.responseCode}` };
}
@Injectable()
@@ -40,6 +44,14 @@ export class DigestService {
if (!this.mail.configured) {
return;
}
+ await this.prisma.$executeRaw`
+ UPDATE notification_events AS event
+ SET state = 'queued', sent_at = NULL
+ FROM notification_preferences AS preference
+ WHERE preference.user_id = event.user_id
+ AND event.state = 'sent'
+ AND event.sent_at < ${new Date(now.getTime() - interruptedSendMs)}
+ AND (preference.last_digest_at IS NULL OR preference.last_digest_at < event.sent_at)`;
const pending = await this.prisma.notificationEvent.findMany({
where: { state: NotificationState.QUEUED },
distinct: ['userId'],
@@ -80,30 +92,39 @@ export class DigestService {
const wanted = preferences
? events.filter((event) => wantsRelease(event.libraryEntry, event.releaseMarker, preferences))
: [];
- const wantedIds = wanted.map(({ id }) => id);
+ const wantedIds = new Set(wanted.map(({ id }) => id));
await this.prisma.notificationEvent.updateMany({
- where: { userId, state: NotificationState.QUEUED, id: { notIn: wantedIds } },
+ where: {
+ id: { in: events.filter(({ id }) => !wantedIds.has(id)).map(({ id }) => id) },
+ state: NotificationState.QUEUED,
+ },
data: { state: NotificationState.SKIPPED },
});
if (!preference || wanted.length === 0) {
return true;
}
- const claimed = await this.prisma.notificationEvent.updateMany({
- where: { id: { in: wantedIds }, state: NotificationState.QUEUED },
- data: { state: NotificationState.SENT, sentAt: now },
- });
- if (claimed.count !== wanted.length) {
+ const claimed = new Set(
+ (
+ await this.prisma.notificationEvent.updateManyAndReturn({
+ where: { id: { in: [...wantedIds] }, state: NotificationState.QUEUED },
+ data: { state: NotificationState.SENT, sentAt: now },
+ select: { id: true },
+ })
+ ).map(({ id }) => id),
+ );
+ const sending = wanted.filter(({ id }) => claimed.has(id));
+ if (sending.length === 0) {
return true;
}
try {
- await this.mail.send(this.message(user, wanted));
+ await this.mail.send(this.message(user, sending));
} catch (error) {
- const refused = recipientFailure(error);
+ const refused = readerFailure(error);
const failures = preference.deliveryFailures + 1;
const suspend = refused?.permanent === true && failures >= maxDeliveryFailures;
await this.prisma.$transaction([
this.prisma.notificationEvent.updateMany({
- where: { id: { in: wantedIds } },
+ where: { id: { in: [...claimed] } },
data:
suspend && refused
? { state: NotificationState.FAILED, sentAt: null, error: refused.reason }
@@ -120,7 +141,7 @@ export class DigestService {
]);
if (!refused) {
this.logger.warn(
- `Digest delivery is unavailable (${(error as { code?: string }).code ?? 'unknown error'}); retrying on the next run`,
+ `Digest delivery is unavailable (${error instanceof Error ? error.message : 'unknown error'}); retrying on the next run`,
);
return false;
}
diff --git a/app/server/src/notifications/notifications.module.ts b/app/server/src/notifications/notifications.module.ts
index f13ae41..858c29d 100644
--- a/app/server/src/notifications/notifications.module.ts
+++ b/app/server/src/notifications/notifications.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthModule } from '../auth/auth.module';
+import { CatalogModule } from '../catalog/catalog.module';
import { SourcesModule } from '../sources/sources.module';
import { DigestService } from './digest.service';
import { InboxController } from './inbox.controller';
@@ -12,7 +13,7 @@ import { ReleaseMonitorService } from './release-monitor.service';
import { UnsubscribeTokensService } from './unsubscribe-tokens.service';
@Module({
- imports: [AuthModule, SourcesModule, JwtModule.register({})],
+ imports: [AuthModule, CatalogModule, SourcesModule, JwtModule.register({})],
controllers: [InboxController, NotificationsController],
providers: [
DigestService,
diff --git a/app/server/src/notifications/notifications.scheduler.ts b/app/server/src/notifications/notifications.scheduler.ts
index 3239bfd..e876f3d 100644
--- a/app/server/src/notifications/notifications.scheduler.ts
+++ b/app/server/src/notifications/notifications.scheduler.ts
@@ -1,4 +1,5 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
+import { CatalogRefreshService } from '../catalog/catalog-refresh.service';
import { DigestService } from './digest.service';
import { InboxService } from './inbox.service';
import { ReleaseMonitorService } from './release-monitor.service';
@@ -12,6 +13,7 @@ export class NotificationsScheduler implements OnModuleInit, OnModuleDestroy {
private running = false;
constructor(
+ private readonly catalog: CatalogRefreshService,
private readonly monitor: ReleaseMonitorService,
private readonly digests: DigestService,
private readonly inbox: InboxService,
@@ -31,6 +33,7 @@ export class NotificationsScheduler implements OnModuleInit, OnModuleDestroy {
if (this.running) return;
this.running = true;
try {
+ await this.catalog.refreshDue();
await this.monitor.refreshDue();
await this.digests.sendDue();
await this.digests.removeOld();
diff --git a/app/server/src/notifications/notifications.service.ts b/app/server/src/notifications/notifications.service.ts
index 03873ec..42e1627 100644
--- a/app/server/src/notifications/notifications.service.ts
+++ b/app/server/src/notifications/notifications.service.ts
@@ -51,18 +51,20 @@ export class NotificationsService {
});
return { scope: claim.scope, title: entry?.catalogItem.canonicalTitle ?? null };
}
- const preference = await this.prisma.notificationPreference.upsert({
- where: { userId: claim.sub },
- update: {},
- create: { userId: claim.sub },
- });
- await this.prisma.notificationPreference.update({
- where: { userId: claim.sub },
- data:
- claim.scope === 'category'
- ? { categories: preference.categories.filter((category) => category !== claim.category) }
- : { enabled: false },
- });
+ if (await this.prisma.user.findUnique({ where: { id: claim.sub }, select: { id: true } })) {
+ const preference = await this.prisma.notificationPreference.upsert({
+ where: { userId: claim.sub },
+ update: {},
+ create: { userId: claim.sub },
+ });
+ await this.prisma.notificationPreference.update({
+ where: { userId: claim.sub },
+ data:
+ claim.scope === 'category'
+ ? { categories: preference.categories.filter((category) => category !== claim.category) }
+ : { enabled: false },
+ });
+ }
return claim.scope === 'category'
? { scope: claim.scope, category: claim.category.toLowerCase() }
: { scope: claim.scope };
diff --git a/app/server/src/notifications/release-monitor.service.ts b/app/server/src/notifications/release-monitor.service.ts
index bcf58ae..49c861d 100644
--- a/app/server/src/notifications/release-monitor.service.ts
+++ b/app/server/src/notifications/release-monitor.service.ts
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
+import { randomUUID } from 'node:crypto';
import { loadSourcePreferences, SourcePreferences } from '../library/effective-source';
import { PrismaService } from '../prisma/prisma.service';
import { withLowPriority } from '../sources/connector-http.service';
@@ -87,30 +88,41 @@ export class ReleaseMonitorService {
where: { sourceEntryId: entry.id },
select: { key: true, kind: true, ordinal: true },
});
- const [markers] = await this.prisma.$transaction([
- this.prisma.releaseMarker.createManyAndReturn({
- data: newSignals(signals, existing).map((signal) => ({
- sourceEntryId: entry.id,
- key: signal.key,
- kind: releaseKinds[signal.kind],
- label: signal.label,
- platform: signal.platform,
- ordinal: signal.ordinal,
- occurredAt: new Date(`${signal.occurredAt}T00:00:00.000Z`),
- })),
- skipDuplicates: true,
- }),
- this.prisma.sourceEntry.update({
- where: { id: entry.id },
- data: { releasesCheckedAt: now },
- }),
- ]);
- if (!baseline && markers.length > 0) {
- await this.queue(entry.catalogItemId, markers);
+ const markers = newSignals(signals, existing).map((signal) => ({
+ id: randomUUID(),
+ sourceEntryId: entry.id,
+ key: signal.key,
+ kind: releaseKinds[signal.kind],
+ label: signal.label,
+ platform: signal.platform ?? null,
+ ordinal: signal.ordinal,
+ occurredAt: new Date(`${signal.occurredAt}T00:00:00.000Z`),
+ }));
+ const { inbox, events } =
+ !baseline && markers.length > 0
+ ? await this.recipients(entry.catalogItemId, markers)
+ : { inbox: [], events: [] };
+ try {
+ await this.prisma.$transaction([
+ this.prisma.releaseMarker.createMany({ data: markers }),
+ this.prisma.sourceEntry.update({
+ where: { id: entry.id },
+ data: { releasesCheckedAt: now },
+ }),
+ this.prisma.inboxNotification.createMany({ data: inbox, skipDuplicates: true }),
+ this.prisma.notificationEvent.createMany({ data: events, skipDuplicates: true }),
+ ]);
+ } catch (error) {
+ if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')) {
+ throw error;
+ }
+ this.logger.warn(
+ `Releases for ${entry.source.key}:${entry.externalId} were recorded by another run`,
+ );
}
}
- private async queue(
+ private async recipients(
catalogItemId: string,
markers: Array<{ id: string; sourceEntryId: string; platform: string | null }>,
) {
@@ -143,13 +155,9 @@ export class ReleaseMonitorService {
}
return rows;
};
- await this.prisma.inboxNotification.createMany({
- data: await releasesFor(followers, followsRelease),
- skipDuplicates: true,
- });
- await this.prisma.notificationEvent.createMany({
- data: await releasesFor(subscribers, wantsRelease),
- skipDuplicates: true,
- });
+ return {
+ inbox: await releasesFor(followers, followsRelease),
+ events: await releasesFor(subscribers, wantsRelease),
+ };
}
}
diff --git a/app/server/src/notifications/subscriptions.ts b/app/server/src/notifications/subscriptions.ts
index dfd243e..cd42113 100644
--- a/app/server/src/notifications/subscriptions.ts
+++ b/app/server/src/notifications/subscriptions.ts
@@ -1,5 +1,5 @@
import { DigestCadence, LibraryState, MediaCategory, Prisma, ReleaseKind } from '@prisma/client';
-import { effectiveSourceEntry, SourcePreferences } from '../library/effective-source';
+import { releaseSourceEntry, SourcePreferences } from '../library/effective-source';
import { ReleaseSignal } from '../sources/source.types';
export const followedEntryWhere = {
@@ -39,7 +39,7 @@ export function followsRelease(
return (
entry.notificationsEnabled &&
(entry.state === LibraryState.PLANNED || entry.state === LibraryState.IN_PROGRESS) &&
- effectiveSourceEntry(sourceEntries, entry.preferredSourceId, category, preferences)?.id ===
+ releaseSourceEntry(sourceEntries, entry.preferredSourceId, category, preferences)?.id ===
marker.sourceEntryId &&
(!marker.platform || entry.platforms.includes(marker.platform))
);
@@ -65,12 +65,14 @@ export function newSignals(
for (const { kind, ordinal } of existing) {
if (ordinal !== null) highest.set(kind, Math.max(highest.get(kind) ?? ordinal, ordinal));
}
- return signals.filter(
- (signal) =>
+ return signals.filter((signal) => {
+ const fresh =
!keys.has(signal.key) &&
(signal.ordinal === undefined ||
- signal.ordinal > (highest.get(releaseKinds[signal.kind]) ?? Number.NEGATIVE_INFINITY)),
- );
+ signal.ordinal > (highest.get(releaseKinds[signal.kind]) ?? Number.NEGATIVE_INFINITY));
+ keys.add(signal.key);
+ return fresh;
+ });
}
const digestHour = 8;
diff --git a/app/server/src/sources/igdb/igdb.service.ts b/app/server/src/sources/igdb/igdb.service.ts
index d92cfc2..e9d7542 100644
--- a/app/server/src/sources/igdb/igdb.service.ts
+++ b/app/server/src/sources/igdb/igdb.service.ts
@@ -296,17 +296,16 @@ export class IgdbService {
[],
),
platforms: game.platforms?.map((platform) => platform.name) ?? [],
- releaseDates:
- game.release_dates?.flatMap((release) =>
- release.date
- ? [
- {
- date: new Date(release.date * 1000).toISOString().slice(0, 10),
- platform: release.platform?.name ?? null,
- },
- ]
- : [],
- ) ?? [],
+ releaseDates: [
+ ...new Map(
+ (game.release_dates ?? []).flatMap((release) => {
+ if (!release.date) return [];
+ const date = new Date(release.date * 1000).toISOString().slice(0, 10);
+ const platform = release.platform?.name ?? null;
+ return [[`${date}:${platform}`, { date, platform }] as const];
+ }),
+ ).values(),
+ ],
relationships,
deepLinks: game.url ? [{ label: 'View on IGDB', url: game.url }] : [],
capabilities: {
diff --git a/app/server/src/users/profiles.service.ts b/app/server/src/users/profiles.service.ts
index 54b7a7e..b09b8d0 100644
--- a/app/server/src/users/profiles.service.ts
+++ b/app/server/src/users/profiles.service.ts
@@ -1,5 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
-import { ActivityKind, Prisma } from '@prisma/client';
+import { ActivityKind, Prisma, ReviewVisibility } from '@prisma/client';
import {
catalogItemSummary,
catalogItemSummaryInclude,
@@ -32,6 +32,14 @@ function withoutPrivateEntries(userId: string, admin: boolean) {
return admin ? {} : { catalogItem: { libraryEntries: { none: { userId, isPrivate: true } } } };
}
+function shownRatingWhere(admin: boolean) {
+ return {
+ rating: { not: null },
+ hiddenAt: null,
+ ...(admin ? {} : { visibility: ReviewVisibility.PUBLIC }),
+ } satisfies Prisma.ReviewWhereInput;
+}
+
function paged(page: number, total: number, results: T[]) {
return {
page,
@@ -82,7 +90,7 @@ export class ProfilesService {
? [{ kind: { in: [ActivityKind.ADDED, ActivityKind.STATE_CHANGED] } }]
: []),
...(admin || privacy.showRatings
- ? [{ kind: ActivityKind.RATED, review: { is: { rating: { not: null }, hiddenAt: null } } }]
+ ? [{ kind: ActivityKind.RATED, review: { is: shownRatingWhere(admin) } }]
: []),
...(admin || privacy.showReviews
? [{ kind: ActivityKind.REVIEWED, review: { is: admin ? readableReviewWhere(viewer) : publicReviewWhere } }]
@@ -135,7 +143,7 @@ export class ProfilesService {
include: {
...catalogItemSummaryInclude,
reviews: {
- where: { userId: user.id, rating: { not: null }, hiddenAt: null },
+ where: { userId: user.id, ...shownRatingWhere(admin) },
select: { rating: true },
},
},
@@ -172,8 +180,7 @@ export class ProfilesService {
const { user, admin } = await this.section(handle, 'showRatings', viewer);
const where = {
userId: user.id,
- rating: { not: null },
- hiddenAt: null,
+ ...shownRatingWhere(admin),
...withoutPrivateEntries(user.id, admin),
};
const [total, reviews] = await this.prisma.$transaction([
@@ -243,8 +250,7 @@ export class ProfilesService {
? this.prisma.review.aggregate({
where: {
userId,
- rating: { not: null },
- hiddenAt: null,
+ ...shownRatingWhere(admin),
...withoutPrivateEntries(userId, admin),
},
_avg: { rating: true },
diff --git a/app/server/test/account.spec.ts b/app/server/test/account.spec.ts
index 049c8cb..81ddda2 100644
--- a/app/server/test/account.spec.ts
+++ b/app/server/test/account.spec.ts
@@ -1,6 +1,7 @@
import {
ConflictException,
ForbiddenException,
+ Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
@@ -12,7 +13,7 @@ import { validate } from 'class-validator';
import { createHash, randomBytes, scryptSync } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import { AuthService } from '../src/auth/auth.service';
-import { MailService } from '../src/mail/mail.service';
+import { MailDeliveryError, MailService } from '../src/mail/mail.service';
import { UpdateProfileDto } from '../src/users/dto/users.dto';
const sendMail = vi.fn();
@@ -70,11 +71,21 @@ function setup(overrides: Record = {}) {
return { mail, prisma, service };
}
-function sentToken(mail: { send: ReturnType }) {
- const [message] = mail.send.mock.calls.at(-1) as [{ text: string }];
+function sentToken(mail: { send: ReturnType }, to?: string) {
+ const [message] = (
+ to ? mail.send.mock.calls.find(([sent]) => (sent as { to: string }).to === to) : mail.send.mock.calls.at(-1)
+ ) as [{ text: string }];
return /token=([0-9a-f]{64})/.exec(message.text)?.[1] ?? '';
}
+const refusedRecipient = () =>
+ new MailDeliveryError({
+ code: 'EENVELOPE',
+ responseCode: 550,
+ command: 'RCPT TO',
+ message: `Recipient command failed: 550 5.1.1 <${user.email}>: Recipient address rejected`,
+ });
+
describe('Account email flows', () => {
it('sends the verification link inside the registration transaction and never returns it', async () => {
const { mail, prisma, service } = setup();
@@ -102,6 +113,7 @@ describe('Account email flows', () => {
it('fails registration when the verification email cannot be sent', async () => {
const { mail, service } = setup();
+ const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
mail.send.mockRejectedValue(new ServiceUnavailableException('Email delivery is not configured'));
await expect(
@@ -112,9 +124,10 @@ describe('Account email flows', () => {
password: 'correct horse battery',
}),
).rejects.toBeInstanceOf(ServiceUnavailableException);
+ warn.mockRestore();
});
- it('applies a confirmed email change and refuses reset links as verification', async () => {
+ it('applies a confirmed email change, cancels reset links to the old address and refuses reset links as verification', async () => {
const { prisma, service } = setup();
prisma.verificationToken.findUnique.mockResolvedValueOnce({
userId: user.id,
@@ -130,7 +143,10 @@ describe('Account email flows', () => {
data: { verifiedAt: expect.any(Date), email: 'new@example.com' },
});
expect(prisma.verificationToken.deleteMany).toHaveBeenCalledWith({
- where: { userId: user.id, purpose: TokenPurpose.CHANGE_EMAIL },
+ where: {
+ userId: user.id,
+ purpose: { in: [TokenPurpose.CHANGE_EMAIL, TokenPurpose.RESET_PASSWORD] },
+ },
});
prisma.verificationToken.findUnique.mockResolvedValueOnce({
@@ -142,6 +158,35 @@ describe('Account email flows', () => {
await expect(service.verifyEmail('b'.repeat(64))).rejects.toBeInstanceOf(UnauthorizedException);
});
+ it('removes only verification links when a new account confirms its address', async () => {
+ const { prisma, service } = setup();
+ prisma.verificationToken.findUnique.mockResolvedValueOnce({
+ userId: user.id,
+ purpose: TokenPurpose.VERIFY_EMAIL,
+ email: null,
+ expiresAt: new Date(Date.now() + 60_000),
+ });
+
+ await service.verifyEmail('e'.repeat(64));
+
+ expect(prisma.verificationToken.deleteMany).toHaveBeenCalledWith({
+ where: { userId: user.id, purpose: { in: [TokenPurpose.VERIFY_EMAIL] } },
+ });
+ });
+
+ it('answers a reset request the same way when the email cannot be sent, without logging the address', async () => {
+ const { mail, prisma, service } = setup();
+ const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
+ prisma.user.findUnique.mockResolvedValueOnce(user);
+ mail.send.mockRejectedValueOnce(refusedRecipient());
+
+ await expect(service.requestPasswordReset(user.email)).resolves.toBeUndefined();
+
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('SMTP 550'));
+ expect(JSON.stringify(warn.mock.calls)).not.toContain(user.email);
+ warn.mockRestore();
+ });
+
it('sends reset links only to existing active accounts and says nothing either way', async () => {
const { mail, prisma, service } = setup();
@@ -158,7 +203,7 @@ describe('Account email flows', () => {
});
});
- it('resets the password, confirms the address and signs every device out', async () => {
+ it('resets the password, confirms the address, cancels pending email changes and signs every device out', async () => {
const { prisma, service } = setup();
prisma.verificationToken.findUnique.mockResolvedValueOnce({
userId: user.id,
@@ -174,6 +219,12 @@ describe('Account email flows', () => {
where: { id: user.id },
data: { passwordHash: expect.stringMatching(/^[0-9a-f]{32}:[0-9a-f]{128}$/), verifiedAt: expect.any(Date) },
});
+ expect(prisma.verificationToken.deleteMany).toHaveBeenCalledWith({
+ where: {
+ userId: user.id,
+ purpose: { in: [TokenPurpose.CHANGE_EMAIL, TokenPurpose.RESET_PASSWORD] },
+ },
+ });
expect(prisma.refreshSession.updateMany).toHaveBeenCalledWith({
where: { userId: user.id, revokedAt: null },
data: { revokedAt: expect.any(Date) },
@@ -206,6 +257,12 @@ describe('Account email flows', () => {
const session = await service.changePassword(user.id, 'correct horse battery', 'a brand new password');
expect(session.user.id).toBe(user.id);
+ expect(prisma.verificationToken.deleteMany).toHaveBeenCalledWith({
+ where: {
+ userId: user.id,
+ purpose: { in: [TokenPurpose.CHANGE_EMAIL, TokenPurpose.RESET_PASSWORD] },
+ },
+ });
expect(prisma.refreshSession.updateMany).toHaveBeenCalledWith({
where: { userId: user.id, revokedAt: null },
data: { revokedAt: expect.any(Date) },
@@ -216,7 +273,7 @@ describe('Account email flows', () => {
expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: user.id } });
});
- it('sends an email change confirmation to the new address unless it is taken', async () => {
+ it('sends an email change confirmation to the new address unless it is taken, and warns the old one', async () => {
const { mail, prisma, service } = setup();
prisma.user.findUnique.mockResolvedValueOnce({ id: 'someone-else' });
@@ -230,9 +287,27 @@ describe('Account email flows', () => {
data: expect.objectContaining({
purpose: TokenPurpose.CHANGE_EMAIL,
email: 'new@example.com',
- tokenHash: digest(sentToken(mail)),
+ tokenHash: digest(sentToken(mail, 'new@example.com')),
}),
});
+ const [[warning]] = mail.send.mock.calls.filter(([sent]) => (sent as { to: string }).to === user.email) as [
+ [{ text: string }],
+ ];
+ expect(warning.text).toContain('https://tracker.example/forgot-password');
+ expect(warning.text).not.toContain('new@example.com');
+ expect(warning.text).not.toMatch(/token=/);
+ });
+
+ it('refuses an email change when the confirmation cannot be sent', async () => {
+ const { mail, service } = setup();
+ const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
+ mail.send.mockRejectedValueOnce(refusedRecipient());
+
+ await expect(
+ service.requestEmailChange(user.id, 'new@example.com', 'correct horse battery'),
+ ).rejects.toBeInstanceOf(ServiceUnavailableException);
+ expect(mail.send).toHaveBeenCalledTimes(1);
+ warn.mockRestore();
});
it('accepts IANA time zones only', async () => {
@@ -271,4 +346,25 @@ describe('MailService', () => {
});
expect(mail.link('/verify?token=abc')).toBe('https://tracker.example/verify?token=abc');
});
+
+ it('reports SMTP failures by code, status and command without the server response', async () => {
+ const mail = new MailService(
+ new ConfigService({ EMAIL_HOST: 'smtp.example', DEFAULT_FROM_EMAIL: 'tracker@example.com' }),
+ );
+ sendMail.mockRejectedValueOnce(
+ Object.assign(new Error(`Recipient command failed: 550 <${user.email}> rejected`), {
+ code: 'EENVELOPE',
+ responseCode: 550,
+ command: 'RCPT TO',
+ response: `550 <${user.email}> rejected`,
+ }),
+ );
+
+ const error = await mail.send({ to: user.email, subject: 'Hi', text: 'Hi' }).catch((reason: unknown) => reason);
+
+ expect(error).toBeInstanceOf(MailDeliveryError);
+ expect(error).toMatchObject({ code: 'EENVELOPE', responseCode: 550, command: 'RCPT TO' });
+ expect((error as Error).message).toBe('Email delivery failed (EENVELOPE, SMTP 550, RCPT TO)');
+ expect(JSON.stringify(error)).not.toContain(user.email);
+ });
});
diff --git a/app/server/test/admin.spec.ts b/app/server/test/admin.spec.ts
index 519bfae..8d9bf69 100644
--- a/app/server/test/admin.spec.ts
+++ b/app/server/test/admin.spec.ts
@@ -63,7 +63,7 @@ describe('Administration', () => {
expect(prisma.reviewReport.update).not.toHaveBeenCalled();
});
- it('lists public and private reviews that still have text, with their reports', async () => {
+ it('lists public and private reviews that still have text, and every open report', async () => {
const reportCount = vi.fn();
const reportFindMany = vi.fn();
const reviewCount = vi.fn();
@@ -79,7 +79,7 @@ describe('Administration', () => {
await service.reviews('private', 1);
await service.reviews('hidden', 1);
- const reportWhere = { resolution: null, review: { body: { not: null } } };
+ const reportWhere = { resolution: null };
expect(reportCount).toHaveBeenCalledWith({ where: reportWhere });
expect(reportFindMany).toHaveBeenCalledWith(expect.objectContaining({ where: reportWhere }));
expect(reviewCount.mock.calls.map(([args]) => (args as { where: object }).where)).toEqual([
diff --git a/app/server/test/catalog-items.service.spec.ts b/app/server/test/catalog-items.service.spec.ts
index 5c051c9..0067a65 100644
--- a/app/server/test/catalog-items.service.spec.ts
+++ b/app/server/test/catalog-items.service.spec.ts
@@ -1,6 +1,8 @@
+import { Logger } from '@nestjs/common';
import { MediaCategory } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { CatalogItemsService } from '../src/catalog/catalog-items.service';
+import { CatalogRefreshService } from '../src/catalog/catalog-refresh.service';
import { CatalogCandidate } from '../src/sources/source.types';
const candidate: CatalogCandidate = {
@@ -81,4 +83,102 @@ describe('CatalogItemsService', () => {
).resolves.toEqual({ id: 'new-item' });
expect(transaction.catalogItem.update).not.toHaveBeenCalled();
});
+
+ it('never overwrites a stored item with a partial search result', async () => {
+ const transaction = {
+ sourceEntry: {
+ findUnique: vi.fn().mockResolvedValue({ id: 'entry', catalogItemId: 'stored-item' }),
+ },
+ catalogItem: {
+ findUniqueOrThrow: vi.fn().mockResolvedValue({ id: 'stored-item' }),
+ update: vi.fn(),
+ },
+ };
+
+ await expect(
+ new CatalogItemsService().upsert(transaction as never, candidate, 'rawg-source', { partial: true }),
+ ).resolves.toEqual({ id: 'stored-item' });
+ expect(transaction.catalogItem.update).not.toHaveBeenCalled();
+ });
+
+ it('marks an item created from a partial search result for a full refresh', async () => {
+ const transaction = {
+ sourceEntry: { findUnique: vi.fn().mockResolvedValue(null) },
+ catalogItem: {
+ findMany: vi.fn().mockResolvedValue([]),
+ create: vi.fn().mockResolvedValue({ id: 'new-item' }),
+ },
+ };
+
+ await new CatalogItemsService().upsert(transaction as never, candidate, 'rawg-source', { partial: true });
+
+ expect(transaction.catalogItem.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ sourceEntries: { create: expect.objectContaining({ lastRefreshedAt: null }) },
+ }),
+ });
+ });
+});
+
+describe('CatalogRefreshService', () => {
+ const due = [
+ { id: 'entry-1', sourceId: 'rawg-source', externalId: '42', source: { key: 'rawg' }, catalogItem: { category: MediaCategory.GAME } },
+ { id: 'entry-2', sourceId: 'rawg-source', externalId: '43', source: { key: 'rawg' }, catalogItem: { category: MediaCategory.GAME } },
+ ];
+
+ function refreshWith(details: ReturnType) {
+ const prisma = {
+ sourceEntry: { findMany: vi.fn().mockResolvedValue(due), update: vi.fn() },
+ $transaction: vi.fn((work: (client: unknown) => unknown) => work('transaction')),
+ };
+ const catalogItems = { upsert: vi.fn() };
+ return {
+ catalogItems,
+ prisma,
+ refresh: new CatalogRefreshService(prisma as never, { details } as never, catalogItems as never),
+ };
+ }
+
+ it('refreshes library titles that were never or not recently refreshed with full details', async () => {
+ const now = new Date('2026-09-26T12:00:00Z');
+ const details = vi.fn((_category: string, externalId: string) => Promise.resolve({ ...candidate, externalId }));
+ const { catalogItems, prisma, refresh } = refreshWith(details);
+
+ await refresh.refreshDue(now);
+
+ expect(prisma.sourceEntry.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ source: { enabled: true },
+ catalogItem: { libraryEntries: { some: {} } },
+ OR: [
+ { lastRefreshedAt: null },
+ { lastRefreshedAt: { lt: new Date('2026-09-19T12:00:00Z') } },
+ ],
+ },
+ orderBy: { lastRefreshedAt: { sort: 'asc', nulls: 'first' } },
+ }),
+ );
+ expect(details).toHaveBeenCalledWith('game', '42', 'rawg', true);
+ expect(catalogItems.upsert).toHaveBeenCalledWith('transaction', { ...candidate, externalId: '43' }, 'rawg-source');
+ expect(prisma.sourceEntry.update).not.toHaveBeenCalled();
+ });
+
+ it('moves on from a title its source cannot return and tries it again next week', async () => {
+ vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
+ const now = new Date('2026-09-26T12:00:00Z');
+ const details = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('RAWG returned 404'))
+ .mockResolvedValueOnce({ ...candidate, externalId: '43' });
+ const { catalogItems, prisma, refresh } = refreshWith(details);
+
+ await refresh.refreshDue(now);
+
+ expect(prisma.sourceEntry.update).toHaveBeenCalledWith({
+ where: { id: 'entry-1' },
+ data: { lastRefreshedAt: now },
+ });
+ expect(catalogItems.upsert).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/app/server/test/connectors.spec.ts b/app/server/test/connectors.spec.ts
index fd4a04d..b6defbd 100644
--- a/app/server/test/connectors.spec.ts
+++ b/app/server/test/connectors.spec.ts
@@ -137,7 +137,7 @@ describe('Source connectors', () => {
expect(url.searchParams.get('order[year]')).toBe('desc');
});
- it('normalizes IGDB platforms, releases, and game relationships', async () => {
+ it('normalizes IGDB platforms, releases listed once per region, and game relationships', async () => {
const cache = {
getOrLoad: vi.fn().mockResolvedValue({
value: { access_token: 'token', expires_in: 3600 },
@@ -158,6 +158,7 @@ describe('Source connectors', () => {
platforms: [{ id: 6, name: 'PC' }],
release_dates: [
{ date: 1_893_456_000, platform: { id: 6, name: 'PC' } },
+ { date: 1_893_456_000, platform: { id: 6, name: 'PC' } },
],
franchises: [{ id: 8, name: 'Graphite' }],
dlcs: [{ id: 43, name: 'Graphite Quest: More' }],
diff --git a/app/server/test/imports.spec.ts b/app/server/test/imports.spec.ts
index b38888e..65e5620 100644
--- a/app/server/test/imports.spec.ts
+++ b/app/server/test/imports.spec.ts
@@ -9,13 +9,13 @@ import {
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { createHash } from 'node:crypto';
-import { access, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { DecideCandidateDto } from '../src/imports/dto/import.dto';
import { ImportMatcherService, titleSimilarity } from '../src/imports/import-matcher.service';
-import { ImportsService } from '../src/imports/imports.service';
+import { importUploadDirectory, ImportsService } from '../src/imports/imports.service';
import { CatalogCandidate } from '../src/sources/source.types';
function candidate(overrides: Partial): CatalogCandidate {
@@ -291,6 +291,72 @@ describe('ImportsService', () => {
}
});
+ it('deletes uploads a previous run left behind before taking new ones', async () => {
+ await mkdir(importUploadDirectory, { recursive: true });
+ const leftover = join(importUploadDirectory, 'left-behind');
+ await writeFile(leftover, 'backup');
+ const imports = service({
+ importBatch: {
+ updateMany: vi.fn().mockResolvedValue({ count: 0 }),
+ findMany: vi.fn().mockResolvedValue([]),
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+ },
+ });
+
+ try {
+ await imports.onModuleInit();
+ await expect(access(leftover)).rejects.toThrow();
+ } finally {
+ imports.onModuleDestroy();
+ }
+ });
+
+ it.each([ImportState.PARSING, ImportState.MATCHING, ImportState.APPLYING])(
+ 'refuses to delete an import while it is %s',
+ async (state) => {
+ const remove = vi.fn();
+ const imports = service({
+ importBatch: { findFirst: vi.fn().mockResolvedValue({ id: 'batch-id', state }), delete: remove },
+ });
+
+ await expect(imports.remove('user-id', 'batch-id')).rejects.toBeInstanceOf(BadRequestException);
+ expect(remove).not.toHaveBeenCalled();
+ },
+ );
+
+ it('marks adult matches so the review can blur their covers', async () => {
+ const imports = service(
+ {
+ importBatch: { findFirst: vi.fn().mockResolvedValue({ id: 'batch-id' }) },
+ $transaction: (queries: Array>) => Promise.all(queries),
+ importCandidate: {
+ count: vi.fn().mockResolvedValue(1),
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: 'candidate-id',
+ kind: 'manga',
+ title: 'Tower Story',
+ sourceName: null,
+ progress: null,
+ state: LibraryState.PLANNED,
+ match: ImportMatch.SUGGESTED,
+ issue: null,
+ choice: 0,
+ decision: null,
+ outcome: null,
+ options: [{ score: 0.8, item: candidate({ adult: true }) }],
+ },
+ ]),
+ },
+ },
+ { bySources: vi.fn().mockResolvedValue([]) },
+ );
+
+ const page = await imports.candidates('user-id', 'batch-id', ImportMatch.SUGGESTED, 1);
+
+ expect(page.results[0]?.options[0]).toMatchObject({ adult: true });
+ });
+
it('lists titles already in the library as conflicts in position order', async () => {
const findMany = vi.fn().mockResolvedValue([]);
const imports = service(
@@ -392,6 +458,37 @@ describe('ImportsService', () => {
};
}
+ it('attaches matches without overwriting what the catalogue already stores', async () => {
+ const upsert = vi.fn().mockResolvedValue({ id: 'item-id' });
+ const transaction = { importCandidate: { update: vi.fn() } };
+ const findMany = vi
+ .fn()
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([{ ...imported, choice: 0, options: [option] }])
+ .mockResolvedValue([]);
+ const imports = new ImportsService(
+ {
+ importCandidate: { findMany, updateMany: vi.fn() },
+ importBatch: { update: vi.fn() },
+ $transaction: (run: (client: object) => Promise) => run(transaction),
+ } as never,
+ { list: () => [{ key: option.item.source, enabled: true }] } as never,
+ { sourceRecord: vi.fn().mockResolvedValue({ id: 'mangadex-source' }), upsert } as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ );
+ const internals = imports as unknown as {
+ applyCandidate(): Promise;
+ applyChunks(userId: string, batchId: string, policy: ImportConflictPolicy): Promise;
+ };
+ vi.spyOn(internals, 'applyCandidate').mockResolvedValue('added');
+
+ await internals.applyChunks('user-id', 'batch-id', ImportConflictPolicy.ADD_MISSING);
+
+ expect(upsert).toHaveBeenCalledWith(transaction, option.item, 'mangadex-source', { partial: true });
+ });
+
it('adds a missing title with its progress and an inactive source reference', async () => {
const { outcome, transaction } = applyWith(null, ImportConflictPolicy.ADD_MISSING);
diff --git a/app/server/test/library.service.spec.ts b/app/server/test/library.service.spec.ts
index 5b3765a..8a5127b 100644
--- a/app/server/test/library.service.spec.ts
+++ b/app/server/test/library.service.spec.ts
@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common';
-import { LibraryState, MediaCategory } from '@prisma/client';
+import { LibraryState, MediaCategory, Prisma } from '@prisma/client';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, expect, it, vi } from 'vitest';
@@ -133,6 +133,98 @@ describe('LibraryService', () => {
expect(update).not.toHaveBeenCalled();
});
+ const presented = {
+ id: 'entry-id',
+ state: LibraryState.PLANNED,
+ notificationsEnabled: false,
+ isPrivate: false,
+ progressSeason: null,
+ progressEpisode: null,
+ progressChapter: null,
+ progressVolume: null,
+ hoursPlayed: null,
+ completionPercentage: null,
+ platforms: [],
+ preferredSource: null,
+ preferredSourceId: null,
+ importedSources: [],
+ catalogItem: {
+ id: 'item-id',
+ category: MediaCategory.GAME,
+ canonicalTitle: 'Graphite Quest',
+ posterPath: null,
+ releaseDate: null,
+ metadata: {},
+ sourceEntries: [],
+ },
+ };
+ const noSourcePreferences = {
+ globalSourcePreference: { findFirst: vi.fn().mockResolvedValue(null) },
+ categorySourcePreference: { findMany: vi.fn().mockResolvedValue([]) },
+ };
+
+ it('turns game release notifications off when the last platform is removed', async () => {
+ const update = vi.fn().mockResolvedValue(presented);
+ const service = new LibraryService(
+ {
+ ...noSourcePreferences,
+ libraryEntry: {
+ findFirst: vi.fn().mockResolvedValue({
+ id: 'entry-id',
+ userId: 'user-id',
+ catalogItemId: 'item-id',
+ state: LibraryState.PLANNED,
+ notificationsEnabled: true,
+ platforms: ['PC'],
+ catalogItem: {
+ category: MediaCategory.GAME,
+ metadata: {
+ capabilities: { progressUnits: ['hours', 'percentage'] },
+ platforms: ['PC'],
+ },
+ },
+ }),
+ update,
+ },
+ } as never,
+ {} as never,
+ {} as never,
+ );
+
+ await service.update('user-id', 'entry-id', { platforms: [] });
+
+ expect(update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ platforms: [], notificationsEnabled: false }),
+ }),
+ );
+ });
+
+ it('adds a title once when another request stores it at the same moment', async () => {
+ const transaction = vi
+ .fn()
+ .mockRejectedValueOnce(
+ new Prisma.PrismaClientKnownRequestError('Unique constraint failed', {
+ code: 'P2002',
+ clientVersion: 'test',
+ }),
+ )
+ .mockResolvedValueOnce(presented);
+ const service = new LibraryService(
+ { ...noSourcePreferences, $transaction: transaction } as never,
+ {
+ resolve: () => ({ descriptor: { key: 'rawg' } }),
+ details: vi.fn().mockResolvedValue({ externalId: '42' }),
+ } as never,
+ {} as never,
+ );
+
+ await expect(
+ service.create('user-id', { category: 'game', source: 'rawg', externalId: '42', state: 'planned' } as never),
+ ).resolves.toMatchObject({ id: 'entry-id' });
+ expect(transaction).toHaveBeenCalledTimes(2);
+ });
+
it('resolves the source by title, category and global preference before the default', () => {
const entries = ['tmdb', 'rawg', 'igdb', 'retired'].map((sourceId) => ({
sourceId,
diff --git a/app/server/test/notifications.spec.ts b/app/server/test/notifications.spec.ts
index 5a2425e..46cd73c 100644
--- a/app/server/test/notifications.spec.ts
+++ b/app/server/test/notifications.spec.ts
@@ -6,11 +6,13 @@ import {
LibraryState,
MediaCategory,
NotificationState,
+ Prisma,
ReleaseKind,
} from '@prisma/client';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, expect, it, vi } from 'vitest';
+import { MailDeliveryError } from '../src/mail/mail.service';
import { DigestService } from '../src/notifications/digest.service';
import { UpdateNotificationPreferencesDto } from '../src/notifications/dto/notifications.dto';
import { NotificationsService } from '../src/notifications/notifications.service';
@@ -97,6 +99,12 @@ describe('Release subscriptions', () => {
).toEqual(['episode:2x8', 'release:Switch']);
});
+ it('keeps a release listed twice in one check only once', () => {
+ const chapter = { key: 'chapter:12', kind: 'chapter' as const, label: 'Chapter 12', occurredAt: '2026-09-20' };
+
+ expect(newSignals([chapter, { ...chapter, label: 'Chapter 12 (another group)' }], [])).toEqual([chapter]);
+ });
+
it('honours the category, title, list, platform and source switches', () => {
const marker = { sourceEntryId: 'tmdb-entry', platform: null as string | null };
const wants = (overrides: Record, releaseMarker = marker) =>
@@ -113,6 +121,30 @@ describe('Release subscriptions', () => {
expect(wants({ platforms: ['PC'] }, { sourceEntryId: 'tmdb-entry', platform: 'PC' })).toBe(true);
});
+ it('never moves a title to another source when its chosen source is switched off', () => {
+ const offline = subscriber({
+ preferredSourceId: 'other',
+ catalogItem: {
+ ...subscriber().catalogItem,
+ sourceEntries: [
+ { id: 'tmdb-entry', sourceId: 'tmdb', source: { enabled: true } },
+ { id: 'other-entry', sourceId: 'other', source: { enabled: false } },
+ ],
+ },
+ });
+
+ expect(
+ wantsRelease(offline as never, { sourceEntryId: 'tmdb-entry', platform: null }, noPreferences),
+ ).toBe(false);
+ expect(
+ wantsRelease(
+ subscriber({ catalogItem: offline.catalogItem }) as never,
+ { sourceEntryId: 'tmdb-entry', platform: null },
+ noPreferences,
+ ),
+ ).toBe(true);
+ });
+
it('accepts only known categories and cadences in preferences', async () => {
const errors = async (value: object) =>
(await validate(plainToInstance(UpdateNotificationPreferencesDto, value))).map(
@@ -151,13 +183,11 @@ describe('ReleaseMonitorService', () => {
},
releaseMarker: {
findMany: vi.fn().mockResolvedValue(options.markers ?? []),
- createManyAndReturn: vi.fn(({ data }: { data: Array> }) =>
- data.map((marker, index) => ({ id: `marker-${index}`, platform: null, ...marker })),
- ),
+ createMany: vi.fn().mockReturnValue('marker-insert'),
},
libraryEntry: { findMany: vi.fn().mockResolvedValue(options.subscribers ?? []) },
- notificationEvent: { createMany: vi.fn() },
- inboxNotification: { createMany: vi.fn() },
+ notificationEvent: { createMany: vi.fn().mockReturnValue('event-insert') },
+ inboxNotification: { createMany: vi.fn().mockReturnValue('inbox-insert') },
$transaction: vi.fn((queries: unknown[]) => Promise.all(queries)),
};
const connectors = { releases: vi.fn().mockResolvedValue([episode(2, 7), episode(2, 8)]) };
@@ -174,17 +204,57 @@ describe('ReleaseMonitorService', () => {
await monitor.refreshDue(new Date('2026-09-26T12:00:00Z'));
expect(connectors.releases).toHaveBeenCalledWith('tv', '1399', 'tmdb');
- expect(prisma.releaseMarker.createManyAndReturn).toHaveBeenCalledWith(
- expect.objectContaining({ skipDuplicates: true }),
- );
- expect(prisma.notificationEvent.createMany).not.toHaveBeenCalled();
- expect(prisma.inboxNotification.createMany).not.toHaveBeenCalled();
+ expect(prisma.releaseMarker.createMany).toHaveBeenCalledWith({
+ data: [
+ expect.objectContaining({ key: 'episode:2x7' }),
+ expect.objectContaining({ key: 'episode:2x8' }),
+ ],
+ });
+ expect(prisma.notificationEvent.createMany).toHaveBeenCalledWith({ data: [], skipDuplicates: true });
+ expect(prisma.inboxNotification.createMany).toHaveBeenCalledWith({ data: [], skipDuplicates: true });
+ expect(prisma.libraryEntry.findMany).not.toHaveBeenCalled();
expect(prisma.sourceEntry.update).toHaveBeenLastCalledWith({
where: { id: 'tmdb-entry' },
data: { releasesCheckedAt: expect.any(Date) },
});
});
+ it('records new releases, the check and every notification in one transaction', async () => {
+ const { monitor, prisma } = monitorWith({
+ checkedAt: new Date('2026-09-26T06:00:00Z'),
+ markers: [{ key: 'episode:2x7', kind: ReleaseKind.EPISODE, ordinal: 20_007 }],
+ subscribers: [subscriber()],
+ });
+
+ await monitor.refreshDue(new Date('2026-09-26T12:00:00Z'));
+
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1);
+ expect(prisma.$transaction).toHaveBeenCalledWith([
+ 'marker-insert',
+ 'entry-update',
+ 'inbox-insert',
+ 'event-insert',
+ ]);
+ });
+
+ it('leaves releases another run already recorded to that run', async () => {
+ const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
+ const { monitor, prisma } = monitorWith({
+ checkedAt: new Date('2026-09-26T06:00:00Z'),
+ subscribers: [subscriber()],
+ });
+ prisma.$transaction.mockRejectedValueOnce(
+ new Prisma.PrismaClientKnownRequestError('Unique constraint failed', {
+ code: 'P2002',
+ clientVersion: 'test',
+ }),
+ );
+
+ await expect(monitor.refreshDue(new Date('2026-09-26T12:00:00Z'))).resolves.toBeUndefined();
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('recorded by another run'));
+ warn.mockRestore();
+ });
+
it('queues one event per new release for each reader who wants it', async () => {
const { monitor, prisma } = monitorWith({
checkedAt: new Date('2026-09-26T06:00:00Z'),
@@ -198,11 +268,14 @@ describe('ReleaseMonitorService', () => {
await monitor.refreshDue(new Date('2026-09-26T12:00:00Z'));
- expect(prisma.releaseMarker.createManyAndReturn.mock.calls[0]?.[0].data).toEqual([
+ const [[{ data: markers }]] = prisma.releaseMarker.createMany.mock.calls as Array<
+ [{ data: Array<{ id: string }> }]
+ >;
+ expect(markers).toEqual([
expect.objectContaining({ key: 'episode:2x8', kind: ReleaseKind.EPISODE, ordinal: 20_008 }),
]);
expect(prisma.notificationEvent.createMany).toHaveBeenCalledWith({
- data: [{ userId: 'user-1', libraryEntryId: 'entry-1', releaseMarkerId: 'marker-0' }],
+ data: [{ userId: 'user-1', libraryEntryId: 'entry-1', releaseMarkerId: markers[0]!.id }],
skipDuplicates: true,
});
});
@@ -220,10 +293,13 @@ describe('ReleaseMonitorService', () => {
await monitor.refreshDue(new Date('2026-09-26T12:00:00Z'));
+ const [[{ data: markers }]] = prisma.releaseMarker.createMany.mock.calls as Array<
+ [{ data: Array<{ id: string }> }]
+ >;
expect(prisma.inboxNotification.createMany).toHaveBeenCalledWith({
data: [
- { userId: 'user-1', libraryEntryId: 'entry-1', releaseMarkerId: 'marker-0' },
- { userId: 'user-3', libraryEntryId: 'entry-3', releaseMarkerId: 'marker-0' },
+ { userId: 'user-1', libraryEntryId: 'entry-1', releaseMarkerId: markers[0]!.id },
+ { userId: 'user-3', libraryEntryId: 'entry-3', releaseMarkerId: markers[0]!.id },
],
skipDuplicates: true,
});
@@ -237,8 +313,8 @@ describe('ReleaseMonitorService', () => {
await monitor.refreshDue(new Date('2026-09-26T12:00:00Z'));
- expect(prisma.notificationEvent.createMany).not.toHaveBeenCalled();
- expect(prisma.inboxNotification.createMany).not.toHaveBeenCalled();
+ expect(prisma.notificationEvent.createMany).toHaveBeenCalledWith({ data: [], skipDuplicates: true });
+ expect(prisma.inboxNotification.createMany).toHaveBeenCalledWith({ data: [], skipDuplicates: true });
});
it('leaves a title for the next run when its source fails', async () => {
@@ -253,7 +329,7 @@ describe('ReleaseMonitorService', () => {
where: { id: 'tmdb-entry' },
data: { releasesAttemptedAt: expect.any(Date) },
});
- expect(prisma.releaseMarker.createManyAndReturn).not.toHaveBeenCalled();
+ expect(prisma.releaseMarker.createMany).not.toHaveBeenCalled();
});
async function monitorWithDue(count: number) {
@@ -336,8 +412,12 @@ describe('DigestService', () => {
.mockResolvedValueOnce((options.pending ?? ['user-1']).map((userId) => ({ userId })))
.mockResolvedValue(events),
updateMany: vi.fn().mockResolvedValue({ count: events.length }),
+ updateManyAndReturn: vi.fn(({ where }: { where: { id: { in: string[] } } }) =>
+ Promise.resolve(where.id.in.map((id) => ({ id }))),
+ ),
},
notificationPreference: { update: vi.fn().mockReturnValue('preference-update') },
+ $executeRaw: vi.fn().mockResolvedValue(0),
$transaction: vi.fn((queries: unknown[]) => Promise.all(queries)),
};
const mail = {
@@ -362,10 +442,12 @@ describe('DigestService', () => {
'List-Unsubscribe': expect.stringMatching(/^ (args as { data: { state: string } }).data.state === NotificationState.SENT,
- );
- expect(prisma.notificationEvent.updateMany.mock.invocationCallOrder[claim]).toBeLessThan(
+ expect(prisma.notificationEvent.updateManyAndReturn).toHaveBeenCalledWith({
+ where: { id: { in: ['event-1', 'event-2'] }, state: NotificationState.QUEUED },
+ data: { state: NotificationState.SENT, sentAt: now },
+ select: { id: true },
+ });
+ expect(prisma.notificationEvent.updateManyAndReturn.mock.invocationCallOrder[0]).toBeLessThan(
mail.send.mock.invocationCallOrder[0]!,
);
expect(prisma.notificationPreference.update).toHaveBeenCalledWith({
@@ -374,13 +456,35 @@ describe('DigestService', () => {
});
});
- it('never sends a release another run has already claimed', async () => {
+ it('sends only the releases this run claimed and never one another run has claimed', async () => {
const { digests, mail, prisma } = digestWith({});
- prisma.notificationEvent.updateMany.mockResolvedValue({ count: 1 });
+ prisma.notificationEvent.updateManyAndReturn.mockResolvedValueOnce([{ id: 'event-2' }]);
+
+ await digests.sendDue(now);
+
+ expect(mail.send).toHaveBeenCalledTimes(1);
+ const [{ text }] = mail.send.mock.calls[0] as [{ text: string }];
+ expect(text).toContain('Season 2, episode 9');
+ expect(text).not.toContain('Season 2, episode 8');
+
+ const other = digestWith({});
+ other.prisma.notificationEvent.updateManyAndReturn.mockResolvedValueOnce([]);
+ await other.digests.sendDue(now);
+ expect(other.mail.send).not.toHaveBeenCalled();
+ });
+
+ it('puts back releases whose digest was interrupted more than an hour ago', async () => {
+ const { digests, prisma } = digestWith({});
await digests.sendDue(now);
- expect(mail.send).not.toHaveBeenCalled();
+ const [[sql, cutoff]] = prisma.$executeRaw.mock.calls as Array<[TemplateStringsArray, Date]>;
+ expect(sql.join('?')).toMatch(/SET state = 'queued', sent_at = NULL/);
+ expect(sql.join('?')).toMatch(/last_digest_at < event\.sent_at/);
+ expect(cutoff).toEqual(new Date(now.getTime() - 60 * 60 * 1000));
+ expect(prisma.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
+ prisma.notificationEvent.findMany.mock.invocationCallOrder[0]!,
+ );
});
it('waits for the digest hour and skips releases nobody wants any more', async () => {
@@ -393,7 +497,7 @@ describe('DigestService', () => {
await unsubscribed.digests.sendDue(now);
expect(unsubscribed.mail.send).not.toHaveBeenCalled();
expect(unsubscribed.prisma.notificationEvent.updateMany).toHaveBeenCalledWith({
- where: { userId: 'user-1', state: NotificationState.QUEUED, id: { notIn: [] } },
+ where: { id: { in: ['event-1', 'event-2'] }, state: NotificationState.QUEUED },
data: { state: NotificationState.SKIPPED },
});
@@ -408,10 +512,7 @@ describe('DigestService', () => {
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const { digests, mail, prisma } = digestWith({ user: reader({ deliveryFailures: 2 }) });
mail.send.mockRejectedValue(
- Object.assign(new Error('550 5.1.1 unknown'), {
- code: 'EENVELOPE',
- responseCode: 550,
- }),
+ new MailDeliveryError({ code: 'EENVELOPE', responseCode: 550, command: 'RCPT TO' }),
);
await digests.sendDue(now);
@@ -429,22 +530,42 @@ describe('DigestService', () => {
it('keeps sending to other readers when one address is refused', async () => {
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
- const { digests, mail, prisma } = digestWith({ pending: ['user-1', 'user-2', 'user-3'] });
+ const { digests, mail, prisma } = digestWith({ pending: ['user-1', 'user-2', 'user-3', 'user-4'] });
mail.send
- .mockRejectedValueOnce(Object.assign(new Error('Invalid recipient'), { code: 'EENVELOPE' }))
- .mockRejectedValueOnce(
- Object.assign(new Error('450 mailbox busy'), { code: 'EENVELOPE', responseCode: 450 }),
- )
+ .mockRejectedValueOnce(new MailDeliveryError({ code: 'EENVELOPE', responseCode: 550, command: 'RCPT TO' }))
+ .mockRejectedValueOnce(new MailDeliveryError({ code: 'EENVELOPE', responseCode: 450, command: 'RCPT TO' }))
+ .mockRejectedValueOnce(new MailDeliveryError({ code: 'EMESSAGE', responseCode: 554, command: 'DATA' }))
.mockResolvedValueOnce(undefined);
await digests.sendDue(now);
- expect(mail.send).toHaveBeenCalledTimes(3);
+ expect(mail.send).toHaveBeenCalledTimes(4);
expect(prisma.notificationPreference.update).toHaveBeenCalledWith({
where: { userId: 'user-1' },
data: { deliveryFailures: 1, suspendedAt: null },
});
- expect(prisma.notificationPreference.update).toHaveBeenCalledTimes(2);
+ expect(prisma.notificationPreference.update).toHaveBeenCalledWith({
+ where: { userId: 'user-3' },
+ data: { deliveryFailures: 1, suspendedAt: null },
+ });
+ expect(prisma.notificationPreference.update).toHaveBeenCalledTimes(3);
+ });
+
+ it('treats a refused sender as an outage instead of blaming the reader', async () => {
+ vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
+ const { digests, mail, prisma } = digestWith({ pending: ['user-1', 'user-2'] });
+ mail.send.mockRejectedValue(
+ new MailDeliveryError({ code: 'EENVELOPE', responseCode: 553, command: 'MAIL FROM' }),
+ );
+
+ await digests.sendDue(now);
+
+ expect(prisma.notificationEvent.updateMany).toHaveBeenLastCalledWith({
+ where: { id: { in: ['event-1', 'event-2'] } },
+ data: { state: NotificationState.QUEUED, sentAt: null },
+ });
+ expect(prisma.notificationPreference.update).not.toHaveBeenCalled();
+ expect(mail.send).toHaveBeenCalledTimes(1);
});
it('keeps releases queued and stops the run while SMTP is unreachable', async () => {
@@ -469,6 +590,7 @@ describe('DigestService', () => {
const link = (label: string) =>
new URL(new RegExp(`${label}: (\\S+)`).exec(text)?.[1] ?? '').searchParams.get('token') ?? '';
const prisma = {
+ user: { findUnique: vi.fn().mockResolvedValue({ id: 'user-1' }) },
libraryEntry: {
updateMany: vi.fn(),
findFirst: vi.fn().mockResolvedValue({ catalogItem: { canonicalTitle: 'Tower Chronicles' } }),
@@ -504,6 +626,23 @@ describe('DigestService', () => {
data: { enabled: false },
});
});
+
+ it('confirms an unsubscribe from a deleted account without writing anything', async () => {
+ const prisma = {
+ user: { findUnique: vi.fn().mockResolvedValue(null) },
+ notificationPreference: { upsert: vi.fn(), update: vi.fn() },
+ };
+ const notifications = new NotificationsService(prisma as never, tokens);
+
+ await expect(notifications.unsubscribe(tokens.sign('gone', { scope: 'all' }))).resolves.toEqual({
+ scope: 'all',
+ });
+ await expect(
+ notifications.unsubscribe(tokens.sign('gone', { scope: 'category', category: MediaCategory.TV })),
+ ).resolves.toEqual({ scope: 'category', category: 'tv' });
+ expect(prisma.notificationPreference.upsert).not.toHaveBeenCalled();
+ expect(prisma.notificationPreference.update).not.toHaveBeenCalled();
+ });
});
describe('Unsubscribe tokens', () => {
diff --git a/app/server/test/profiles.spec.ts b/app/server/test/profiles.spec.ts
index 4448d05..4fa08a7 100644
--- a/app/server/test/profiles.spec.ts
+++ b/app/server/test/profiles.spec.ts
@@ -1,5 +1,5 @@
import { NotFoundException } from '@nestjs/common';
-import { ActivityKind } from '@prisma/client';
+import { ActivityKind, ReviewVisibility } from '@prisma/client';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, expect, it, vi } from 'vitest';
@@ -94,7 +94,9 @@ describe('ProfilesService privacy', () => {
OR: [
{
kind: ActivityKind.RATED,
- review: { is: { rating: { not: null }, hiddenAt: null } },
+ review: {
+ is: { rating: { not: null }, hiddenAt: null, visibility: ReviewVisibility.PUBLIC },
+ },
},
],
},
@@ -184,6 +186,51 @@ describe('ProfilesService privacy', () => {
expect(admin.prisma.libraryEntry.count).toHaveBeenCalledWith({ where: { userId: 'user-id' } });
});
+ it('keeps private ratings off the profile for everyone but administrators', async () => {
+ const everything = {
+ isPublic: true,
+ showLibrary: true,
+ showActivity: true,
+ showRatings: true,
+ showStatistics: true,
+ };
+ const publicOnly = { visibility: ReviewVisibility.PUBLIC };
+ const { prisma, service } = serviceFor(everything);
+
+ await service.profile('reader');
+ await service.library('reader', { page: 1 });
+ await service.activity('reader', 1);
+ await service.ratings('reader', 1);
+
+ expect(prisma.review.aggregate).toHaveBeenCalledWith(
+ expect.objectContaining({ where: expect.objectContaining(publicOnly) }),
+ );
+ expect(prisma.review.count).toHaveBeenCalledWith({ where: expect.objectContaining(publicOnly) });
+ expect(prisma.libraryEntry.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ include: {
+ catalogItem: {
+ include: expect.objectContaining({
+ reviews: expect.objectContaining({ where: expect.objectContaining(publicOnly) }),
+ }),
+ },
+ },
+ }),
+ );
+ expect(prisma.activityEvent.count).toHaveBeenCalledWith({
+ where: expect.objectContaining({
+ OR: expect.arrayContaining([
+ { kind: ActivityKind.RATED, review: { is: expect.objectContaining(publicOnly) } },
+ ]),
+ }),
+ });
+
+ const admin = serviceFor(everything);
+ await admin.service.ratings('reader', 1, { id: 'admin-id', isAdmin: true } as never);
+ const [[{ where }]] = admin.prisma.review.count.mock.calls as Array<[{ where: object }]>;
+ expect(where).not.toHaveProperty('visibility');
+ });
+
it('keeps rating statistics out when ratings are hidden', async () => {
const { prisma, service } = serviceFor({ isPublic: true, showStatistics: true });