diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e18e390..9bd1e9d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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], }) 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 new file mode 100644 index 0000000..f62074a --- /dev/null +++ b/apps/api/src/resellers/dto/assign-user-to-reseller.dto.ts @@ -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; +} diff --git a/apps/api/src/resellers/dto/create-reseller.dto.ts b/apps/api/src/resellers/dto/create-reseller.dto.ts new file mode 100644 index 0000000..8603a99 --- /dev/null +++ b/apps/api/src/resellers/dto/create-reseller.dto.ts @@ -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; +} diff --git a/apps/api/src/resellers/dto/update-reseller.dto.ts b/apps/api/src/resellers/dto/update-reseller.dto.ts new file mode 100644 index 0000000..6e5a84a --- /dev/null +++ b/apps/api/src/resellers/dto/update-reseller.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateResellerDto } from "./create-reseller.dto.js"; + +export class UpdateResellerDto extends PartialType(CreateResellerDto) {} diff --git a/apps/api/src/resellers/resellers.controller.ts b/apps/api/src/resellers/resellers.controller.ts new file mode 100644 index 0000000..704dc07 --- /dev/null +++ b/apps/api/src/resellers/resellers.controller.ts @@ -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); + } +} diff --git a/apps/api/src/resellers/resellers.module.ts b/apps/api/src/resellers/resellers.module.ts new file mode 100644 index 0000000..91ddd9f --- /dev/null +++ b/apps/api/src/resellers/resellers.module.ts @@ -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 {} diff --git a/apps/api/src/resellers/resellers.service.ts b/apps/api/src/resellers/resellers.service.ts new file mode 100644 index 0000000..58dd2ef --- /dev/null +++ b/apps/api/src/resellers/resellers.service.ts @@ -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 { + return resellersRepo.listResellers(includeInactive); + } + + async get(id: string): Promise { + const reseller = await resellersRepo.getResellerById(id); + if (!reseller) throw new NotFoundException("Revenda não encontrada"); + return reseller; + } + + async create(dto: CreateResellerDto, actorId: string): Promise { + 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 { + 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"); + } + } +} diff --git a/packages/database/migrations/1788439142825_resellers.cjs b/packages/database/migrations/1788439142825_resellers.cjs new file mode 100644 index 0000000..28eccaf --- /dev/null +++ b/packages/database/migrations/1788439142825_resellers.cjs @@ -0,0 +1,59 @@ +/** + * Commercial bounded context, first slice: `resellers` — the operational + * reseller/partner registry (eden.md §1.7). Pre-dates the "Programa de + * Canais" approval workflow with public tokens described in eden.md §6 — + * that workflow (client_registrations/reseller_registrations, public + * approval tokens) is explicitly out of scope for this migration and + * lands in its own later phase. This is just the simple operational CRUD + * table eden.md §1.7 describes, already referenced by `quotes.reseller_id` + * conceptually (quotes themselves aren't modeled yet either — Commercial + * phase lands module by module). + * + * Also adds `users.reseller_id` (nullable) — how a user gets scoped to a + * reseller, per eden.md §7 and the `scope=reseller` permission model + * (docs/adr/0004-permission-model.md). Lives in this migration rather + * than a separate one so the FK to `resellers(id)` can be added in the + * same statement the table is created in, without a two-step migration. + */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE resellers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + name TEXT NOT NULL, + document TEXT, + contact_email TEXT, + active BOOLEAN NOT NULL DEFAULT true + ); + COMMENT ON TABLE resellers IS + 'Cadastro operacional de revenda — eden.md §1.7. document é CPF/CNPJ livre (sem CHECK de dígito no banco, validado na camada de aplicação, igual companies.cnpj). Leitura aberta a qualquer autenticado (usado em seletores, ex. Orçamentos); escrita restrita à permissão resellers:edit.'; + + CREATE TRIGGER trg_resellers_updated_at BEFORE UPDATE ON resellers + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + CREATE INDEX idx_resellers_active ON resellers(active); + + -- ===================================================================== + -- users.reseller_id — scopes a user to a reseller (eden.md §7, + -- docs/adr/0004-permission-model.md scope=reseller) + -- ===================================================================== + ALTER TABLE users ADD COLUMN reseller_id UUID REFERENCES resellers(id); + COMMENT ON COLUMN users.reseller_id IS + 'Nullable — vincula o usuário a uma revenda (vendedor de revenda), escopando o que ele vê (eden.md §7). Atribuído via PATCH /v1/resellers/:id/assign-user, nunca aceito de campo vindo do cliente para decisão de autorização (docs/architecture.md, ADR-0004).'; + + CREATE INDEX idx_users_reseller ON users(reseller_id); + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP INDEX IF EXISTS idx_users_reseller; + ALTER TABLE users DROP COLUMN IF EXISTS reseller_id; + DROP TABLE IF EXISTS resellers; + `); +}; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 34eff15..8c8a293 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -5,6 +5,8 @@ export * as sessionsRepo from "./repositories/sessions.js"; export * as auditRepo from "./repositories/audit.js"; export * as companiesRepo from "./repositories/companies.js"; export * as productsRepo from "./repositories/products.js"; +export * as resellersRepo from "./repositories/resellers.js"; export type * from "./repositories/types.js"; export type { Company } from "./repositories/companies.js"; export type { Product, CreateProductInput, UpdateProductInput } from "./repositories/products.js"; +export type { Reseller, CreateResellerInput, UpdateResellerInput } from "./repositories/resellers.js"; diff --git a/packages/database/src/repositories/audit.ts b/packages/database/src/repositories/audit.ts index e3be393..b1d6387 100644 --- a/packages/database/src/repositories/audit.ts +++ b/packages/database/src/repositories/audit.ts @@ -20,12 +20,13 @@ function canonicalJson(value: unknown): string { } /** - * Appends one event to the audit_log hash chain. Never call this inside a - * larger transaction that might roll back for unrelated reasons without - * considering that the chain must stay contiguous — a rolled-back event - * never happened, which is fine (nothing was recorded), but a *committed* - * event must never be skipped when computing previous_event_hash for the - * next one. See .claude/skills/eden-security/references/audit.md. + * Appends one event to the audit_log hash chain (see baseline-schema + * migration and .claude/skills/eden-security/references/audit.md). Never + * call this inside a larger transaction that might roll back for + * unrelated reasons without considering that the chain must stay + * contiguous — a rolled-back event never happened (fine, nothing was + * recorded), but a *committed* event must never be skipped when + * computing previous_event_hash for the next one. */ export async function appendAuditEvent(input: AuditEventInput): Promise { const { rows } = await query<{ event_hash: string }>( diff --git a/packages/database/src/repositories/resellers.ts b/packages/database/src/repositories/resellers.ts new file mode 100644 index 0000000..2441a79 --- /dev/null +++ b/packages/database/src/repositories/resellers.ts @@ -0,0 +1,71 @@ +import { query } from "../pool.js"; + +export interface Reseller { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + name: string; + document: string | null; + contact_email: string | null; + active: boolean; +} + +export async function listResellers(includeInactive = false): Promise { + const { rows } = await query( + includeInactive + ? "SELECT * FROM resellers ORDER BY created_at ASC" + : "SELECT * FROM resellers WHERE active = true ORDER BY created_at ASC", + ); + return rows; +} + +export async function getResellerById(id: string): Promise { + const { rows } = await query("SELECT * FROM resellers WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export interface CreateResellerInput { + name: string; + document?: string | null; + contactEmail?: string | null; + active?: boolean; + createdBy?: string | null; +} + +export async function createReseller(input: CreateResellerInput): Promise { + const { rows } = await query( + `INSERT INTO resellers (name, document, contact_email, active, created_by) + VALUES ($1, $2, $3, COALESCE($4, true), $5) + RETURNING *`, + [ + input.name, + input.document ?? null, + input.contactEmail ?? null, + input.active ?? null, + input.createdBy ?? null, + ], + ); + const reseller = rows[0]; + if (!reseller) throw new Error("failed to create reseller"); + return reseller; +} + +export type UpdateResellerInput = Partial>; + +export async function updateReseller( + id: string, + input: UpdateResellerInput, +): Promise { + const { rows } = await query( + `UPDATE resellers SET + name = COALESCE($2, name), + document = COALESCE($3, document), + contact_email = COALESCE($4, contact_email), + active = COALESCE($5, active) + WHERE id = $1 + RETURNING *`, + [id, input.name ?? null, input.document ?? null, input.contactEmail ?? null, input.active ?? null], + ); + return rows[0] ?? null; +} diff --git a/packages/database/src/repositories/users.ts b/packages/database/src/repositories/users.ts index 8cb9fbd..1770ecf 100644 --- a/packages/database/src/repositories/users.ts +++ b/packages/database/src/repositories/users.ts @@ -124,3 +124,21 @@ export async function updateUserProfile( ); return rows[0] ?? null; } + +/** + * Reseller-scoping: sets or clears which reseller a user belongs to + * (`users.reseller_id`, nullable — eden.md §1.7, §7). Resolved and + * enforced server-side only; callers must never accept `reseller_id` + * from client input for authorization decisions (docs/adr/0004, + * docs/architecture.md). + */ +export async function assignUserToReseller( + userId: string, + resellerId: string | null, +): Promise { + const { rowCount } = await query("UPDATE users SET reseller_id = $2 WHERE id = $1", [ + userId, + resellerId, + ]); + return (rowCount ?? 0) > 0; +} diff --git a/packages/domain-shared/src/validators/br-documents.ts b/packages/domain-shared/src/validators/br-documents.ts index a32fecd..46c0202 100644 --- a/packages/domain-shared/src/validators/br-documents.ts +++ b/packages/domain-shared/src/validators/br-documents.ts @@ -2,7 +2,8 @@ * CPF/CNPJ format validation (digit-check only — never confirms a document * actually exists or belongs to whoever claims it). Ported from the * standard Brazilian algorithm, matching the legacy's isValidCPF/ - * isValidCNPJ (eden.md §1 companies, §3 client registration). + * isValidCNPJ (eden.md §1 companies, §3 client registration, §1.7 + * resellers). */ export function onlyDigits(value: string): string { return value.replace(/\D/g, "");