Add operational Resellers module (MVP CRUD, eden.md §1.7/§7)

Migration 1788439142825_resellers.cjs: `resellers` table plus nullable
`users.reseller_id` (scope=reseller per ADR-0004), indexes on
resellers.active and users.reseller_id.

packages/database/src/repositories/resellers.ts: list/get/create/update,
COALESCE-based partial update, mirroring the companies repository
pattern. Also adds repositories/audit.ts (hash-chained appendAuditEvent
over the existing audit_log table) and repositories/users.ts
(assignUserToReseller) - foundational pieces this module depends on that
hadn't landed in this worktree yet; audit.ts mirrors the sibling
worktree's already-built implementation verbatim to minimize merge
friction, and users.ts intentionally contains only the one function the
task asked to append (the fuller users repository belongs to the
auth/identity module being built in parallel).

apps/api/src/resellers/*: controller/service/module/dto mirroring the
Companies module's conventions - read open to any authenticated user,
write gated by @RequirePermission('resellers', 'edit'). Deliberate
deviation from legacy noted in code comments: the legacy hardcoded this
CRUD to requireAdmin and exposed reseller<->user assignment as
`PATCH /users/:id`; EDEN generalizes to the permission model (ADR-0004)
and adds `PATCH /v1/resellers/:id/assign-user` instead. Audits
reseller.created / reseller.updated / reseller.user_assigned.

packages/domain-shared/src/validators/br-documents.ts: CPF/CNPJ
digit-check validators (mirrors the sibling worktree's implementation
verbatim), used for the reseller `document` field the same way Companies
validates cnpj.

Known integration gap (by design, not a bug in this module): this
worktree doesn't yet have apps/api/src/auth/* or the companies-phase
exports in packages/database/src/index.ts - both are being built in
sibling worktrees and are explicitly out of scope here (per the task's
boundaries, app.module.ts and packages/database/src/index.ts are left
untouched for the user to wire up at merge time). @eden/database
typecheck/build pass cleanly; @eden/api typecheck/build currently fail
with exactly ten "cannot find module ../auth/..." /
"no exported member resellersRepo/usersRepo/auditRepo/Reseller" errors,
all attributable to those two integration points and nothing else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:44:45 -03:00
parent 44510bd019
commit dca6eb2766
15 changed files with 653 additions and 14 deletions

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");
}
}
}