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:
15
apps/api/src/resellers/dto/assign-user-to-reseller.dto.ts
Normal file
15
apps/api/src/resellers/dto/assign-user-to-reseller.dto.ts
Normal 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;
|
||||
}
|
||||
11
apps/api/src/resellers/dto/create-reseller.dto.ts
Normal file
11
apps/api/src/resellers/dto/create-reseller.dto.ts
Normal 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;
|
||||
}
|
||||
4
apps/api/src/resellers/dto/update-reseller.dto.ts
Normal file
4
apps/api/src/resellers/dto/update-reseller.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateResellerDto } from "./create-reseller.dto.js";
|
||||
|
||||
export class UpdateResellerDto extends PartialType(CreateResellerDto) {}
|
||||
82
apps/api/src/resellers/resellers.controller.ts
Normal file
82
apps/api/src/resellers/resellers.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/resellers/resellers.module.ts
Normal file
11
apps/api/src/resellers/resellers.module.ts
Normal 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 {}
|
||||
101
apps/api/src/resellers/resellers.service.ts
Normal file
101
apps/api/src/resellers/resellers.service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user