All checks were successful
CD / Build and push images (push) Successful in 2m5s
CI / Lint, typecheck, test (push) Successful in 1m45s
CI / Auth e2e pack (push) Successful in 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 10s
Backend: a generic maintenance-job scheduler (SchedulerService, `jobs` table) that any later maintenance job registers with instead of growing its own timer loop. Due-ness and the run-mutex both live in the DB row (`lastRunAt` survives a restart; claiming a due job is one atomic `UPDATE ... WHERE status != 'RUNNING'`), and an injectable ClockService lets tests simulate retention elapsing without waiting or faking the global clock. Trash endpoints: GET /ponds/:id/trash (list), POST /pages/:id/restore, DELETE /pages/:id/purge (manual, bypasses retention) — all sharing the same purge logic as the scheduled daily job (default 30-day retention, new trash.retentionDays instance setting). Purging deletes a page's content cache, update log, and attachment files/quota; page_versions is a placeholder until M3 exists. Direct navigation to a trashed page now 404s with a distinguishable `page_trashed` code for editors (a plain 404 for everyone else) instead of the generic not-found. Attachment.pageId — added in #27 but never wired up — now gets set on every page state save to whichever page's document currently embeds the file, which is what lets purge find a page's files. Frontend: a per-pond trash view (restore/purge), a "move to trash" action with confirmation in the page menu, and a trash link in the sidebar for pond owners. Also fixes react-query retrying 4xx responses for several seconds by default, which was masking the trash-hint 404 in the UI (and would have affected any other not-found/permission error the same way). Closes #31
104 lines
4.5 KiB
TypeScript
104 lines
4.5 KiB
TypeScript
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 { InterimAccessService } from '../ponds/interim-access.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 access: InterimAccessService,
|
||
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 — same access rule as restoring/purging from it. */
|
||
async list(user: User, pondId: string): Promise<PageView[]> {
|
||
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||
this.access.assertCanModify(user, pond);
|
||
const pages = await this.prisma.page.findMany({
|
||
where: { pondId, deletedAt: { not: null } },
|
||
orderBy: { deletedAt: 'desc' },
|
||
});
|
||
return pages.map((page) => this.pages.viewOf(page));
|
||
}
|
||
|
||
async restore(user: User, id: string): Promise<PageView> {
|
||
const page = await this.prisma.page.findFirst({ where: { id }, include: { pond: true } });
|
||
if (!page || !page.deletedAt) throw new NotFoundException();
|
||
this.access.assertCanModify(user, page.pond);
|
||
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 }, include: { pond: true } });
|
||
if (!page || !page.deletedAt) throw new NotFoundException();
|
||
this.access.assertCanModify(user, page.pond);
|
||
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)');
|
||
}
|
||
}
|