dorfteich/apps/api/src/trash/trash.service.ts
Claude Fable 5 0c6494f209
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 2m25s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 12s
Enforce permissions in API guards and retire interim access (#52)
Every route now declares its access rule explicitly and is enforced
through the shared resolution algorithm (permissions.md):

- PermissionGuard + decorators (@RequiresPondRole, @RequiresPagePermission,
  @RequiresAttachmentPermission, @AuthenticatedOnly) applied to every
  route; a route-enumeration test proves full coverage alongside
  @Public()/Site-Admin-guarded routes.
- 404/403 policy (documented in README conventions): denied reads answer
  404 (existence hiding), denied writes on readable things answer 403;
  trash views need write capability (ADR 0013).
- PermissionService resolves page/pond questions via the shared resolver,
  with an in-process pond-context cache (grants + label parents) that is
  invalidated on every grant/label-tree change and TTL-bounded as a
  multi-process safety net. Grant changes also fire pond_access_changed
  for collab revalidation (#39/#53).
- shared: pond-scope resolution (hasPondRole, canSeePond) next to the
  page resolver; grant wire schemas + GrantView.
- Owner Pond-Admin grants: migration backfill for all existing ponds,
  created transactionally with every new pond (shared + personal + seed).
- Grant CRUD under /ponds/:id/grants (pond_admin-gated) with structural
  and referential validation, last-admin protection, audit logs.
- InterimAccessService deleted; page lists, search, backlinks, phantom
  links, and trash listings are filtered per page through the resolver;
  collab tokens are now truly ro for readers.
- Fixture-matrix e2e (reader/editor/pond admin/foreign, label-deny,
  authenticated-subject, revoke-then-immediate-deny cache test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 16:31:41 +02:00

109 lines
4.6 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 { PermissionService } from '../permissions/permission.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 permissions: PermissionService,
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: 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));
}
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 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 } });
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);
}
}
/**
* 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)');
}
}