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 { 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 { 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([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 { 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 { 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 { 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 { 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 { 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)'); } }