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:
59
packages/database/migrations/1788439142825_resellers.cjs
Normal file
59
packages/database/migrations/1788439142825_resellers.cjs
Normal file
@@ -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;
|
||||
`);
|
||||
};
|
||||
66
packages/database/src/repositories/audit.ts
Normal file
66
packages/database/src/repositories/audit.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { query } from "../pool.js";
|
||||
|
||||
export interface AuditEventInput {
|
||||
actorType: "user" | "system" | "service";
|
||||
actorId?: string | null;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
ipAddress?: string | null;
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
const { rows } = await query<{ event_hash: string }>(
|
||||
"SELECT event_hash FROM audit_log ORDER BY seq DESC LIMIT 1",
|
||||
);
|
||||
const previousEventHash = rows[0]?.event_hash ?? "";
|
||||
|
||||
const fields = {
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId ?? null,
|
||||
action: input.action,
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId ?? null,
|
||||
metadata: input.metadata ?? {},
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
};
|
||||
const eventHash = createHash("sha256")
|
||||
.update(previousEventHash + canonicalJson(fields))
|
||||
.digest("hex");
|
||||
|
||||
await query(
|
||||
`INSERT INTO audit_log
|
||||
(actor_type, actor_id, action, resource_type, resource_id, metadata, ip_address, previous_event_hash, event_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
input.actorType,
|
||||
input.actorId ?? null,
|
||||
input.action,
|
||||
input.resourceType,
|
||||
input.resourceId ?? null,
|
||||
JSON.stringify(input.metadata ?? {}),
|
||||
input.ipAddress ?? null,
|
||||
previousEventHash || null,
|
||||
eventHash,
|
||||
],
|
||||
);
|
||||
}
|
||||
71
packages/database/src/repositories/resellers.ts
Normal file
71
packages/database/src/repositories/resellers.ts
Normal file
@@ -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<Reseller[]> {
|
||||
const { rows } = await query<Reseller>(
|
||||
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<Reseller | null> {
|
||||
const { rows } = await query<Reseller>("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<Reseller> {
|
||||
const { rows } = await query<Reseller>(
|
||||
`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<Omit<CreateResellerInput, "createdBy">>;
|
||||
|
||||
export async function updateReseller(
|
||||
id: string,
|
||||
input: UpdateResellerInput,
|
||||
): Promise<Reseller | null> {
|
||||
const { rows } = await query<Reseller>(
|
||||
`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;
|
||||
}
|
||||
30
packages/database/src/repositories/users.ts
Normal file
30
packages/database/src/repositories/users.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { query } from "../pool.js";
|
||||
|
||||
/**
|
||||
* NOTE: at the time this file was created, the fuller `users` repository
|
||||
* (createUser, findUserByEmail, findUserById, etc. — identity/auth
|
||||
* bounded context) hadn't landed in this worktree yet, since it's being
|
||||
* built in parallel by another agent. Per the resellers-module task
|
||||
* spec this function was meant to be appended to the end of that
|
||||
* existing file rather than living in a file of its own — do that at
|
||||
* merge time; this file's only reason to exist standalone right now is
|
||||
* that the one it was supposed to be appended to didn't exist here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
const { rowCount } = await query("UPDATE users SET reseller_id = $2 WHERE id = $1", [
|
||||
userId,
|
||||
resellerId,
|
||||
]);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
@@ -5,3 +5,4 @@
|
||||
* bounded context needs to share a type with the first.
|
||||
*/
|
||||
export const DOMAIN_SHARED_PACKAGE_VERSION = "0.1.0";
|
||||
export { onlyDigits, isValidCPF, isValidCNPJ } from "./validators/br-documents.js";
|
||||
|
||||
50
packages/domain-shared/src/validators/br-documents.ts
Normal file
50
packages/domain-shared/src/validators/br-documents.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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, §1.7
|
||||
* resellers).
|
||||
*/
|
||||
export function onlyDigits(value: string): string {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
export function isValidCPF(value: string): boolean {
|
||||
const cpf = onlyDigits(value);
|
||||
if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) return false;
|
||||
|
||||
const digits = cpf.split("").map(Number);
|
||||
const checkDigit = (length: number): number => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
sum += digits[i]! * (length + 1 - i);
|
||||
}
|
||||
const remainder = (sum * 10) % 11;
|
||||
return remainder === 10 ? 0 : remainder;
|
||||
};
|
||||
|
||||
return checkDigit(9) === digits[9] && checkDigit(10) === digits[10];
|
||||
}
|
||||
|
||||
export function isValidCNPJ(value: string): boolean {
|
||||
const cnpj = onlyDigits(value);
|
||||
if (cnpj.length !== 14 || /^(\d)\1{13}$/.test(cnpj)) return false;
|
||||
|
||||
const digits = cnpj.split("").map(Number);
|
||||
// Standard CNPJ weight sequences — the second is the first with a
|
||||
// leading 6, not a generic slice (easy to get subtly wrong otherwise).
|
||||
const WEIGHTS_12 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
const WEIGHTS_13 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
const checkDigit = (length: number, weights: number[]): number => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
sum += digits[i]! * weights[i]!;
|
||||
}
|
||||
const remainder = sum % 11;
|
||||
return remainder < 2 ? 0 : 11 - remainder;
|
||||
};
|
||||
|
||||
return (
|
||||
checkDigit(12, WEIGHTS_12) === digits[12] && checkDigit(13, WEIGHTS_13) === digits[13]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user