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
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
222 lines
8.7 KiB
TypeScript
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);
|
|
}
|
|
}
|