import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { AdminUserListQuery, AdminUserListView, AdminUserStatus, AdminUserView, } from '@dorfteich/shared'; import { Prisma, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { AuthService } from '../auth/auth.service'; import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { PseudonymizationService } from './pseudonymization.service'; /** * Site-Admin user administration (issue #59): the searchable list plus the * lifecycle actions (disable/enable, resend verification, delete, grant/revoke * Site Admin). Guards protect the operator from locking the instance out — * you cannot act on your own account, and the last Site Admin cannot be * dropped. Every action is audit-logged with the actor; Site-Admin gating is * the controller's job. */ @Injectable() export class UserAdminService { constructor( private readonly prisma: PrismaService, private readonly pseudonymizer: PseudonymizationService, private readonly auth: AuthService, private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(UserAdminService.name); } async list(query: AdminUserListQuery): Promise { const q = query.q?.trim(); const where: Prisma.UserWhereInput = q ? { OR: [ { username: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }, { displayName: { contains: q, mode: 'insensitive' } }, ], } : {}; const [total, rows] = await Promise.all([ this.prisma.user.count({ where }), this.prisma.user.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (query.page - 1) * query.pageSize, take: query.pageSize, }), ]); const owned = await this.prisma.pond.groupBy({ by: ['ownerId'], where: { ownerId: { in: rows.map((u) => u.id) }, deletedAt: null }, _count: { _all: true }, }); const pondCount = new Map(owned.map((o) => [o.ownerId, o._count._all])); return { users: rows.map((u) => this.viewOf(u, pondCount.get(u.id) ?? 0)), total, page: query.page, pageSize: query.pageSize, }; } async setDisabled(actor: User, id: string, disabled: boolean): Promise { await this.requireOther(actor, id); const updated = await this.prisma.user.update({ where: { id }, data: { status: disabled ? 'DISABLED' : 'ACTIVE' }, }); // A disabled user is logged out everywhere; login then blocks with a // distinct message (auth.service: account_disabled). if (disabled) await this.prisma.session.deleteMany({ where: { userId: id } }); await this.audit.record({ action: 'user.disabled_set', actorId: actor.id, targetType: 'user', targetId: id, details: { disabled }, }); return this.viewOf(updated, await this.pondCountOf(id)); } async resendVerification(actor: User, id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new NotFoundException(); await this.auth.resendVerification(user.email); // no-op unless PENDING await this.audit.record({ action: 'user.verification_resent', actorId: actor.id, targetType: 'user', targetId: id, }); } async deleteUser(actor: User, id: string): Promise { const user = await this.requireOther(actor, id); if (user.isSiteAdmin) await this.assertNotLastSiteAdmin(); await this.pseudonymizer.pseudonymize(id); await this.audit.record({ action: 'user.deleted', actorId: actor.id, targetType: 'user', targetId: id, }); } async setSiteAdmin(actor: User, id: string, value: boolean): Promise { const user = await this.requireOther(actor, id); // not on self if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin(); const updated = await this.prisma.user.update({ where: { id }, // A manual toggle takes ownership of the flag: the IdP mapping // (#217) may only revoke what it itself set. data: { isSiteAdmin: value, isSiteAdminManaged: false }, }); await this.audit.record({ action: 'user.site_admin_set', actorId: actor.id, targetType: 'user', targetId: id, details: { isSiteAdmin: value }, }); return this.viewOf(updated, await this.pondCountOf(id)); } private async requireOther(actor: User, id: string): Promise { if (id === actor.id) throw new BadRequestException({ code: 'cannot_modify_self' }); const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new NotFoundException(); return user; } private async assertNotLastSiteAdmin(): Promise { const admins = await this.prisma.user.count({ where: { isSiteAdmin: true, status: { not: 'DISABLED' } }, }); if (admins <= 1) throw new BadRequestException({ code: 'last_site_admin' }); } private async pondCountOf(id: string): Promise { return this.prisma.pond.count({ where: { ownerId: id, deletedAt: null } }); } private viewOf(user: User, pondCount: number): AdminUserView { return { id: user.id, username: user.username, email: user.email, displayName: user.displayName, status: user.status as AdminUserStatus, isSiteAdmin: user.isSiteAdmin, createdAt: user.createdAt.toISOString(), lastLoginAt: user.lastLoginAt?.toISOString() ?? null, pondCount, }; } }