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
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
243 lines
10 KiB
TypeScript
243 lines
10 KiB
TypeScript
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)');
|
||
}
|
||
}
|