feat(api,ui): MFA/TOTP, Inventory, Support, HR-Timeclock, Fiscal, Billing e Design System
Leva grande, pedida como "pelo menos mais 20 etapas, use subagentes":
fecha a pendência de MFA/TOTP da Fase 1 (feito diretamente, autenticação é
sensível demais para delegar) e lança 5 subagentes em paralelo em 2 ondas
(eden-inventory, eden-support, eden-hr-timeclock; depois eden-finance,
eden-fiscal, eden-frontend), cada um em worktree isolado, com integração
seletiva manual (barrel, app.module.ts, migrations renomeadas/aplicadas,
build/typecheck/teste ao vivo antes de cada merge).
## Backend
- MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `crypto/field-encryption.service.ts`):
login em duas etapas quando `mfa_enabled=true`, `FieldEncryptionService`
genérico (AES-256-GCM versionado, ADR-0005) reaproveitado por outros
segredos reversíveis (ex. credencial de equipamento Control iD).
- Inventory: warehouses, stock_movements (ledger append-only), serialized_assets,
asset_assignments, stock_reservations — invariante "serial/patrimônio/MAC
nunca duplicado entre ativos ativos" reforçada por índices únicos parciais
no banco (Master Prompt §15.3).
- Support/Service Desk: tickets com máquina de estados, SLA com pausa/retomada
em `waiting_customer`/`waiting_third_party` (funções puras testáveis).
- HR-Timeclock (fundação): dispositivos Control iD (senha cifrada), import de
AFD com parser + CRC-16/KERMIT, `afd_records` imutável sem exceção nem
para super_admin (sem válvula de escape, diferente de audit_log).
- Fiscal: NCM/CFOP/municípios/naturezas de operação (eden.md módulo 5, MVP de
5 das ~19 tabelas), product_fiscal_profiles com invariante de único
profile ativo por billing_component.
- Billing: subscription→billing_run→invoice idempotente (regenerar em draft
substitui, nunca duplica) e imutável após consolidar (ADR-0008: nunca
confunde faturamento/documento fiscal/recebimento).
- FKs cruzadas fechadas: quotes/client_registrations/contracts.
## Frontend — primeira leva
- `packages/ui`: Design System real extraído do tema DreamsERP comprado —
achado que mudou o escopo: o tema já vem em React+Tailwind (não só
Angular), tokens visuais reais portados (cores, Geist como fonte real,
divergindo da doc antiga que citava Nunito/Poppins).
- `apps/core-web`: login real (com suporte a MFA), sessão, shell autenticado
via `AppShell` — substitui a tela estática "Bootstrap da Fase 1".
## Bugs reais encontrados e corrigidos (todos via teste ao vivo, não leitura)
1. **Segurança**: o token de desafio MFA era aceito como access token normal
pelo `JwtAuthGuard` (mesma assinatura, nenhuma checagem da claim
`purpose`) — bypass total do segundo fator. Explorado e fechado ao vivo
contra o container real; teste de regressão em `test/mfa.spec.ts`.
2. Crase solta dentro de comentário SQL na migration de hr-timeclock quebrava
o template literal JS (`SyntaxError` ao rodar a migration).
3. CORS ausente por completo na API.
4. `turbo.json` não declarava `VITE_API_BASE_URL` como input de build — o
Turborepo cacheava o bundle do frontend ignorando mudança na env var.
5. `compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`, que colidia com a
variável de mesmo nome do `.env` local — o default nunca era aplicado.
6. Não existia `.dockerignore` — todo build copiava node_modules/.git/
worktrees de subagentes (665MB) para dentro do contexto Docker.
Decisão do operador incorporada: a API nunca tem domínio público próprio
(só IP interno) — os 3 frontends chamam por caminho relativo `/api/v1`,
proxy externo roteia same-origin; CORS só é relevante para dev local.
Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter
@eden/api test` (30/30, cresceu de 15), todo o lote testado ao vivo via
curl contra os containers reais reconstruídos, incluindo os 6 bugs acima
já fechados e confirmados, e a trilha de auditoria conferida no Postgres.
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,11 @@ export * as quotesRepo from "./repositories/quotes.js";
|
||||
export * as contractsRepo from "./repositories/contracts.js";
|
||||
export * as clientRegistrationsRepo from "./repositories/client-registrations.js";
|
||||
export * as clientRegistrationPartnersRepo from "./repositories/client-registration-partners.js";
|
||||
export * as inventoryRepo from "./repositories/inventory.js";
|
||||
export * as supportTicketsRepo from "./repositories/support-tickets.js";
|
||||
export * as hrTimeclockRepo from "./repositories/hr-timeclock.js";
|
||||
export * as fiscalRepo from "./repositories/fiscal.js";
|
||||
export * as billingRepo from "./repositories/billing.js";
|
||||
export {
|
||||
listLeads,
|
||||
getLead,
|
||||
@@ -74,3 +79,87 @@ export type {
|
||||
CreatePartnerInput,
|
||||
UpdatePartnerInput,
|
||||
} from "./repositories/client-registration-partners.js";
|
||||
export type {
|
||||
Warehouse,
|
||||
CreateWarehouseInput,
|
||||
UpdateWarehouseInput,
|
||||
StockMovement,
|
||||
StockMovementType,
|
||||
ListStockMovementsFilter,
|
||||
CreateStockMovementInput,
|
||||
SerializedAsset,
|
||||
SerializedAssetStatus,
|
||||
ListSerializedAssetsFilter,
|
||||
CreateAssetInput,
|
||||
ReserveAssetInput,
|
||||
InstallAssetInput,
|
||||
ReturnAssetInput,
|
||||
AssetAssignment,
|
||||
StockReservation,
|
||||
StockReservationStatus,
|
||||
ListStockReservationsFilter,
|
||||
CreateGenericReservationInput,
|
||||
} from "./repositories/inventory.js";
|
||||
export type {
|
||||
TicketStatus,
|
||||
TicketPriority,
|
||||
SupportTicket,
|
||||
TicketComment,
|
||||
TicketStatusHistoryEntry,
|
||||
SlaTimer,
|
||||
TicketWithDetails,
|
||||
ListTicketsFilter,
|
||||
CreateTicketInput,
|
||||
CreateTicketCommentInput,
|
||||
UpdateTicketStatusExtra,
|
||||
} from "./repositories/support-tickets.js";
|
||||
export type {
|
||||
TimeclockDeviceStatus,
|
||||
TimeclockDevice,
|
||||
CreateTimeclockDeviceInput,
|
||||
UpdateTimeclockDeviceInput,
|
||||
TimeclockEmployee,
|
||||
AfdImportBatchStatus,
|
||||
AfdCrcValidity,
|
||||
AfdValidationStatus,
|
||||
AfdImportBatch,
|
||||
CreateAfdImportBatchInput,
|
||||
AfdRecord,
|
||||
AfdRecordInsertInput,
|
||||
ImportAfdBatchInput,
|
||||
ImportAfdBatchResult,
|
||||
ListAfdRecordsFilter,
|
||||
} from "./repositories/hr-timeclock.js";
|
||||
export type {
|
||||
FiscalNcm,
|
||||
CreateSimpleCatalogInput,
|
||||
UpdateSimpleCatalogInput,
|
||||
ListCatalogFilter,
|
||||
FiscalCfop,
|
||||
CreateCfopInput,
|
||||
UpdateCfopInput,
|
||||
FiscalMunicipality,
|
||||
CreateMunicipalityInput,
|
||||
UpdateMunicipalityInput,
|
||||
FiscalOperationNature,
|
||||
FiscalBillingComponent,
|
||||
FiscalDocumentType,
|
||||
ProductFiscalProfile,
|
||||
CreateProductFiscalProfileInput,
|
||||
} from "./repositories/fiscal.js";
|
||||
export type {
|
||||
BillingSubscriptionStatus,
|
||||
BillingSubscription,
|
||||
BillingRunStatus,
|
||||
BillingRun,
|
||||
InvoiceStatus,
|
||||
Invoice,
|
||||
InvoiceItemType,
|
||||
InvoiceItem,
|
||||
BillingRunWithInvoices,
|
||||
CreateBillingSubscriptionInput,
|
||||
ListBillingSubscriptionsFilter,
|
||||
GenerateBillingRunInput,
|
||||
ListBillingRunsFilter,
|
||||
ListInvoicesFilter,
|
||||
} from "./repositories/billing.js";
|
||||
|
||||
471
packages/database/src/repositories/billing.ts
Normal file
471
packages/database/src/repositories/billing.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { query, withTransaction } from "../pool.js";
|
||||
|
||||
/**
|
||||
* Billing bounded context (Master Prompt §6.10, ADR-0008) — recurring
|
||||
* subscription -> billing_run -> invoice flow. See PENDING_billing.cjs's
|
||||
* header comment for the full list of tables this MVP builds vs. defers
|
||||
* (billing_accounts, billing_cycles, charge_components/usage_charges,
|
||||
* invoice_adjustments, billing_run_logs, proration — all deferred).
|
||||
*
|
||||
* Mirrors contracts.ts's conventions: snake_case rows, withTransaction for
|
||||
* multi-row writes that must commit/rollback together, typed domain
|
||||
* errors mapped to HTTP status by the service layer (never here).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BillingSubscriptionStatus = "active" | "suspended" | "cancelled";
|
||||
|
||||
export interface BillingSubscription {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
contract_id: string;
|
||||
reseller_id: string | null;
|
||||
monthly_value: string;
|
||||
billing_day: number;
|
||||
status: BillingSubscriptionStatus;
|
||||
started_at: Date;
|
||||
cancelled_at: Date | null;
|
||||
}
|
||||
|
||||
export type BillingRunStatus = "draft" | "consolidated" | "failed";
|
||||
|
||||
export interface BillingRun {
|
||||
id: string;
|
||||
reference_year: number;
|
||||
reference_month: number;
|
||||
billing_day: number;
|
||||
status: BillingRunStatus;
|
||||
consolidated_at: Date | null;
|
||||
triggered_by: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export type InvoiceStatus = "draft" | "consolidated" | "cancelled";
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
billing_run_id: string;
|
||||
billing_subscription_id: string;
|
||||
reseller_id: string | null;
|
||||
amount: string;
|
||||
reference_year: number;
|
||||
reference_month: number;
|
||||
status: InvoiceStatus;
|
||||
due_date: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export type InvoiceItemType = "recurring" | "proration" | "implementation" | "adjustment";
|
||||
|
||||
export interface InvoiceItem {
|
||||
id: string;
|
||||
invoice_id: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
item_type: InvoiceItemType;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface BillingRunWithInvoices extends BillingRun {
|
||||
invoices: Invoice[];
|
||||
totalAmount: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors — mapped to HTTP status by the service layer, never here
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BillingSubscriptionNotFoundError extends Error {
|
||||
constructor(public readonly subscriptionId: string) {
|
||||
super(`Billing subscription ${subscriptionId} not found`);
|
||||
this.name = "BillingSubscriptionNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class BillingRunNotFoundError extends Error {
|
||||
constructor(public readonly billingRunId: string) {
|
||||
super(`Billing run ${billingRunId} not found`);
|
||||
this.name = "BillingRunNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when an operation tries to write (regenerate invoices, alter
|
||||
* items) against a billing_run that is already 'consolidated' — the
|
||||
* central invariant of this module. The service layer maps this to 409.
|
||||
*/
|
||||
export class BillingRunConsolidatedError extends Error {
|
||||
constructor(public readonly billingRunId: string) {
|
||||
super(`Billing run ${billingRunId} is already consolidated and cannot be modified`);
|
||||
this.name = "BillingRunConsolidatedError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when creating/regenerating a run for a (year, month, billing_day)
|
||||
* that already has a CONSOLIDATED run. Controlled re-billing (a new run
|
||||
* deliberately superseding a consolidated one) is explicitly deferred
|
||||
* (see migration header) — until it exists, this is a hard stop rather
|
||||
* than a silent double-bill.
|
||||
*/
|
||||
export class BillingPeriodAlreadyConsolidatedError extends Error {
|
||||
constructor(
|
||||
public readonly referenceYear: number,
|
||||
public readonly referenceMonth: number,
|
||||
public readonly billingDay: number,
|
||||
) {
|
||||
super(
|
||||
`Billing run for ${referenceYear}-${referenceMonth}/day ${billingDay} is already consolidated`,
|
||||
);
|
||||
this.name = "BillingPeriodAlreadyConsolidatedError";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// billing_subscriptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateBillingSubscriptionInput {
|
||||
contractId: string;
|
||||
resellerId: string | null;
|
||||
monthlyValue: number;
|
||||
billingDay: number;
|
||||
createdBy: string | null;
|
||||
}
|
||||
|
||||
export async function createBillingSubscription(
|
||||
input: CreateBillingSubscriptionInput,
|
||||
): Promise<BillingSubscription> {
|
||||
const { rows } = await query<BillingSubscription>(
|
||||
`INSERT INTO billing_subscriptions
|
||||
(contract_id, reseller_id, monthly_value, billing_day, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)
|
||||
RETURNING *`,
|
||||
[input.contractId, input.resellerId, input.monthlyValue, input.billingDay, input.createdBy],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("failed to create billing subscription");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getBillingSubscriptionById(id: string): Promise<BillingSubscription | null> {
|
||||
const { rows } = await query<BillingSubscription>(
|
||||
"SELECT * FROM billing_subscriptions WHERE id = $1",
|
||||
[id],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export interface ListBillingSubscriptionsFilter {
|
||||
scope: "all" | "reseller";
|
||||
/** Required (and enforced by the caller) when scope === 'reseller'. */
|
||||
resellerId?: string | null;
|
||||
status?: BillingSubscriptionStatus;
|
||||
}
|
||||
|
||||
export async function listBillingSubscriptions(
|
||||
filter: ListBillingSubscriptionsFilter,
|
||||
): Promise<BillingSubscription[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filter.scope === "reseller") {
|
||||
// Deliberately never falls back to unfiltered: an actor with
|
||||
// reseller-only scope and no resellerId sees nothing, not everything.
|
||||
params.push(filter.resellerId ?? null);
|
||||
conditions.push(`reseller_id = $${params.length}`);
|
||||
}
|
||||
if (filter.status) {
|
||||
params.push(filter.status);
|
||||
conditions.push(`status = $${params.length}`);
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const { rows } = await query<BillingSubscription>(
|
||||
`SELECT * FROM billing_subscriptions ${where} ORDER BY created_at DESC`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a subscription — idempotent: cancelling an already-cancelled
|
||||
* subscription is a no-op that returns the existing row rather than
|
||||
* erroring, mirroring the "safe to retry" philosophy applied to billing
|
||||
* runs elsewhere in this module. cancelled_at is set only the first time.
|
||||
*/
|
||||
export async function cancelBillingSubscription(
|
||||
id: string,
|
||||
actorId: string | null,
|
||||
): Promise<BillingSubscription> {
|
||||
const { rows } = await query<BillingSubscription>(
|
||||
`UPDATE billing_subscriptions SET
|
||||
status = 'cancelled',
|
||||
cancelled_at = COALESCE(cancelled_at, now()),
|
||||
updated_by = $2
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[id, actorId],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new BillingSubscriptionNotFoundError(id);
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// billing_runs / invoices / invoice_items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function dueDateFor(referenceYear: number, referenceMonth: number, billingDay: number): string {
|
||||
const mm = String(referenceMonth).padStart(2, "0");
|
||||
const dd = String(billingDay).padStart(2, "0");
|
||||
return `${referenceYear}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export interface GenerateBillingRunInput {
|
||||
referenceYear: number;
|
||||
referenceMonth: number;
|
||||
billingDay: number;
|
||||
triggeredBy: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent billing close. Behavior for (referenceYear, referenceMonth,
|
||||
* billingDay):
|
||||
* - if a CONSOLIDATED run already exists for this exact period ->
|
||||
* throws BillingPeriodAlreadyConsolidatedError (controlled re-billing
|
||||
* is deferred, see migration header — this is a hard stop, not a
|
||||
* silent double-bill).
|
||||
* - if a DRAFT run already exists -> reused in place: its invoices (and
|
||||
* their items) are deleted and regenerated from the current set of
|
||||
* active subscriptions, all inside one transaction. Running this
|
||||
* twice with the same parameters therefore never duplicates invoices
|
||||
* — the second run replaces the first draft's invoices 1:1.
|
||||
* - otherwise -> a new 'draft' run is created.
|
||||
*
|
||||
* One invoice per active billing_subscription whose billing_day matches,
|
||||
* each carrying a single 'recurring' invoice_item for the full
|
||||
* monthly_value (no proration in this MVP — see migration header).
|
||||
*/
|
||||
export async function generateOrRegenerateBillingRun(
|
||||
input: GenerateBillingRunInput,
|
||||
): Promise<BillingRunWithInvoices> {
|
||||
return withTransaction(async (client) => {
|
||||
const { rows: existingRows } = await client.query<BillingRun>(
|
||||
`SELECT * FROM billing_runs
|
||||
WHERE reference_year = $1 AND reference_month = $2 AND billing_day = $3
|
||||
AND status IN ('draft', 'consolidated')
|
||||
FOR UPDATE`,
|
||||
[input.referenceYear, input.referenceMonth, input.billingDay],
|
||||
);
|
||||
|
||||
const consolidated = existingRows.find((r) => r.status === "consolidated");
|
||||
if (consolidated) {
|
||||
throw new BillingPeriodAlreadyConsolidatedError(
|
||||
input.referenceYear,
|
||||
input.referenceMonth,
|
||||
input.billingDay,
|
||||
);
|
||||
}
|
||||
|
||||
const existingDraft = existingRows.find((r) => r.status === "draft");
|
||||
let run: BillingRun;
|
||||
|
||||
if (existingDraft) {
|
||||
// Regeneration: wipe this draft's invoices (items cascade) and
|
||||
// refresh triggered_by to reflect who ran it last.
|
||||
await client.query(
|
||||
`DELETE FROM invoices WHERE billing_run_id = $1`,
|
||||
[existingDraft.id],
|
||||
);
|
||||
const { rows: updatedRows } = await client.query<BillingRun>(
|
||||
`UPDATE billing_runs SET triggered_by = $2 WHERE id = $1 RETURNING *`,
|
||||
[existingDraft.id, input.triggeredBy],
|
||||
);
|
||||
run = updatedRows[0]!;
|
||||
} else {
|
||||
const { rows: createdRows } = await client.query<BillingRun>(
|
||||
`INSERT INTO billing_runs (reference_year, reference_month, billing_day, triggered_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[input.referenceYear, input.referenceMonth, input.billingDay, input.triggeredBy],
|
||||
);
|
||||
run = createdRows[0]!;
|
||||
}
|
||||
|
||||
const { rows: subscriptions } = await client.query<BillingSubscription>(
|
||||
`SELECT * FROM billing_subscriptions WHERE status = 'active' AND billing_day = $1`,
|
||||
[input.billingDay],
|
||||
);
|
||||
|
||||
const dueDate = dueDateFor(input.referenceYear, input.referenceMonth, input.billingDay);
|
||||
const invoices: Invoice[] = [];
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const { rows: invoiceRows } = await client.query<Invoice>(
|
||||
`INSERT INTO invoices
|
||||
(billing_run_id, billing_subscription_id, reseller_id, amount, reference_year, reference_month, due_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
run.id,
|
||||
subscription.id,
|
||||
subscription.reseller_id,
|
||||
subscription.monthly_value,
|
||||
input.referenceYear,
|
||||
input.referenceMonth,
|
||||
dueDate,
|
||||
],
|
||||
);
|
||||
const invoice = invoiceRows[0]!;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO invoice_items (invoice_id, description, amount, item_type)
|
||||
VALUES ($1, $2, $3, 'recurring')`,
|
||||
[invoice.id, "Mensalidade recorrente", subscription.monthly_value],
|
||||
);
|
||||
|
||||
invoices.push(invoice);
|
||||
}
|
||||
|
||||
return { ...run, invoices, totalAmount: sumAmounts(invoices) };
|
||||
});
|
||||
}
|
||||
|
||||
export interface ListBillingRunsFilter {
|
||||
referenceYear?: number;
|
||||
referenceMonth?: number;
|
||||
status?: BillingRunStatus;
|
||||
}
|
||||
|
||||
export async function listBillingRuns(filter: ListBillingRunsFilter = {}): Promise<BillingRun[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filter.referenceYear != null) {
|
||||
params.push(filter.referenceYear);
|
||||
conditions.push(`reference_year = $${params.length}`);
|
||||
}
|
||||
if (filter.referenceMonth != null) {
|
||||
params.push(filter.referenceMonth);
|
||||
conditions.push(`reference_month = $${params.length}`);
|
||||
}
|
||||
if (filter.status) {
|
||||
params.push(filter.status);
|
||||
conditions.push(`status = $${params.length}`);
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const { rows } = await query<BillingRun>(
|
||||
`SELECT * FROM billing_runs ${where} ORDER BY created_at DESC`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function sumAmounts(invoices: Invoice[]): string {
|
||||
const total = invoices.reduce((sum, inv) => sum + Number(inv.amount), 0);
|
||||
return total.toFixed(2);
|
||||
}
|
||||
|
||||
export async function getBillingRunById(id: string): Promise<BillingRunWithInvoices | null> {
|
||||
const { rows } = await query<BillingRun>("SELECT * FROM billing_runs WHERE id = $1", [id]);
|
||||
const run = rows[0];
|
||||
if (!run) return null;
|
||||
|
||||
const { rows: invoices } = await query<Invoice>(
|
||||
"SELECT * FROM invoices WHERE billing_run_id = $1 ORDER BY created_at ASC",
|
||||
[id],
|
||||
);
|
||||
return { ...run, invoices, totalAmount: sumAmounts(invoices) };
|
||||
}
|
||||
|
||||
export async function listInvoiceItems(invoiceId: string): Promise<InvoiceItem[]> {
|
||||
const { rows } = await query<InvoiceItem>(
|
||||
"SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY created_at ASC",
|
||||
[invoiceId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidates a billing_run: flips the run and every invoice it owns to
|
||||
* 'consolidated' and stamps consolidated_at, all in one transaction.
|
||||
* 409s (via BillingRunConsolidatedError) if already consolidated —
|
||||
* consolidation is a one-way door in this MVP (no un-consolidate).
|
||||
*/
|
||||
export async function consolidateBillingRun(
|
||||
id: string,
|
||||
_actorId: string | null,
|
||||
): Promise<BillingRunWithInvoices> {
|
||||
return withTransaction(async (client) => {
|
||||
const { rows } = await client.query<BillingRun>(
|
||||
"SELECT * FROM billing_runs WHERE id = $1 FOR UPDATE",
|
||||
[id],
|
||||
);
|
||||
const run = rows[0];
|
||||
if (!run) throw new BillingRunNotFoundError(id);
|
||||
if (run.status === "consolidated") throw new BillingRunConsolidatedError(id);
|
||||
|
||||
const { rows: updatedRows } = await client.query<BillingRun>(
|
||||
`UPDATE billing_runs SET status = 'consolidated', consolidated_at = now() WHERE id = $1 RETURNING *`,
|
||||
[id],
|
||||
);
|
||||
const updated = updatedRows[0]!;
|
||||
|
||||
await client.query(
|
||||
`UPDATE invoices SET status = 'consolidated' WHERE billing_run_id = $1`,
|
||||
[id],
|
||||
);
|
||||
|
||||
const { rows: invoices } = await client.query<Invoice>(
|
||||
"SELECT * FROM invoices WHERE billing_run_id = $1 ORDER BY created_at ASC",
|
||||
[id],
|
||||
);
|
||||
return { ...updated, invoices, totalAmount: sumAmounts(invoices) };
|
||||
});
|
||||
}
|
||||
|
||||
export interface ListInvoicesFilter {
|
||||
scope: "all" | "reseller";
|
||||
/** Required (and enforced by the caller) when scope === 'reseller'. */
|
||||
resellerId?: string | null;
|
||||
status?: InvoiceStatus;
|
||||
referenceYear?: number;
|
||||
referenceMonth?: number;
|
||||
}
|
||||
|
||||
export async function listInvoices(filter: ListInvoicesFilter): Promise<Invoice[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filter.scope === "reseller") {
|
||||
params.push(filter.resellerId ?? null);
|
||||
conditions.push(`reseller_id = $${params.length}`);
|
||||
}
|
||||
if (filter.status) {
|
||||
params.push(filter.status);
|
||||
conditions.push(`status = $${params.length}`);
|
||||
}
|
||||
if (filter.referenceYear != null) {
|
||||
params.push(filter.referenceYear);
|
||||
conditions.push(`reference_year = $${params.length}`);
|
||||
}
|
||||
if (filter.referenceMonth != null) {
|
||||
params.push(filter.referenceMonth);
|
||||
conditions.push(`reference_month = $${params.length}`);
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const { rows } = await query<Invoice>(
|
||||
`SELECT * FROM invoices ${where} ORDER BY created_at DESC`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
484
packages/database/src/repositories/fiscal.ts
Normal file
484
packages/database/src/repositories/fiscal.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { query, withTransaction } from "../pool.js";
|
||||
|
||||
/**
|
||||
* Fiscal bounded context (Master Prompt §6.11, ADR-0011, eden.md módulo 5).
|
||||
* See PENDING_fiscal.cjs migration header for full scope/deferral notes.
|
||||
* This MVP covers 3 "catalog" tables (fiscal_ncm, fiscal_cfop,
|
||||
* fiscal_municipality), the read-only fiscal_operation_nature seed, and
|
||||
* product_fiscal_profiles — the piece that actually links a commercial
|
||||
* product to its fiscal classification.
|
||||
*/
|
||||
|
||||
// ===========================================================================
|
||||
// fiscal_ncm — "simple catalog" shape (eden.md §2.2)
|
||||
// ===========================================================================
|
||||
|
||||
export interface FiscalNcm {
|
||||
id: string;
|
||||
code: string;
|
||||
description: string;
|
||||
valid_from: string | null;
|
||||
valid_until: string | null;
|
||||
active: boolean;
|
||||
source: string | null;
|
||||
source_version: string | null;
|
||||
source_updated_at: Date | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateSimpleCatalogInput {
|
||||
code: string;
|
||||
description: string;
|
||||
validFrom?: string | null;
|
||||
validUntil?: string | null;
|
||||
active?: boolean;
|
||||
source?: string | null;
|
||||
sourceVersion?: string | null;
|
||||
sourceUpdatedAt?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface UpdateSimpleCatalogInput {
|
||||
code?: string;
|
||||
description?: string;
|
||||
validFrom?: string | null;
|
||||
validUntil?: string | null;
|
||||
active?: boolean;
|
||||
source?: string | null;
|
||||
sourceVersion?: string | null;
|
||||
sourceUpdatedAt?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ListCatalogFilter {
|
||||
q?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export async function listNcm(filter: ListCatalogFilter = {}): Promise<FiscalNcm[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (filter.q) {
|
||||
params.push(`%${filter.q}%`);
|
||||
conditions.push(`(code ILIKE $${params.length} OR description ILIKE $${params.length})`);
|
||||
}
|
||||
if (filter.active !== undefined) {
|
||||
params.push(filter.active);
|
||||
conditions.push(`active = $${params.length}`);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const { rows } = await query<FiscalNcm>(
|
||||
`SELECT * FROM fiscal_ncm ${where} ORDER BY code ASC NULLS LAST`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getNcmById(id: string): Promise<FiscalNcm | null> {
|
||||
const { rows } = await query<FiscalNcm>("SELECT * FROM fiscal_ncm WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createNcm(input: CreateSimpleCatalogInput): Promise<FiscalNcm> {
|
||||
const { rows } = await query<FiscalNcm>(
|
||||
`INSERT INTO fiscal_ncm (code, description, valid_from, valid_until, active, source, source_version, source_updated_at, metadata)
|
||||
VALUES ($1,$2,$3,$4,COALESCE($5,true),$6,$7,$8,$9)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.code,
|
||||
input.description,
|
||||
input.validFrom ?? null,
|
||||
input.validUntil ?? null,
|
||||
input.active ?? null,
|
||||
input.source ?? null,
|
||||
input.sourceVersion ?? null,
|
||||
input.sourceUpdatedAt ?? null,
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("failed to create fiscal_ncm row");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateNcm(id: string, input: UpdateSimpleCatalogInput): Promise<FiscalNcm | null> {
|
||||
const { rows } = await query<FiscalNcm>(
|
||||
`UPDATE fiscal_ncm SET
|
||||
code = COALESCE($2, code),
|
||||
description = COALESCE($3, description),
|
||||
valid_from = COALESCE($4, valid_from),
|
||||
valid_until = COALESCE($5, valid_until),
|
||||
active = COALESCE($6, active),
|
||||
source = COALESCE($7, source),
|
||||
source_version = COALESCE($8, source_version),
|
||||
source_updated_at = COALESCE($9, source_updated_at),
|
||||
metadata = COALESCE($10, metadata)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
input.code ?? null,
|
||||
input.description ?? null,
|
||||
input.validFrom ?? null,
|
||||
input.validUntil ?? null,
|
||||
input.active ?? null,
|
||||
input.source ?? null,
|
||||
input.sourceVersion ?? null,
|
||||
input.sourceUpdatedAt ?? null,
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// fiscal_cfop — "simple catalog" shape + direction/scope (eden.md §2.3/§4)
|
||||
// ===========================================================================
|
||||
|
||||
export interface FiscalCfop {
|
||||
id: string;
|
||||
code: string;
|
||||
description: string;
|
||||
direction: string | null;
|
||||
scope: string | null;
|
||||
valid_from: string | null;
|
||||
valid_until: string | null;
|
||||
active: boolean;
|
||||
source: string | null;
|
||||
source_version: string | null;
|
||||
source_updated_at: Date | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateCfopInput extends CreateSimpleCatalogInput {
|
||||
direction?: string | null;
|
||||
scope?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateCfopInput extends UpdateSimpleCatalogInput {
|
||||
direction?: string | null;
|
||||
scope?: string | null;
|
||||
}
|
||||
|
||||
export async function listCfop(filter: ListCatalogFilter = {}): Promise<FiscalCfop[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (filter.q) {
|
||||
params.push(`%${filter.q}%`);
|
||||
conditions.push(`(code ILIKE $${params.length} OR description ILIKE $${params.length})`);
|
||||
}
|
||||
if (filter.active !== undefined) {
|
||||
params.push(filter.active);
|
||||
conditions.push(`active = $${params.length}`);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const { rows } = await query<FiscalCfop>(
|
||||
`SELECT * FROM fiscal_cfop ${where} ORDER BY code ASC NULLS LAST`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getCfopById(id: string): Promise<FiscalCfop | null> {
|
||||
const { rows } = await query<FiscalCfop>("SELECT * FROM fiscal_cfop WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createCfop(input: CreateCfopInput): Promise<FiscalCfop> {
|
||||
const { rows } = await query<FiscalCfop>(
|
||||
`INSERT INTO fiscal_cfop (code, description, direction, scope, valid_from, valid_until, active, source, source_version, source_updated_at, metadata)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,COALESCE($7,true),$8,$9,$10,$11)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.code,
|
||||
input.description,
|
||||
input.direction ?? null,
|
||||
input.scope ?? null,
|
||||
input.validFrom ?? null,
|
||||
input.validUntil ?? null,
|
||||
input.active ?? null,
|
||||
input.source ?? null,
|
||||
input.sourceVersion ?? null,
|
||||
input.sourceUpdatedAt ?? null,
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("failed to create fiscal_cfop row");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateCfop(id: string, input: UpdateCfopInput): Promise<FiscalCfop | null> {
|
||||
const { rows } = await query<FiscalCfop>(
|
||||
`UPDATE fiscal_cfop SET
|
||||
code = COALESCE($2, code),
|
||||
description = COALESCE($3, description),
|
||||
direction = COALESCE($4, direction),
|
||||
scope = COALESCE($5, scope),
|
||||
valid_from = COALESCE($6, valid_from),
|
||||
valid_until = COALESCE($7, valid_until),
|
||||
active = COALESCE($8, active),
|
||||
source = COALESCE($9, source),
|
||||
source_version = COALESCE($10, source_version),
|
||||
source_updated_at = COALESCE($11, source_updated_at),
|
||||
metadata = COALESCE($12, metadata)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
input.code ?? null,
|
||||
input.description ?? null,
|
||||
input.direction ?? null,
|
||||
input.scope ?? null,
|
||||
input.validFrom ?? null,
|
||||
input.validUntil ?? null,
|
||||
input.active ?? null,
|
||||
input.source ?? null,
|
||||
input.sourceVersion ?? null,
|
||||
input.sourceUpdatedAt ?? null,
|
||||
input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// fiscal_municipality — distinct shape, no valid_from/valid_until/metadata
|
||||
// (eden.md §2.3)
|
||||
// ===========================================================================
|
||||
|
||||
export interface FiscalMunicipality {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
state: string;
|
||||
active: boolean;
|
||||
source: string | null;
|
||||
source_version: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateMunicipalityInput {
|
||||
code: string;
|
||||
name: string;
|
||||
state: string;
|
||||
active?: boolean;
|
||||
source?: string | null;
|
||||
sourceVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateMunicipalityInput {
|
||||
code?: string;
|
||||
name?: string;
|
||||
state?: string;
|
||||
active?: boolean;
|
||||
source?: string | null;
|
||||
sourceVersion?: string | null;
|
||||
}
|
||||
|
||||
export async function listMunicipalities(filter: ListCatalogFilter = {}): Promise<FiscalMunicipality[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (filter.q) {
|
||||
params.push(`%${filter.q}%`);
|
||||
conditions.push(`(code ILIKE $${params.length} OR name ILIKE $${params.length})`);
|
||||
}
|
||||
if (filter.active !== undefined) {
|
||||
params.push(filter.active);
|
||||
conditions.push(`active = $${params.length}`);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const { rows } = await query<FiscalMunicipality>(
|
||||
`SELECT * FROM fiscal_municipality ${where} ORDER BY code ASC NULLS LAST`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getMunicipalityById(id: string): Promise<FiscalMunicipality | null> {
|
||||
const { rows } = await query<FiscalMunicipality>("SELECT * FROM fiscal_municipality WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createMunicipality(input: CreateMunicipalityInput): Promise<FiscalMunicipality> {
|
||||
const { rows } = await query<FiscalMunicipality>(
|
||||
`INSERT INTO fiscal_municipality (code, name, state, active, source, source_version)
|
||||
VALUES ($1,$2,$3,COALESCE($4,true),$5,$6)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.code,
|
||||
input.name,
|
||||
input.state,
|
||||
input.active ?? null,
|
||||
input.source ?? null,
|
||||
input.sourceVersion ?? null,
|
||||
],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("failed to create fiscal_municipality row");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateMunicipality(
|
||||
id: string,
|
||||
input: UpdateMunicipalityInput,
|
||||
): Promise<FiscalMunicipality | null> {
|
||||
const { rows } = await query<FiscalMunicipality>(
|
||||
`UPDATE fiscal_municipality SET
|
||||
code = COALESCE($2, code),
|
||||
name = COALESCE($3, name),
|
||||
state = COALESCE($4, state),
|
||||
active = COALESCE($5, active),
|
||||
source = COALESCE($6, source),
|
||||
source_version = COALESCE($7, source_version)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
input.code ?? null,
|
||||
input.name ?? null,
|
||||
input.state ?? null,
|
||||
input.active ?? null,
|
||||
input.source ?? null,
|
||||
input.sourceVersion ?? null,
|
||||
],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// fiscal_operation_nature — internal enum-like catalog, read-only via API
|
||||
// (eden.md §2.3) — seeded by the migration, no create/update in this MVP.
|
||||
// ===========================================================================
|
||||
|
||||
export interface FiscalOperationNature {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
active: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export async function listOperationNatures(activeOnly = false): Promise<FiscalOperationNature[]> {
|
||||
const { rows } = await query<FiscalOperationNature>(
|
||||
activeOnly
|
||||
? "SELECT * FROM fiscal_operation_nature WHERE active = true ORDER BY code ASC"
|
||||
: "SELECT * FROM fiscal_operation_nature ORDER BY code ASC",
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// product_fiscal_profiles — links products to fiscal classification
|
||||
// (eden.md §2.5). Reduced to the 4 catalogs created in this MVP.
|
||||
// ===========================================================================
|
||||
|
||||
export type FiscalBillingComponent = "RECURRING" | "IMPLEMENTATION";
|
||||
export type FiscalDocumentType = "NFCOM" | "NFSE" | "NFE" | "NONE";
|
||||
|
||||
export interface ProductFiscalProfile {
|
||||
id: string;
|
||||
product_id: string;
|
||||
billing_component: FiscalBillingComponent;
|
||||
document_type: FiscalDocumentType;
|
||||
ncm_id: string | null;
|
||||
cfop_id: string | null;
|
||||
municipality_id: string | null;
|
||||
operation_nature_id: string | null;
|
||||
valid_from: string | null;
|
||||
valid_until: string | null;
|
||||
active: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
export async function listProductFiscalProfiles(productId: string): Promise<ProductFiscalProfile[]> {
|
||||
const { rows } = await query<ProductFiscalProfile>(
|
||||
`SELECT * FROM product_fiscal_profiles
|
||||
WHERE product_id = $1
|
||||
ORDER BY billing_component ASC, created_at ASC`,
|
||||
[productId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getActiveProductFiscalProfile(
|
||||
productId: string,
|
||||
billingComponent: FiscalBillingComponent,
|
||||
): Promise<ProductFiscalProfile | null> {
|
||||
const { rows } = await query<ProductFiscalProfile>(
|
||||
`SELECT * FROM product_fiscal_profiles
|
||||
WHERE product_id = $1 AND billing_component = $2 AND active = true`,
|
||||
[productId, billingComponent],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export interface CreateProductFiscalProfileInput {
|
||||
productId: string;
|
||||
billingComponent: FiscalBillingComponent;
|
||||
documentType?: FiscalDocumentType;
|
||||
ncmId?: string | null;
|
||||
cfopId?: string | null;
|
||||
municipalityId?: string | null;
|
||||
operationNatureId?: string | null;
|
||||
validFrom?: string | null;
|
||||
validUntil?: string | null;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ACTIVE profile for (productId, billingComponent), and — in
|
||||
* the same transaction — inactivates whichever profile was previously
|
||||
* active for that same pair (never a destructive UPDATE/DELETE of the old
|
||||
* one, per eden.md §2.5's central invariant: at most one active profile per
|
||||
* product per billing_component, enforced again at the DB level by
|
||||
* idx_product_fiscal_profiles_active_component). If two concurrent callers
|
||||
* raced here without the transaction ordering below, the partial unique
|
||||
* index is the actual backstop — it would reject the second INSERT outright.
|
||||
*/
|
||||
export async function createActiveProductFiscalProfile(
|
||||
input: CreateProductFiscalProfileInput,
|
||||
): Promise<ProductFiscalProfile> {
|
||||
return withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`UPDATE product_fiscal_profiles
|
||||
SET active = false, updated_by = $3
|
||||
WHERE product_id = $1 AND billing_component = $2 AND active = true`,
|
||||
[input.productId, input.billingComponent, input.createdBy ?? null],
|
||||
);
|
||||
|
||||
const { rows } = await client.query<ProductFiscalProfile>(
|
||||
`INSERT INTO product_fiscal_profiles (
|
||||
product_id, billing_component, document_type, ncm_id, cfop_id,
|
||||
municipality_id, operation_nature_id, valid_from, valid_until,
|
||||
created_by, updated_by
|
||||
) VALUES ($1,$2,COALESCE($3,'NONE'),$4,$5,$6,$7,$8,$9,$10,$10)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.productId,
|
||||
input.billingComponent,
|
||||
input.documentType ?? null,
|
||||
input.ncmId ?? null,
|
||||
input.cfopId ?? null,
|
||||
input.municipalityId ?? null,
|
||||
input.operationNatureId ?? null,
|
||||
input.validFrom ?? null,
|
||||
input.validUntil ?? null,
|
||||
input.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("failed to create product_fiscal_profiles row");
|
||||
return row;
|
||||
});
|
||||
}
|
||||
468
packages/database/src/repositories/hr-timeclock.ts
Normal file
468
packages/database/src/repositories/hr-timeclock.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import { query, withTransaction } from "../pool.js";
|
||||
import type { PoolClient } from "pg";
|
||||
|
||||
/**
|
||||
* HR/Timeclock (eden.md módulo 6) — foundation leva. See
|
||||
* packages/database/migrations/PENDING_hr-timeclock.cjs for the full data
|
||||
* model rationale (employees-vs-users decision, deferred tables, naming
|
||||
* deviations from eden.md). This module deliberately does NOT expose any
|
||||
* UPDATE/DELETE for afd_records — see importAfdBatch below and the
|
||||
* afd-records.controller.ts (read-only) that consumes listAfdRecords.
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// timeclock_devices
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TimeclockDeviceStatus = "UNKNOWN" | "ONLINE" | "OFFLINE" | "ERROR";
|
||||
|
||||
export interface TimeclockDevice {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
company_id: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
branch: string | null;
|
||||
host: string;
|
||||
port: number;
|
||||
use_https: boolean;
|
||||
username: string;
|
||||
password_encrypted: string;
|
||||
serial_number: string | null;
|
||||
model: string | null;
|
||||
firmware_version: string | null;
|
||||
timezone: string;
|
||||
status: TimeclockDeviceStatus;
|
||||
last_seen_at: Date | null;
|
||||
last_sync_at: Date | null;
|
||||
last_error: string | null;
|
||||
last_afd_nsr: string | null; // BIGINT comes back as string from `pg`
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTimeclockDeviceInput {
|
||||
companyId?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
branch?: string | null;
|
||||
host: string;
|
||||
port?: number;
|
||||
useHttps?: boolean;
|
||||
username: string;
|
||||
/** Already encrypted (v1:iv:tag:ciphertext) by the caller via FieldEncryptionService — this repo never sees plaintext. */
|
||||
passwordEncrypted: string;
|
||||
timezone?: string;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
export async function listTimeclockDevices(includeInactive = false): Promise<TimeclockDevice[]> {
|
||||
const { rows } = await query<TimeclockDevice>(
|
||||
includeInactive
|
||||
? "SELECT * FROM timeclock_devices ORDER BY created_at DESC"
|
||||
: "SELECT * FROM timeclock_devices WHERE active = true ORDER BY created_at DESC",
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getTimeclockDeviceById(id: string): Promise<TimeclockDevice | null> {
|
||||
const { rows } = await query<TimeclockDevice>("SELECT * FROM timeclock_devices WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createTimeclockDevice(input: CreateTimeclockDeviceInput): Promise<TimeclockDevice> {
|
||||
const { rows } = await query<TimeclockDevice>(
|
||||
`INSERT INTO timeclock_devices (
|
||||
company_id, name, description, branch, host, port, use_https, username,
|
||||
password_encrypted, timezone, created_by
|
||||
) VALUES ($1,$2,$3,$4,$5,COALESCE($6,443),COALESCE($7,true),$8,$9,COALESCE($10,'America/Sao_Paulo'),$11)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.companyId ?? null,
|
||||
input.name,
|
||||
input.description ?? null,
|
||||
input.branch ?? null,
|
||||
input.host,
|
||||
input.port ?? null,
|
||||
input.useHttps ?? null,
|
||||
input.username,
|
||||
input.passwordEncrypted,
|
||||
input.timezone ?? null,
|
||||
input.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
const device = rows[0];
|
||||
if (!device) throw new Error("failed to create timeclock device");
|
||||
return device;
|
||||
}
|
||||
|
||||
export interface UpdateTimeclockDeviceInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
branch?: string | null;
|
||||
host?: string;
|
||||
port?: number;
|
||||
useHttps?: boolean;
|
||||
username?: string;
|
||||
/** Only re-encrypted/set when the caller actually supplied a new password — omit to leave unchanged. */
|
||||
passwordEncrypted?: string;
|
||||
active?: boolean;
|
||||
updatedBy?: string | null;
|
||||
}
|
||||
|
||||
export async function updateTimeclockDevice(
|
||||
id: string,
|
||||
input: UpdateTimeclockDeviceInput,
|
||||
): Promise<TimeclockDevice | null> {
|
||||
const { rows } = await query<TimeclockDevice>(
|
||||
`UPDATE timeclock_devices SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
branch = COALESCE($4, branch),
|
||||
host = COALESCE($5, host),
|
||||
port = COALESCE($6, port),
|
||||
use_https = COALESCE($7, use_https),
|
||||
username = COALESCE($8, username),
|
||||
password_encrypted = COALESCE($9, password_encrypted),
|
||||
active = COALESCE($10, active),
|
||||
updated_by = COALESCE($11, updated_by)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
input.name ?? null,
|
||||
input.description ?? null,
|
||||
input.branch ?? null,
|
||||
input.host ?? null,
|
||||
input.port ?? null,
|
||||
input.useHttps ?? null,
|
||||
input.username ?? null,
|
||||
input.passwordEncrypted ?? null,
|
||||
input.active ?? null,
|
||||
input.updatedBy ?? null,
|
||||
],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// timeclock_employees — no CRUD in this leva (see migration header); only
|
||||
// the CPF-resolution lookup that AFD import needs is implemented here.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TimeclockEmployee {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
company_id: string | null;
|
||||
user_id: string | null;
|
||||
full_name: string;
|
||||
cpf: string;
|
||||
registration: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up employees by normalized CPF (11 digits) for AFD import
|
||||
* resolution — never invents a match. Returns every row found; the
|
||||
* caller (afd-imports.service.ts) is responsible for the "same CPF
|
||||
* matched in more than one company and the device has no company" case,
|
||||
* where it must leave employee_id unresolved rather than guess.
|
||||
*/
|
||||
export async function findTimeclockEmployeesByCpfs(cpfs: string[]): Promise<TimeclockEmployee[]> {
|
||||
if (cpfs.length === 0) return [];
|
||||
const { rows } = await query<TimeclockEmployee>(
|
||||
"SELECT * FROM timeclock_employees WHERE cpf = ANY($1)",
|
||||
[cpfs],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// afd_import_batches / afd_records
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AfdImportBatchStatus = "VALID" | "WARNING" | "INVALID";
|
||||
export type AfdCrcValidity = "VALID" | "INVALID" | "NOT_APPLICABLE" | "NOT_VALIDATED";
|
||||
export type AfdValidationStatus = "VALID" | "WARNING" | "INVALID" | "UNSUPPORTED_LAYOUT";
|
||||
|
||||
export interface AfdImportBatch {
|
||||
id: string;
|
||||
device_id: string;
|
||||
company_id: string | null;
|
||||
source: "manual_upload";
|
||||
file_hash: string;
|
||||
file_size: number;
|
||||
record_count: number;
|
||||
inserted_count: number;
|
||||
valid_count: number;
|
||||
warning_count: number;
|
||||
invalid_count: number;
|
||||
unsupported_count: number;
|
||||
parse_failure_count: number;
|
||||
status: AfdImportBatchStatus;
|
||||
first_nsr: string | null;
|
||||
last_nsr: string | null;
|
||||
summary: Record<string, unknown>;
|
||||
imported_by: string | null;
|
||||
imported_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateAfdImportBatchInput {
|
||||
deviceId: string;
|
||||
companyId: string | null;
|
||||
fileHash: string;
|
||||
fileSize: number;
|
||||
recordCount: number;
|
||||
validCount: number;
|
||||
warningCount: number;
|
||||
invalidCount: number;
|
||||
unsupportedCount: number;
|
||||
parseFailureCount: number;
|
||||
status: AfdImportBatchStatus;
|
||||
firstNsr: number | null;
|
||||
lastNsr: number | null;
|
||||
summary: Record<string, unknown>;
|
||||
importedBy: string | null;
|
||||
}
|
||||
|
||||
export interface AfdRecord {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
afd_import_batch_id: string;
|
||||
device_id: string;
|
||||
company_id: string | null;
|
||||
nsr: string | null; // BIGINT -> string
|
||||
record_type: string;
|
||||
event_datetime: Date | null;
|
||||
event_datetime_raw: string | null;
|
||||
employee_cpf_raw: string | null;
|
||||
employee_id: string | null;
|
||||
raw_line: string;
|
||||
crc_received: string | null;
|
||||
crc_calculated: string | null;
|
||||
crc_valid: AfdCrcValidity;
|
||||
parsed_data: Record<string, unknown>;
|
||||
validation_status: AfdValidationStatus;
|
||||
validation_notes: string | null;
|
||||
}
|
||||
|
||||
export interface AfdRecordInsertInput {
|
||||
nsr: number | null;
|
||||
recordType: string;
|
||||
eventDatetime: Date | null;
|
||||
eventDatetimeRaw: string | null;
|
||||
employeeCpfRaw: string | null;
|
||||
employeeId: string | null;
|
||||
rawLine: string;
|
||||
crcReceived: string | null;
|
||||
crcCalculated: string | null;
|
||||
crcValid: AfdCrcValidity;
|
||||
parsedData: Record<string, unknown>;
|
||||
validationStatus: AfdValidationStatus;
|
||||
validationNotes: string | null;
|
||||
}
|
||||
|
||||
export class TimeclockDeviceNotFoundError extends Error {
|
||||
constructor(public readonly deviceId: string) {
|
||||
super(`Timeclock device ${deviceId} not found`);
|
||||
this.name = "TimeclockDeviceNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAfdImportBatchByFileHash(fileHash: string): Promise<AfdImportBatch | null> {
|
||||
const { rows } = await query<AfdImportBatch>("SELECT * FROM afd_import_batches WHERE file_hash = $1", [fileHash]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getAfdImportBatchById(id: string): Promise<AfdImportBatch | null> {
|
||||
const { rows } = await query<AfdImportBatch>("SELECT * FROM afd_import_batches WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export interface ImportAfdBatchInput {
|
||||
batch: CreateAfdImportBatchInput;
|
||||
records: AfdRecordInsertInput[];
|
||||
}
|
||||
|
||||
export interface ImportAfdBatchResult {
|
||||
batch: AfdImportBatch;
|
||||
insertedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole AFD import write path, in one transaction:
|
||||
*
|
||||
* 1. `SELECT ... FOR UPDATE` locks the device row — this is what
|
||||
* guarantees "never two concurrent imports for the same device"
|
||||
* (eden.md módulo 6 §12's sequential-execution invariant for anything
|
||||
* device-adjacent): a second concurrent import for the same device_id
|
||||
* blocks here until the first one commits or rolls back.
|
||||
* 2. Inserts the afd_import_batches row (needs to exist first — afd_records
|
||||
* has a NOT NULL FK to it).
|
||||
* 3. Inserts each afd_records row one at a time (matches this codebase's
|
||||
* established multi-row-child pattern, see contracts.ts's item/party
|
||||
* loops) with `ON CONFLICT (device_id, nsr) DO NOTHING` — the
|
||||
* per-record idempotency layer (eden.md §12): a record whose
|
||||
* (device_id, nsr) already exists from an earlier, different import is
|
||||
* silently skipped, never duplicated, and never overwritten (afd_records
|
||||
* has no UPDATE route at all — see the migration's trigger).
|
||||
* 4. Updates the batch's `inserted_count` with how many rows actually
|
||||
* landed (can be less than `records.length` because of step 3's
|
||||
* ON CONFLICT skips).
|
||||
*
|
||||
* Whole-file idempotency (SHA-256 of the received content) is checked by
|
||||
* the caller (afd-imports.service.ts) BEFORE calling this, via
|
||||
* findAfdImportBatchByFileHash — this function does not re-check it,
|
||||
* though the UNIQUE constraint on afd_import_batches.file_hash is a
|
||||
* database-level backstop against a race between the two.
|
||||
*/
|
||||
export async function importAfdBatch(input: ImportAfdBatchInput): Promise<ImportAfdBatchResult> {
|
||||
return withTransaction(async (client: PoolClient) => {
|
||||
const { rows: deviceRows } = await client.query<{ id: string }>(
|
||||
"SELECT id FROM timeclock_devices WHERE id = $1 FOR UPDATE",
|
||||
[input.batch.deviceId],
|
||||
);
|
||||
if (!deviceRows[0]) {
|
||||
throw new TimeclockDeviceNotFoundError(input.batch.deviceId);
|
||||
}
|
||||
|
||||
const { rows: batchRows } = await client.query<AfdImportBatch>(
|
||||
`INSERT INTO afd_import_batches (
|
||||
device_id, company_id, file_hash, file_size, record_count, inserted_count,
|
||||
valid_count, warning_count, invalid_count, unsupported_count, parse_failure_count,
|
||||
status, first_nsr, last_nsr, summary, imported_by
|
||||
) VALUES ($1,$2,$3,$4,$5,0,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.batch.deviceId,
|
||||
input.batch.companyId,
|
||||
input.batch.fileHash,
|
||||
input.batch.fileSize,
|
||||
input.batch.recordCount,
|
||||
input.batch.validCount,
|
||||
input.batch.warningCount,
|
||||
input.batch.invalidCount,
|
||||
input.batch.unsupportedCount,
|
||||
input.batch.parseFailureCount,
|
||||
input.batch.status,
|
||||
input.batch.firstNsr,
|
||||
input.batch.lastNsr,
|
||||
JSON.stringify(input.batch.summary),
|
||||
input.batch.importedBy,
|
||||
],
|
||||
);
|
||||
const batch = batchRows[0];
|
||||
if (!batch) throw new Error("failed to create afd_import_batch");
|
||||
|
||||
let insertedCount = 0;
|
||||
for (const record of input.records) {
|
||||
const { rowCount } = await client.query(
|
||||
`INSERT INTO afd_records (
|
||||
afd_import_batch_id, device_id, company_id, nsr, record_type,
|
||||
event_datetime, event_datetime_raw, employee_cpf_raw, employee_id, raw_line,
|
||||
crc_received, crc_calculated, crc_valid, parsed_data, validation_status, validation_notes
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
ON CONFLICT (device_id, nsr) DO NOTHING`,
|
||||
[
|
||||
batch.id,
|
||||
input.batch.deviceId,
|
||||
input.batch.companyId,
|
||||
record.nsr,
|
||||
record.recordType,
|
||||
record.eventDatetime,
|
||||
record.eventDatetimeRaw,
|
||||
record.employeeCpfRaw,
|
||||
record.employeeId,
|
||||
record.rawLine,
|
||||
record.crcReceived,
|
||||
record.crcCalculated,
|
||||
record.crcValid,
|
||||
JSON.stringify(record.parsedData),
|
||||
record.validationStatus,
|
||||
record.validationNotes,
|
||||
],
|
||||
);
|
||||
insertedCount += rowCount ?? 0;
|
||||
}
|
||||
|
||||
const { rows: updatedBatchRows } = await client.query<AfdImportBatch>(
|
||||
"UPDATE afd_import_batches SET inserted_count = $2 WHERE id = $1 RETURNING *",
|
||||
[batch.id, insertedCount],
|
||||
);
|
||||
const updatedBatch = updatedBatchRows[0] ?? batch;
|
||||
|
||||
return { batch: updatedBatch, insertedCount };
|
||||
});
|
||||
}
|
||||
|
||||
export interface ListAfdRecordsFilter {
|
||||
deviceId?: string;
|
||||
employeeId?: string;
|
||||
recordType?: string;
|
||||
/** Inclusive lower bound on event_datetime. */
|
||||
dateFrom?: Date;
|
||||
/** Exclusive upper bound on event_datetime (caller passes "day after dateTo" to include the whole end day). */
|
||||
dateTo?: Date;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Read-only, paginated. There is deliberately no equivalent write
|
||||
* function for arbitrary fields — afd_records rows are only ever created
|
||||
* via importAfdBatch's INSERT, never updated.
|
||||
*/
|
||||
export async function listAfdRecords(filter: ListAfdRecordsFilter): Promise<{ records: AfdRecord[]; total: number }> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filter.deviceId) {
|
||||
params.push(filter.deviceId);
|
||||
conditions.push(`device_id = $${params.length}`);
|
||||
}
|
||||
if (filter.employeeId) {
|
||||
params.push(filter.employeeId);
|
||||
conditions.push(`employee_id = $${params.length}`);
|
||||
}
|
||||
if (filter.recordType) {
|
||||
params.push(filter.recordType);
|
||||
conditions.push(`record_type = $${params.length}`);
|
||||
}
|
||||
if (filter.dateFrom) {
|
||||
params.push(filter.dateFrom);
|
||||
conditions.push(`event_datetime >= $${params.length}`);
|
||||
}
|
||||
if (filter.dateTo) {
|
||||
params.push(filter.dateTo);
|
||||
conditions.push(`event_datetime < $${params.length}`);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const limit = filter.limit ?? DEFAULT_PAGE_SIZE;
|
||||
const offset = filter.offset ?? 0;
|
||||
|
||||
const { rows: countRows } = await query<{ count: string }>(
|
||||
`SELECT count(*) FROM afd_records ${where}`,
|
||||
params,
|
||||
);
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
|
||||
const { rows } = await query<AfdRecord>(
|
||||
`SELECT * FROM afd_records ${where} ORDER BY event_datetime ASC NULLS LAST, created_at ASC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||
[...params, limit, offset],
|
||||
);
|
||||
|
||||
return { records: rows, total };
|
||||
}
|
||||
|
||||
export async function getAfdRecordById(id: string): Promise<AfdRecord | null> {
|
||||
const { rows } = await query<AfdRecord>("SELECT * FROM afd_records WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
747
packages/database/src/repositories/inventory.ts
Normal file
747
packages/database/src/repositories/inventory.ts
Normal file
@@ -0,0 +1,747 @@
|
||||
import { query, withTransaction } from "../pool.js";
|
||||
import type { PoolClient } from "pg";
|
||||
|
||||
/**
|
||||
* Inventory/Warehouse/Assets bounded context — Master Prompt §6.8,
|
||||
* ADR-0007. See the migration's header comment (PENDING_inventory.cjs)
|
||||
* for the full list of possible entities and which 5 this MVP builds vs.
|
||||
* explicitly deferred ones.
|
||||
*
|
||||
* Mirrors the conventions of companies.ts/products.ts (COALESCE-based
|
||||
* partial writes, snake_case rows) and contracts.ts (withTransaction for
|
||||
* multi-row writes that must commit/rollback together, custom error
|
||||
* classes mapped to 404/409 by the service layer).
|
||||
*
|
||||
* NOT registered in the barrel (packages/database/src/index.ts) yet —
|
||||
* that's deliberate integration work left to the merge step (see
|
||||
* .claude/agents/eden-inventory.md task boundaries).
|
||||
*/
|
||||
|
||||
// ===========================================================================
|
||||
// warehouses
|
||||
// ===========================================================================
|
||||
|
||||
export interface Warehouse {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
company_id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export async function listWarehouses(includeInactive = false): Promise<Warehouse[]> {
|
||||
const { rows } = await query<Warehouse>(
|
||||
includeInactive
|
||||
? "SELECT * FROM warehouses ORDER BY created_at ASC"
|
||||
: "SELECT * FROM warehouses WHERE is_active = true ORDER BY created_at ASC",
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getWarehouseById(id: string): Promise<Warehouse | null> {
|
||||
const { rows } = await query<Warehouse>("SELECT * FROM warehouses WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export interface CreateWarehouseInput {
|
||||
companyId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
isActive?: boolean;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
export async function createWarehouse(input: CreateWarehouseInput): Promise<Warehouse> {
|
||||
const { rows } = await query<Warehouse>(
|
||||
`INSERT INTO warehouses (company_id, name, code, is_active, created_by)
|
||||
VALUES ($1,$2,$3,COALESCE($4,true),$5)
|
||||
RETURNING *`,
|
||||
[input.companyId, input.name, input.code, input.isActive ?? null, input.createdBy ?? null],
|
||||
);
|
||||
const warehouse = rows[0];
|
||||
if (!warehouse) throw new Error("failed to create warehouse");
|
||||
return warehouse;
|
||||
}
|
||||
|
||||
export interface UpdateWarehouseInput {
|
||||
name?: string;
|
||||
code?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export async function updateWarehouse(id: string, input: UpdateWarehouseInput): Promise<Warehouse | null> {
|
||||
const { rows } = await query<Warehouse>(
|
||||
`UPDATE warehouses SET
|
||||
name = COALESCE($2, name),
|
||||
code = COALESCE($3, code),
|
||||
is_active = COALESCE($4, is_active)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[id, input.name ?? null, input.code ?? null, input.isActive ?? null],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// stock_movements — append-only ledger
|
||||
// ===========================================================================
|
||||
|
||||
export type StockMovementType =
|
||||
| "purchase_in"
|
||||
| "adjustment_in"
|
||||
| "adjustment_out"
|
||||
| "transfer_out"
|
||||
| "transfer_in"
|
||||
| "reservation"
|
||||
| "reservation_release"
|
||||
| "sale_out"
|
||||
| "install_out"
|
||||
| "comodato_out"
|
||||
| "return_in"
|
||||
| "rma_out"
|
||||
| "write_off";
|
||||
|
||||
export interface StockMovement {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
warehouse_id: string;
|
||||
product_id: string;
|
||||
movement_type: StockMovementType;
|
||||
quantity: string;
|
||||
reference_type: string | null;
|
||||
reference_id: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface ListStockMovementsFilter {
|
||||
warehouseId?: string;
|
||||
productId?: string;
|
||||
referenceType?: string;
|
||||
referenceId?: string;
|
||||
}
|
||||
|
||||
export async function listStockMovements(filter: ListStockMovementsFilter = {}): Promise<StockMovement[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filter.warehouseId) {
|
||||
params.push(filter.warehouseId);
|
||||
conditions.push(`warehouse_id = $${params.length}`);
|
||||
}
|
||||
if (filter.productId) {
|
||||
params.push(filter.productId);
|
||||
conditions.push(`product_id = $${params.length}`);
|
||||
}
|
||||
if (filter.referenceType) {
|
||||
params.push(filter.referenceType);
|
||||
conditions.push(`reference_type = $${params.length}`);
|
||||
}
|
||||
if (filter.referenceId) {
|
||||
params.push(filter.referenceId);
|
||||
conditions.push(`reference_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const { rows } = await query<StockMovement>(
|
||||
`SELECT * FROM stock_movements ${where} ORDER BY created_at DESC`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface CreateStockMovementInput {
|
||||
warehouseId: string;
|
||||
productId: string;
|
||||
movementType: StockMovementType;
|
||||
quantity: number;
|
||||
referenceType?: string | null;
|
||||
referenceId?: string | null;
|
||||
notes?: string | null;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends one movement. No update/edit function exists on purpose — a
|
||||
* correction is always a new call to this function with the reversing
|
||||
* type (also enforced at the DB level by trg_stock_movements_no_update/
|
||||
* no_delete). Accepts an optional `client` so callers that need this
|
||||
* insert inside a larger transaction (asset entry/install/return) can
|
||||
* pass the transaction's client instead of grabbing a new pool
|
||||
* connection — used by future endpoints (adjustment/transfer/write_off)
|
||||
* that aren't wired up yet in this MVP; the asset lifecycle functions
|
||||
* below (createAssetWithPurchaseMovement, reserveAsset, installAsset,
|
||||
* returnAsset) inline their own movement INSERTs instead of calling this,
|
||||
* since they already hold the transaction's client for the surrounding
|
||||
* asset row lock.
|
||||
*/
|
||||
export async function recordStockMovement(
|
||||
input: CreateStockMovementInput,
|
||||
client?: PoolClient,
|
||||
): Promise<StockMovement> {
|
||||
const sql = `INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
RETURNING *`;
|
||||
const params = [
|
||||
input.warehouseId,
|
||||
input.productId,
|
||||
input.movementType,
|
||||
input.quantity,
|
||||
input.referenceType ?? null,
|
||||
input.referenceId ?? null,
|
||||
input.notes ?? null,
|
||||
input.createdBy ?? null,
|
||||
];
|
||||
const { rows } = client
|
||||
? await client.query<StockMovement>(sql, params)
|
||||
: await query<StockMovement>(sql, params);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("failed to record stock movement");
|
||||
return row;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// serialized_assets
|
||||
// ===========================================================================
|
||||
|
||||
export type SerializedAssetStatus =
|
||||
| "in_stock"
|
||||
| "reserved"
|
||||
| "installed"
|
||||
| "comodato"
|
||||
| "returned"
|
||||
| "written_off";
|
||||
|
||||
export interface SerializedAsset {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
product_id: string;
|
||||
serial: string | null;
|
||||
patrimony_tag: string | null;
|
||||
mac_address: string | null;
|
||||
warehouse_id: string | null;
|
||||
status: SerializedAssetStatus;
|
||||
current_contract_id: string | null;
|
||||
warranty_expires_at: Date | null;
|
||||
}
|
||||
|
||||
export interface ListSerializedAssetsFilter {
|
||||
status?: SerializedAssetStatus;
|
||||
warehouseId?: string;
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
export async function listSerializedAssets(filter: ListSerializedAssetsFilter = {}): Promise<SerializedAsset[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filter.status) {
|
||||
params.push(filter.status);
|
||||
conditions.push(`status = $${params.length}`);
|
||||
}
|
||||
if (filter.warehouseId) {
|
||||
params.push(filter.warehouseId);
|
||||
conditions.push(`warehouse_id = $${params.length}`);
|
||||
}
|
||||
if (filter.productId) {
|
||||
params.push(filter.productId);
|
||||
conditions.push(`product_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const { rows } = await query<SerializedAsset>(
|
||||
`SELECT * FROM serialized_assets ${where} ORDER BY created_at DESC`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getSerializedAssetById(id: string): Promise<SerializedAsset | null> {
|
||||
const { rows } = await query<SerializedAsset>("SELECT * FROM serialized_assets WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function getSerializedAssetByIdForUpdate(client: PoolClient, id: string): Promise<SerializedAsset | null> {
|
||||
const { rows } = await client.query<SerializedAsset>(
|
||||
"SELECT * FROM serialized_assets WHERE id = $1 FOR UPDATE",
|
||||
[id],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export class AssetNotFoundError extends Error {
|
||||
constructor(public readonly assetId: string) {
|
||||
super(`Serialized asset ${assetId} not found`);
|
||||
this.name = "AssetNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidAssetTransitionError extends Error {
|
||||
constructor(
|
||||
public readonly fromStatus: SerializedAssetStatus,
|
||||
public readonly action: string,
|
||||
) {
|
||||
super(`Asset in status '${fromStatus}' cannot be ${action}`);
|
||||
this.name = "InvalidAssetTransitionError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateAssetInput {
|
||||
productId: string;
|
||||
warehouseId: string;
|
||||
serial?: string | null;
|
||||
patrimonyTag?: string | null;
|
||||
macAddress?: string | null;
|
||||
warrantyExpiresAt?: Date | string | null;
|
||||
notes?: string | null;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entrada de um ativo novo — cria a linha em serialized_assets (status
|
||||
* in_stock) e o stock_movement purchase_in correspondente numa única
|
||||
* transação. Se o serial/patrimônio/MAC já estiver "ativo" em outro
|
||||
* ativo, a constraint de banco (índice único parcial) rejeita o INSERT —
|
||||
* o erro do Postgres (23505) é deixado subir; a camada de serviço decide
|
||||
* como traduzir para 409.
|
||||
*/
|
||||
export async function createAssetWithPurchaseMovement(input: CreateAssetInput): Promise<{
|
||||
asset: SerializedAsset;
|
||||
movement: StockMovement;
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const { rows: assetRows } = await client.query<SerializedAsset>(
|
||||
`INSERT INTO serialized_assets (
|
||||
product_id, serial, patrimony_tag, mac_address, warehouse_id, status,
|
||||
warranty_expires_at, created_by, updated_by
|
||||
) VALUES ($1,$2,$3,$4,$5,'in_stock',$6,$7,$7)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.productId,
|
||||
input.serial ?? null,
|
||||
input.patrimonyTag ?? null,
|
||||
input.macAddress ?? null,
|
||||
input.warehouseId,
|
||||
input.warrantyExpiresAt ?? null,
|
||||
input.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
const asset = assetRows[0];
|
||||
if (!asset) throw new Error("failed to create serialized asset");
|
||||
|
||||
const { rows: movementRows } = await client.query<StockMovement>(
|
||||
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
|
||||
VALUES ($1,$2,'purchase_in',1,'serialized_asset',$3,$4,$5)
|
||||
RETURNING *`,
|
||||
[input.warehouseId, input.productId, asset.id, input.notes ?? null, input.createdBy ?? null],
|
||||
);
|
||||
const movement = movementRows[0];
|
||||
if (!movement) throw new Error("failed to record purchase_in movement");
|
||||
|
||||
return { asset, movement };
|
||||
});
|
||||
}
|
||||
|
||||
export interface ReserveAssetInput {
|
||||
referenceType?: string | null;
|
||||
referenceId?: string | null;
|
||||
notes?: string | null;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserva um ativo específico já em estoque: cria a stock_reservation
|
||||
* (quantity=1, reference_type='serialized_asset', reference_id=asset.id)
|
||||
* e move o ativo para status='reserved'. Só permitido a partir de
|
||||
* 'in_stock' — throws InvalidAssetTransitionError caso contrário.
|
||||
*/
|
||||
export async function reserveAsset(
|
||||
assetId: string,
|
||||
input: ReserveAssetInput,
|
||||
): Promise<{ asset: SerializedAsset; reservation: StockReservation; movement: StockMovement }> {
|
||||
return withTransaction(async (client) => {
|
||||
const asset = await getSerializedAssetByIdForUpdate(client, assetId);
|
||||
if (!asset) throw new AssetNotFoundError(assetId);
|
||||
if (asset.status !== "in_stock") throw new InvalidAssetTransitionError(asset.status, "reserved");
|
||||
if (!asset.warehouse_id) throw new Error(`asset ${assetId} has status in_stock but no warehouse_id`);
|
||||
|
||||
const { rows: updatedRows } = await client.query<SerializedAsset>(
|
||||
`UPDATE serialized_assets SET status = 'reserved', updated_by = $2 WHERE id = $1 RETURNING *`,
|
||||
[assetId, input.createdBy ?? null],
|
||||
);
|
||||
const updated = updatedRows[0];
|
||||
if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`);
|
||||
|
||||
const { rows: reservationRows } = await client.query<StockReservation>(
|
||||
`INSERT INTO stock_reservations (warehouse_id, product_id, quantity, reference_type, reference_id, status, notes, created_by)
|
||||
VALUES ($1,$2,1,'serialized_asset',$3,'active',$4,$5)
|
||||
RETURNING *`,
|
||||
[asset.warehouse_id, asset.product_id, assetId, input.notes ?? null, input.createdBy ?? null],
|
||||
);
|
||||
const reservation = reservationRows[0];
|
||||
if (!reservation) throw new Error("failed to create reservation");
|
||||
|
||||
const { rows: movementRows } = await client.query<StockMovement>(
|
||||
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
|
||||
VALUES ($1,$2,'reservation',1,'serialized_asset',$3,$4,$5)
|
||||
RETURNING *`,
|
||||
[asset.warehouse_id, asset.product_id, assetId, input.notes ?? null, input.createdBy ?? null],
|
||||
);
|
||||
const movement = movementRows[0];
|
||||
if (!movement) throw new Error("failed to record reservation movement");
|
||||
|
||||
return { asset: updated, reservation, movement };
|
||||
});
|
||||
}
|
||||
|
||||
export interface InstallAssetInput {
|
||||
contractId: string;
|
||||
assignmentType: "installed" | "comodato";
|
||||
notes?: string | null;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vincula um ativo (em 'in_stock' ou 'reserved') a um contrato: cria
|
||||
* asset_assignments, move o ativo para status='installed'/'comodato'
|
||||
* (limpando warehouse_id — a localização agora é "no cliente"), grava o
|
||||
* stock_movement install_out/comodato_out, e — se havia uma reserva
|
||||
* ATIVA para este ativo específico — marca-a 'fulfilled'. Tudo numa
|
||||
* transação. Só permitido a partir de in_stock/reserved.
|
||||
*/
|
||||
export async function installAsset(
|
||||
assetId: string,
|
||||
input: InstallAssetInput,
|
||||
): Promise<{ asset: SerializedAsset; assignment: AssetAssignment; movement: StockMovement }> {
|
||||
return withTransaction(async (client) => {
|
||||
const asset = await getSerializedAssetByIdForUpdate(client, assetId);
|
||||
if (!asset) throw new AssetNotFoundError(assetId);
|
||||
if (asset.status !== "in_stock" && asset.status !== "reserved") {
|
||||
throw new InvalidAssetTransitionError(asset.status, input.assignmentType);
|
||||
}
|
||||
if (!asset.warehouse_id) throw new Error(`asset ${assetId} has no warehouse_id before install`);
|
||||
|
||||
const originWarehouseId = asset.warehouse_id;
|
||||
const movementType = input.assignmentType === "comodato" ? "comodato_out" : "install_out";
|
||||
|
||||
const { rows: updatedRows } = await client.query<SerializedAsset>(
|
||||
`UPDATE serialized_assets
|
||||
SET status = $2, warehouse_id = NULL, current_contract_id = $3, updated_by = $4
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[assetId, input.assignmentType, input.contractId, input.createdBy ?? null],
|
||||
);
|
||||
const updated = updatedRows[0];
|
||||
if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`);
|
||||
|
||||
const { rows: assignmentRows } = await client.query<AssetAssignment>(
|
||||
`INSERT INTO asset_assignments (asset_id, contract_id, assignment_type, created_by)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
RETURNING *`,
|
||||
[assetId, input.contractId, input.assignmentType, input.createdBy ?? null],
|
||||
);
|
||||
const assignment = assignmentRows[0];
|
||||
if (!assignment) throw new Error("failed to create asset assignment");
|
||||
|
||||
const { rows: movementRows } = await client.query<StockMovement>(
|
||||
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
|
||||
VALUES ($1,$2,$3,1,'contract',$4,$5,$6)
|
||||
RETURNING *`,
|
||||
[originWarehouseId, asset.product_id, movementType, input.contractId, input.notes ?? null, input.createdBy ?? null],
|
||||
);
|
||||
const movement = movementRows[0];
|
||||
if (!movement) throw new Error("failed to record install/comodato movement");
|
||||
|
||||
// If there was an active asset-specific reservation, close it as fulfilled.
|
||||
await client.query(
|
||||
`UPDATE stock_reservations
|
||||
SET status = 'fulfilled'
|
||||
WHERE reference_type = 'serialized_asset' AND reference_id = $1 AND status = 'active'`,
|
||||
[assetId],
|
||||
);
|
||||
|
||||
return { asset: updated, assignment, movement };
|
||||
});
|
||||
}
|
||||
|
||||
export interface ReturnAssetInput {
|
||||
/** Warehouse receiving the physical unit back — not necessarily the one it left from (see migration comment on serialized_assets.warehouse_id). */
|
||||
warehouseId: string;
|
||||
notes?: string | null;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
export class NoOpenAssignmentError extends Error {
|
||||
constructor(public readonly assetId: string) {
|
||||
super(`Asset ${assetId} has no open assignment to close`);
|
||||
this.name = "NoOpenAssignmentError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fecha o assignment aberto (unassigned_at = now()), grava o
|
||||
* stock_movement return_in no warehouse de destino, e volta o ativo para
|
||||
* status='in_stock' com current_contract_id limpo. Só permitido a partir
|
||||
* de installed/comodato.
|
||||
*/
|
||||
export async function returnAsset(
|
||||
assetId: string,
|
||||
input: ReturnAssetInput,
|
||||
): Promise<{ asset: SerializedAsset; movement: StockMovement }> {
|
||||
return withTransaction(async (client) => {
|
||||
const asset = await getSerializedAssetByIdForUpdate(client, assetId);
|
||||
if (!asset) throw new AssetNotFoundError(assetId);
|
||||
if (asset.status !== "installed" && asset.status !== "comodato") {
|
||||
throw new InvalidAssetTransitionError(asset.status, "returned");
|
||||
}
|
||||
|
||||
const { rows: assignmentRows } = await client.query<AssetAssignment>(
|
||||
`UPDATE asset_assignments SET unassigned_at = now()
|
||||
WHERE asset_id = $1 AND unassigned_at IS NULL
|
||||
RETURNING *`,
|
||||
[assetId],
|
||||
);
|
||||
if (!assignmentRows[0]) throw new NoOpenAssignmentError(assetId);
|
||||
|
||||
const { rows: updatedRows } = await client.query<SerializedAsset>(
|
||||
`UPDATE serialized_assets
|
||||
SET status = 'in_stock', warehouse_id = $2, current_contract_id = NULL, updated_by = $3
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[assetId, input.warehouseId, input.createdBy ?? null],
|
||||
);
|
||||
const updated = updatedRows[0];
|
||||
if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`);
|
||||
|
||||
const { rows: movementRows } = await client.query<StockMovement>(
|
||||
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
|
||||
VALUES ($1,$2,'return_in',1,'contract',$3,$4,$5)
|
||||
RETURNING *`,
|
||||
[input.warehouseId, asset.product_id, asset.current_contract_id, input.notes ?? null, input.createdBy ?? null],
|
||||
);
|
||||
const movement = movementRows[0];
|
||||
if (!movement) throw new Error("failed to record return_in movement");
|
||||
|
||||
return { asset: updated, movement };
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// asset_assignments
|
||||
// ===========================================================================
|
||||
|
||||
export interface AssetAssignment {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
asset_id: string;
|
||||
contract_id: string;
|
||||
assignment_type: "installed" | "comodato";
|
||||
assigned_at: Date;
|
||||
unassigned_at: Date | null;
|
||||
}
|
||||
|
||||
export async function listAssetAssignments(assetId: string): Promise<AssetAssignment[]> {
|
||||
const { rows } = await query<AssetAssignment>(
|
||||
"SELECT * FROM asset_assignments WHERE asset_id = $1 ORDER BY assigned_at DESC",
|
||||
[assetId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// stock_reservations
|
||||
// ===========================================================================
|
||||
|
||||
export type StockReservationStatus = "active" | "released" | "fulfilled";
|
||||
|
||||
export interface StockReservation {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
warehouse_id: string;
|
||||
product_id: string;
|
||||
quantity: string;
|
||||
reference_type: string | null;
|
||||
reference_id: string | null;
|
||||
status: StockReservationStatus;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface ListStockReservationsFilter {
|
||||
status?: StockReservationStatus;
|
||||
warehouseId?: string;
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
export async function listStockReservations(
|
||||
filter: ListStockReservationsFilter = {},
|
||||
): Promise<StockReservation[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filter.status) {
|
||||
params.push(filter.status);
|
||||
conditions.push(`status = $${params.length}`);
|
||||
}
|
||||
if (filter.warehouseId) {
|
||||
params.push(filter.warehouseId);
|
||||
conditions.push(`warehouse_id = $${params.length}`);
|
||||
}
|
||||
if (filter.productId) {
|
||||
params.push(filter.productId);
|
||||
conditions.push(`product_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const { rows } = await query<StockReservation>(
|
||||
`SELECT * FROM stock_reservations ${where} ORDER BY created_at DESC`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getStockReservationById(id: string): Promise<StockReservation | null> {
|
||||
const { rows } = await query<StockReservation>("SELECT * FROM stock_reservations WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export class ReservationNotFoundError extends Error {
|
||||
constructor(public readonly reservationId: string) {
|
||||
super(`Stock reservation ${reservationId} not found`);
|
||||
this.name = "ReservationNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidReservationTransitionError extends Error {
|
||||
constructor(
|
||||
public readonly fromStatus: StockReservationStatus,
|
||||
public readonly action: string,
|
||||
) {
|
||||
super(`Reservation in status '${fromStatus}' cannot be ${action}`);
|
||||
this.name = "InvalidReservationTransitionError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateGenericReservationInput {
|
||||
warehouseId: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
referenceType?: string | null;
|
||||
referenceId?: string | null;
|
||||
notes?: string | null;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserva GENÉRICA de quantidade (produto+warehouse), sem serial
|
||||
* selecionado ainda — o primeiro passo do fluxo descrito no Master
|
||||
* Prompt §6.8 antes de "posteriormente selecionar unidade serializada
|
||||
* específica". Distinta de reserveAsset() (que reserva um serial já
|
||||
* escolhido).
|
||||
*/
|
||||
export async function createGenericReservation(
|
||||
input: CreateGenericReservationInput,
|
||||
): Promise<{ reservation: StockReservation; movement: StockMovement }> {
|
||||
return withTransaction(async (client) => {
|
||||
const { rows: reservationRows } = await client.query<StockReservation>(
|
||||
`INSERT INTO stock_reservations (warehouse_id, product_id, quantity, reference_type, reference_id, status, notes, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,'active',$6,$7)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.warehouseId,
|
||||
input.productId,
|
||||
input.quantity,
|
||||
input.referenceType ?? null,
|
||||
input.referenceId ?? null,
|
||||
input.notes ?? null,
|
||||
input.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
const reservation = reservationRows[0];
|
||||
if (!reservation) throw new Error("failed to create reservation");
|
||||
|
||||
const { rows: movementRows } = await client.query<StockMovement>(
|
||||
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
|
||||
VALUES ($1,$2,'reservation',$3,$4,$5,$6,$7)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.warehouseId,
|
||||
input.productId,
|
||||
input.quantity,
|
||||
input.referenceType ?? null,
|
||||
input.referenceId ?? null,
|
||||
input.notes ?? null,
|
||||
input.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
const movement = movementRows[0];
|
||||
if (!movement) throw new Error("failed to record reservation movement");
|
||||
|
||||
return { reservation, movement };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela uma reserva ATIVA (status -> released) e grava o
|
||||
* reservation_release correspondente. Se a reserva era de um ativo
|
||||
* específico (reference_type='serialized_asset'), também devolve esse
|
||||
* ativo para 'in_stock' — desfazendo o que reserveAsset() fez.
|
||||
*/
|
||||
export async function releaseReservation(
|
||||
reservationId: string,
|
||||
actorId: string | null,
|
||||
): Promise<{ reservation: StockReservation; movement: StockMovement }> {
|
||||
return withTransaction(async (client) => {
|
||||
const { rows: currentRows } = await client.query<StockReservation>(
|
||||
"SELECT * FROM stock_reservations WHERE id = $1 FOR UPDATE",
|
||||
[reservationId],
|
||||
);
|
||||
const current = currentRows[0];
|
||||
if (!current) throw new ReservationNotFoundError(reservationId);
|
||||
if (current.status !== "active") {
|
||||
throw new InvalidReservationTransitionError(current.status, "released");
|
||||
}
|
||||
|
||||
const { rows: updatedRows } = await client.query<StockReservation>(
|
||||
`UPDATE stock_reservations SET status = 'released' WHERE id = $1 RETURNING *`,
|
||||
[reservationId],
|
||||
);
|
||||
const updated = updatedRows[0];
|
||||
if (!updated) throw new Error(`reservation ${reservationId} disappeared mid-transaction`);
|
||||
|
||||
const { rows: movementRows } = await client.query<StockMovement>(
|
||||
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
|
||||
VALUES ($1,$2,'reservation_release',$3,$4,$5,$6,$7)
|
||||
RETURNING *`,
|
||||
[
|
||||
current.warehouse_id,
|
||||
current.product_id,
|
||||
current.quantity,
|
||||
current.reference_type,
|
||||
current.reference_id,
|
||||
null,
|
||||
actorId,
|
||||
],
|
||||
);
|
||||
const movement = movementRows[0];
|
||||
if (!movement) throw new Error("failed to record reservation_release movement");
|
||||
|
||||
if (current.reference_type === "serialized_asset" && current.reference_id) {
|
||||
await client.query(
|
||||
`UPDATE serialized_assets SET status = 'in_stock', updated_by = $2
|
||||
WHERE id = $1 AND status = 'reserved'`,
|
||||
[current.reference_id, actorId],
|
||||
);
|
||||
}
|
||||
|
||||
return { reservation: updated, movement };
|
||||
});
|
||||
}
|
||||
423
packages/database/src/repositories/support-tickets.ts
Normal file
423
packages/database/src/repositories/support-tickets.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { query, withTransaction } from "../pool.js";
|
||||
|
||||
/**
|
||||
* Support/Service Desk bounded context (Master Prompt §6.13). Greenfield —
|
||||
* no legacy module to port. See the migration's header comment
|
||||
* (PENDING_support.cjs) for the full list of tables this MVP builds vs.
|
||||
* explicitly deferred ones (real S3 attachments, CSAT, dedicated work
|
||||
* order entity, routing queues, configurable attendance calendar).
|
||||
*
|
||||
* Mirrors contracts.ts's conventions closely: withTransaction + SELECT
|
||||
* ... FOR UPDATE for the status-machine transition, an explicit
|
||||
* transitions table, a NotFound/InvalidTransition error pair the service
|
||||
* layer maps to 404/409, and a *WithDetails aggregate for the "get one"
|
||||
* endpoint.
|
||||
*
|
||||
* SLA breach/elapsed-time math (computeElapsedSeconds/isSlaBreached) is
|
||||
* deliberately NOT implemented in this file — it's pure, DB-independent
|
||||
* business logic that lives in apps/api/src/support/sla.ts (this package
|
||||
* must not depend on apps/api). This repository only performs the SQL
|
||||
* side effect of pausing/resuming the timer (using the DB's own `now()`,
|
||||
* to avoid app/DB clock skew for that specific bookkeeping) and persists
|
||||
* whatever met/unmet flags the service layer has already computed.
|
||||
*/
|
||||
|
||||
export type TicketStatus =
|
||||
| "new"
|
||||
| "triage"
|
||||
| "in_progress"
|
||||
| "waiting_customer"
|
||||
| "waiting_third_party"
|
||||
| "resolved"
|
||||
| "closed"
|
||||
| "cancelled";
|
||||
|
||||
export type TicketPriority = "low" | "medium" | "high" | "critical";
|
||||
|
||||
export interface SupportTicket {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
protocol_number: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
subcategory: string | null;
|
||||
priority: TicketPriority;
|
||||
status: TicketStatus;
|
||||
reseller_id: string | null;
|
||||
client_registration_id: string | null;
|
||||
contract_id: string | null;
|
||||
owner_user_id: string | null;
|
||||
opened_by_user_id: string;
|
||||
root_cause: string | null;
|
||||
resolution_notes: string | null;
|
||||
first_response_at: Date | null;
|
||||
resolved_at: Date | null;
|
||||
closed_at: Date | null;
|
||||
}
|
||||
|
||||
export interface TicketComment {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
author_user_id: string;
|
||||
body: string;
|
||||
is_internal: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface TicketStatusHistoryEntry {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
from_status: TicketStatus | null;
|
||||
to_status: TicketStatus;
|
||||
reason: string | null;
|
||||
changed_by: string | null;
|
||||
changed_at: Date;
|
||||
}
|
||||
|
||||
export interface SlaTimer {
|
||||
ticket_id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
first_response_due_at: Date;
|
||||
resolution_due_at: Date;
|
||||
first_response_met: boolean | null;
|
||||
resolution_met: boolean | null;
|
||||
paused_since: Date | null;
|
||||
accumulated_pause_seconds: number;
|
||||
}
|
||||
|
||||
export interface TicketWithDetails extends SupportTicket {
|
||||
comments: TicketComment[];
|
||||
statusHistory: TicketStatusHistoryEntry[];
|
||||
slaTimer: SlaTimer | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* MVP lifecycle (eden-support persona spec): new -> triage -> in_progress
|
||||
* <-> {waiting_customer, waiting_third_party} -> resolved -> closed, plus
|
||||
* cancelled reachable from any non-terminal state. closed/cancelled are
|
||||
* terminal (no outgoing transitions modeled).
|
||||
*/
|
||||
export const TICKET_STATUS_TRANSITIONS: Record<TicketStatus, TicketStatus[]> = {
|
||||
new: ["triage", "cancelled"],
|
||||
triage: ["in_progress", "cancelled"],
|
||||
in_progress: ["waiting_customer", "waiting_third_party", "resolved", "cancelled"],
|
||||
waiting_customer: ["in_progress", "cancelled"],
|
||||
waiting_third_party: ["in_progress", "cancelled"],
|
||||
resolved: ["closed", "cancelled"],
|
||||
closed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export class TicketNotFoundError extends Error {
|
||||
constructor(public readonly ticketId: string) {
|
||||
super(`Support ticket ${ticketId} not found`);
|
||||
this.name = "TicketNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTicketTransitionError extends Error {
|
||||
constructor(
|
||||
public readonly fromStatus: TicketStatus,
|
||||
public readonly toStatus: TicketStatus,
|
||||
) {
|
||||
super(`Invalid ticket status transition: '${fromStatus}' -> '${toStatus}'`);
|
||||
this.name = "InvalidTicketTransitionError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ListTicketsFilter {
|
||||
scope: "all" | "reseller";
|
||||
/** Required (and enforced) when scope === 'reseller'. Ignored when scope === 'all'. */
|
||||
resellerId?: string | null;
|
||||
}
|
||||
|
||||
export async function listTickets(filter: ListTicketsFilter): Promise<SupportTicket[]> {
|
||||
if (filter.scope === "reseller") {
|
||||
// Deliberately never falls back to an unfiltered query — deny-by-default,
|
||||
// same discipline as contracts.ts::listContracts.
|
||||
const { rows } = await query<SupportTicket>(
|
||||
"SELECT * FROM support_tickets WHERE reseller_id = $1 ORDER BY created_at DESC",
|
||||
[filter.resellerId ?? null],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
const { rows } = await query<SupportTicket>("SELECT * FROM support_tickets ORDER BY created_at DESC");
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function listTicketComments(ticketId: string): Promise<TicketComment[]> {
|
||||
const { rows } = await query<TicketComment>(
|
||||
"SELECT * FROM ticket_comments WHERE ticket_id = $1 ORDER BY created_at ASC",
|
||||
[ticketId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function listTicketStatusHistory(ticketId: string): Promise<TicketStatusHistoryEntry[]> {
|
||||
const { rows } = await query<TicketStatusHistoryEntry>(
|
||||
"SELECT * FROM ticket_status_history WHERE ticket_id = $1 ORDER BY changed_at ASC",
|
||||
[ticketId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getSlaTimer(ticketId: string): Promise<SlaTimer | null> {
|
||||
const { rows } = await query<SlaTimer>("SELECT * FROM sla_timers WHERE ticket_id = $1", [ticketId]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getTicketById(id: string): Promise<TicketWithDetails | null> {
|
||||
const { rows } = await query<SupportTicket>("SELECT * FROM support_tickets WHERE id = $1", [id]);
|
||||
const ticket = rows[0];
|
||||
if (!ticket) return null;
|
||||
|
||||
const [comments, statusHistory, slaTimer] = await Promise.all([
|
||||
listTicketComments(id),
|
||||
listTicketStatusHistory(id),
|
||||
getSlaTimer(id),
|
||||
]);
|
||||
|
||||
return { ...ticket, comments, statusHistory, slaTimer };
|
||||
}
|
||||
|
||||
export interface CreateTicketInput {
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
subcategory?: string | null;
|
||||
priority: TicketPriority;
|
||||
resellerId?: string | null;
|
||||
clientRegistrationId?: string | null;
|
||||
contractId?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
openedByUserId: string;
|
||||
createdBy?: string | null;
|
||||
/** Server-computed by the caller (apps/api/src/support/sla.ts) — never accepted raw from a client. */
|
||||
firstResponseDueAt: Date;
|
||||
/** Server-computed by the caller (apps/api/src/support/sla.ts) — never accepted raw from a client. */
|
||||
resolutionDueAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the ticket plus its 1:1 sla_timers row in a single transaction
|
||||
* (all-or-nothing) — mirrors contracts.ts::createContract. Status always
|
||||
* starts 'new'; protocol_number is allocated by the column DEFAULT
|
||||
* (support_tickets_protocol_number_seq), same pattern as quotes.quote_number.
|
||||
*/
|
||||
export async function createTicket(input: CreateTicketInput): Promise<TicketWithDetails> {
|
||||
return withTransaction(async (client) => {
|
||||
const { rows } = await client.query<SupportTicket>(
|
||||
`INSERT INTO support_tickets (
|
||||
title, description, category, subcategory, priority,
|
||||
reseller_id, client_registration_id, contract_id,
|
||||
owner_user_id, opened_by_user_id, created_by
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.title,
|
||||
input.description,
|
||||
input.category,
|
||||
input.subcategory ?? null,
|
||||
input.priority,
|
||||
input.resellerId ?? null,
|
||||
input.clientRegistrationId ?? null,
|
||||
input.contractId ?? null,
|
||||
input.ownerUserId ?? null,
|
||||
input.openedByUserId,
|
||||
input.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
const ticket = rows[0];
|
||||
if (!ticket) throw new Error("failed to create support ticket");
|
||||
|
||||
const { rows: timerRows } = await client.query<SlaTimer>(
|
||||
`INSERT INTO sla_timers (ticket_id, first_response_due_at, resolution_due_at)
|
||||
VALUES ($1,$2,$3)
|
||||
RETURNING *`,
|
||||
[ticket.id, input.firstResponseDueAt, input.resolutionDueAt],
|
||||
);
|
||||
const slaTimer = timerRows[0];
|
||||
if (!slaTimer) throw new Error("failed to create sla timer");
|
||||
|
||||
return { ...ticket, comments: [], statusHistory: [], slaTimer };
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreateTicketCommentInput {
|
||||
authorUserId: string;
|
||||
body: string;
|
||||
isInternal: boolean;
|
||||
}
|
||||
|
||||
export async function addTicketComment(
|
||||
ticketId: string,
|
||||
input: CreateTicketCommentInput,
|
||||
): Promise<TicketComment> {
|
||||
const { rows } = await query<TicketComment>(
|
||||
`INSERT INTO ticket_comments (ticket_id, author_user_id, body, is_internal)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
RETURNING *`,
|
||||
[ticketId, input.authorUserId, input.body, input.isInternal],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("failed to add ticket comment");
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the first public (non-internal) comment is added on a ticket whose
|
||||
* first_response_at is still NULL, the service layer computes whether
|
||||
* that response met the SLA (apps/api/src/support/sla.ts::isSlaBreached)
|
||||
* and calls this to persist it. Idempotent by construction (WHERE ... IS
|
||||
* NULL on both tables) — a second call after first_response_at is already
|
||||
* set is a silent no-op, never overwrites.
|
||||
*/
|
||||
export async function recordFirstResponse(
|
||||
ticketId: string,
|
||||
met: boolean,
|
||||
respondedAt: Date,
|
||||
): Promise<void> {
|
||||
await query(
|
||||
`UPDATE support_tickets SET first_response_at = $2 WHERE id = $1 AND first_response_at IS NULL`,
|
||||
[ticketId, respondedAt],
|
||||
);
|
||||
await query(
|
||||
`UPDATE sla_timers SET first_response_met = $2, updated_at = now()
|
||||
WHERE ticket_id = $1 AND first_response_met IS NULL`,
|
||||
[ticketId, met],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the service layer right after a transition into 'resolved',
|
||||
* once it has computed the met/unmet flag from the (already-returned,
|
||||
* necessarily-unpaused — see updateTicketStatus below) sla timer via
|
||||
* sla.ts::isSlaBreached.
|
||||
*/
|
||||
export async function recordResolutionMet(ticketId: string, met: boolean): Promise<void> {
|
||||
await query(`UPDATE sla_timers SET resolution_met = $2, updated_at = now() WHERE ticket_id = $1`, [
|
||||
ticketId,
|
||||
met,
|
||||
]);
|
||||
}
|
||||
|
||||
export interface UpdateTicketStatusExtra {
|
||||
rootCause?: string | null;
|
||||
resolutionNotes?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the transition against TICKET_STATUS_TRANSITIONS and, only if
|
||||
* valid, atomically: updates support_tickets.status, sets resolved_at/
|
||||
* closed_at via COALESCE the first time the ticket enters that state
|
||||
* (never overwritten afterwards, same semantics as contracts.activated_at),
|
||||
* accepts root_cause/resolution_notes only on the transition into
|
||||
* 'closed', appends a ticket_status_history row, AND pauses/resumes the
|
||||
* SLA timer:
|
||||
* - entering waiting_customer/waiting_third_party -> paused_since = now()
|
||||
* (COALESCE — a no-op if somehow already paused)
|
||||
* - waiting_customer/waiting_third_party -> in_progress -> the elapsed
|
||||
* pause is folded into accumulated_pause_seconds and paused_since is
|
||||
* cleared
|
||||
* All using the DB's own now() for this specific bookkeeping (single
|
||||
* statement, no app/DB clock skew risk). Returns the updated ticket AND
|
||||
* the post-transition sla timer row so the service layer can compute
|
||||
* first_response_met/resolution_met with the pure functions in
|
||||
* apps/api/src/support/sla.ts. Throws TicketNotFoundError /
|
||||
* InvalidTicketTransitionError instead of silently no-oping — the service
|
||||
* layer maps these to 404/409.
|
||||
*/
|
||||
export async function updateTicketStatus(
|
||||
id: string,
|
||||
toStatus: TicketStatus,
|
||||
reason: string | null,
|
||||
actorId: string | null,
|
||||
extra?: UpdateTicketStatusExtra,
|
||||
): Promise<{ ticket: SupportTicket; slaTimer: SlaTimer }> {
|
||||
return withTransaction(async (client) => {
|
||||
const { rows } = await client.query<{ status: TicketStatus }>(
|
||||
"SELECT status FROM support_tickets WHERE id = $1 FOR UPDATE",
|
||||
[id],
|
||||
);
|
||||
const current = rows[0];
|
||||
if (!current) {
|
||||
throw new TicketNotFoundError(id);
|
||||
}
|
||||
|
||||
const fromStatus = current.status;
|
||||
const allowed = TICKET_STATUS_TRANSITIONS[fromStatus] ?? [];
|
||||
if (!allowed.includes(toStatus)) {
|
||||
throw new InvalidTicketTransitionError(fromStatus, toStatus);
|
||||
}
|
||||
|
||||
const enteringWaiting = toStatus === "waiting_customer" || toStatus === "waiting_third_party";
|
||||
const resumingFromWaiting =
|
||||
(fromStatus === "waiting_customer" || fromStatus === "waiting_third_party") && toStatus === "in_progress";
|
||||
const enteringResolved = toStatus === "resolved";
|
||||
const enteringClosed = toStatus === "closed";
|
||||
|
||||
// Lock the timer row too (1:1, always exists once the ticket does) and
|
||||
// apply the pause/resume side effect. All SET expressions in a single
|
||||
// UPDATE see the row's OLD values, so referencing paused_since while
|
||||
// also overwriting it in the same statement is safe standard SQL.
|
||||
await client.query(
|
||||
`UPDATE sla_timers SET
|
||||
accumulated_pause_seconds = CASE
|
||||
WHEN $3 AND paused_since IS NOT NULL
|
||||
THEN accumulated_pause_seconds + GREATEST(0, EXTRACT(EPOCH FROM (now() - paused_since))::int)
|
||||
ELSE accumulated_pause_seconds
|
||||
END,
|
||||
paused_since = CASE
|
||||
WHEN $2 THEN COALESCE(paused_since, now())
|
||||
WHEN $3 THEN NULL
|
||||
ELSE paused_since
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE ticket_id = $1`,
|
||||
[id, enteringWaiting, resumingFromWaiting],
|
||||
);
|
||||
|
||||
const { rows: updatedRows } = await client.query<SupportTicket>(
|
||||
`UPDATE support_tickets SET
|
||||
status = $2,
|
||||
resolved_at = CASE WHEN $3 THEN COALESCE(resolved_at, now()) ELSE resolved_at END,
|
||||
closed_at = CASE WHEN $4 THEN COALESCE(closed_at, now()) ELSE closed_at END,
|
||||
root_cause = CASE WHEN $4 THEN COALESCE($5, root_cause) ELSE root_cause END,
|
||||
resolution_notes = CASE WHEN $4 THEN COALESCE($6, resolution_notes) ELSE resolution_notes END,
|
||||
updated_by = $7
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
toStatus,
|
||||
enteringResolved,
|
||||
enteringClosed,
|
||||
extra?.rootCause ?? null,
|
||||
extra?.resolutionNotes ?? null,
|
||||
actorId ?? null,
|
||||
],
|
||||
);
|
||||
const ticket = updatedRows[0];
|
||||
if (!ticket) throw new Error(`support ticket ${id} disappeared mid-transaction`);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO ticket_status_history (ticket_id, from_status, to_status, reason, changed_by)
|
||||
VALUES ($1,$2,$3,$4,$5)`,
|
||||
[id, fromStatus, toStatus, reason ?? null, actorId ?? null],
|
||||
);
|
||||
|
||||
const { rows: timerRows } = await client.query<SlaTimer>(
|
||||
"SELECT * FROM sla_timers WHERE ticket_id = $1",
|
||||
[id],
|
||||
);
|
||||
const slaTimer = timerRows[0];
|
||||
if (!slaTimer) throw new Error(`sla timer for ticket ${id} missing`);
|
||||
|
||||
return { ticket, slaTimer };
|
||||
});
|
||||
}
|
||||
@@ -142,3 +142,25 @@ export async function assignUserToReseller(
|
||||
]);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages a TOTP secret without enabling MFA yet — `mfa_enabled` stays
|
||||
* false until `confirmMfaEnrollment` verifies a code against it. Login
|
||||
* only checks `mfa_enabled`, so an unconfirmed enrollment is inert.
|
||||
*/
|
||||
export async function setMfaSecret(userId: string, encryptedSecret: string): Promise<void> {
|
||||
await query("UPDATE users SET mfa_secret_encrypted = $2, mfa_enabled = false WHERE id = $1", [
|
||||
userId,
|
||||
encryptedSecret,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function confirmMfaEnrollment(userId: string): Promise<void> {
|
||||
await query("UPDATE users SET mfa_enabled = true WHERE id = $1", [userId]);
|
||||
}
|
||||
|
||||
export async function disableMfa(userId: string): Promise<void> {
|
||||
await query("UPDATE users SET mfa_enabled = false, mfa_secret_encrypted = null WHERE id = $1", [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user