121 lines
4.1 KiB
TypeScript
121 lines
4.1 KiB
TypeScript
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<RoleDetail[]> {
|
|
const rows = await this.repository.findAll();
|
|
return rows.map((row) => this.mapper.toRoleDetail(row));
|
|
}
|
|
|
|
async getById(id: string): Promise<RoleDetail> {
|
|
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<PermissionGroup[]> {
|
|
const rows = await this.repository.listPermissions();
|
|
return this.mapper.toPermissionGroups(rows);
|
|
}
|
|
|
|
async create(input: CreateRoleInput): Promise<RoleDetail> {
|
|
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<RoleDetail> {
|
|
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<void> {
|
|
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<string>(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)];
|
|
}
|
|
}
|