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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); } }