fix: assign-user de reseller usava resellerId do body, não o :id da URL
Bug de design encontrado em revisão: PATCH /v1/resellers/:id/assign-user aceitava um resellerId diferente no corpo, e a implementação usava esse valor do corpo como alvo real da atribuição — o :id da URL virava só uma checagem de existência, não o alvo de fato. Corrigido: o :id da URL é agora a única fonte de verdade; desatribuição (reseller_id = null) virou um endpoint próprio (POST /v1/resellers/unassign-user), já que 'nenhuma revenda' não pertence à URL de nenhuma revenda específica. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
* The reseller being assigned to is always the `:id` from the URL — never
|
||||||
* explicit `null`, not by omitting the field) — @ValidateIf lets a null
|
* a value from the body (an earlier version accepted `resellerId` here
|
||||||
* value through while still requiring a valid UUID whenever it's present.
|
* 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 {
|
export class AssignUserToResellerDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
userId!: string;
|
userId!: string;
|
||||||
|
|
||||||
@ValidateIf((_object, value) => value !== null)
|
|
||||||
@IsUUID()
|
|
||||||
resellerId!: string | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ export class ResellersController {
|
|||||||
* deviation — so it stays gated by `resellers:edit` (matching the
|
* deviation — so it stays gated by `resellers:edit` (matching the
|
||||||
* legacy's `requireAdmin` scope for this screen) rather than needing a
|
* legacy's `requireAdmin` scope for this screen) rather than needing a
|
||||||
* separate `users:edit` grant just to manage a reseller's roster from
|
* 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")
|
@Patch(":id/assign-user")
|
||||||
@RequirePermission("resellers", "edit")
|
@RequirePermission("resellers", "edit")
|
||||||
@@ -79,4 +80,11 @@ export class ResellersController {
|
|||||||
) {
|
) {
|
||||||
return this.resellers.assignUser(id, dto, user.id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,17 +63,15 @@ export class ResellersService {
|
|||||||
return reseller;
|
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(
|
async assignUser(
|
||||||
id: string,
|
id: string,
|
||||||
dto: AssignUserToResellerDto,
|
dto: AssignUserToResellerDto,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
): Promise<{ userId: string; resellerId: string | null }> {
|
): Promise<{ userId: string; resellerId: string }> {
|
||||||
await this.get(id); // 404 if the reseller in the URL doesn't exist
|
await this.get(id); // 404 if the reseller doesn't exist
|
||||||
if (dto.resellerId !== null && dto.resellerId !== id) {
|
|
||||||
await this.get(dto.resellerId); // 404 if a different target 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");
|
if (!found) throw new NotFoundException("Usuário não encontrado");
|
||||||
|
|
||||||
await auditRepo.appendAuditEvent({
|
await auditRepo.appendAuditEvent({
|
||||||
@@ -82,10 +80,26 @@ export class ResellersService {
|
|||||||
action: "reseller.user_assigned",
|
action: "reseller.user_assigned",
|
||||||
resourceType: "user",
|
resourceType: "user",
|
||||||
resourceId: dto.userId,
|
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. */
|
/** Mirrors Companies' assertValidCnpjIfPresent — document is CPF or CNPJ here. */
|
||||||
|
|||||||
Reference in New Issue
Block a user