dorfteich/apps/api/src/trash/trash.service.ts
Claude Fable 5 960a806ee3
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m4s
CI / Build container images (pull_request) Successful in 2m47s
CI / Auth e2e pack (pull_request) Successful in 7m44s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m9s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m53s
CI / Import/export fidelity gate (push) Successful in 53s
#195: trashed content leaves the search index itself
Trashing a page (promote and subtree modes) clears the affected search
vectors, restoring rebuilds them; pond trash clears every page vector of
the pond, pond restore reindexes only the live pages (pages trashed
inside stay out); the GDPR pseudonymization's personal-pond trash does
the same. reindexAll now converges to the invariant (clears trashed,
rebuilds live), and a one-off migration backfills vectors of
already-trashed content.

The query-side deleted_at guards stay untouched as the independent
second layer - the test proves both layers separately, including writing
a vector back onto a trashed page (simulating a future path that forgot
the clear) and asserting the query still hides it. New provider methods
removePond/reindexPond behind the SearchProvider seam.

Refs #195

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

243 lines
10 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 { SearchProvider } from '../search/search.provider';
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 search: SearchProvider,
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 },
});
// Back into the search index (issue #195) — trash had cleared its vector.
await this.search.indexPage(id);
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)');
}
}