import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { AddMemberInput, ChangeMemberRoleInput, Grant, MemberRole, MemberView, PondMembersView, grantValidationError, } from '@dorfteich/shared'; import { Pond, Prisma, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { toGrant, toGrantColumns } from '../grants/grant-mappers'; import { PermissionService } from '../permissions/permission.service'; import { PondPermissionCache } from '../permissions/pond-permission-cache'; import { PondAccessNotifier } from '../ponds/pond-access-notifier.service'; import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { QuotaService, quotaExceeded } from '../quotas/quota.service'; import { UsersService } from '../users/users.service'; /** Highest capability wins when a user somehow holds several pond-scope grants. */ const ROLE_RANK: Record = { reader: 0, editor: 1, pond_admin: 2 }; /** * Pond membership management (issue #54): a member-centric view over the * pond-scope user grants (#51/#52). Adding by exact username/e-mail keeps the * user directory private; editor/reader seats are enforced against the * per-pond quotas (#22). Personal-pond and last-admin rules come from the * shared grant model and the grant table, respectively. Every change * invalidates the pond's permission cache and notifies the collab server so * live sessions re-validate at once (#39/#53). */ @Injectable() export class MembersService { constructor( private readonly prisma: PrismaService, private readonly users: UsersService, private readonly quotas: QuotaService, private readonly permissions: PermissionService, private readonly permissionCache: PondPermissionCache, private readonly accessNotifier: PondAccessNotifier, private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(MembersService.name); } private async requireLivePond(pondId: string): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); if (!pond) throw new NotFoundException(); return pond; } private static pondScopeUserGrant(pondId: string, userId: string) { return { pondId, subjectType: 'USER' as const, subjectId: userId, scopeType: 'POND' as const, effect: 'ALLOW' as const, }; } private grantOf(role: MemberRole, userId: string): Grant { return { subjectType: 'user', subjectId: userId, role, scopeType: 'pond', scopeId: null, effect: 'allow', }; } private static seatKey(role: 'editor' | 'reader'): 'editors_per_pond' | 'readers_per_pond' { return role === 'editor' ? 'editors_per_pond' : 'readers_per_pond'; } /** The member list, seat usage, and whether the caller may manage (issue #54). */ async list(user: User | null, pondId: string): Promise { const pond = await this.requireLivePond(pondId); const grants = await this.prisma.roleGrant.findMany({ where: { pondId, subjectType: 'USER', scopeType: 'POND', effect: 'ALLOW' }, }); // A user's effective role is the strongest grant they hold at pond scope. const roleByUser = new Map(); for (const row of grants) { const userId = row.subjectId; if (!userId) continue; const role = toGrant(row).role; const current = roleByUser.get(userId); if (!current || ROLE_RANK[role] > ROLE_RANK[current]) roleByUser.set(userId, role); } const users = await this.prisma.user.findMany({ where: { id: { in: [...roleByUser.keys()] } }, select: { id: true, username: true, displayName: true }, }); const members: MemberView[] = users .map((u) => ({ userId: u.id, username: u.username, displayName: u.displayName, role: roleByUser.get(u.id)!, isOwner: u.id === pond.ownerId, })) .sort( (a, b) => ROLE_RANK[b.role] - ROLE_RANK[a.role] || a.displayName.localeCompare(b.displayName), ); const editorLimit = await this.quotas.getEffective('editors_per_pond', { pondId, userId: pond.ownerId, }); const readerLimit = await this.quotas.getEffective('readers_per_pond', { pondId, userId: pond.ownerId, }); const canManage = await this.permissions.hasPondRole(user, pondId, 'pond_admin'); return { members, seats: { editor: { used: members.filter((m) => m.role === 'editor').length, limit: editorLimit }, reader: { used: members.filter((m) => m.role === 'reader').length, limit: readerLimit }, }, pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared', canManage, }; } /** Add a user (by exact username or e-mail) as a member with a role. */ async add(actor: User, pondId: string, input: AddMemberInput): Promise { const pond = await this.requireLivePond(pondId); const target = await this.users.findByUsernameOrEmail(input.usernameOrEmail); if (!target) throw new BadRequestException({ code: 'member_not_found' }); const grant = this.grantOf(input.role, target.id); this.assertGrantValid(grant, pond); await this.prisma.$transaction(async (tx) => { await this.lockMembership(tx, pondId); const existing = await tx.roleGrant.count({ where: MembersService.pondScopeUserGrant(pondId, target.id), }); if (existing > 0) throw new ConflictException({ code: 'member_exists' }); await this.assertSeatAvailable(tx, pond, input.role, null); await tx.roleGrant.create({ data: { pondId, createdBy: actor.id, ...toGrantColumns(grant) }, }); }); await this.accessChanged(pondId); await this.audit.record({ action: 'member.added', actorId: actor.id, targetType: 'pond', targetId: pondId, details: { member: target.id, role: input.role }, }); return this.viewOf(target, input.role, pond); } /** Change a member's role, replacing their pond-scope grant. */ async changeRole( actor: User, pondId: string, memberUserId: string, input: ChangeMemberRoleInput, ): Promise { const pond = await this.requireLivePond(pondId); if (memberUserId === pond.ownerId) throw new ConflictException({ code: 'member_is_owner' }); const target = await this.prisma.user.findUnique({ where: { id: memberUserId }, select: { id: true, username: true, displayName: true }, }); if (!target) throw new NotFoundException({ code: 'member_not_a_member' }); const grant = this.grantOf(input.role, memberUserId); this.assertGrantValid(grant, pond); const changed = await this.prisma.$transaction(async (tx) => { await this.lockMembership(tx, pondId); const current = await tx.roleGrant.findMany({ where: MembersService.pondScopeUserGrant(pondId, memberUserId), }); if (current.length === 0) throw new NotFoundException({ code: 'member_not_a_member' }); // Already exactly this role: nothing to do. if (current.length === 1 && toGrant(current[0]!).role === input.role) return false; // Demoting away from admin must leave at least one Pond Admin behind. if (current.some((g) => g.role === 'POND_ADMIN') && input.role !== 'pond_admin') { await this.assertNotLastAdmin(tx, pondId); } // The replaced grants free their own seat, so exclude this user from the count. await this.assertSeatAvailable(tx, pond, input.role, memberUserId); await tx.roleGrant.deleteMany({ where: MembersService.pondScopeUserGrant(pondId, memberUserId), }); await tx.roleGrant.create({ data: { pondId, createdBy: actor.id, ...toGrantColumns(grant) }, }); return true; }); if (changed) { await this.accessChanged(pondId); await this.audit.record({ action: 'member.role_changed', actorId: actor.id, targetType: 'pond', targetId: pondId, details: { member: memberUserId, role: input.role }, }); } return this.viewOf(target, input.role, pond); } /** Remove a member (drops their pond-scope grant). */ async remove(actor: User, pondId: string, memberUserId: string): Promise { const pond = await this.requireLivePond(pondId); if (memberUserId === pond.ownerId) throw new ConflictException({ code: 'member_is_owner' }); await this.prisma.$transaction(async (tx) => { await this.lockMembership(tx, pondId); const current = await tx.roleGrant.findMany({ where: MembersService.pondScopeUserGrant(pondId, memberUserId), }); if (current.length === 0) throw new NotFoundException({ code: 'member_not_a_member' }); if (current.some((g) => g.role === 'POND_ADMIN')) await this.assertNotLastAdmin(tx, pondId); await tx.roleGrant.deleteMany({ where: MembersService.pondScopeUserGrant(pondId, memberUserId), }); }); await this.accessChanged(pondId); await this.audit.record({ action: 'member.removed', actorId: actor.id, targetType: 'pond', targetId: pondId, details: { member: memberUserId }, }); } private assertGrantValid(grant: Grant, pond: Pond): void { const invalid = grantValidationError(grant, { pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared', }); if (invalid) throw new BadRequestException({ code: invalid }); } /** Serialize concurrent membership changes on one pond (seat counts can't race). */ private async lockMembership(tx: Prisma.TransactionClient, pondId: string): Promise { // ::text because Prisma cannot deserialize the function's void result. await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${`members:${pondId}`}))::text`; } private async assertNotLastAdmin(tx: Prisma.TransactionClient, pondId: string): Promise { const admins = await tx.roleGrant.count({ where: { pondId, role: 'POND_ADMIN', effect: 'ALLOW' }, }); if (admins <= 1) throw new ConflictException({ code: 'grant_last_admin' }); } /** Enforce editor/reader seat quotas; admins are unlimited. */ private async assertSeatAvailable( tx: Prisma.TransactionClient, pond: Pond, role: MemberRole, excludeUserId: string | null, ): Promise { if (role === 'pond_admin') return; const key = MembersService.seatKey(role); const limit = await this.quotas.getEffective(key, { pondId: pond.id, userId: pond.ownerId }); const used = await tx.roleGrant.count({ where: { pondId: pond.id, role: role === 'editor' ? 'EDITOR' : 'READER', scopeType: 'POND', subjectType: 'USER', effect: 'ALLOW', ...(excludeUserId ? { subjectId: { not: excludeUserId } } : {}), }, }); if (used >= limit) throw quotaExceeded(key, limit); } private viewOf( user: { id: string; username: string; displayName: string }, role: MemberRole, pond: Pond, ): MemberView { return { userId: user.id, username: user.username, displayName: user.displayName, role, isOwner: user.id === pond.ownerId, }; } private async accessChanged(pondId: string): Promise { this.permissionCache.invalidate(pondId); await this.accessNotifier.notifyAccessChanged(pondId); } }