diff --git a/apps/api/src/resellers/dto/assign-user-to-reseller.dto.ts b/apps/api/src/resellers/dto/assign-user-to-reseller.dto.ts index f62074a..8fbb16f 100644 --- a/apps/api/src/resellers/dto/assign-user-to-reseller.dto.ts +++ b/apps/api/src/resellers/dto/assign-user-to-reseller.dto.ts @@ -1,15 +1,15 @@ -import { IsUUID, ValidateIf } from "class-validator"; +import { IsUUID } from "class-validator"; /** - * `resellerId` is required-but-nullable (unassign is expressed as an - * explicit `null`, not by omitting the field) — @ValidateIf lets a null - * value through while still requiring a valid UUID whenever it's present. + * The reseller being assigned to is always the `:id` from the URL — never + * a value from the body (an earlier version accepted `resellerId` here + * too, which let the body silently redirect the assignment to a + * *different* reseller than the one in the URL; fixed so the URL is the + * single source of truth, matching REST convention and avoiding a + * confusing mismatch between what the endpoint path implies and what it + * actually does). */ export class AssignUserToResellerDto { @IsUUID() userId!: string; - - @ValidateIf((_object, value) => value !== null) - @IsUUID() - resellerId!: string | null; } diff --git a/apps/api/src/resellers/resellers.controller.ts b/apps/api/src/resellers/resellers.controller.ts index 704dc07..4f5fde1 100644 --- a/apps/api/src/resellers/resellers.controller.ts +++ b/apps/api/src/resellers/resellers.controller.ts @@ -68,7 +68,8 @@ export class ResellersController { * deviation — so it stays gated by `resellers:edit` (matching the * legacy's `requireAdmin` scope for this screen) rather than needing a * separate `users:edit` grant just to manage a reseller's roster from - * the Resellers screen. + * the Resellers screen. The reseller assigned to is always the `:id` + * in this URL, never a value from the body. */ @Patch(":id/assign-user") @RequirePermission("resellers", "edit") @@ -79,4 +80,11 @@ export class ResellersController { ) { return this.resellers.assignUser(id, dto, user.id); } + + /** Clears a user's reseller assignment — not scoped under `:id` since "unassigned" isn't any particular reseller. */ + @Post("unassign-user") + @RequirePermission("resellers", "edit") + unassignUser(@Body() dto: AssignUserToResellerDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.resellers.unassignUser(dto.userId, user.id); + } } diff --git a/apps/api/src/resellers/resellers.service.ts b/apps/api/src/resellers/resellers.service.ts index 58dd2ef..d9bf02d 100644 --- a/apps/api/src/resellers/resellers.service.ts +++ b/apps/api/src/resellers/resellers.service.ts @@ -63,17 +63,15 @@ export class ResellersService { return reseller; } + /** Always assigns to the reseller identified by the URL `:id` — see the DTO's comment for why that's now the only source of truth. */ async assignUser( id: string, dto: AssignUserToResellerDto, actorId: string, - ): Promise<{ userId: string; resellerId: string | null }> { - await this.get(id); // 404 if the reseller in the URL doesn't exist - if (dto.resellerId !== null && dto.resellerId !== id) { - await this.get(dto.resellerId); // 404 if a different target reseller doesn't exist - } + ): Promise<{ userId: string; resellerId: string }> { + await this.get(id); // 404 if the reseller doesn't exist - const found = await usersRepo.assignUserToReseller(dto.userId, dto.resellerId); + const found = await usersRepo.assignUserToReseller(dto.userId, id); if (!found) throw new NotFoundException("Usuário não encontrado"); await auditRepo.appendAuditEvent({ @@ -82,10 +80,26 @@ export class ResellersService { action: "reseller.user_assigned", resourceType: "user", resourceId: dto.userId, - metadata: { resellerId: dto.resellerId }, + metadata: { resellerId: id }, }); - return { userId: dto.userId, resellerId: dto.resellerId }; + return { userId: dto.userId, resellerId: id }; + } + + /** Clears a user's reseller assignment (sets `reseller_id = null`) — not scoped to any particular reseller's URL, since "no reseller" isn't a reseller. */ + async unassignUser(userId: string, actorId: string): Promise<{ userId: string; resellerId: null }> { + const found = await usersRepo.assignUserToReseller(userId, null); + if (!found) throw new NotFoundException("Usuário não encontrado"); + + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId, + action: "reseller.user_unassigned", + resourceType: "user", + resourceId: userId, + }); + + return { userId, resellerId: null }; } /** Mirrors Companies' assertValidCnpjIfPresent — document is CPF or CNPJ here. */