This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3d6b0e0d4e
commit 5386bc51d1
65 changed files with 4058 additions and 161 deletions
+120
View File
@@ -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)];
}
}