dorfteich/apps/api/src/trash/trash.service.ts
Claude Sonnet 5 a645763679
All checks were successful
CD / Build and push images (push) Successful in 2m5s
CI / Lint, typecheck, test (push) Successful in 1m45s
CI / Auth e2e pack (push) Successful in 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 10s
Add page trash: soft delete, restore, and purge job (#31)
Backend: a generic maintenance-job scheduler (SchedulerService, `jobs`
table) that any later maintenance job registers with instead of
growing its own timer loop. Due-ness and the run-mutex both live in
the DB row (`lastRunAt` survives a restart; claiming a due job is one
atomic `UPDATE ... WHERE status != 'RUNNING'`), and an injectable
ClockService lets tests simulate retention elapsing without waiting or
faking the global clock.

Trash endpoints: GET /ponds/:id/trash (list), POST /pages/:id/restore,
DELETE /pages/:id/purge (manual, bypasses retention) — all sharing the
same purge logic as the scheduled daily job (default 30-day retention,
new trash.retentionDays instance setting). Purging deletes a page's
content cache, update log, and attachment files/quota; page_versions
is a placeholder until M3 exists. Direct navigation to a trashed page
now 404s with a distinguishable `page_trashed` code for editors (a
plain 404 for everyone else) instead of the generic not-found.

Attachment.pageId — added in #27 but never wired up — now gets set on
every page state save to whichever page's document currently embeds
the file, which is what lets purge find a page's files.

Frontend: a per-pond trash view (restore/purge), a "move to trash"
action with confirmation in the page menu, and a trash link in the
sidebar for pond owners. Also fixes react-query retrying 4xx responses
for several seconds by default, which was masking the trash-hint 404
in the UI (and would have affected any other not-found/permission
error the same way).

Closes #31
2026-07-08 12:48:17 +02:00

104 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, NotFoundException } from '@nestjs/common';
import { PageView } from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { ClockService } from '../common/clock.service';
import { PagesService } from '../pages/pages.service';
import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { FileStorageService } from '../files/file-storage.service';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Page trash: soft delete (already done by `PagesService.softDelete`,
* issue #23), list/restore/purge-single, and the scheduled purge job
* (issue #31, ADR 0013). Deleting a page never touches its content or
* files — only `purgePage` does, so restore is always intact by
* construction as long as purge hasn't run yet.
*/
@Injectable()
export class TrashService {
constructor(
private readonly prisma: PrismaService,
private readonly access: InterimAccessService,
private readonly pages: PagesService,
private readonly settings: InstanceSettingsService,
private readonly quotas: QuotaService,
private readonly storage: FileStorageService,
private readonly clock: ClockService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(TrashService.name);
}
/** A pond's trash — same access rule as restoring/purging from it. */
async list(user: User, pondId: string): Promise<PageView[]> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanModify(user, pond);
const pages = await this.prisma.page.findMany({
where: { pondId, deletedAt: { not: null } },
orderBy: { deletedAt: 'desc' },
});
return pages.map((page) => this.pages.viewOf(page));
}
async restore(user: User, id: string): Promise<PageView> {
const page = await this.prisma.page.findFirst({ where: { id }, include: { pond: true } });
if (!page || !page.deletedAt) throw new NotFoundException();
this.access.assertCanModify(user, page.pond);
const restored = await this.prisma.page.update({
where: { id },
data: { deletedAt: null, deletedBy: null },
});
this.logger.info({ pageId: id, userId: user.id }, 'audit: page restored from trash');
return this.pages.viewOf(restored);
}
/** Manual "purge single" (scope's third trash endpoint) — bypasses retention. */
async purgeNow(user: User, id: string): Promise<void> {
const page = await this.prisma.page.findFirst({ where: { id }, include: { pond: true } });
if (!page || !page.deletedAt) throw new NotFoundException();
this.access.assertCanModify(user, page.pond);
await this.purgePage(id);
this.logger.info({ pageId: id, userId: user.id }, 'audit: page purged from trash');
}
/** Scheduled job entry point (registered with SchedulerService, trash.module.ts). */
async purgeDuePages(): Promise<void> {
const retentionDays = await this.settings.get('trash.retentionDays');
const cutoff = new Date(this.clock.now().getTime() - retentionDays * MS_PER_DAY);
const due = await this.prisma.page.findMany({
where: { deletedAt: { lte: cutoff } },
select: { id: true },
});
for (const { id } of due) {
await this.purgePage(id);
}
}
/**
* Deletes state, content cache, files, and (once M3 exists) versions for
* one page — the fixed set of things `#31`'s scope names. Version rows
* are a placeholder: `page_versions` doesn't exist until M3 (#33#42).
*/
private async purgePage(pageId: string): Promise<void> {
const page = await this.prisma.page.findUnique({ where: { id: pageId } });
if (!page) return; // already gone — e.g. a manual purge raced the job
const attachments = await this.prisma.attachment.findMany({ where: { pageId } });
for (const attachment of attachments) {
await this.storage.delete(attachment.pondId, attachment.id);
await this.quotas.release(attachment.pondId, attachment.sizeBytes);
}
await this.prisma.attachment.deleteMany({ where: { pageId } });
await this.prisma.pageContentCache.deleteMany({ where: { pageId } });
await this.prisma.pageUpdate.deleteMany({ where: { pageId } });
await this.prisma.page.delete({ where: { id: pageId } });
this.logger.info({ pageId }, 'audit: page purged (retention)');
}
}