dorfteich/apps/api/src/ponds/ponds.service.ts
Claude Fable 5 960a806ee3
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
#195: trashed content leaves the search index itself
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
2026-07-30 14:07:34 +02:00

222 lines
8.7 KiB
TypeScript

import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import {
CreatePondInput,
PondView,
UpdatePondInput,
pondSettingsSchema,
slugify,
} from '@dorfteich/shared';
import { Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
import { SearchProvider } from '../search/search.provider';
import { PondAccessNotifier } from './pond-access-notifier.service';
@Injectable()
export class PondsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly quotas: QuotaService,
private readonly accessNotifier: PondAccessNotifier,
private readonly search: SearchProvider,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PondsService.name);
}
viewOf(pond: Pond): PondView {
return {
id: pond.id,
slug: pond.slug,
name: pond.name,
description: pond.description,
type: pond.type === 'PERSONAL' ? 'personal' : 'shared',
ownerId: pond.ownerId,
// Stored settings hold only deviations; the schema fills defaults.
settings: pondSettingsSchema.parse(pond.settings ?? {}),
createdAt: pond.createdAt.toISOString(),
deletedAt: pond.deletedAt?.toISOString() ?? null,
};
}
/**
* Deterministic unique slug: the base slug, else `base-2`, `base-3`, …
* (never `-1`, so the unsuffixed original reads as number one). Deleted
* ponds keep their slug reserved — restore must not collide.
*/
async generateUniqueSlug(base: string, fallback: string): Promise<string> {
const slug = slugify(base) || slugify(fallback) || 'pond';
const taken = new Set(
(
await this.prisma.pond.findMany({
where: { OR: [{ slug }, { slug: { startsWith: `${slug}-` } }] },
select: { slug: true },
})
).map((row) => row.slug),
);
if (!taken.has(slug)) return slug;
for (let n = 2; ; n += 1) {
const candidate = `${slug}-${n}`;
if (!taken.has(candidate)) return candidate;
}
}
/**
* The owner's Pond Admin grant, created with the pond itself (issue #52) —
* access is decided solely by grants, so a pond without this row would be
* invisible even to its owner. Written directly (not through GrantsService)
* because the "no pond_admin grants on personal ponds" rule is about
* *additional* admins; the owner is the one admin every pond starts with.
*/
private static ownerAdminGrant(pondId: string, ownerId: string): Prisma.RoleGrantCreateInput {
return {
pond: { connect: { id: pondId } },
subjectType: 'USER',
subjectId: ownerId,
role: 'POND_ADMIN',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
};
}
async createShared(owner: User, input: CreatePondInput): Promise<PondView> {
const slug = await this.generateUniqueSlug(input.name, owner.username);
// Quota check and create share one transaction — the advisory lock in
// the check makes concurrent creations by the same user race-safe. The
// owner grant joins it so no pond ever exists without its admin.
const pond = await this.prisma.$transaction(async (tx) => {
await this.quotas.assertCanCreateSharedPond(tx, owner.id);
const created = await tx.pond.create({
data: {
slug,
name: input.name,
description: input.description,
type: 'SHARED',
ownerId: owner.id,
},
});
await tx.roleGrant.create({ data: PondsService.ownerAdminGrant(created.id, owner.id) });
return created;
});
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
return this.viewOf(pond);
}
/**
* Creates the personal pond on first e-mail verification (issue #21).
* Idempotent: a user has at most one personal pond, even a trashed one
* blocks re-creation (restore instead of duplicating).
*/
async ensurePersonalPond(user: User): Promise<void> {
const existing = await this.prisma.pond.findFirst({
where: { ownerId: user.id, type: 'PERSONAL' },
select: { id: true },
});
if (existing) return;
const slug = await this.generateUniqueSlug(user.displayName, user.username);
const pond = await this.prisma.$transaction(async (tx) => {
const created = await tx.pond.create({
data: { slug, name: user.displayName, type: 'PERSONAL', ownerId: user.id },
});
await tx.roleGrant.create({ data: PondsService.ownerAdminGrant(created.id, user.id) });
return created;
});
this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created');
}
async listVisible(user: User): Promise<PondView[]> {
const ponds = await this.prisma.pond.findMany({
where: { ...(await this.permissions.visiblePondsWhere(user)), deletedAt: null },
orderBy: { name: 'asc' },
});
return ponds.map((pond) => this.viewOf(pond));
}
/** Existence/permission are the guard's job (#52); this only loads. */
async getVisibleBySlug(_user: User, slug: string): Promise<PondView> {
const pond = await this.prisma.pond.findFirst({ where: { slug, deletedAt: null } });
if (!pond) throw new NotFoundException();
return this.viewOf(pond);
}
async update(_user: User, id: string, input: UpdatePondInput): Promise<PondView> {
const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } });
if (!pond) throw new NotFoundException();
// Stored settings hold only deviations from the defaults; merge in
// whichever of the settings keys this request changes (#26/#66/#91/#104).
const settingsChanged =
input.sidebarSort !== undefined ||
input.sidebarView !== undefined ||
input.fonts !== undefined ||
input.commentPolicy !== undefined ||
input.apiEnabled !== undefined ||
input.mcpEnabled !== undefined ||
input.theme !== undefined;
const settings = !settingsChanged
? undefined
: {
...(pond.settings as object),
...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}),
...(input.sidebarView !== undefined ? { sidebarView: input.sidebarView } : {}),
...(input.fonts !== undefined ? { fonts: input.fonts } : {}),
...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}),
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
...(input.theme !== undefined ? { theme: input.theme } : {}),
};
const updated = await this.prisma.pond.update({
where: { id },
data: { name: input.name, description: input.description, settings },
});
return this.viewOf(updated);
}
async softDelete(user: User, id: string): Promise<void> {
const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } });
if (!pond) throw new NotFoundException();
if (pond.type === 'PERSONAL') {
// The personal pond is the account's home — it cannot be trashed.
throw new ForbiddenException({ code: 'personal_pond_undeletable' });
}
await this.prisma.pond.update({
where: { id },
data: { deletedAt: new Date(), deletedBy: user.id },
});
// The whole pond leaves the search index (issue #195); the query-side
// deleted_at guards in the provider stay as the second layer.
await this.search.removePond(id);
this.logger.info({ pondId: id, userId: user.id }, 'audit: pond trashed');
// Revalidate any live collaboration sessions on the pond's pages
// (issue #39); grant changes fire the same notification (#52/#53).
await this.accessNotifier.notifyAccessChanged(id);
}
/** Site-Admin-only (guarded at the controller): the pond-level trash. */
async listTrash(): Promise<PondView[]> {
const ponds = await this.prisma.pond.findMany({
where: { deletedAt: { not: null } },
orderBy: { deletedAt: 'desc' },
});
return ponds.map((pond) => this.viewOf(pond));
}
/** Site-Admin-only (guarded at the controller). */
async restore(id: string): Promise<PondView> {
const pond = await this.prisma.pond.update({
where: { id },
data: { deletedAt: null, deletedBy: null },
});
// Live pages return to the search index; pages trashed inside the pond
// stay out (issue #195).
await this.search.reindexPond(id);
this.logger.info({ pondId: id }, 'audit: pond restored');
return this.viewOf(pond);
}
}