Integra módulo de Resellers (agente eden-commercial)

Resolve conflitos de merge (users.ts, audit.ts, br-documents.ts,
pnpm-lock/workspace.yaml — o worktree partiu de uma base anterior à
existência do módulo de auth/companies, então recriou versões próprias
de arquivos que já existiam no main; mantido o conteúdo do main,
incorporando só a função nova assignUserToReseller). Adiciona
resellersRepo ao barrel @eden/database e ResellersModule ao app.module.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:54:33 -03:00
13 changed files with 385 additions and 7 deletions

View File

@@ -8,6 +8,7 @@ import { CompaniesModule } from "./companies/companies.module.js";
import { UsersModule } from "./users/users.module.js";
import { RolesModule } from "./roles/roles.module.js";
import { ProductsModule } from "./products/products.module.js";
import { ResellersModule } from "./resellers/resellers.module.js";
@Module({
imports: [
@@ -19,6 +20,7 @@ import { ProductsModule } from "./products/products.module.js";
UsersModule,
RolesModule,
ProductsModule,
ResellersModule,
],
controllers: [HealthController],
})

View File

@@ -0,0 +1,15 @@
import { IsUUID, ValidateIf } 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.
*/
export class AssignUserToResellerDto {
@IsUUID()
userId!: string;
@ValidateIf((_object, value) => value !== null)
@IsUUID()
resellerId!: string | null;
}

View File

@@ -0,0 +1,11 @@
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
export class CreateResellerDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsString() document?: string;
@IsOptional() @IsEmail() contactEmail?: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateResellerDto } from "./create-reseller.dto.js";
export class UpdateResellerDto extends PartialType(CreateResellerDto) {}

View File

@@ -0,0 +1,82 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ResellersService } from "./resellers.service.js";
import { CreateResellerDto } from "./dto/create-reseller.dto.js";
import { UpdateResellerDto } from "./dto/update-reseller.dto.js";
import { AssignUserToResellerDto } from "./dto/assign-user-to-reseller.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Operational reseller CRUD (eden.md §1.7, §7) — MVP scope only. The full
* "Programa de Canais" approval workflow with public tokens (eden.md §6)
* is a separate, later phase and isn't touched here.
*
* Read is open to any authenticated user (no @RequirePermission) —
* mirrors Companies: needed for reseller-selector dropdowns elsewhere
* (e.g. Orçamentos, cadastro de usuário) and isn't sensitive data.
* Write requires `resellers:edit`.
*
* Deliberate deviation from legacy, noted per eden-commercial rules:
* the legacy (`resellers.routes.js`, eden.md §7) hardcoded this whole
* CRUD to `requireAdmin` (a role check — admin/super_admin only,
* stricter than the revenda cadastro/aprovação feature). EDEN
* generalizes that role check to the resource+action+scope permission
* model (docs/adr/0004-permission-model.md) instead, so a role can be
* granted `resellers:edit` without needing the full `admin` role.
*/
@ApiTags("resellers")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/resellers")
export class ResellersController {
constructor(private readonly resellers: ResellersService) {}
@Get()
list(@Query("includeInactive") includeInactive?: string) {
return this.resellers.list(includeInactive === "true");
}
@Get(":id")
get(@Param("id") id: string) {
return this.resellers.get(id);
}
@Post()
@RequirePermission("resellers", "edit")
create(@Body() dto: CreateResellerDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.resellers.create(dto, user.id);
}
@Patch(":id")
@RequirePermission("resellers", "edit")
update(
@Param("id") id: string,
@Body() dto: UpdateResellerDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.resellers.update(id, dto, user.id);
}
/**
* Legacy (eden.md §7) exposed reseller↔user assignment as a
* users-domain route: `PATCH /users/:id { reseller_id }`. EDEN
* deliberately places the same operation here instead — documented
* 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.
*/
@Patch(":id/assign-user")
@RequirePermission("resellers", "edit")
assignUser(
@Param("id") id: string,
@Body() dto: AssignUserToResellerDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.resellers.assignUser(id, dto, user.id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { ResellersController } from "./resellers.controller.js";
import { ResellersService } from "./resellers.service.js";
@Module({
imports: [AuthModule],
controllers: [ResellersController],
providers: [ResellersService],
})
export class ResellersModule {}

View File

@@ -0,0 +1,101 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { resellersRepo, usersRepo, auditRepo, type Reseller } from "@eden/database";
import { onlyDigits, isValidCPF, isValidCNPJ } from "@eden/domain-shared";
import type { CreateResellerDto } from "./dto/create-reseller.dto.js";
import type { UpdateResellerDto } from "./dto/update-reseller.dto.js";
import type { AssignUserToResellerDto } from "./dto/assign-user-to-reseller.dto.js";
@Injectable()
export class ResellersService {
list(includeInactive: boolean): Promise<Reseller[]> {
return resellersRepo.listResellers(includeInactive);
}
async get(id: string): Promise<Reseller> {
const reseller = await resellersRepo.getResellerById(id);
if (!reseller) throw new NotFoundException("Revenda não encontrada");
return reseller;
}
async create(dto: CreateResellerDto, actorId: string): Promise<Reseller> {
this.assertValidDocumentIfPresent(dto.document);
const reseller = await resellersRepo.createReseller({
name: dto.name,
document: dto.document ? onlyDigits(dto.document) : undefined,
contactEmail: dto.contactEmail,
active: dto.active,
createdBy: actorId,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
action: "reseller.created",
resourceType: "reseller",
resourceId: reseller.id,
});
return reseller;
}
async update(id: string, dto: UpdateResellerDto, actorId: string): Promise<Reseller> {
await this.get(id); // 404 if missing
this.assertValidDocumentIfPresent(dto.document);
const reseller = await resellersRepo.updateReseller(id, {
name: dto.name,
document: dto.document ? onlyDigits(dto.document) : undefined,
contactEmail: dto.contactEmail,
active: dto.active,
});
if (!reseller) throw new NotFoundException("Revenda não encontrada");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
action: "reseller.updated",
resourceType: "reseller",
resourceId: id,
metadata: { fields: Object.keys(dto) },
});
return reseller;
}
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
}
const found = await usersRepo.assignUserToReseller(dto.userId, dto.resellerId);
if (!found) throw new NotFoundException("Usuário não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
action: "reseller.user_assigned",
resourceType: "user",
resourceId: dto.userId,
metadata: { resellerId: dto.resellerId },
});
return { userId: dto.userId, resellerId: dto.resellerId };
}
/** Mirrors Companies' assertValidCnpjIfPresent — document is CPF or CNPJ here. */
private assertValidDocumentIfPresent(document: string | undefined): void {
if (!document) return;
const digits = onlyDigits(document);
const valid =
digits.length === 11 ? isValidCPF(digits) : digits.length === 14 ? isValidCNPJ(digits) : false;
if (!valid) {
throw new BadRequestException("CPF/CNPJ inválido");
}
}
}