import { Injectable } from '@nestjs/common'; import { ALL_PERMISSIONS, type PermissionGroup, type RoleDetail } from '@sport/types'; import type { CreateRoleInput, UpdateRoleInput } from '@sport/validation'; import { AppException } from '@/common/errors/app.exception'; import { RolesRepository } from './roles.repository'; import { UsersMapper } from './users.mapper'; @Injectable() export class RolesService { constructor( private readonly repository: RolesRepository, private readonly mapper: UsersMapper, ) {} async list(): Promise { const rows = await this.repository.findAll(); return rows.map((row) => this.mapper.toRoleDetail(row)); } async getById(id: string): Promise { const row = await this.repository.findById(id); if (!row) throw AppException.notFound('Role'); return this.mapper.toRoleDetail(row); } /** * The permission catalog, served from the database. * * The seed reconciles this table against the code catalog in @sport/types, so * what the role editor shows is exactly what the running guards enforce — a * deploy skew surfaces as a missing checkbox rather than a grant that silently * does nothing. */ async listPermissions(): Promise { const rows = await this.repository.listPermissions(); return this.mapper.toPermissionGroups(rows); } async create(input: CreateRoleInput): Promise { const existing = await this.repository.findByKey(input.key); if (existing) { throw AppException.conflict('A role with that key already exists.'); } const row = await this.repository.create({ key: input.key, name: input.name, description: input.description ?? null, permissionKeys: this.assertKnownPermissions(input.permissions), }); return this.mapper.toRoleDetail(row); } async update(id: string, input: UpdateRoleInput): Promise { const existing = await this.repository.findById(id); if (!existing) throw AppException.notFound('Role'); /** * System roles may have their permissions edited but not their identity. * * The seed reconciles system roles from code on every run, so a renamed key * would be silently recreated — and an operator would be left wondering why * their change vanished. Rejecting it is clearer than losing it. */ if (existing.isSystem && input.name !== undefined && input.name !== existing.name) { throw AppException.badRequest('System roles cannot be renamed.'); } const row = await this.repository.update(id, { ...(input.name === undefined ? {} : { name: input.name }), ...(input.description === undefined ? {} : { description: input.description ?? null }), ...(input.permissions === undefined ? {} : { permissionKeys: this.assertKnownPermissions(input.permissions) }), }); return this.mapper.toRoleDetail(row); } async delete(id: string): Promise { const existing = await this.repository.findById(id); if (!existing) throw AppException.notFound('Role'); if (existing.isSystem) { throw AppException.badRequest('System roles cannot be deleted.'); } // Deleting a role that people hold would silently strip their access. // Making the operator reassign first keeps the consequence visible. if (existing._count.users > 0) { throw AppException.conflict( `This role is assigned to ${existing._count.users} user(s). Reassign them first.`, ); } await this.repository.delete(id); } /** * Rejects permission keys the code does not define. * * Without this a typo'd key would be stored, displayed as granted, and never * match a guard — an access-control bug that looks like working configuration. */ private assertKnownPermissions(keys: readonly string[]): string[] { const known = new Set(ALL_PERMISSIONS); const unknown = keys.filter((key) => !known.has(key)); if (unknown.length > 0) { throw AppException.badRequest(`Unknown permission(s): ${unknown.join(', ')}`); } return [...new Set(keys)]; } }