Stage M2
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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)];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user