#193: pond purge — retention job and manual Site-Admin endpoint
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m58s
CI / Build container images (pull_request) Successful in 2m47s
CI / Auth e2e pack (pull_request) Successful in 7m46s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 28s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 5m4s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m58s
CI / Build container images (pull_request) Successful in 2m47s
CI / Auth e2e pack (pull_request) Successful in 7m46s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 28s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 5m4s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
Deletion now actually deletes: a trashed pond past the trash retention (same clock as pages, extended trash-purge job) or purged manually via DELETE /ponds/:id/purge (Site-Admin-only, like pond restore) is removed with everything it holds. Files go first (idempotent rm, resumable on a crash), then one transaction ordered around the FK actions: attachments and labels (Restrict) precede the pond; the page delete cascades versions, comments, content cache incl. the search vector, update log, mentions, label assignments, favorites, outgoing links and open collab sessions; the pond delete cascades grants, usage counters (that is the quota correction), pond-plugin opt-ins and conversion jobs; polymorphic watches and pond quota overrides are deleted explicitly. A purge racing a restore or another purge is a no-op; both paths record a pond.purged audit event. Known residues by design, documented in operations.md: target_slug in other ponds' page links (#235) and backups within their retention. Refs #193 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
This commit is contained in:
parent
394d1c811d
commit
402b22e05f
269
apps/api/src/trash/pond-purge.e2e.db.test.ts
Normal file
269
apps/api/src/trash/pond-purge.e2e.db.test.ts
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
import { TrashService } from './trash.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pond purge end to end (issue #193): deletion must actually delete —
|
||||||
|
* after the purge NOTHING referencing the pond survives (rows, files on
|
||||||
|
* disk, search index), the operation is Site-Admin-only, idempotent, and
|
||||||
|
* both the manual and the retention path leave an audit event.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('pond purge (e2e, issue #193)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'pond purge pass 1';
|
||||||
|
const ids: Record<string, string> = {};
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
let pondId: string;
|
||||||
|
let pageAId: string;
|
||||||
|
let fileId: string;
|
||||||
|
const needle = `zzpurgeneedle${suffix.replaceAll('-', '')}`;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
async function makeUser(handle: string, siteAdmin = false): Promise<void> {
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const username = `pp-${handle}-${suffix}`;
|
||||||
|
const user = await users.createUser({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.org`,
|
||||||
|
displayName: `Purge ${handle}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
ids[handle] = user.id;
|
||||||
|
await users.markEmailVerified(user.id);
|
||||||
|
if (siteAdmin) {
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
|
||||||
|
}
|
||||||
|
cookies[handle] = sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
app = await createTestApp();
|
||||||
|
await makeUser('owner');
|
||||||
|
await makeUser('admin', true);
|
||||||
|
await api()
|
||||||
|
.put(`/api/v1/admin/quotas/user/${ids.owner!}/additional_ponds`)
|
||||||
|
.set('Cookie', cookies.admin!)
|
||||||
|
.send({ value: 5 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// The pond and two pages through the real API (grants, usage, slugs).
|
||||||
|
const pond = await api()
|
||||||
|
.post('/api/v1/ponds')
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ name: `Purge Pond ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
pondId = pond.body.id;
|
||||||
|
const pageA = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ title: `Purge Page A ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
pageAId = pageA.body.id;
|
||||||
|
const pageB = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ title: `Purge Page B ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// A real uploaded file (disk + quota usage), attached to page A.
|
||||||
|
const upload = await api()
|
||||||
|
.post(`/api/v1/pages/${pageAId}/files`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.attach('file', Buffer.from('purge me'), 'purge.txt')
|
||||||
|
.expect(201);
|
||||||
|
fileId = upload.body.id;
|
||||||
|
|
||||||
|
// Every remaining representation, seeded directly: content cache with a
|
||||||
|
// searchable vector, update log, version, comment, label + assignment,
|
||||||
|
// favorite, watches (page + pond), a page link, a pond quota override.
|
||||||
|
// Page creation already seeded a cache row — fill it with content.
|
||||||
|
await prisma.pageContentCache.upsert({
|
||||||
|
where: { pageId: pageAId },
|
||||||
|
create: {
|
||||||
|
pageId: pageAId,
|
||||||
|
plainText: `text with ${needle}`,
|
||||||
|
markdown: needle,
|
||||||
|
html: `<p>${needle}</p>`,
|
||||||
|
outline: [],
|
||||||
|
},
|
||||||
|
update: { plainText: `text with ${needle}`, markdown: needle, html: `<p>${needle}</p>` },
|
||||||
|
});
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`UPDATE page_content_cache SET search_vector = to_tsvector('simple', plain_text) WHERE page_id = $1`,
|
||||||
|
pageAId,
|
||||||
|
);
|
||||||
|
await prisma.pageUpdate.create({
|
||||||
|
data: { pageId: pageAId, seq: 1, update: new Uint8Array([1, 2, 3]) },
|
||||||
|
});
|
||||||
|
await prisma.pageVersion.create({
|
||||||
|
data: {
|
||||||
|
pageId: pageAId,
|
||||||
|
ydocSnapshot: new Uint8Array(),
|
||||||
|
trigger: 'MANUAL',
|
||||||
|
createdBy: ids.owner!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.comment.create({
|
||||||
|
data: { pageId: pageAId, authorId: ids.owner!, body: 'purge comment', anchor: null },
|
||||||
|
});
|
||||||
|
const label = await prisma.label.create({
|
||||||
|
data: { pondId, name: `purge-label-${suffix}`, color: '#00aa00' },
|
||||||
|
});
|
||||||
|
await prisma.pageLabel.create({ data: { pageId: pageAId, labelId: label.id } });
|
||||||
|
await prisma.pageFavorite.create({ data: { pageId: pageAId, userId: ids.owner! } });
|
||||||
|
// The API page creation auto-watched page A already (autoWatchOwnPages).
|
||||||
|
await prisma.watch.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId: ids.owner!, targetType: 'PAGE', targetId: pageAId },
|
||||||
|
{ userId: ids.owner!, targetType: 'POND', targetId: pondId },
|
||||||
|
],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
await prisma.pageLink.create({
|
||||||
|
data: { fromPageId: pageAId, toPageId: pageB.body.id, targetSlug: pageB.body.slug },
|
||||||
|
});
|
||||||
|
await prisma.quotaOverride.create({
|
||||||
|
data: {
|
||||||
|
subjectType: 'POND',
|
||||||
|
subjectId: pondId,
|
||||||
|
quotaKey: 'storage_bytes',
|
||||||
|
value: 123456789n,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const all = Object.values(ids);
|
||||||
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...all, pondId] } } });
|
||||||
|
await prisma.auditEntry.deleteMany({
|
||||||
|
where: { OR: [{ actorId: { in: all } }, { targetId: pondId }] },
|
||||||
|
});
|
||||||
|
const ponds = await prisma.pond.findMany({
|
||||||
|
where: { ownerId: { in: all } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const pondIds = ponds.map((p) => p.id);
|
||||||
|
await prisma.attachment.deleteMany({ where: { pondId: { in: pondIds } } });
|
||||||
|
await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } });
|
||||||
|
await prisma.label.deleteMany({ where: { pondId: { in: pondIds } } });
|
||||||
|
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
|
||||||
|
await prisma.watch.deleteMany({ where: { userId: { in: all } } });
|
||||||
|
await prisma.session.deleteMany({ where: { userId: { in: all } } });
|
||||||
|
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('purges a trashed pond without leaving anything behind', async () => {
|
||||||
|
// Pre-flight: the content is findable and the file exists on disk.
|
||||||
|
const hitsBefore = await api()
|
||||||
|
.get(`/api/v1/search?q=${needle}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.expect(200);
|
||||||
|
expect(hitsBefore.body.length).toBeGreaterThan(0);
|
||||||
|
const filePath = join(process.env.UPLOADS_DIR!, pondId, fileId);
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
|
||||||
|
// Only a TRASHED pond can be purged, and only by a Site Admin.
|
||||||
|
await api().delete(`/api/v1/ponds/${pondId}/purge`).set('Cookie', cookies.admin!).expect(404);
|
||||||
|
await api().delete(`/api/v1/ponds/${pondId}`).set('Cookie', cookies.owner!).expect(204);
|
||||||
|
await api().delete(`/api/v1/ponds/${pondId}/purge`).set('Cookie', cookies.owner!).expect(403);
|
||||||
|
|
||||||
|
await api().delete(`/api/v1/ponds/${pondId}/purge`).set('Cookie', cookies.admin!).expect(204);
|
||||||
|
|
||||||
|
// Nothing referencing the pond survives.
|
||||||
|
expect(await prisma.pond.findUnique({ where: { id: pondId } })).toBeNull();
|
||||||
|
expect(await prisma.page.count({ where: { pondId } })).toBe(0);
|
||||||
|
expect(await prisma.attachment.count({ where: { pondId } })).toBe(0);
|
||||||
|
expect(await prisma.label.count({ where: { pondId } })).toBe(0);
|
||||||
|
expect(await prisma.roleGrant.count({ where: { pondId } })).toBe(0);
|
||||||
|
expect(await prisma.pondUsage.count({ where: { pondId } })).toBe(0);
|
||||||
|
expect(await prisma.pageContentCache.count({ where: { pageId: pageAId } })).toBe(0);
|
||||||
|
expect(await prisma.pageUpdate.count({ where: { pageId: pageAId } })).toBe(0);
|
||||||
|
expect(await prisma.pageVersion.count({ where: { pageId: pageAId } })).toBe(0);
|
||||||
|
expect(await prisma.comment.count({ where: { pageId: pageAId } })).toBe(0);
|
||||||
|
expect(await prisma.pageLabel.count({ where: { pageId: pageAId } })).toBe(0);
|
||||||
|
expect(await prisma.pageFavorite.count({ where: { pageId: pageAId } })).toBe(0);
|
||||||
|
expect(await prisma.pageLink.count({ where: { fromPageId: pageAId } })).toBe(0);
|
||||||
|
expect(
|
||||||
|
await prisma.watch.count({
|
||||||
|
where: { targetId: { in: [pondId, pageAId] } },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
await prisma.quotaOverride.count({ where: { subjectType: 'POND', subjectId: pondId } }),
|
||||||
|
).toBe(0);
|
||||||
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
|
||||||
|
// The follow-up search finds nothing of the purged pond.
|
||||||
|
const hitsAfter = await api()
|
||||||
|
.get(`/api/v1/search?q=${needle}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.expect(200);
|
||||||
|
expect(hitsAfter.body).toEqual([]);
|
||||||
|
|
||||||
|
// Idempotent: a second purge is a clean 404, not an error.
|
||||||
|
await api().delete(`/api/v1/ponds/${pondId}/purge`).set('Cookie', cookies.admin!).expect(404);
|
||||||
|
|
||||||
|
// The manual purge left an audit event.
|
||||||
|
const audit = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'pond.purged', targetId: pondId },
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 2, attachments: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('purges due ponds on the retention path with an audit event', async () => {
|
||||||
|
const trashedLongAgo = await prisma.pond.create({
|
||||||
|
data: {
|
||||||
|
slug: `pp-ret-${suffix}`,
|
||||||
|
name: 'Retention Pond',
|
||||||
|
type: 'SHARED',
|
||||||
|
ownerId: ids.owner!,
|
||||||
|
deletedAt: new Date('2020-01-01T00:00:00Z'),
|
||||||
|
deletedBy: ids.owner!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
pondId: trashedLongAgo.id,
|
||||||
|
slug: `pp-ret-page-${suffix}`,
|
||||||
|
title: 'Retention Page',
|
||||||
|
createdBy: ids.owner!,
|
||||||
|
sortKey: 'a0',
|
||||||
|
ydocState: new Uint8Array(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.get(TrashService).purgeDuePonds();
|
||||||
|
|
||||||
|
expect(await prisma.pond.findUnique({ where: { id: trashedLongAgo.id } })).toBeNull();
|
||||||
|
expect(await prisma.page.count({ where: { pondId: trashedLongAgo.id } })).toBe(0);
|
||||||
|
const audit = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'pond.purged', targetId: trashedLongAgo.id },
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
expect(audit!.details).toMatchObject({ trigger: 'retention' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,12 +1,13 @@
|
|||||||
import { Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common';
|
import { Controller, Delete, Get, HttpCode, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
import { PageView } from '@dorfteich/shared';
|
import { PageView } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
||||||
|
|
||||||
import { TrashService } from './trash.service';
|
import { TrashService } from './trash.service';
|
||||||
|
|
||||||
/** Page trash: list, restore, purge-single (issue #31). */
|
/** Page trash: list, restore, purge-single (issue #31); pond purge (#193). */
|
||||||
@Controller()
|
@Controller()
|
||||||
export class TrashController {
|
export class TrashController {
|
||||||
constructor(private readonly trash: TrashService) {}
|
constructor(private readonly trash: TrashService) {}
|
||||||
@ -29,4 +30,15 @@ export class TrashController {
|
|||||||
async purge(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
async purge(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||||
await this.trash.purgeNow(request.user!, id);
|
await this.trash.purgeNow(request.user!, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual pond purge (issue #193) — Site-Admin-only, like pond restore:
|
||||||
|
* the pond trash is a Site-Admin surface (ponds.controller.ts).
|
||||||
|
*/
|
||||||
|
@Delete('ponds/:id/purge')
|
||||||
|
@HttpCode(204)
|
||||||
|
@UseGuards(SiteAdminGuard)
|
||||||
|
async purgePond(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||||
|
await this.trash.purgePondNow(request.user!, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,11 @@ export class TrashModule implements OnModuleInit {
|
|||||||
this.scheduler.register({
|
this.scheduler.register({
|
||||||
name: 'trash-purge',
|
name: 'trash-purge',
|
||||||
cadenceSeconds: TRASH_PURGE_CADENCE_SECONDS,
|
cadenceSeconds: TRASH_PURGE_CADENCE_SECONDS,
|
||||||
run: () => this.trash.purgeDuePages(),
|
// Pages first, then whole ponds (issue #193) — same retention clock.
|
||||||
|
run: async () => {
|
||||||
|
await this.trash.purgeDuePages();
|
||||||
|
await this.trash.purgeDuePonds();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { PageView } from '@dorfteich/shared';
|
|||||||
import { User } from '@prisma/client';
|
import { User } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { ClockService } from '../common/clock.service';
|
import { ClockService } from '../common/clock.service';
|
||||||
import { PagesService } from '../pages/pages.service';
|
import { PagesService } from '../pages/pages.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
@ -32,6 +33,7 @@ export class TrashService {
|
|||||||
private readonly storage: FileStorageService,
|
private readonly storage: FileStorageService,
|
||||||
private readonly clock: ClockService,
|
private readonly clock: ClockService,
|
||||||
private readonly watches: WatchesService,
|
private readonly watches: WatchesService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(TrashService.name);
|
this.logger.setContext(TrashService.name);
|
||||||
@ -113,6 +115,98 @@ export class TrashService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
* Deletes state, content cache, files, and (once M3 exists) versions for
|
||||||
* one page — the fixed set of things `#31`'s scope names. Version rows
|
* one page — the fixed set of things `#31`'s scope names. Version rows
|
||||||
|
|||||||
@ -110,6 +110,17 @@ monitoring, structured logs, backup alerting — no dedicated metrics stack.
|
|||||||
Job outcomes are visible in the Site Admin UI (last run, status) — that
|
Job outcomes are visible in the Site Admin UI (last run, status) — that
|
||||||
panel is the operator's single glance for instance health.
|
panel is the operator's single glance for instance health.
|
||||||
|
|
||||||
|
Pond purge (issue #193): a trashed pond past the trash retention is
|
||||||
|
removed with everything it holds — pages (cascading versions, comments,
|
||||||
|
content cache incl. the search vector, update log, mentions, label
|
||||||
|
assignments, favorites, outgoing links, open collab sessions),
|
||||||
|
attachments (rows and files on disk), labels, grants, usage counters,
|
||||||
|
watches, pond quota overrides, pond-plugin opt-ins, and conversion jobs.
|
||||||
|
A Site Admin can purge a trashed pond immediately
|
||||||
|
(`DELETE /ponds/:id/purge`); both paths record a `pond.purged` audit
|
||||||
|
event. Known residues by design: `page_links.target_slug` in OTHER
|
||||||
|
ponds' pages (#235) and backups within their retention.
|
||||||
|
|
||||||
## Update strategy
|
## Update strategy
|
||||||
|
|
||||||
- **Own stages**: pipeline-driven (see `deployment.md`); Prod only via
|
- **Own stages**: pipeline-driven (see `deployment.md`); Prod only via
|
||||||
|
|||||||
@ -99,7 +99,7 @@ chain`_
|
|||||||
abschaltbar · 2 AT · #191
|
abschaltbar · 2 AT · #191
|
||||||
- [x] **Backup-Ziele einschränkbar** — Allowlist, WebDAV/rsync per Deploy
|
- [x] **Backup-Ziele einschränkbar** — Allowlist, WebDAV/rsync per Deploy
|
||||||
vollständig deaktivierbar · 2 AT · #192
|
vollständig deaktivierbar · 2 AT · #192
|
||||||
- [ ] **Pond-Purge implementieren** — getrashte Ponds bleiben ewig liegen · 3 AT · #193
|
- [x] **Pond-Purge implementieren** — getrashte Ponds bleiben ewig liegen · 3 AT · #193
|
||||||
- [ ] **Orphan-File-Sweep** implementieren, `Attachment.deletedAt` nutzen
|
- [ ] **Orphan-File-Sweep** implementieren, `Attachment.deletedAt` nutzen
|
||||||
oder entfernen · 2 AT · #194
|
oder entfernen · 2 AT · #194
|
||||||
- [ ] **Papierkorb aus dem Suchindex** entfernen statt query-seitig filtern · 2 AT · #195
|
- [ ] **Papierkorb aus dem Suchindex** entfernen statt query-seitig filtern · 2 AT · #195
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user