dorfteich/apps/api/src/trash/trash.service.ts
Claude Fable 5 402b22e05f
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m58s
CI / Build container images (pull_request) Successful in 2m47s
CI / Auth e2e pack (pull_request) Successful in 7m46s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 28s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 5m4s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
#193: pond purge — retention job and manual Site-Admin endpoint
Deletion now actually deletes: a trashed pond past the trash retention
(same clock as pages, extended trash-purge job) or purged manually via
DELETE /ponds/:id/purge (Site-Admin-only, like pond restore) is removed
with everything it holds. Files go first (idempotent rm, resumable on a
crash), then one transaction ordered around the FK actions: attachments
and labels (Restrict) precede the pond; the page delete cascades
versions, comments, content cache incl. the search vector, update log,
mentions, label assignments, favorites, outgoing links and open collab
sessions; the pond delete cascades grants, usage counters (that is the
quota correction), pond-plugin opt-ins and conversion jobs; polymorphic
watches and pond quota overrides are deleted explicitly. A purge racing
a restore or another purge is a no-op; both paths record a pond.purged
audit event.

Known residues by design, documented in operations.md: target_slug in
other ponds' page links (#235) and backups within their retention.

Refs #193

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 12:44:52 +02:00

239 lines
9.7 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 { AuditService } from '../audit/audit.service';
import { ClockService } from '../common/clock.service';
import { PagesService } from '../pages/pages.service';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { WatchesService } from '../watches/watches.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 permissions: PermissionService,
private readonly pages: PagesService,
private readonly settings: InstanceSettingsService,
private readonly quotas: QuotaService,
private readonly storage: FileStorageService,
private readonly clock: ClockService,
private readonly watches: WatchesService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(TrashService.name);
}
/** A pond's trash: the trashed pages the user could edit — trash access is
* write capability (ADR 0013), resolved per page (issue #52). */
async list(user: User, pondId: string): Promise<PageView[]> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
if (!pond) throw new NotFoundException();
const pages = await this.prisma.page.findMany({
where: { pondId, deletedAt: { not: null } },
orderBy: { deletedAt: 'desc' },
});
const editable = await this.permissions.filterPages(
user,
pondId,
pages.map((page) => ({ id: page.id })),
'write',
);
return pages.filter((page) => editable.has(page.id)).map((page) => this.pages.viewOf(page));
}
/**
* Restore a trashed page. Trashed pages keep their `parentId` (issue #107),
* so the original spot may itself be in the trash by now — the page
* re-attaches to its nearest **live** ancestor, or to the root when the
* whole chain is gone. That makes restore order-independent: restoring the
* parent afterwards does not re-claim an already-restored child.
*/
async restore(user: User, id: string): Promise<PageView> {
const page = await this.prisma.page.findFirst({ where: { id } });
if (!page || !page.deletedAt) throw new NotFoundException();
const pondPages = await this.prisma.page.findMany({
where: { pondId: page.pondId },
select: { id: true, parentId: true, deletedAt: true },
});
const byId = new Map(pondPages.map((p) => [p.id, p]));
let parentId: string | null = null;
const seen = new Set<string>([page.id]);
for (let current = page.parentId; current && !seen.has(current);) {
seen.add(current);
const ancestor = byId.get(current);
if (!ancestor) break;
if (ancestor.deletedAt === null) {
parentId = ancestor.id;
break;
}
current = ancestor.parentId;
}
const restored = await this.prisma.page.update({
where: { id },
data: { deletedAt: null, deletedBy: null, parentId },
});
this.logger.info({ pageId: id, userId: user.id, parentId }, '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 } });
if (!page || !page.deletedAt) throw new NotFoundException();
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);
}
}
/** Manual pond purge (issue #193) — Site-Admin-only, guarded at the controller. */
async purgePondNow(actor: User, pondId: string): Promise<void> {
const pond = await this.prisma.pond.findFirst({
where: { id: pondId, deletedAt: { not: null } },
});
if (!pond) throw new NotFoundException();
const counts = await this.purgePond(pondId);
if (!counts) throw new NotFoundException(); // restored or raced away meanwhile
await this.audit.record({
action: 'pond.purged',
actorId: actor.id,
targetType: 'pond',
targetId: pondId,
details: { trigger: 'manual', ...counts },
});
}
/** Scheduled half of the pond purge (issue #193) — same retention as pages. */
async purgeDuePonds(): 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.pond.findMany({
where: { deletedAt: { lte: cutoff } },
select: { id: true },
});
for (const { id } of due) {
const counts = await this.purgePond(id);
if (counts) {
await this.audit.record({
action: 'pond.purged',
targetType: 'pond',
targetId: id,
details: { trigger: 'retention', ...counts },
});
}
}
}
/**
* Deletes a trashed pond with everything it holds (issue #193). Files go
* first — `rm(force)` is idempotent, so a crash between files and rows
* leaves a resumable state (rows intact, next run retries). The rows go
* in ONE transaction, ordered around the FK actions: attachments and
* labels are `Restrict` against the pond and must precede it; the page
* delete cascades versions, comments, content cache (incl. the search
* vector), update log, mentions, pending contributors, label
* assignments, favorites, outgoing links, and open collab sessions; the
* pond delete cascades grants, usage counters (that IS the quota
* correction — pond capacity derives from live pond rows), pond-plugin
* opt-ins, and conversion jobs. Watches and quota overrides are
* polymorphic (no FK) and are deleted explicitly. A purge racing a
* restore or another purge is a no-op (`null`).
*/
private async purgePond(pondId: string): Promise<{ pages: number; attachments: number } | null> {
const pond = await this.prisma.pond.findUnique({ where: { id: pondId } });
if (!pond || !pond.deletedAt) return null;
const attachments = await this.prisma.attachment.findMany({
where: { pondId },
select: { id: true },
});
for (const attachment of attachments) {
await this.storage.delete(pondId, attachment.id);
}
const pageIds = (
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
).map((page) => page.id);
await this.prisma.$transaction([
this.prisma.attachment.deleteMany({ where: { pondId } }),
this.prisma.watch.deleteMany({
where: {
OR: [
{ targetType: 'POND', targetId: pondId },
{ targetType: 'PAGE', targetId: { in: pageIds } },
],
},
}),
this.prisma.quotaOverride.deleteMany({
where: { subjectType: 'POND', subjectId: pondId },
}),
this.prisma.page.deleteMany({ where: { pondId } }),
this.prisma.label.deleteMany({ where: { pondId } }),
this.prisma.pond.delete({ where: { id: pondId } }),
]);
this.logger.info(
{ pondId, pages: pageIds.length, attachments: attachments.length },
'audit: pond purged',
);
return { pages: pageIds.length, attachments: attachments.length };
}
/**
* 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.watches.removeForPage(pageId);
// Children (live or trashed) move up to the purged page's parent (issue
// #107) — the FK's SetNull is only the backstop for rows created outside
// this path.
await this.prisma.page.updateMany({
where: { parentId: pageId },
data: { parentId: page.parentId },
});
await this.prisma.page.delete({ where: { id: pageId } });
this.logger.info({ pageId }, 'audit: page purged (retention)');
}
}