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:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.turbo
|
||||
.claude/worktrees
|
||||
.tmp
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
tema_do_Eden.zip
|
||||
23
.env.example
23
.env.example
@@ -21,6 +21,29 @@ EDEN_CORE_PORT=3001
|
||||
EDEN_PARCEIROS_PORT=3002
|
||||
EDEN_ASSINANTE_PORT=3003
|
||||
|
||||
# --- Frontend: URL base da API consumida pelo browser (Vite, build-time) ---
|
||||
# Lida via import.meta.env.VITE_API_BASE_URL. Dev local (`pnpm dev`, fora de
|
||||
# Docker): a API roda em localhost:8080, origem DIFERENTE da porta do Vite —
|
||||
# precisa de URL absoluta + CORS (ver EDEN_CORS_ORIGINS abaixo). Copiar para
|
||||
# o .env de cada app (apps/core-web/.env etc.) — Vite não lê o .env da raiz.
|
||||
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||
|
||||
# Build de produção via Docker (compose.yaml `VITE_API_BASE_URL` de build
|
||||
# arg, default /api/v1): a API NÃO tem domínio público próprio (decisão do
|
||||
# operador — só acessível por IP interno). O browser chama a API pelo MESMO
|
||||
# domínio do frontend, caminho relativo /api/v1 — o proxy reverso na frente
|
||||
# de cada um dos 3 domínios públicos precisa rotear internamente esse path
|
||||
# para o container eden-api (rede interna, nunca exposto publicamente).
|
||||
# Nesse fluxo (mesma origem) o navegador não faz preflight de CORS.
|
||||
|
||||
# --- CORS (origens de browser permitidas a chamar a API cross-origin) ---
|
||||
# Só é relevante para dev local (core-web em localhost:3001+, API em
|
||||
# localhost:8080 — origens diferentes). Nunca usar "*" — a API emite bearer
|
||||
# token (ver packages/config/src/env-schema.ts). Os 3 domínios públicos NÃO
|
||||
# entram aqui: eles chamam a API por caminho relativo via proxy (acima),
|
||||
# nunca cross-origin diretamente na API.
|
||||
EDEN_CORS_ORIGINS=http://localhost:3001,http://localhost:3002,http://localhost:3003
|
||||
|
||||
# --- Autenticação / sessão (ADR-0003) ---
|
||||
# Gerar com: openssl rand -hex 48
|
||||
JWT_SIGNING_SECRET=
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"otplib": "^13.5.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,11 @@ import { OpportunitiesModule } from "./opportunities/opportunities.module.js";
|
||||
import { QuotesModule } from "./quotes/quotes.module.js";
|
||||
import { ClientRegistrationsModule } from "./client-registrations/client-registrations.module.js";
|
||||
import { ContractsModule } from "./contracts/contracts.module.js";
|
||||
import { InventoryModule } from "./inventory/inventory.module.js";
|
||||
import { SupportModule } from "./support/support.module.js";
|
||||
import { HrTimeclockModule } from "./hr-timeclock/hr-timeclock.module.js";
|
||||
import { FiscalModule } from "./fiscal/fiscal.module.js";
|
||||
import { BillingModule } from "./billing/billing.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -31,6 +36,11 @@ import { ContractsModule } from "./contracts/contracts.module.js";
|
||||
QuotesModule,
|
||||
ClientRegistrationsModule,
|
||||
ContractsModule,
|
||||
InventoryModule,
|
||||
SupportModule,
|
||||
HrTimeclockModule,
|
||||
FiscalModule,
|
||||
BillingModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Body, Controller, Get, HttpCode, Post, Req, UseGuards } from "@nestjs/c
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import type { Request } from "express";
|
||||
import { AuthService } from "./auth.service.js";
|
||||
import { MfaService } from "./mfa.service.js";
|
||||
import { LoginDto } from "./dto/login.dto.js";
|
||||
import { RefreshDto } from "./dto/refresh.dto.js";
|
||||
import { MfaChallengeDto } from "./dto/mfa-challenge.dto.js";
|
||||
import { MfaCodeDto } from "./dto/mfa-code.dto.js";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard.js";
|
||||
import { CurrentUser } from "./decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
|
||||
@@ -18,7 +21,10 @@ function requestContext(req: Request) {
|
||||
@ApiTags("auth")
|
||||
@Controller("v1/auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly mfaService: MfaService,
|
||||
) {}
|
||||
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
@@ -26,6 +32,35 @@ export class AuthController {
|
||||
return this.auth.login(dto.email, dto.password, dto.applicationKey, requestContext(req));
|
||||
}
|
||||
|
||||
@Post("mfa/challenge")
|
||||
@HttpCode(200)
|
||||
async mfaChallenge(@Body() dto: MfaChallengeDto, @Req() req: Request) {
|
||||
return this.auth.completeMfaLogin(dto.mfaChallenge, dto.code, requestContext(req));
|
||||
}
|
||||
|
||||
@Post("mfa/enroll")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async mfaEnroll(@CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.mfaService.startEnrollment(user.id, user.email);
|
||||
}
|
||||
|
||||
@Post("mfa/confirm")
|
||||
@HttpCode(204)
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async mfaConfirm(@Body() dto: MfaCodeDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
await this.mfaService.confirmEnrollment(user.id, dto.code);
|
||||
}
|
||||
|
||||
@Post("mfa/disable")
|
||||
@HttpCode(204)
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async mfaDisable(@Body() dto: MfaCodeDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
await this.mfaService.disable(user.id, dto.code);
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@HttpCode(200)
|
||||
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {
|
||||
|
||||
@@ -3,13 +3,24 @@ import { AuthController } from "./auth.controller.js";
|
||||
import { AuthService } from "./auth.service.js";
|
||||
import { PasswordService } from "./password.service.js";
|
||||
import { TokenService } from "./token.service.js";
|
||||
import { MfaService } from "./mfa.service.js";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "./permission.guard.js";
|
||||
import { SuperAdminGuard } from "./super-admin.guard.js";
|
||||
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, PasswordService, TokenService, JwtAuthGuard, PermissionGuard, SuperAdminGuard],
|
||||
providers: [
|
||||
AuthService,
|
||||
PasswordService,
|
||||
TokenService,
|
||||
MfaService,
|
||||
FieldEncryptionService,
|
||||
JwtAuthGuard,
|
||||
PermissionGuard,
|
||||
SuperAdminGuard,
|
||||
],
|
||||
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard, SuperAdminGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ForbiddenException, Injectable, UnauthorizedException } from "@nestjs/c
|
||||
import { usersRepo, sessionsRepo, auditRepo, type User } from "@eden/database";
|
||||
import { PasswordService } from "./password.service.js";
|
||||
import { TokenService } from "./token.service.js";
|
||||
import { MfaService } from "./mfa.service.js";
|
||||
import { resolvePermissions } from "./permissions.js";
|
||||
|
||||
export interface RequestContext {
|
||||
@@ -16,6 +17,14 @@ export interface AuthResult {
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
export interface MfaRequiredResult {
|
||||
mfaRequired: true;
|
||||
mfaChallenge: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export type LoginResult = AuthResult | MfaRequiredResult;
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -34,6 +43,7 @@ export class AuthService {
|
||||
constructor(
|
||||
private readonly passwords: PasswordService,
|
||||
private readonly tokens: TokenService,
|
||||
private readonly mfa: MfaService,
|
||||
) {}
|
||||
|
||||
async login(
|
||||
@@ -41,7 +51,7 @@ export class AuthService {
|
||||
password: string,
|
||||
applicationKey: "core" | "reseller" | "subscriber",
|
||||
ctx: RequestContext,
|
||||
): Promise<AuthResult> {
|
||||
): Promise<LoginResult> {
|
||||
const user = await usersRepo.findUserByEmail(email);
|
||||
|
||||
// Same generic error whether the account doesn't exist or the password
|
||||
@@ -65,6 +75,20 @@ export class AuthService {
|
||||
throw new ForbiddenException("Usuário sem acesso a esta aplicação");
|
||||
}
|
||||
|
||||
if (user.mfa_enabled) {
|
||||
const challenge = this.tokens.signMfaChallenge({ sub: user.id, app: applicationKey });
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.mfa_challenge_issued",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
metadata: { applicationKey },
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
return { mfaRequired: true, mfaChallenge: challenge.token, expiresIn: challenge.expiresIn };
|
||||
}
|
||||
|
||||
const result = await this.issueTokens(user, applicationKey, ctx);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
@@ -80,6 +104,47 @@ export class AuthService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Second step of login when the account has MFA enabled — exchanges a password-verified challenge + TOTP code for real tokens. */
|
||||
async completeMfaLogin(challengeToken: string, code: string, ctx: RequestContext): Promise<AuthResult> {
|
||||
let payload;
|
||||
try {
|
||||
payload = this.tokens.verifyMfaChallenge(challengeToken);
|
||||
} catch {
|
||||
throw new UnauthorizedException("Desafio MFA inválido ou expirado");
|
||||
}
|
||||
|
||||
const user = await usersRepo.findUserById(payload.sub);
|
||||
if (!user || !user.is_active || !user.mfa_enabled || !user.mfa_secret_encrypted) {
|
||||
throw new UnauthorizedException("Desafio MFA inválido ou expirado");
|
||||
}
|
||||
|
||||
if (!(await this.mfa.verifyCode(user.mfa_secret_encrypted, code))) {
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.mfa_challenge_failed",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
throw new UnauthorizedException("Código inválido");
|
||||
}
|
||||
|
||||
const result = await this.issueTokens(user, payload.app, ctx);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.login",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
metadata: { applicationKey: payload.app, viaMfa: true },
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async refresh(refreshTokenPlaintext: string, ctx: RequestContext): Promise<AuthResult> {
|
||||
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
|
||||
const active = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
|
||||
|
||||
11
apps/api/src/auth/dto/mfa-challenge.dto.ts
Normal file
11
apps/api/src/auth/dto/mfa-challenge.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, Length, MinLength } from "class-validator";
|
||||
|
||||
export class MfaChallengeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
mfaChallenge!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
code!: string;
|
||||
}
|
||||
7
apps/api/src/auth/dto/mfa-code.dto.ts
Normal file
7
apps/api/src/auth/dto/mfa-code.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, Length } from "class-validator";
|
||||
|
||||
export class MfaCodeDto {
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
code!: string;
|
||||
}
|
||||
49
apps/api/src/auth/mfa.service.ts
Normal file
49
apps/api/src/auth/mfa.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { generateSecret, generateURI, verify } from "otplib";
|
||||
import { usersRepo } from "@eden/database";
|
||||
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
|
||||
|
||||
const ISSUER = "EDEN";
|
||||
|
||||
@Injectable()
|
||||
export class MfaService {
|
||||
constructor(private readonly fieldEncryption: FieldEncryptionService) {}
|
||||
|
||||
/** Stages a new TOTP secret (not yet active — see confirmEnrollment). */
|
||||
async startEnrollment(userId: string, email: string): Promise<{ secret: string; otpauthUrl: string }> {
|
||||
const secret = generateSecret();
|
||||
await usersRepo.setMfaSecret(userId, this.fieldEncryption.encrypt(secret));
|
||||
const otpauthUrl = generateURI({ issuer: ISSUER, label: email, secret });
|
||||
return { secret, otpauthUrl };
|
||||
}
|
||||
|
||||
/** Verifies the first code against the staged secret and flips mfa_enabled on. */
|
||||
async confirmEnrollment(userId: string, code: string): Promise<void> {
|
||||
const user = await usersRepo.findUserById(userId);
|
||||
if (!user?.mfa_secret_encrypted) {
|
||||
throw new BadRequestException("Nenhum cadastro de MFA pendente para este usuário");
|
||||
}
|
||||
if (!(await this.verifyCode(user.mfa_secret_encrypted, code))) {
|
||||
throw new BadRequestException("Código inválido");
|
||||
}
|
||||
await usersRepo.confirmMfaEnrollment(userId);
|
||||
}
|
||||
|
||||
/** Requires a currently-valid code — proves the factor, not just an authenticated session. */
|
||||
async disable(userId: string, code: string): Promise<void> {
|
||||
const user = await usersRepo.findUserById(userId);
|
||||
if (!user?.mfa_enabled || !user.mfa_secret_encrypted) {
|
||||
throw new BadRequestException("MFA não está habilitado para este usuário");
|
||||
}
|
||||
if (!(await this.verifyCode(user.mfa_secret_encrypted, code))) {
|
||||
throw new UnauthorizedException("Código inválido");
|
||||
}
|
||||
await usersRepo.disableMfa(userId);
|
||||
}
|
||||
|
||||
async verifyCode(encryptedSecret: string, code: string): Promise<boolean> {
|
||||
const secret = this.fieldEncryption.decrypt(encryptedSecret);
|
||||
const result = await verify({ token: code, secret });
|
||||
return result.valid;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,14 @@ export interface AccessTokenPayload {
|
||||
|
||||
const ACCESS_TOKEN_TTL_SECONDS = 15 * 60; // 15 min — ADR-0003
|
||||
const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||
const MFA_CHALLENGE_TTL_SECONDS = 2 * 60;
|
||||
const MFA_CHALLENGE_PURPOSE = "mfa_challenge" as const;
|
||||
|
||||
export interface MfaChallengePayload {
|
||||
sub: string;
|
||||
app: "core" | "reseller" | "subscriber";
|
||||
purpose: typeof MFA_CHALLENGE_PURPOSE;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
@@ -26,7 +34,18 @@ export class TokenService {
|
||||
}
|
||||
|
||||
verifyAccessToken(token: string): AccessTokenPayload {
|
||||
return jwt.verify(token, this.secret) as AccessTokenPayload;
|
||||
const payload = jwt.verify(token, this.secret) as AccessTokenPayload & { purpose?: unknown };
|
||||
// Both token kinds are signed with the same secret, so signature
|
||||
// verification alone doesn't distinguish them — a real access token
|
||||
// must never carry a `purpose` claim (only single-purpose tokens like
|
||||
// the MFA challenge do). Without this check, a password-verified MFA
|
||||
// challenge token would be indistinguishable from a full access token
|
||||
// and grant complete API access without ever checking the second
|
||||
// factor — this was caught live (a real bug, not hypothetical).
|
||||
if (payload.purpose !== undefined) {
|
||||
throw new Error("Token não é um access token válido");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Opaque, high-entropy refresh token — never a JWT (ADR-0003). Returns the
|
||||
@@ -42,4 +61,27 @@ export class TokenService {
|
||||
hashRefreshToken(plaintext: string): string {
|
||||
return createHash("sha256").update(plaintext).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived, single-purpose token issued after password verification
|
||||
* when the account has MFA enabled — proves "password already checked"
|
||||
* without granting API access. The `purpose` claim is what keeps this
|
||||
* out of JwtAuthGuard (see verifyAccessToken's explicit rejection of any
|
||||
* `purpose` claim) — without it this token would be a fully valid
|
||||
* access token, defeating MFA entirely.
|
||||
*/
|
||||
signMfaChallenge(payload: Omit<MfaChallengePayload, "purpose">): { token: string; expiresIn: number } {
|
||||
const token = jwt.sign({ ...payload, purpose: MFA_CHALLENGE_PURPOSE }, this.secret, {
|
||||
expiresIn: MFA_CHALLENGE_TTL_SECONDS,
|
||||
});
|
||||
return { token, expiresIn: MFA_CHALLENGE_TTL_SECONDS };
|
||||
}
|
||||
|
||||
verifyMfaChallenge(token: string): MfaChallengePayload {
|
||||
const payload = jwt.verify(token, this.secret) as Partial<MfaChallengePayload>;
|
||||
if (payload.purpose !== MFA_CHALLENGE_PURPOSE || !payload.sub || !payload.app) {
|
||||
throw new Error("Token de desafio MFA inválido");
|
||||
}
|
||||
return payload as MfaChallengePayload;
|
||||
}
|
||||
}
|
||||
|
||||
104
apps/api/src/billing/billing.controller.ts
Normal file
104
apps/api/src/billing/billing.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { BillingService } from "./billing.service.js";
|
||||
import { CreateBillingSubscriptionDto } from "./dto/create-billing-subscription.dto.js";
|
||||
import { CreateBillingRunDto } from "./dto/create-billing-run.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import type {
|
||||
BillingRunStatus,
|
||||
BillingSubscriptionStatus,
|
||||
InvoiceStatus,
|
||||
} from "@eden/database";
|
||||
|
||||
/**
|
||||
* Billing — Master Prompt §6.10, ADR-0008. Recurring subscription ->
|
||||
* billing_run -> invoice MVP. Explicitly NOT Finance/AR-AP (títulos,
|
||||
* boletos, dunning) or Fiscal (documento fiscal) — see ADR-0008. See
|
||||
* billing.service.ts for the authorization rules (why run
|
||||
* creation/consolidation requires 'all' scope).
|
||||
*/
|
||||
@ApiTags("billing")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/billing")
|
||||
export class BillingController {
|
||||
constructor(private readonly billing: BillingService) {}
|
||||
|
||||
@Get("subscriptions")
|
||||
@RequirePermission("billing", "view")
|
||||
listSubscriptions(
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
@Query("status") status?: BillingSubscriptionStatus,
|
||||
) {
|
||||
return this.billing.listSubscriptions(user, status);
|
||||
}
|
||||
|
||||
@Post("subscriptions")
|
||||
@RequirePermission("billing", "edit")
|
||||
createSubscription(
|
||||
@Body() dto: CreateBillingSubscriptionDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.billing.createSubscription(dto, user);
|
||||
}
|
||||
|
||||
@Patch("subscriptions/:id/cancel")
|
||||
@RequirePermission("billing", "edit")
|
||||
cancelSubscription(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.billing.cancelSubscription(id, user);
|
||||
}
|
||||
|
||||
@Post("runs")
|
||||
@RequirePermission("billing", "edit")
|
||||
createRun(@Body() dto: CreateBillingRunDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.billing.createOrRegenerateRun(dto, user);
|
||||
}
|
||||
|
||||
@Get("runs")
|
||||
@RequirePermission("billing", "view")
|
||||
listRuns(
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
@Query("referenceYear") referenceYear?: string,
|
||||
@Query("referenceMonth") referenceMonth?: string,
|
||||
@Query("status") status?: BillingRunStatus,
|
||||
) {
|
||||
return this.billing.listRuns(user, {
|
||||
referenceYear: referenceYear != null ? Number(referenceYear) : undefined,
|
||||
referenceMonth: referenceMonth != null ? Number(referenceMonth) : undefined,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("runs/:id")
|
||||
@RequirePermission("billing", "view")
|
||||
getRun(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.billing.getRun(id, user);
|
||||
}
|
||||
|
||||
@Post("runs/:id/consolidate")
|
||||
@RequirePermission("billing", "edit")
|
||||
consolidateRun(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.billing.consolidateRun(id, user);
|
||||
}
|
||||
|
||||
@Get("invoices")
|
||||
@RequirePermission("billing", "view")
|
||||
listInvoices(
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
@Query("status") status?: InvoiceStatus,
|
||||
@Query("referenceYear") referenceYear?: string,
|
||||
@Query("referenceMonth") referenceMonth?: string,
|
||||
@Query("resellerId") resellerId?: string,
|
||||
) {
|
||||
return this.billing.listInvoices(user, {
|
||||
status,
|
||||
referenceYear: referenceYear != null ? Number(referenceYear) : undefined,
|
||||
referenceMonth: referenceMonth != null ? Number(referenceMonth) : undefined,
|
||||
resellerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
12
apps/api/src/billing/billing.module.ts
Normal file
12
apps/api/src/billing/billing.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { BillingController } from "./billing.controller.js";
|
||||
import { BillingService } from "./billing.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
262
apps/api/src/billing/billing.service.ts
Normal file
262
apps/api/src/billing/billing.service.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
billingRepo,
|
||||
type BillingRun,
|
||||
type BillingRunWithInvoices,
|
||||
type BillingSubscription,
|
||||
type Invoice,
|
||||
} from "@eden/database";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import { maxGrantedScope } from "../auth/permissions.js";
|
||||
import type { CreateBillingSubscriptionDto } from "./dto/create-billing-subscription.dto.js";
|
||||
import type { CreateBillingRunDto } from "./dto/create-billing-run.dto.js";
|
||||
|
||||
const {
|
||||
BillingSubscriptionNotFoundError,
|
||||
BillingRunNotFoundError,
|
||||
BillingRunConsolidatedError,
|
||||
BillingPeriodAlreadyConsolidatedError,
|
||||
} = billingRepo;
|
||||
|
||||
const RESOURCE = "billing";
|
||||
|
||||
/**
|
||||
* Billing (Master Prompt §6.10, ADR-0008) — recurring subscription ->
|
||||
* billing_run -> invoice flow. Strictly distinct from Finance/AR-AP
|
||||
* (títulos/boletos/cobrança) and Fiscal (documento fiscal) — neither is
|
||||
* touched here.
|
||||
*
|
||||
* Authorization: creating/regenerating and consolidating a billing_run
|
||||
* always requires 'all' scope for 'billing'/'edit' — a run spans every
|
||||
* reseller with a matching billing_day, so only central back-office
|
||||
* operators (not a single reseller) can trigger a billing close. Reading
|
||||
* and per-subscription/invoice mutations respect the actor's scope
|
||||
* ('all' vs 'reseller'), same pattern as contracts.service.ts.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
// -------------------------------------------------------------------
|
||||
// billing_subscriptions
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
async listSubscriptions(
|
||||
user: EdenAuthenticatedUser,
|
||||
status?: BillingSubscription["status"],
|
||||
): Promise<BillingSubscription[]> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
|
||||
if (scope === "all") return billingRepo.listBillingSubscriptions({ scope: "all", status });
|
||||
return billingRepo.listBillingSubscriptions({
|
||||
scope: "reseller",
|
||||
resellerId: user.resellerId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
async createSubscription(
|
||||
dto: CreateBillingSubscriptionDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<BillingSubscription> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "edit");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'edit' em '${RESOURCE}'`);
|
||||
|
||||
// Never trust a client-supplied resellerId to place a subscription
|
||||
// under an arbitrary reseller (CLAUDE.md: no authz decision trusts a
|
||||
// client-supplied reseller_id). A 'reseller'-scoped actor always gets
|
||||
// their own session-resolved reseller; only 'all' scope may target an
|
||||
// explicit resellerId from the body.
|
||||
let resellerId: string | null;
|
||||
if (scope === "all") {
|
||||
resellerId = dto.resellerId ?? null;
|
||||
} else {
|
||||
resellerId = user.resellerId;
|
||||
if (!resellerId) {
|
||||
throw new ForbiddenException("Usuário não está vinculado a nenhuma revenda");
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = await billingRepo.createBillingSubscription({
|
||||
contractId: dto.contractId,
|
||||
resellerId,
|
||||
monthlyValue: dto.monthlyValue,
|
||||
billingDay: dto.billingDay,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "billing_subscription.created",
|
||||
resourceType: "billing_subscription",
|
||||
resourceId: subscription.id,
|
||||
metadata: { contractId: dto.contractId, billingDay: dto.billingDay },
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async cancelSubscription(id: string, user: EdenAuthenticatedUser): Promise<BillingSubscription> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "edit");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'edit' em '${RESOURCE}'`);
|
||||
|
||||
const existing = await billingRepo.getBillingSubscriptionById(id);
|
||||
if (!existing) throw new NotFoundException("Subscription não encontrada");
|
||||
if (scope !== "all" && existing.reseller_id !== user.resellerId) {
|
||||
throw new NotFoundException("Subscription não encontrada");
|
||||
}
|
||||
|
||||
const cancelled = await billingRepo.cancelBillingSubscription(id, user.id);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "billing_subscription.cancelled",
|
||||
resourceType: "billing_subscription",
|
||||
resourceId: id,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// billing_runs / invoices
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates a new draft run, or — if a draft already exists for the same
|
||||
* (referenceYear, referenceMonth, billingDay) — regenerates its
|
||||
* invoices in place (idempotent: never duplicates). 409s if a run for
|
||||
* that exact period is already consolidated (controlled re-billing is
|
||||
* deferred, see PENDING_billing.cjs header).
|
||||
*/
|
||||
async createOrRegenerateRun(
|
||||
dto: CreateBillingRunDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<BillingRunWithInvoices> {
|
||||
this.requireAllScope(user, "edit");
|
||||
|
||||
try {
|
||||
const run = await billingRepo.generateOrRegenerateBillingRun({
|
||||
referenceYear: dto.referenceYear,
|
||||
referenceMonth: dto.referenceMonth,
|
||||
billingDay: dto.billingDay,
|
||||
triggeredBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "billing_run.generated",
|
||||
resourceType: "billing_run",
|
||||
resourceId: run.id,
|
||||
metadata: {
|
||||
referenceYear: dto.referenceYear,
|
||||
referenceMonth: dto.referenceMonth,
|
||||
billingDay: dto.billingDay,
|
||||
invoiceCount: run.invoices.length,
|
||||
totalAmount: run.totalAmount,
|
||||
},
|
||||
});
|
||||
|
||||
return run;
|
||||
} catch (err) {
|
||||
if (err instanceof BillingPeriodAlreadyConsolidatedError) {
|
||||
throw new ConflictException(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async listRuns(
|
||||
user: EdenAuthenticatedUser,
|
||||
filter: { referenceYear?: number; referenceMonth?: number; status?: BillingRun["status"] },
|
||||
): Promise<BillingRun[]> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
|
||||
return billingRepo.listBillingRuns(filter);
|
||||
}
|
||||
|
||||
async getRun(id: string, user: EdenAuthenticatedUser): Promise<BillingRunWithInvoices> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
|
||||
|
||||
const run = await billingRepo.getBillingRunById(id);
|
||||
if (!run) throw new NotFoundException("Billing run não encontrado");
|
||||
|
||||
if (scope !== "all") {
|
||||
// Reseller-scoped actors only ever see their own reseller's slice
|
||||
// of a run's invoices — never another reseller's amounts.
|
||||
run.invoices = run.invoices.filter((inv: Invoice) => inv.reseller_id === user.resellerId);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async consolidateRun(id: string, user: EdenAuthenticatedUser): Promise<BillingRunWithInvoices> {
|
||||
this.requireAllScope(user, "edit");
|
||||
|
||||
try {
|
||||
const run = await billingRepo.consolidateBillingRun(id, user.id);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "billing_run.consolidated",
|
||||
resourceType: "billing_run",
|
||||
resourceId: id,
|
||||
metadata: { invoiceCount: run.invoices.length, totalAmount: run.totalAmount },
|
||||
});
|
||||
|
||||
return run;
|
||||
} catch (err) {
|
||||
if (err instanceof BillingRunNotFoundError) throw new NotFoundException("Billing run não encontrado");
|
||||
if (err instanceof BillingRunConsolidatedError) {
|
||||
throw new ConflictException("Billing run já está consolidado");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async listInvoices(
|
||||
user: EdenAuthenticatedUser,
|
||||
filter: {
|
||||
status?: Invoice["status"];
|
||||
referenceYear?: number;
|
||||
referenceMonth?: number;
|
||||
/** Only honored when the actor holds 'all' scope — see below. */
|
||||
resellerId?: string;
|
||||
},
|
||||
): Promise<Invoice[]> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
|
||||
const { resellerId: requestedResellerId, ...rest } = filter;
|
||||
|
||||
if (scope === "all") {
|
||||
// A client-supplied resellerId is only ever honored once the
|
||||
// actor's own server-resolved scope already grants full ('all')
|
||||
// visibility — it narrows an already-unrestricted view, it never
|
||||
// grants access beyond what the session allows.
|
||||
return billingRepo.listInvoices(
|
||||
requestedResellerId
|
||||
? { scope: "reseller", resellerId: requestedResellerId, ...rest }
|
||||
: { scope: "all", ...rest },
|
||||
);
|
||||
}
|
||||
return billingRepo.listInvoices({ scope: "reseller", resellerId: user.resellerId, ...rest });
|
||||
}
|
||||
|
||||
private requireAllScope(user: EdenAuthenticatedUser, action: "view" | "edit"): void {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, action);
|
||||
if (scope !== "all") {
|
||||
throw new ForbiddenException(
|
||||
`Fechamento de billing exige escopo 'all' para '${RESOURCE}:${action}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/api/src/billing/dto/create-billing-run.dto.ts
Normal file
18
apps/api/src/billing/dto/create-billing-run.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsInt, Max, Min } from "class-validator";
|
||||
|
||||
export class CreateBillingRunDto {
|
||||
@IsInt()
|
||||
@Min(2000)
|
||||
@Max(2100)
|
||||
referenceYear!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(12)
|
||||
referenceMonth!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(28)
|
||||
billingDay!: number;
|
||||
}
|
||||
30
apps/api/src/billing/dto/create-billing-subscription.dto.ts
Normal file
30
apps/api/src/billing/dto/create-billing-subscription.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsInt, IsNumber, IsOptional, IsUUID, Max, Min } from "class-validator";
|
||||
|
||||
/**
|
||||
* `monthlyValue` is accepted directly in the body (never recalculated
|
||||
* from a contract) — this worktree/leva has no guaranteed access to the
|
||||
* real `contracts` module to look the value up server-side, so the
|
||||
* caller (typically a Contracts-side workflow) supplies the snapshot
|
||||
* explicitly. `resellerId` is only actually trusted from the client when
|
||||
* the caller holds 'all' scope; for 'reseller' scope it is always
|
||||
* overridden server-side with the actor's own reseller (see
|
||||
* billing.service.ts#create) — no authorization decision ever trusts a
|
||||
* client-supplied resellerId (CLAUDE.md).
|
||||
*/
|
||||
export class CreateBillingSubscriptionDto {
|
||||
@IsUUID()
|
||||
contractId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
resellerId?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
monthlyValue!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(28)
|
||||
billingDay!: number;
|
||||
}
|
||||
61
apps/api/src/crypto/field-encryption.service.ts
Normal file
61
apps/api/src/crypto/field-encryption.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import type { EdenEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "../config/config.constants.js";
|
||||
|
||||
const KEY_VERSION = "v1";
|
||||
const IV_LENGTH_BYTES = 12; // 96 bits — AES-GCM recommended nonce size
|
||||
|
||||
/**
|
||||
* Generic reversible field encryption (ADR-0005) — AES-256-GCM, random IV
|
||||
* per operation, authenticated (tampering makes decrypt() throw). Stored
|
||||
* format mirrors the legacy's `iv:tag:ciphertext` scheme with an added key
|
||||
* version prefix so a future root-key rotation can be applied incrementally
|
||||
* instead of re-encrypting everything at once: `v1:iv:tag:ciphertext` (all
|
||||
* three payload segments base64).
|
||||
*
|
||||
* Root key comes from EDEN_FIELD_ENCRYPTION_KEY (env-only, never in the
|
||||
* database) and is hashed to exactly 32 bytes via SHA-256 so any
|
||||
* sufficiently-long passphrase works as AES-256 key material.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FieldEncryptionService {
|
||||
private readonly key: Buffer | null;
|
||||
|
||||
constructor(@Inject(EDEN_ENV) env: EdenEnv) {
|
||||
this.key = env.EDEN_FIELD_ENCRYPTION_KEY
|
||||
? createHash("sha256").update(env.EDEN_FIELD_ENCRYPTION_KEY).digest()
|
||||
: null;
|
||||
}
|
||||
|
||||
encrypt(plaintext: string): string {
|
||||
const key = this.requireKey();
|
||||
const iv = randomBytes(IV_LENGTH_BYTES);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [KEY_VERSION, iv.toString("base64"), tag.toString("base64"), ciphertext.toString("base64")].join(":");
|
||||
}
|
||||
|
||||
decrypt(stored: string): string {
|
||||
const key = this.requireKey();
|
||||
const [version, ivB64, tagB64, ciphertextB64] = stored.split(":");
|
||||
if (version !== KEY_VERSION || !ivB64 || !tagB64 || !ciphertextB64) {
|
||||
throw new Error("Formato de campo cifrado inválido ou versão de chave desconhecida");
|
||||
}
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64"));
|
||||
decipher.setAuthTag(Buffer.from(tagB64, "base64"));
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextB64, "base64")),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plaintext.toString("utf8");
|
||||
}
|
||||
|
||||
private requireKey(): Buffer {
|
||||
if (!this.key) {
|
||||
throw new Error("EDEN_FIELD_ENCRYPTION_KEY não configurada — obrigatória para criptografia de campo reversível");
|
||||
}
|
||||
return this.key;
|
||||
}
|
||||
}
|
||||
21
apps/api/src/fiscal/dto/create-cfop.dto.ts
Normal file
21
apps/api/src/fiscal/dto/create-cfop.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/**
|
||||
* direction/scope are free-text, no CHECK/enum — exactly as the legacy
|
||||
* (eden.md §4): the CFOP code's 1st digit encodes direction+scope in
|
||||
* practice, but neither this DTO nor the schema derives it automatically.
|
||||
*/
|
||||
export class CreateCfopDto {
|
||||
@IsString() code!: string;
|
||||
@IsString() description!: string;
|
||||
|
||||
@IsOptional() @IsString() direction?: string;
|
||||
@IsOptional() @IsString() scope?: string;
|
||||
@IsOptional() @IsDateString() validFrom?: string;
|
||||
@IsOptional() @IsDateString() validUntil?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
12
apps/api/src/fiscal/dto/create-municipality.dto.ts
Normal file
12
apps/api/src/fiscal/dto/create-municipality.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
/** fiscal_municipality has NO valid_from/valid_until/metadata — eden.md §2.3 ("não faz sentido um município expirar"). */
|
||||
export class CreateMunicipalityDto {
|
||||
@IsString() code!: string;
|
||||
@IsString() name!: string;
|
||||
@IsString() @Length(2, 2) state!: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
}
|
||||
14
apps/api/src/fiscal/dto/create-ncm.dto.ts
Normal file
14
apps/api/src/fiscal/dto/create-ncm.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CreateNcmDto {
|
||||
@IsString() code!: string;
|
||||
@IsString() description!: string;
|
||||
|
||||
@IsOptional() @IsDateString() validFrom?: string;
|
||||
@IsOptional() @IsDateString() validUntil?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
23
apps/api/src/fiscal/dto/create-product-fiscal-profile.dto.ts
Normal file
23
apps/api/src/fiscal/dto/create-product-fiscal-profile.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsDateString, IsIn, IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
/**
|
||||
* Creates a new ACTIVE profile for a (productId, billingComponent) pair.
|
||||
* If one is already active, the service inactivates it and creates this
|
||||
* one in the same transaction (eden.md §2.5's central invariant) — never a
|
||||
* destructive update of the old profile.
|
||||
*/
|
||||
export class CreateProductFiscalProfileDto {
|
||||
@IsIn(["RECURRING", "IMPLEMENTATION"])
|
||||
billingComponent!: "RECURRING" | "IMPLEMENTATION";
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["NFCOM", "NFSE", "NFE", "NONE"])
|
||||
documentType?: "NFCOM" | "NFSE" | "NFE" | "NONE";
|
||||
|
||||
@IsOptional() @IsUUID() ncmId?: string;
|
||||
@IsOptional() @IsUUID() cfopId?: string;
|
||||
@IsOptional() @IsUUID() municipalityId?: string;
|
||||
@IsOptional() @IsUUID() operationNatureId?: string;
|
||||
@IsOptional() @IsDateString() validFrom?: string;
|
||||
@IsOptional() @IsDateString() validUntil?: string;
|
||||
}
|
||||
15
apps/api/src/fiscal/dto/update-cfop.dto.ts
Normal file
15
apps/api/src/fiscal/dto/update-cfop.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateCfopDto {
|
||||
@IsOptional() @IsString() code?: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsString() direction?: string;
|
||||
@IsOptional() @IsString() scope?: string;
|
||||
@IsOptional() @IsDateString() validFrom?: string;
|
||||
@IsOptional() @IsDateString() validUntil?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
10
apps/api/src/fiscal/dto/update-municipality.dto.ts
Normal file
10
apps/api/src/fiscal/dto/update-municipality.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
export class UpdateMunicipalityDto {
|
||||
@IsOptional() @IsString() code?: string;
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() @Length(2, 2) state?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
}
|
||||
14
apps/api/src/fiscal/dto/update-ncm.dto.ts
Normal file
14
apps/api/src/fiscal/dto/update-ncm.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** All fields optional — PATCH is a partial update, including flipping `active` alone. */
|
||||
export class UpdateNcmDto {
|
||||
@IsOptional() @IsString() code?: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsDateString() validFrom?: string;
|
||||
@IsOptional() @IsDateString() validUntil?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
142
apps/api/src/fiscal/fiscal.controller.ts
Normal file
142
apps/api/src/fiscal/fiscal.controller.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { FiscalService } from "./fiscal.service.js";
|
||||
import { CreateNcmDto } from "./dto/create-ncm.dto.js";
|
||||
import { UpdateNcmDto } from "./dto/update-ncm.dto.js";
|
||||
import { CreateCfopDto } from "./dto/create-cfop.dto.js";
|
||||
import { UpdateCfopDto } from "./dto/update-cfop.dto.js";
|
||||
import { CreateMunicipalityDto } from "./dto/create-municipality.dto.js";
|
||||
import { UpdateMunicipalityDto } from "./dto/update-municipality.dto.js";
|
||||
import { CreateProductFiscalProfileDto } from "./dto/create-product-fiscal-profile.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* Fiscal catalogs + product fiscal profiles (eden.md módulo 5). Uses the
|
||||
* same `fiscal` permission feature as the legacy, with `view`/`edit`
|
||||
* levels (eden.md §8). No bulk/mass operations in this MVP — writes always
|
||||
* touch a single catalog row, never a whole table.
|
||||
*/
|
||||
@ApiTags("fiscal")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/fiscal")
|
||||
export class FiscalController {
|
||||
constructor(private readonly fiscal: FiscalService) {}
|
||||
|
||||
// -- fiscal_ncm ---------------------------------------------------------
|
||||
|
||||
@Get("ncm")
|
||||
@RequirePermission("fiscal", "view")
|
||||
listNcm(@Query("q") q?: string, @Query("active") active?: string) {
|
||||
return this.fiscal.listNcm(q, parseActive(active));
|
||||
}
|
||||
|
||||
@Get("ncm/:id")
|
||||
@RequirePermission("fiscal", "view")
|
||||
getNcm(@Param("id") id: string) {
|
||||
return this.fiscal.getNcm(id);
|
||||
}
|
||||
|
||||
@Post("ncm")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
createNcm(@Body() dto: CreateNcmDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.fiscal.createNcm(dto, user);
|
||||
}
|
||||
|
||||
@Patch("ncm/:id")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
updateNcm(@Param("id") id: string, @Body() dto: UpdateNcmDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.fiscal.updateNcm(id, dto, user);
|
||||
}
|
||||
|
||||
// -- fiscal_cfop ----------------------------------------------------------
|
||||
|
||||
@Get("cfop")
|
||||
@RequirePermission("fiscal", "view")
|
||||
listCfop(@Query("q") q?: string, @Query("active") active?: string) {
|
||||
return this.fiscal.listCfop(q, parseActive(active));
|
||||
}
|
||||
|
||||
@Get("cfop/:id")
|
||||
@RequirePermission("fiscal", "view")
|
||||
getCfop(@Param("id") id: string) {
|
||||
return this.fiscal.getCfop(id);
|
||||
}
|
||||
|
||||
@Post("cfop")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
createCfop(@Body() dto: CreateCfopDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.fiscal.createCfop(dto, user);
|
||||
}
|
||||
|
||||
@Patch("cfop/:id")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
updateCfop(@Param("id") id: string, @Body() dto: UpdateCfopDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.fiscal.updateCfop(id, dto, user);
|
||||
}
|
||||
|
||||
// -- fiscal_municipality --------------------------------------------------
|
||||
|
||||
@Get("municipalities")
|
||||
@RequirePermission("fiscal", "view")
|
||||
listMunicipalities(@Query("q") q?: string, @Query("active") active?: string) {
|
||||
return this.fiscal.listMunicipalities(q, parseActive(active));
|
||||
}
|
||||
|
||||
@Get("municipalities/:id")
|
||||
@RequirePermission("fiscal", "view")
|
||||
getMunicipality(@Param("id") id: string) {
|
||||
return this.fiscal.getMunicipality(id);
|
||||
}
|
||||
|
||||
@Post("municipalities")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
createMunicipality(@Body() dto: CreateMunicipalityDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.fiscal.createMunicipality(dto, user);
|
||||
}
|
||||
|
||||
@Patch("municipalities/:id")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
updateMunicipality(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateMunicipalityDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.fiscal.updateMunicipality(id, dto, user);
|
||||
}
|
||||
|
||||
// -- fiscal_operation_nature — read-only in this MVP -----------------------
|
||||
|
||||
@Get("operation-natures")
|
||||
@RequirePermission("fiscal", "view")
|
||||
listOperationNatures() {
|
||||
return this.fiscal.listOperationNatures();
|
||||
}
|
||||
|
||||
// -- product_fiscal_profiles ----------------------------------------------
|
||||
|
||||
@Get("products/:productId/profiles")
|
||||
@RequirePermission("fiscal", "view")
|
||||
listProfiles(@Param("productId") productId: string) {
|
||||
return this.fiscal.listProfiles(productId);
|
||||
}
|
||||
|
||||
@Post("products/:productId/profiles")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
createProfile(
|
||||
@Param("productId") productId: string,
|
||||
@Body() dto: CreateProductFiscalProfileDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.fiscal.createProfile(productId, dto, user);
|
||||
}
|
||||
}
|
||||
|
||||
function parseActive(value?: string): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return value === "true";
|
||||
}
|
||||
12
apps/api/src/fiscal/fiscal.module.ts
Normal file
12
apps/api/src/fiscal/fiscal.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { FiscalController } from "./fiscal.controller.js";
|
||||
import { FiscalService } from "./fiscal.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [FiscalController],
|
||||
providers: [FiscalService],
|
||||
exports: [FiscalService],
|
||||
})
|
||||
export class FiscalModule {}
|
||||
219
apps/api/src/fiscal/fiscal.service.ts
Normal file
219
apps/api/src/fiscal/fiscal.service.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
fiscalRepo,
|
||||
productsRepo,
|
||||
type FiscalCfop,
|
||||
type FiscalMunicipality,
|
||||
type FiscalNcm,
|
||||
type FiscalOperationNature,
|
||||
type ProductFiscalProfile,
|
||||
} from "@eden/database";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import type { CreateNcmDto } from "./dto/create-ncm.dto.js";
|
||||
import type { UpdateNcmDto } from "./dto/update-ncm.dto.js";
|
||||
import type { CreateCfopDto } from "./dto/create-cfop.dto.js";
|
||||
import type { UpdateCfopDto } from "./dto/update-cfop.dto.js";
|
||||
import type { CreateMunicipalityDto } from "./dto/create-municipality.dto.js";
|
||||
import type { UpdateMunicipalityDto } from "./dto/update-municipality.dto.js";
|
||||
import type { CreateProductFiscalProfileDto } from "./dto/create-product-fiscal-profile.dto.js";
|
||||
|
||||
/**
|
||||
* Fiscal catalogs (eden.md módulo 5) — see PENDING_fiscal.cjs migration
|
||||
* header for the MVP scope. Read (`fiscal:view`) is open to any
|
||||
* authenticated user holding that grant; write (`fiscal:edit`) creates or
|
||||
* PATCHes a single catalog row — no bulk/mass operations in this MVP.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FiscalService {
|
||||
// ---------------------------------------------------------------------
|
||||
// fiscal_ncm
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listNcm(q?: string, active?: boolean): Promise<FiscalNcm[]> {
|
||||
return fiscalRepo.listNcm({ q, active });
|
||||
}
|
||||
|
||||
async getNcm(id: string): Promise<FiscalNcm> {
|
||||
const row = await fiscalRepo.getNcmById(id);
|
||||
if (!row) throw new NotFoundException("NCM não encontrado");
|
||||
return row;
|
||||
}
|
||||
|
||||
async createNcm(dto: CreateNcmDto, user: EdenAuthenticatedUser): Promise<FiscalNcm> {
|
||||
const row = await fiscalRepo.createNcm(dto);
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.ncm.created",
|
||||
resourceType: "fiscal_ncm",
|
||||
resourceId: row.id,
|
||||
metadata: { code: row.code },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateNcm(id: string, dto: UpdateNcmDto, user: EdenAuthenticatedUser): Promise<FiscalNcm> {
|
||||
const row = await fiscalRepo.updateNcm(id, dto);
|
||||
if (!row) throw new NotFoundException("NCM não encontrado");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.ncm.updated",
|
||||
resourceType: "fiscal_ncm",
|
||||
resourceId: row.id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// fiscal_cfop
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listCfop(q?: string, active?: boolean): Promise<FiscalCfop[]> {
|
||||
return fiscalRepo.listCfop({ q, active });
|
||||
}
|
||||
|
||||
async getCfop(id: string): Promise<FiscalCfop> {
|
||||
const row = await fiscalRepo.getCfopById(id);
|
||||
if (!row) throw new NotFoundException("CFOP não encontrado");
|
||||
return row;
|
||||
}
|
||||
|
||||
async createCfop(dto: CreateCfopDto, user: EdenAuthenticatedUser): Promise<FiscalCfop> {
|
||||
const row = await fiscalRepo.createCfop(dto);
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.cfop.created",
|
||||
resourceType: "fiscal_cfop",
|
||||
resourceId: row.id,
|
||||
metadata: { code: row.code },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateCfop(id: string, dto: UpdateCfopDto, user: EdenAuthenticatedUser): Promise<FiscalCfop> {
|
||||
const row = await fiscalRepo.updateCfop(id, dto);
|
||||
if (!row) throw new NotFoundException("CFOP não encontrado");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.cfop.updated",
|
||||
resourceType: "fiscal_cfop",
|
||||
resourceId: row.id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// fiscal_municipality
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listMunicipalities(q?: string, active?: boolean): Promise<FiscalMunicipality[]> {
|
||||
return fiscalRepo.listMunicipalities({ q, active });
|
||||
}
|
||||
|
||||
async getMunicipality(id: string): Promise<FiscalMunicipality> {
|
||||
const row = await fiscalRepo.getMunicipalityById(id);
|
||||
if (!row) throw new NotFoundException("Município não encontrado");
|
||||
return row;
|
||||
}
|
||||
|
||||
async createMunicipality(
|
||||
dto: CreateMunicipalityDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<FiscalMunicipality> {
|
||||
const row = await fiscalRepo.createMunicipality(dto);
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.municipality.created",
|
||||
resourceType: "fiscal_municipality",
|
||||
resourceId: row.id,
|
||||
metadata: { code: row.code },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateMunicipality(
|
||||
id: string,
|
||||
dto: UpdateMunicipalityDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<FiscalMunicipality> {
|
||||
const row = await fiscalRepo.updateMunicipality(id, dto);
|
||||
if (!row) throw new NotFoundException("Município não encontrado");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.municipality.updated",
|
||||
resourceType: "fiscal_municipality",
|
||||
resourceId: row.id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// fiscal_operation_nature — read-only in this MVP (fixed business enum,
|
||||
// seeded by the migration)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listOperationNatures(): Promise<FiscalOperationNature[]> {
|
||||
return fiscalRepo.listOperationNatures();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// product_fiscal_profiles
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listProfiles(productId: string): Promise<ProductFiscalProfile[]> {
|
||||
await this.assertProductExists(productId);
|
||||
return fiscalRepo.listProductFiscalProfiles(productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ACTIVE profile for (productId, billingComponent). If one
|
||||
* is already active, the repository layer inactivates it and creates the
|
||||
* new one in a single transaction — never a destructive update of the
|
||||
* old profile (eden.md §2.5).
|
||||
*/
|
||||
async createProfile(
|
||||
productId: string,
|
||||
dto: CreateProductFiscalProfileDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<ProductFiscalProfile> {
|
||||
await this.assertProductExists(productId);
|
||||
|
||||
const created = await fiscalRepo.createActiveProductFiscalProfile({
|
||||
productId,
|
||||
billingComponent: dto.billingComponent,
|
||||
documentType: dto.documentType,
|
||||
ncmId: dto.ncmId ?? null,
|
||||
cfopId: dto.cfopId ?? null,
|
||||
municipalityId: dto.municipalityId ?? null,
|
||||
operationNatureId: dto.operationNatureId ?? null,
|
||||
validFrom: dto.validFrom ?? null,
|
||||
validUntil: dto.validUntil ?? null,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.product_profile.created",
|
||||
resourceType: "product_fiscal_profile",
|
||||
resourceId: created.id,
|
||||
metadata: { productId, billingComponent: dto.billingComponent, documentType: created.document_type },
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async assertProductExists(productId: string): Promise<void> {
|
||||
const product = await productsRepo.getProductById(productId);
|
||||
if (!product) throw new NotFoundException("Produto não encontrado");
|
||||
}
|
||||
}
|
||||
35
apps/api/src/hr-timeclock/afd-imports.controller.ts
Normal file
35
apps/api/src/hr-timeclock/afd-imports.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { AfdImportsService } from "./afd-imports.service.js";
|
||||
import { CreateAfdImportDto } from "./dto/create-afd-import.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* eden.md módulo 6 §4.4/§4.5 — AFD import. See afd-imports.service.ts and
|
||||
* CreateAfdImportDto for this leva's transport simplification (JSON
|
||||
* string body, not multipart) and its documented encoding caveat.
|
||||
*/
|
||||
@ApiTags("hr-timeclock-afd-imports")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
|
||||
@Controller("v1/hr-timeclock/afd-imports")
|
||||
export class AfdImportsController {
|
||||
constructor(private readonly afdImports: AfdImportsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("afd-import", "edit")
|
||||
import(@Body() dto: CreateAfdImportDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.afdImports.import(dto, user);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("afd-import", "view")
|
||||
get(@Param("id") id: string) {
|
||||
return this.afdImports.getById(id);
|
||||
}
|
||||
}
|
||||
202
apps/api/src/hr-timeclock/afd-imports.service.ts
Normal file
202
apps/api/src/hr-timeclock/afd-imports.service.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
hrTimeclockRepo,
|
||||
type AfdImportBatch,
|
||||
type AfdImportBatchStatus,
|
||||
type TimeclockEmployee,
|
||||
} from "@eden/database";
|
||||
import { parseAfdFile, normalizeAfdCpf, type AfdParseSummary } from "./afd-parser.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import type { CreateAfdImportDto } from "./dto/create-afd-import.dto.js";
|
||||
|
||||
export interface ImportAfdFileResult {
|
||||
alreadyImported: boolean;
|
||||
batch: AfdImportBatch;
|
||||
summary?: AfdParseSummary;
|
||||
}
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && (err as { code?: unknown }).code === "23505";
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AfdImportsService {
|
||||
/**
|
||||
* The whole AFD import orchestration: whole-file idempotency (SHA-256),
|
||||
* pure parsing (afd-parser.ts — never touches the DB), best-effort
|
||||
* employee resolution by CPF (never guesses — ambiguous/unmatched stays
|
||||
* null), then the transactional write (hrTimeclockRepo.importAfdBatch,
|
||||
* which does the per-device locking + per-record idempotency).
|
||||
*
|
||||
* ENCODING CAVEAT (see afd-parser.ts's header): `dto.fileContent` is
|
||||
* expected to already be decoded as ISO-8859-1 by the caller — this
|
||||
* leva's transport is a JSON string, not a raw multipart upload.
|
||||
*/
|
||||
async import(dto: CreateAfdImportDto, user: EdenAuthenticatedUser): Promise<ImportAfdFileResult> {
|
||||
const device = await hrTimeclockRepo.getTimeclockDeviceById(dto.deviceId);
|
||||
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
|
||||
|
||||
// Whole-file idempotency layer (eden.md módulo 6 §12) — hash is over
|
||||
// the content AS RECEIVED by this endpoint (see migration comment on
|
||||
// afd_import_batches.file_hash for why this isn't necessarily
|
||||
// byte-identical to a raw .afd file's own hash in this leva's
|
||||
// string-transport simplification).
|
||||
const fileHash = createHash("sha256").update(dto.fileContent, "utf8").digest("hex");
|
||||
const fileSize = Buffer.byteLength(dto.fileContent, "utf8");
|
||||
|
||||
const existing = await hrTimeclockRepo.findAfdImportBatchByFileHash(fileHash);
|
||||
if (existing) {
|
||||
throw new ConflictException({
|
||||
message: "Este arquivo já foi importado anteriormente (idempotência por SHA-256)",
|
||||
alreadyImported: true,
|
||||
importId: existing.id,
|
||||
status: existing.status,
|
||||
});
|
||||
}
|
||||
|
||||
const parseResult = parseAfdFile(dto.fileContent);
|
||||
|
||||
const employeeIdByCpf = await this.resolveEmployeeIds(device.company_id, parseResult.records.map((r) => r.employeeCpfRaw));
|
||||
|
||||
const byStatus = parseResult.summary.byValidationStatus;
|
||||
const validCount = byStatus.VALID ?? 0;
|
||||
const warningCount = byStatus.WARNING ?? 0;
|
||||
const invalidCount = byStatus.INVALID ?? 0;
|
||||
const unsupportedCount = byStatus.UNSUPPORTED_LAYOUT ?? 0;
|
||||
const parseFailureCount = parseResult.failures.length;
|
||||
|
||||
// Batch-level status: INVALID when nothing usable came out of the
|
||||
// file at all; WARNING when the file is usable but has some
|
||||
// invalid/unsupported/failed lines; VALID only when everything parsed
|
||||
// clean. Never silently treated as fully VALID when problems exist.
|
||||
const status: AfdImportBatchStatus =
|
||||
validCount === 0 ? "INVALID" : invalidCount + unsupportedCount + parseFailureCount > 0 ? "WARNING" : "VALID";
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await hrTimeclockRepo.importAfdBatch({
|
||||
batch: {
|
||||
deviceId: dto.deviceId,
|
||||
companyId: device.company_id,
|
||||
fileHash,
|
||||
fileSize,
|
||||
recordCount: parseResult.summary.parsedCount,
|
||||
validCount,
|
||||
warningCount,
|
||||
invalidCount,
|
||||
unsupportedCount,
|
||||
parseFailureCount,
|
||||
status,
|
||||
firstNsr: parseResult.summary.firstNsr,
|
||||
lastNsr: parseResult.summary.lastNsr,
|
||||
summary: parseResult.summary as unknown as Record<string, unknown>,
|
||||
importedBy: user.id,
|
||||
},
|
||||
records: parseResult.records.map((r) => ({
|
||||
nsr: r.nsr,
|
||||
recordType: r.recordType,
|
||||
eventDatetime: r.eventDatetime,
|
||||
eventDatetimeRaw: r.eventDatetimeRaw,
|
||||
employeeCpfRaw: r.employeeCpfRaw,
|
||||
employeeId: employeeIdByCpf.get(r.employeeCpfRaw ?? "") ?? null,
|
||||
rawLine: r.rawLine,
|
||||
crcReceived: r.crcReceived,
|
||||
crcCalculated: r.crcCalculated,
|
||||
crcValid: r.crcValid,
|
||||
parsedData: r.parsedData,
|
||||
validationStatus: r.validationStatus,
|
||||
validationNotes: r.validationNotes,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof hrTimeclockRepo.TimeclockDeviceNotFoundError) {
|
||||
throw new NotFoundException("Equipamento de ponto não encontrado");
|
||||
}
|
||||
if (isUniqueViolation(err)) {
|
||||
// Race: another request imported the same file_hash between our
|
||||
// check above and this INSERT. Never duplicate — report the
|
||||
// existing batch exactly like the up-front check would have.
|
||||
const raced = await hrTimeclockRepo.findAfdImportBatchByFileHash(fileHash);
|
||||
if (raced) {
|
||||
throw new ConflictException({
|
||||
message: "Este arquivo já foi importado anteriormente (idempotência por SHA-256)",
|
||||
alreadyImported: true,
|
||||
importId: raced.id,
|
||||
status: raced.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Import itself is a mutation — audited like any other, per the task
|
||||
// brief's explicit instruction ("obrigatório em toda mutação incluindo
|
||||
// o próprio import").
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "afd_import.imported",
|
||||
resourceType: "afd_import_batch",
|
||||
resourceId: result.batch.id,
|
||||
metadata: {
|
||||
deviceId: dto.deviceId,
|
||||
fileHash,
|
||||
recordCount: parseResult.summary.parsedCount,
|
||||
insertedCount: result.insertedCount,
|
||||
status,
|
||||
},
|
||||
});
|
||||
|
||||
return { alreadyImported: false, batch: result.batch, summary: parseResult.summary };
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AfdImportBatch> {
|
||||
const batch = await hrTimeclockRepo.getAfdImportBatchById(id);
|
||||
if (!batch) throw new NotFoundException("Importação de AFD não encontrada");
|
||||
return batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort CPF -> timeclock_employees.id resolution. NEVER guesses:
|
||||
* a CPF matched in more than one company when the device has no
|
||||
* company (or vice-versa, ambiguous within the same company — can't
|
||||
* actually happen given the (company_id, cpf) UNIQUE constraint, but
|
||||
* the code doesn't rely on that) is left unresolved (null) rather than
|
||||
* picking arbitrarily. Returns a map keyed by the RAW (unnormalized)
|
||||
* cpf field, since that's what afd_records.employee_cpf_raw stores and
|
||||
* what callers look records up by.
|
||||
*/
|
||||
private async resolveEmployeeIds(
|
||||
deviceCompanyId: string | null,
|
||||
rawCpfs: (string | null)[],
|
||||
): Promise<Map<string, string | null>> {
|
||||
const normalizedByRaw = new Map<string, string>();
|
||||
for (const raw of rawCpfs) {
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeAfdCpf(raw);
|
||||
if (normalized && normalized.length === 11) normalizedByRaw.set(raw, normalized);
|
||||
}
|
||||
|
||||
const distinctNormalized = [...new Set(normalizedByRaw.values())];
|
||||
const candidates = distinctNormalized.length > 0
|
||||
? await hrTimeclockRepo.findTimeclockEmployeesByCpfs(distinctNormalized)
|
||||
: [];
|
||||
|
||||
const candidatesByCpf = new Map<string, TimeclockEmployee[]>();
|
||||
for (const employee of candidates) {
|
||||
const list = candidatesByCpf.get(employee.cpf) ?? [];
|
||||
list.push(employee);
|
||||
candidatesByCpf.set(employee.cpf, list);
|
||||
}
|
||||
|
||||
const result = new Map<string, string | null>();
|
||||
for (const [raw, normalized] of normalizedByRaw) {
|
||||
const matches = candidatesByCpf.get(normalized) ?? [];
|
||||
const scoped = deviceCompanyId ? matches.filter((m) => m.company_id === deviceCompanyId) : matches;
|
||||
result.set(raw, scoped.length === 1 ? scoped[0]!.id : null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
495
apps/api/src/hr-timeclock/afd-parser.ts
Normal file
495
apps/api/src/hr-timeclock/afd-parser.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* AFD (Arquivo Fonte de Dados — Portaria MTP 671/2021, leiaute versão 004)
|
||||
* parser/validator. Ported faithfully from the legacy OrçaFácil
|
||||
* `parser.js`/`crc16.js` — see `eden.md` módulo 6 §4 (linhas ~3660-3771).
|
||||
*
|
||||
* Pure functions only — no I/O, no DB access. This module intentionally
|
||||
* knows nothing about how the caller obtained the file content or what it
|
||||
* will do with the parsed records (persist, preview, whatever).
|
||||
*
|
||||
* SCOPE OF THIS LEVA: parsing + CRC/structural validation only. Nothing
|
||||
* here computes apuração (eden.md §5), fechamento de período (§6), ajustes
|
||||
* (§7), banco de horas (§9) or relatórios (§10) — those are a future leva.
|
||||
*
|
||||
* ── Encoding note (real limitation of this leva's transport, documented
|
||||
* per instruction) ──────────────────────────────────────────────────────
|
||||
* The real AFD file is ISO-8859-1 (latin1), never UTF-8. This leva's HTTP
|
||||
* endpoint accepts the file content as a plain JSON string (multipart
|
||||
* upload is deferred — see afd-imports.controller.ts), which means the
|
||||
* CALLER is responsible for decoding the original file bytes as latin1
|
||||
* into a JS string *before* putting it in the JSON body. If the caller
|
||||
* instead decodes as UTF-8 (or lets some HTTP client guess), any byte
|
||||
* ≥ 0x80 (accented characters in type-2 `razao_social`/`local` fields —
|
||||
* type-3 punch records, the ones that matter for attendance, are plain
|
||||
* ASCII/digits and unaffected) will be corrupted and CRC validation for
|
||||
* that line will legitimately fail (which is the CORRECT behavior for
|
||||
* corrupted input — the parser never silently "fixes" a CRC mismatch).
|
||||
* This module reconstructs bytes for CRC purposes via
|
||||
* `Buffer.from(str, "latin1")`, which round-trips correctly only if the
|
||||
* string was decoded as latin1 to begin with.
|
||||
*
|
||||
* ── Field positions ─────────────────────────────────────────────────────
|
||||
* eden.md documents positions as 1-indexed, inclusive ranges (e.g.
|
||||
* "011-034" = characters 11 through 34, 24 chars wide). Converted here to
|
||||
* JS's 0-indexed, end-exclusive `slice(a-1, b)`. Every record starts with
|
||||
* a 9-digit NSR (positions 001-009) + 1-digit record type (position 010),
|
||||
* i.e. `slice(0, 9)` / `slice(9, 10)` — so every type-specific field list
|
||||
* below picks up at `slice(10, ...)`, matching "011" in the source doc.
|
||||
*
|
||||
* Principle carried over verbatim from the legacy: only fields with an
|
||||
* exact, documented position are decoded. Everything else — undocumented
|
||||
* trailer bytes, unsupported record types, truncated lines — is captured
|
||||
* as `raw_tail` inside `parsedData`, never guessed/invented.
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CRC-16/KERMIT
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* CRC-16/KERMIT: polynomial 0x1021 reflected (used in the bit-reversed
|
||||
* form 0x8408), init 0x0000, no final XOR. Applies to AFD record types
|
||||
* 2/3/4 only — type 7 uses its own hash chain (never recomputed here) and
|
||||
* types 1/5/6/9 have no CRC field at all.
|
||||
*
|
||||
* Known test vector (verified in afd-parser.spec.ts and via an ad-hoc
|
||||
* script before this was wired into the import path): CRC-16/KERMIT of
|
||||
* the ASCII string "123456789" is 0x2189.
|
||||
*/
|
||||
export function crc16Kermit(data: string | Buffer): number {
|
||||
const buffer = typeof data === "string" ? Buffer.from(data, "latin1") : data;
|
||||
let crc = 0x0000;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
crc ^= buffer[i]!;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
if (crc & 0x0001) {
|
||||
crc = (crc >>> 1) ^ 0x8408;
|
||||
} else {
|
||||
crc = crc >>> 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc & 0xffff;
|
||||
}
|
||||
|
||||
/** Zero-padded 4-hex-digit representation, uppercase, matching AFD's own CRC field format. */
|
||||
export function crc16Hex(data: string | Buffer): string {
|
||||
return crc16Kermit(data).toString(16).padStart(4, "0").toUpperCase();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CPF normalization
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The CPF field in AFD types 3/7 is 12 characters wide in the documented
|
||||
* position, but a real CPF is 11 digits — eden.md §4.3: take the LAST 11
|
||||
* numeric digits of the raw field, never assume exact formatting. If
|
||||
* fewer than 11 digits are present (corrupt/short data), returns exactly
|
||||
* what was found — never left-pads with invented zeros, since that would
|
||||
* fabricate digits that were never actually in the file.
|
||||
*/
|
||||
export function normalizeAfdCpf(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
if (digits.length === 0) return null;
|
||||
return digits.length >= 11 ? digits.slice(-11) : digits;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AfdCrcValidity = "VALID" | "INVALID" | "NOT_APPLICABLE" | "NOT_VALIDATED";
|
||||
export type AfdValidationStatus = "VALID" | "WARNING" | "INVALID" | "UNSUPPORTED_LAYOUT";
|
||||
|
||||
export interface ParsedAfdRecord {
|
||||
lineNumber: number;
|
||||
nsr: number | null;
|
||||
nsrRaw: string;
|
||||
recordType: string;
|
||||
eventDatetime: Date | null;
|
||||
eventDatetimeRaw: string | null;
|
||||
employeeCpfRaw: string | null;
|
||||
rawLine: string;
|
||||
crcReceived: string | null;
|
||||
crcCalculated: string | null;
|
||||
crcValid: AfdCrcValidity;
|
||||
/** Type-specific decoded fields (only those with a documented position) + `raw_tail` for the rest. Never guessed. */
|
||||
parsedData: Record<string, unknown>;
|
||||
validationStatus: AfdValidationStatus;
|
||||
validationNotes: string | null;
|
||||
}
|
||||
|
||||
export interface AfdParseFailure {
|
||||
lineNumber: number;
|
||||
rawLine: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface AfdParseSummary {
|
||||
totalLines: number;
|
||||
parsedCount: number;
|
||||
failureCount: number;
|
||||
byRecordType: Record<string, number>;
|
||||
byValidationStatus: Record<string, number>;
|
||||
/** Includes the sentinel trailer NSR (999999999); records whose NSR could not be parsed as a number are excluded. */
|
||||
firstNsr: number | null;
|
||||
lastNsr: number | null;
|
||||
}
|
||||
|
||||
export interface AfdParseResult {
|
||||
records: ParsedAfdRecord[];
|
||||
failures: AfdParseFailure[];
|
||||
summary: AfdParseSummary;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Datetime parsing — AAAA-MM-ddThh:mm:00ZZZZZ (24 chars), e.g. "2026-08-06T08:30:00-0300"
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const AFD_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-]\d{4})$/;
|
||||
|
||||
/**
|
||||
* Parses the fixed-width AFD datetime field. The UTC offset is always
|
||||
* taken verbatim from the text (never assumed/injected from the server's
|
||||
* own timezone) — in practice this is always -0300 (America/Sao_Paulo,
|
||||
* no DST in Brazil since 2019), but this function doesn't hardcode that;
|
||||
* it trusts whatever offset the equipment actually wrote, exactly per the
|
||||
* "never invent" principle. Returns null (never throws) if the 24-char
|
||||
* field doesn't match the documented format — the caller still keeps the
|
||||
* raw text in `eventDatetimeRaw` regardless.
|
||||
*/
|
||||
function parseAfdDatetime(raw: string): Date | null {
|
||||
const match = AFD_DATETIME_RE.exec(raw.trim());
|
||||
if (!match) return null;
|
||||
const [, y, mo, d, h, mi, s, offset] = match;
|
||||
const offsetWithColon = `${offset!.slice(0, 3)}:${offset!.slice(3)}`;
|
||||
const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}${offsetWithColon}`;
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Per-line parsing
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HEADER_LENGTH = 10; // 9-digit NSR + 1-digit record type
|
||||
|
||||
function parseNsr(nsrRaw: string): number | null {
|
||||
// Never default to 0/autoincrement — only a well-formed 9-digit numeric
|
||||
// field becomes a number; anything else stays null (eden.md módulo 6 §12).
|
||||
return /^\d{9}$/.test(nsrRaw) ? Number(nsrRaw) : null;
|
||||
}
|
||||
|
||||
interface CrcCheck {
|
||||
crcReceived: string | null;
|
||||
crcCalculated: string | null;
|
||||
crcValid: AfdCrcValidity;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** CRC is computed over the line bytes up to (not including) the CRC field itself, per eden.md §4.2. */
|
||||
function checkCrc(line: string, dataEnd: number, crcFieldStart: number, crcFieldEnd: number): CrcCheck {
|
||||
if (line.length < crcFieldEnd) {
|
||||
return { crcReceived: null, crcCalculated: null, crcValid: "INVALID", note: "linha mais curta que o esperado para conter o campo de CRC" };
|
||||
}
|
||||
const crcReceived = line.slice(crcFieldStart, crcFieldEnd).trim();
|
||||
const crcCalculated = crc16Hex(line.slice(0, dataEnd));
|
||||
const valid = crcReceived.toUpperCase() === crcCalculated;
|
||||
return {
|
||||
crcReceived,
|
||||
crcCalculated,
|
||||
crcValid: valid ? "VALID" : "INVALID",
|
||||
note: valid ? null : `CRC divergente: recebido=${crcReceived || "(vazio)"} calculado=${crcCalculated}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single AFD line. Never throws under normal malformed input —
|
||||
* a line that doesn't fit any documented shape still produces a record
|
||||
* (validationStatus INVALID/UNSUPPORTED_LAYOUT, nsr possibly null,
|
||||
* rawLine always preserved) rather than being silently dropped, per
|
||||
* eden.md's "preservar mesmo se inválido" invariant. `parseAfdFile` wraps
|
||||
* this in a try/catch as a second line of defense for truly unexpected
|
||||
* exceptions (defensive programming, not the normal path).
|
||||
*/
|
||||
export function parseAfdLine(rawLine: string, lineNumber: number): ParsedAfdRecord {
|
||||
const notes: string[] = [];
|
||||
|
||||
if (rawLine.length < HEADER_LENGTH) {
|
||||
return {
|
||||
lineNumber,
|
||||
nsr: null,
|
||||
nsrRaw: rawLine,
|
||||
recordType: "",
|
||||
eventDatetime: null,
|
||||
eventDatetimeRaw: null,
|
||||
employeeCpfRaw: null,
|
||||
rawLine,
|
||||
crcReceived: null,
|
||||
crcCalculated: null,
|
||||
crcValid: "NOT_VALIDATED",
|
||||
parsedData: { raw_tail: rawLine },
|
||||
validationStatus: "INVALID",
|
||||
validationNotes: "linha menor que o cabeçalho mínimo (NSR de 9 dígitos + 1 dígito de tipo, 10 caracteres)",
|
||||
};
|
||||
}
|
||||
|
||||
const nsrRaw = rawLine.slice(0, 9);
|
||||
const recordType = rawLine.slice(9, 10);
|
||||
const nsr = parseNsr(nsrRaw);
|
||||
if (nsr === null) {
|
||||
notes.push("NSR ausente ou não numérico — preservado como null, nunca substituído por 0/autoincrement");
|
||||
}
|
||||
|
||||
let eventDatetimeRaw: string | null = null;
|
||||
let employeeCpfRaw: string | null = null;
|
||||
let parsedData: Record<string, unknown> = {};
|
||||
let crcValid: AfdCrcValidity = "NOT_VALIDATED";
|
||||
let crcReceived: string | null = null;
|
||||
let crcCalculated: string | null = null;
|
||||
let validationStatus: AfdValidationStatus = "VALID";
|
||||
|
||||
switch (recordType) {
|
||||
case "1": {
|
||||
// Cabeçalho do arquivo — só o NSR fixo (000000000) + tipo são documentados.
|
||||
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH) };
|
||||
if (nsrRaw !== "000000000") notes.push("NSR do cabeçalho difere do valor fixo esperado (000000000)");
|
||||
break;
|
||||
}
|
||||
case "2": {
|
||||
// Inclusão/alteração do empregador no REP
|
||||
const dh = rawLine.slice(10, 34);
|
||||
const cpfResp = rawLine.slice(34, 48);
|
||||
const tipoIdent = rawLine.slice(48, 49);
|
||||
const cnpjEmpregador = rawLine.slice(49, 63);
|
||||
const cnoCaepf = rawLine.slice(63, 77);
|
||||
const razaoSocial = rawLine.slice(77, 227);
|
||||
const local = rawLine.slice(227, 327);
|
||||
eventDatetimeRaw = dh;
|
||||
parsedData = { dh, cpf_resp: cpfResp, tipo_ident: tipoIdent, cnpj_empregador: cnpjEmpregador, cno_caepf: cnoCaepf, razao_social: razaoSocial, local };
|
||||
if (rawLine.length < 331) {
|
||||
validationStatus = "INVALID";
|
||||
notes.push("linha mais curta que o esperado para o tipo 2 (esperado >= 331 caracteres)");
|
||||
crcValid = "INVALID";
|
||||
} else {
|
||||
const crc = checkCrc(rawLine, 327, 327, 331);
|
||||
crcReceived = crc.crcReceived;
|
||||
crcCalculated = crc.crcCalculated;
|
||||
crcValid = crc.crcValid;
|
||||
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "3": {
|
||||
// Marcação REP-C/REP-A — a principal (batida de ponto convencional)
|
||||
const dh = rawLine.slice(10, 34);
|
||||
const cpf = rawLine.slice(34, 46);
|
||||
eventDatetimeRaw = dh;
|
||||
employeeCpfRaw = cpf;
|
||||
parsedData = { dh, cpf };
|
||||
if (rawLine.length < 50) {
|
||||
validationStatus = "INVALID";
|
||||
notes.push("linha mais curta que o esperado para o tipo 3 (esperado 50 caracteres)");
|
||||
crcValid = "INVALID";
|
||||
} else {
|
||||
const crc = checkCrc(rawLine, 46, 46, 50);
|
||||
crcReceived = crc.crcReceived;
|
||||
crcCalculated = crc.crcCalculated;
|
||||
crcValid = crc.crcValid;
|
||||
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "4": {
|
||||
// Ajuste do relógio do REP
|
||||
const dhAntes = rawLine.slice(10, 34);
|
||||
const dhAjustada = rawLine.slice(34, 58);
|
||||
const cpfResp = rawLine.slice(58, 69);
|
||||
eventDatetimeRaw = dhAjustada;
|
||||
// cpf_resp here is the CPF of whoever adjusted the clock, not
|
||||
// necessarily a timeclock_employees row — stored in the same
|
||||
// generic employee_cpf_raw column; resolution against
|
||||
// timeclock_employees simply won't match if they aren't one
|
||||
// (harmless — never fabricates a match).
|
||||
employeeCpfRaw = cpfResp;
|
||||
parsedData = { dh_antes: dhAntes, dh_ajustada: dhAjustada, cpf_resp: cpfResp };
|
||||
if (rawLine.length < 73) {
|
||||
validationStatus = "INVALID";
|
||||
notes.push("linha mais curta que o esperado para o tipo 4 (esperado 73 caracteres)");
|
||||
crcValid = "INVALID";
|
||||
} else {
|
||||
const crc = checkCrc(rawLine, 69, 69, 73);
|
||||
crcReceived = crc.crcReceived;
|
||||
crcCalculated = crc.crcCalculated;
|
||||
crcValid = crc.crcValid;
|
||||
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "5": {
|
||||
// Inclusão/alteração/exclusão de empregado no REP — só dh + operação documentados
|
||||
const dh = rawLine.slice(10, 34);
|
||||
const operacao = rawLine.slice(34, 35);
|
||||
eventDatetimeRaw = dh;
|
||||
parsedData = { dh, operacao, raw_tail: rawLine.slice(35) };
|
||||
if (!["I", "A", "E"].includes(operacao)) {
|
||||
notes.push(`código de operação não documentado: '${operacao}'`);
|
||||
validationStatus = "WARNING";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "6": {
|
||||
// Evento sensível do REP — só dh documentado
|
||||
const dh = rawLine.slice(10, 34);
|
||||
eventDatetimeRaw = dh;
|
||||
parsedData = { dh, raw_tail: rawLine.slice(34) };
|
||||
break;
|
||||
}
|
||||
case "7": {
|
||||
// Marcação REP-P — usa hash próprio de encadeamento, NUNCA recalculado aqui
|
||||
const dhMarcacao = rawLine.slice(10, 34);
|
||||
const cpf = rawLine.slice(34, 46);
|
||||
const dhGravacao = rawLine.slice(46, 70);
|
||||
const coletor = rawLine.slice(70, 72);
|
||||
const onlineOffline = rawLine.slice(72, 73);
|
||||
const hash = rawLine.slice(73, 137);
|
||||
eventDatetimeRaw = dhMarcacao;
|
||||
employeeCpfRaw = cpf;
|
||||
parsedData = { dh_marcacao: dhMarcacao, cpf, dh_gravacao: dhGravacao, coletor, online_offline: onlineOffline, hash };
|
||||
crcValid = "NOT_APPLICABLE";
|
||||
if (rawLine.length < 137) {
|
||||
validationStatus = "INVALID";
|
||||
notes.push("linha mais curta que o esperado para o tipo 7 (esperado 137 caracteres, hash de 64 posições incompleto)");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "9": {
|
||||
// Trailer — só o NSR fixo (999999999) é documentado; contagens internas não têm posição documentada.
|
||||
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH), trailer_cross_check: "not_implemented_no_documented_positions" };
|
||||
if (nsrRaw !== "999999999") notes.push("NSR do trailer difere do valor fixo esperado (999999999)");
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH) };
|
||||
validationStatus = "UNSUPPORTED_LAYOUT";
|
||||
notes.push(`tipo de registro não reconhecido: '${recordType}'`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const eventDatetime = eventDatetimeRaw ? parseAfdDatetime(eventDatetimeRaw) : null;
|
||||
if (eventDatetimeRaw && eventDatetime === null) {
|
||||
notes.push(`campo de data/hora fora do formato esperado (AAAA-MM-ddThh:mm:00ZZZZZ): '${eventDatetimeRaw}'`);
|
||||
if (validationStatus === "VALID") validationStatus = "WARNING";
|
||||
}
|
||||
|
||||
if (nsr === null && validationStatus === "VALID") {
|
||||
validationStatus = "INVALID";
|
||||
}
|
||||
|
||||
return {
|
||||
lineNumber,
|
||||
nsr,
|
||||
nsrRaw,
|
||||
recordType,
|
||||
eventDatetime,
|
||||
eventDatetimeRaw,
|
||||
employeeCpfRaw,
|
||||
rawLine,
|
||||
crcReceived,
|
||||
crcCalculated,
|
||||
crcValid,
|
||||
parsedData,
|
||||
validationStatus,
|
||||
validationNotes: notes.length > 0 ? notes.join("; ") : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// File-level parsing
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Splits AFD file content into lines (CRLF per spec; also tolerates bare
|
||||
* LF defensively) and parses each one. Never throws for a bad line and
|
||||
* never stops processing because of one — see module doc for the
|
||||
* "records vs. failures" partial-error policy:
|
||||
*
|
||||
* - A line that is merely malformed AFD data (wrong type, bad CRC, too
|
||||
* short, unsupported record type, etc.) still produces a `records`
|
||||
* entry with an INVALID/WARNING/UNSUPPORTED_LAYOUT status — it is
|
||||
* NEVER dropped, because raw_line is legally-relevant raw data that
|
||||
* must always be preserved (eden.md módulo 6 §12).
|
||||
* - A line that causes an actual unexpected exception in `parseAfdLine`
|
||||
* (a defensive safety net, not the normal path) still produces a
|
||||
* fallback INVALID record so nothing is silently lost, AND is also
|
||||
* recorded in `failures` for import-summary visibility.
|
||||
*
|
||||
* Fully blank lines (an artifact of a trailing CRLF at EOF, or stray
|
||||
* blank lines in a hand-edited file) are skipped without generating a
|
||||
* record — they carry no data to preserve.
|
||||
*/
|
||||
export function parseAfdFile(content: string): AfdParseResult {
|
||||
const rawLines = content.split(/\r\n|\n\r|\n/);
|
||||
const records: ParsedAfdRecord[] = [];
|
||||
const failures: AfdParseFailure[] = [];
|
||||
|
||||
let lineNumber = 0;
|
||||
for (const line of rawLines) {
|
||||
lineNumber += 1;
|
||||
if (line.length === 0) continue; // trailing EOF newline / stray blank line — no data to preserve
|
||||
|
||||
try {
|
||||
records.push(parseAfdLine(line, lineNumber));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
failures.push({ lineNumber, rawLine: line, error: message });
|
||||
records.push({
|
||||
lineNumber,
|
||||
nsr: null,
|
||||
nsrRaw: line.slice(0, 9),
|
||||
recordType: line.slice(9, 10) || "",
|
||||
eventDatetime: null,
|
||||
eventDatetimeRaw: null,
|
||||
employeeCpfRaw: null,
|
||||
rawLine: line,
|
||||
crcReceived: null,
|
||||
crcCalculated: null,
|
||||
crcValid: "NOT_VALIDATED",
|
||||
parsedData: { raw_tail: line },
|
||||
validationStatus: "INVALID",
|
||||
validationNotes: `Erro inesperado ao parsear linha: ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const byRecordType: Record<string, number> = {};
|
||||
const byValidationStatus: Record<string, number> = {};
|
||||
let firstNsr: number | null = null;
|
||||
let lastNsr: number | null = null;
|
||||
|
||||
for (const record of records) {
|
||||
byRecordType[record.recordType] = (byRecordType[record.recordType] ?? 0) + 1;
|
||||
byValidationStatus[record.validationStatus] = (byValidationStatus[record.validationStatus] ?? 0) + 1;
|
||||
if (record.nsr !== null) {
|
||||
if (firstNsr === null) firstNsr = record.nsr;
|
||||
lastNsr = record.nsr;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
records,
|
||||
failures,
|
||||
summary: {
|
||||
totalLines: lineNumber,
|
||||
parsedCount: records.length,
|
||||
failureCount: failures.length,
|
||||
byRecordType,
|
||||
byValidationStatus,
|
||||
firstNsr,
|
||||
lastNsr,
|
||||
},
|
||||
};
|
||||
}
|
||||
37
apps/api/src/hr-timeclock/afd-records.controller.ts
Normal file
37
apps/api/src/hr-timeclock/afd-records.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, Param, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { AfdRecordsService } from "./afd-records.service.js";
|
||||
import { ListAfdRecordsQueryDto } from "./dto/list-afd-records-query.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
|
||||
/**
|
||||
* READ-ONLY — eden.md módulo 6 §12: afd_records (dado bruto de ponto) é
|
||||
* imutável. This controller intentionally has no POST/PATCH/PUT/DELETE
|
||||
* handler, at any permission level — the only way a row is ever created
|
||||
* is AfdImportsController's import endpoint (via
|
||||
* hrTimeclockRepo.importAfdBatch's INSERT), and the database itself
|
||||
* additionally rejects UPDATE/DELETE via a trigger with no bypass (see
|
||||
* the migration) — so this is defense in depth, not the only guard.
|
||||
*/
|
||||
@ApiTags("hr-timeclock-afd-records")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
|
||||
@Controller("v1/hr-timeclock/afd-records")
|
||||
export class AfdRecordsController {
|
||||
constructor(private readonly afdRecords: AfdRecordsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission("afd-import", "view")
|
||||
list(@Query() query: ListAfdRecordsQueryDto) {
|
||||
return this.afdRecords.list(query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("afd-import", "view")
|
||||
get(@Param("id") id: string) {
|
||||
return this.afdRecords.get(id);
|
||||
}
|
||||
}
|
||||
45
apps/api/src/hr-timeclock/afd-records.service.ts
Normal file
45
apps/api/src/hr-timeclock/afd-records.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { hrTimeclockRepo, type AfdRecord } from "@eden/database";
|
||||
import type { ListAfdRecordsQueryDto } from "./dto/list-afd-records-query.dto.js";
|
||||
|
||||
/**
|
||||
* Read-only, matching eden.md §4.5 ("nunca há rota de escrita para esta
|
||||
* tabela"). There is deliberately no create/update/delete method here —
|
||||
* see hr-timeclock/afd-records.controller.ts, which exposes GET only.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AfdRecordsService {
|
||||
async list(query: ListAfdRecordsQueryDto): Promise<{ records: AfdRecord[]; total: number; limit: number; offset: number }> {
|
||||
const limit = query.limit ?? 50;
|
||||
const offset = query.offset ?? 0;
|
||||
|
||||
// dateTo is inclusive at the day level per eden.md §4.5 ("date_to usa
|
||||
// limite exclusivo do dia seguinte para incluir o dia inteiro") — the
|
||||
// query DTO takes a plain date/datetime string; if it looks like a
|
||||
// bare date (no time component), advance to the start of the next
|
||||
// day so the whole end day is included.
|
||||
let dateTo: Date | undefined;
|
||||
if (query.dateTo) {
|
||||
const parsed = new Date(query.dateTo);
|
||||
dateTo = /^\d{4}-\d{2}-\d{2}$/.test(query.dateTo) ? new Date(parsed.getTime() + 24 * 60 * 60 * 1000) : parsed;
|
||||
}
|
||||
|
||||
const { records, total } = await hrTimeclockRepo.listAfdRecords({
|
||||
deviceId: query.deviceId,
|
||||
employeeId: query.employeeId,
|
||||
recordType: query.recordType,
|
||||
dateFrom: query.dateFrom ? new Date(query.dateFrom) : undefined,
|
||||
dateTo,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return { records, total, limit, offset };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<AfdRecord> {
|
||||
const record = await hrTimeclockRepo.getAfdRecordById(id);
|
||||
if (!record) throw new NotFoundException("Registro de AFD não encontrado");
|
||||
return record;
|
||||
}
|
||||
}
|
||||
20
apps/api/src/hr-timeclock/dto/create-afd-import.dto.ts
Normal file
20
apps/api/src/hr-timeclock/dto/create-afd-import.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsString, IsUUID, MinLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* SIMPLIFICATION OF THIS LEVA: the AFD file is accepted as a plain string
|
||||
* in the JSON body, not a real multipart upload — see
|
||||
* afd-imports.controller.ts / afd-parser.ts header comments for why (and
|
||||
* the encoding caveat this implies). Real multipart upload (matching
|
||||
* eden.md §4.5's `POST /afd-imports/import`) is future work.
|
||||
*/
|
||||
export class CreateAfdImportDto {
|
||||
@IsUUID() deviceId!: string;
|
||||
|
||||
/**
|
||||
* The AFD file's full text content, decoded by the CALLER as
|
||||
* ISO-8859-1 (latin1) — see afd-parser.ts's encoding note. Sending
|
||||
* UTF-8-decoded content here will cause CRC validation to legitimately
|
||||
* fail for any line with a byte ≥ 0x80.
|
||||
*/
|
||||
@IsString() @MinLength(1) fileContent!: string;
|
||||
}
|
||||
20
apps/api/src/hr-timeclock/dto/create-timeclock-device.dto.ts
Normal file
20
apps/api/src/hr-timeclock/dto/create-timeclock-device.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min, MinLength } from "class-validator";
|
||||
|
||||
export class CreateTimeclockDeviceDto {
|
||||
@IsOptional() @IsUUID() companyId?: string;
|
||||
|
||||
@IsString() @MinLength(1) @MaxLength(200) name!: string;
|
||||
@IsOptional() @IsString() @MaxLength(2000) description?: string;
|
||||
@IsOptional() @IsString() @MaxLength(200) branch?: string;
|
||||
|
||||
@IsString() @MinLength(1) @MaxLength(255) host!: string;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(65535) port?: number;
|
||||
@IsOptional() @IsBoolean() useHttps?: boolean;
|
||||
|
||||
@IsString() @MinLength(1) @MaxLength(200) username!: string;
|
||||
|
||||
/** Plaintext in — encrypted server-side via FieldEncryptionService before storage, never persisted or logged as-is. */
|
||||
@IsString() @MinLength(1) @MaxLength(500) password!: string;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(100) timezone?: string;
|
||||
}
|
||||
18
apps/api/src/hr-timeclock/dto/list-afd-records-query.dto.ts
Normal file
18
apps/api/src/hr-timeclock/dto/list-afd-records-query.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { IsDateString, IsIn, IsInt, IsOptional, IsUUID, Max, Min } from "class-validator";
|
||||
|
||||
const AFD_RECORD_TYPES = ["1", "2", "3", "4", "5", "6", "7", "9"] as const;
|
||||
|
||||
export class ListAfdRecordsQueryDto {
|
||||
@IsOptional() @IsUUID() deviceId?: string;
|
||||
@IsOptional() @IsUUID() employeeId?: string;
|
||||
@IsOptional() @IsIn(AFD_RECORD_TYPES) recordType?: string;
|
||||
|
||||
/** Inclusive lower bound (ISO date/datetime string). */
|
||||
@IsOptional() @IsDateString() dateFrom?: string;
|
||||
/** Inclusive upper bound at the DAY level — the service applies the next-day-exclusive convention (eden.md §4.5). */
|
||||
@IsOptional() @IsDateString() dateTo?: string;
|
||||
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) limit?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) offset?: number;
|
||||
}
|
||||
18
apps/api/src/hr-timeclock/dto/update-timeclock-device.dto.ts
Normal file
18
apps/api/src/hr-timeclock/dto/update-timeclock-device.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateTimeclockDeviceDto {
|
||||
@IsOptional() @IsString() @MinLength(1) @MaxLength(200) name?: string;
|
||||
@IsOptional() @IsString() @MaxLength(2000) description?: string;
|
||||
@IsOptional() @IsString() @MaxLength(200) branch?: string;
|
||||
|
||||
@IsOptional() @IsString() @MinLength(1) @MaxLength(255) host?: string;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(65535) port?: number;
|
||||
@IsOptional() @IsBoolean() useHttps?: boolean;
|
||||
|
||||
@IsOptional() @IsString() @MinLength(1) @MaxLength(200) username?: string;
|
||||
|
||||
/** Only re-encrypted/stored when present — omit to leave the current credential untouched. */
|
||||
@IsOptional() @IsString() @MinLength(1) @MaxLength(500) password?: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
45
apps/api/src/hr-timeclock/hr-timeclock.module.ts
Normal file
45
apps/api/src/hr-timeclock/hr-timeclock.module.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "../config/config.module.js";
|
||||
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { TokenService } from "../auth/token.service.js";
|
||||
import { TimeclockDevicesController } from "./timeclock-devices.controller.js";
|
||||
import { TimeclockDevicesService } from "./timeclock-devices.service.js";
|
||||
import { AfdImportsController } from "./afd-imports.controller.js";
|
||||
import { AfdImportsService } from "./afd-imports.service.js";
|
||||
import { AfdRecordsController } from "./afd-records.controller.js";
|
||||
import { AfdRecordsService } from "./afd-records.service.js";
|
||||
|
||||
/**
|
||||
* HR/Timeclock (eden.md módulo 6) — foundation leva. See
|
||||
* afd-parser.ts and packages/database/migrations/PENDING_hr-timeclock.cjs
|
||||
* for the full scope/deferral notes.
|
||||
*
|
||||
* INTEGRATION NOTE for whoever merges this: `ConfigModule`,
|
||||
* `FieldEncryptionService`, `JwtAuthGuard`, `PermissionGuard` and
|
||||
* `TokenService` are provided directly here (rather than imported from a
|
||||
* shared `AuthModule`) because this worktree was isolated at a bootstrap
|
||||
* commit with no Identity/Auth module yet — see apps/api/src/auth/*'s
|
||||
* header comments. Once the real Identity/Auth module lands with its own
|
||||
* `AuthModule` (exporting these same classes) and a global `ConfigModule`
|
||||
* wired into `AppModule`, this module's `providers` list should shrink to
|
||||
* just the HR-specific services and `imports: [AuthModule]` should
|
||||
* replace the local guard/token/config providers below — no controller or
|
||||
* service code needs to change, since they all import from the same
|
||||
* `../auth/...`/`../crypto/...` paths either way.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [TimeclockDevicesController, AfdImportsController, AfdRecordsController],
|
||||
providers: [
|
||||
FieldEncryptionService,
|
||||
TokenService,
|
||||
JwtAuthGuard,
|
||||
PermissionGuard,
|
||||
TimeclockDevicesService,
|
||||
AfdImportsService,
|
||||
AfdRecordsService,
|
||||
],
|
||||
})
|
||||
export class HrTimeclockModule {}
|
||||
57
apps/api/src/hr-timeclock/timeclock-devices.controller.ts
Normal file
57
apps/api/src/hr-timeclock/timeclock-devices.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { TimeclockDevicesService } from "./timeclock-devices.service.js";
|
||||
import { CreateTimeclockDeviceDto } from "./dto/create-timeclock-device.dto.js";
|
||||
import { UpdateTimeclockDeviceDto } from "./dto/update-timeclock-device.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* eden.md módulo 6 §1.1/§2 — Control iD REP iDClass device registry.
|
||||
* password_encrypted is never returned in plaintext by any route here —
|
||||
* see TimeclockDevicesService's toView (hasCredential flag only).
|
||||
*
|
||||
* NOTE: `@UsePipes(ValidationPipe)` is applied locally (rather than
|
||||
* relying on a global pipe in main.ts) because this bootstrap-stage
|
||||
* worktree's main.ts doesn't wire one up yet — see this task's final
|
||||
* summary. Harmless/redundant once the real global pipe lands.
|
||||
*/
|
||||
@ApiTags("hr-timeclock-devices")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
|
||||
@Controller("v1/hr-timeclock/devices")
|
||||
export class TimeclockDevicesController {
|
||||
constructor(private readonly devices: TimeclockDevicesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission("timeclock-devices", "view")
|
||||
list(@Query("includeInactive") includeInactive?: string) {
|
||||
return this.devices.list(includeInactive === "true");
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("timeclock-devices", "view")
|
||||
get(@Param("id") id: string) {
|
||||
return this.devices.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("timeclock-devices", "edit")
|
||||
create(@Body() dto: CreateTimeclockDeviceDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.devices.create(dto, user);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequirePermission("timeclock-devices", "edit")
|
||||
update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateTimeclockDeviceDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.devices.update(id, dto, user);
|
||||
}
|
||||
}
|
||||
95
apps/api/src/hr-timeclock/timeclock-devices.service.ts
Normal file
95
apps/api/src/hr-timeclock/timeclock-devices.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { auditRepo, hrTimeclockRepo, type TimeclockDevice } from "@eden/database";
|
||||
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import type { CreateTimeclockDeviceDto } from "./dto/create-timeclock-device.dto.js";
|
||||
import type { UpdateTimeclockDeviceDto } from "./dto/update-timeclock-device.dto.js";
|
||||
|
||||
/** Never expose password_encrypted — GET responses only ever carry hasCredential. */
|
||||
export type TimeclockDeviceView = Omit<TimeclockDevice, "password_encrypted"> & { hasCredential: boolean };
|
||||
|
||||
function toView(device: TimeclockDevice): TimeclockDeviceView {
|
||||
const { password_encrypted, ...rest } = device;
|
||||
return { ...rest, hasCredential: password_encrypted.length > 0 };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TimeclockDevicesService {
|
||||
constructor(private readonly fieldEncryption: FieldEncryptionService) {}
|
||||
|
||||
async list(includeInactive: boolean): Promise<TimeclockDeviceView[]> {
|
||||
const devices = await hrTimeclockRepo.listTimeclockDevices(includeInactive);
|
||||
return devices.map(toView);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<TimeclockDeviceView> {
|
||||
const device = await hrTimeclockRepo.getTimeclockDeviceById(id);
|
||||
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
|
||||
return toView(device);
|
||||
}
|
||||
|
||||
async create(dto: CreateTimeclockDeviceDto, user: EdenAuthenticatedUser): Promise<TimeclockDeviceView> {
|
||||
const passwordEncrypted = this.fieldEncryption.encrypt(dto.password);
|
||||
|
||||
const device = await hrTimeclockRepo.createTimeclockDevice({
|
||||
companyId: dto.companyId ?? null,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
branch: dto.branch ?? null,
|
||||
host: dto.host,
|
||||
port: dto.port,
|
||||
useHttps: dto.useHttps,
|
||||
username: dto.username,
|
||||
passwordEncrypted,
|
||||
timezone: dto.timezone,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "timeclock_device.created",
|
||||
resourceType: "timeclock_device",
|
||||
resourceId: device.id,
|
||||
metadata: { name: device.name, host: device.host },
|
||||
});
|
||||
|
||||
return toView(device);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateTimeclockDeviceDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<TimeclockDeviceView> {
|
||||
await this.get(id); // 404 before attempting the update
|
||||
|
||||
const passwordEncrypted = dto.password ? this.fieldEncryption.encrypt(dto.password) : undefined;
|
||||
|
||||
const device = await hrTimeclockRepo.updateTimeclockDevice(id, {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
branch: dto.branch,
|
||||
host: dto.host,
|
||||
port: dto.port,
|
||||
useHttps: dto.useHttps,
|
||||
username: dto.username,
|
||||
passwordEncrypted,
|
||||
active: dto.active,
|
||||
updatedBy: user.id,
|
||||
});
|
||||
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "timeclock_device.updated",
|
||||
resourceType: "timeclock_device",
|
||||
resourceId: id,
|
||||
// Never log the password itself, even encrypted — just whether it changed.
|
||||
metadata: { fields: Object.keys(dto).filter((f) => f !== "password"), credentialChanged: passwordEncrypted !== undefined },
|
||||
});
|
||||
|
||||
return toView(device);
|
||||
}
|
||||
}
|
||||
21
apps/api/src/inventory/dto/create-asset.dto.ts
Normal file
21
apps/api/src/inventory/dto/create-asset.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IsDateString, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* At least one of serial/patrimonyTag is required — enforced in the
|
||||
* service (not here, class-validator doesn't do cross-field rules
|
||||
* cleanly) so the 400 message can name both fields together. Mirrors the
|
||||
* DB-level chk_serialized_assets_has_identifier constraint.
|
||||
*/
|
||||
export class CreateAssetDto {
|
||||
@IsUUID()
|
||||
productId!: string;
|
||||
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@IsOptional() @IsString() @MinLength(1) serial?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) patrimonyTag?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) macAddress?: string;
|
||||
@IsOptional() @IsDateString() warrantyExpiresAt?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
22
apps/api/src/inventory/dto/create-reservation.dto.ts
Normal file
22
apps/api/src/inventory/dto/create-reservation.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, Min } from "class-validator";
|
||||
|
||||
/**
|
||||
* Generic reservation — product+quantity, no serial selected yet. The
|
||||
* first step of the Master Prompt §6.8 flow (reserve → later pick the
|
||||
* specific serialized unit → install).
|
||||
*/
|
||||
export class CreateReservationDto {
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@IsUUID()
|
||||
productId!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
quantity!: number;
|
||||
|
||||
@IsOptional() @IsString() referenceType?: string;
|
||||
@IsOptional() @IsUUID() referenceId?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
16
apps/api/src/inventory/dto/create-warehouse.dto.ts
Normal file
16
apps/api/src/inventory/dto/create-warehouse.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { IsBoolean, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
|
||||
|
||||
export class CreateWarehouseDto {
|
||||
@IsUUID()
|
||||
companyId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
code!: string;
|
||||
|
||||
@IsOptional() @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
11
apps/api/src/inventory/dto/install-asset.dto.ts
Normal file
11
apps/api/src/inventory/dto/install-asset.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsIn, IsOptional, IsString, IsUUID } from "class-validator";
|
||||
|
||||
export class InstallAssetDto {
|
||||
@IsUUID()
|
||||
contractId!: string;
|
||||
|
||||
@IsIn(["installed", "comodato"])
|
||||
assignmentType!: "installed" | "comodato";
|
||||
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
7
apps/api/src/inventory/dto/reserve-asset.dto.ts
Normal file
7
apps/api/src/inventory/dto/reserve-asset.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString, IsUUID } from "class-validator";
|
||||
|
||||
export class ReserveAssetDto {
|
||||
@IsOptional() @IsString() referenceType?: string;
|
||||
@IsOptional() @IsUUID() referenceId?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
9
apps/api/src/inventory/dto/return-asset.dto.ts
Normal file
9
apps/api/src/inventory/dto/return-asset.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsOptional, IsString, IsUUID } from "class-validator";
|
||||
|
||||
export class ReturnAssetDto {
|
||||
/** Warehouse receiving the physical unit back — see serialized_assets.warehouse_id comment in the migration for why this can't default to "the warehouse it left from" (that location was cleared on install). */
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
7
apps/api/src/inventory/dto/update-warehouse.dto.ts
Normal file
7
apps/api/src/inventory/dto/update-warehouse.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateWarehouseDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) code?: string;
|
||||
@IsOptional() @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
120
apps/api/src/inventory/inventory.controller.ts
Normal file
120
apps/api/src/inventory/inventory.controller.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { InventoryService } from "./inventory.service.js";
|
||||
import { CreateAssetDto } from "./dto/create-asset.dto.js";
|
||||
import { ReserveAssetDto } from "./dto/reserve-asset.dto.js";
|
||||
import { InstallAssetDto } from "./dto/install-asset.dto.js";
|
||||
import { ReturnAssetDto } from "./dto/return-asset.dto.js";
|
||||
import { CreateReservationDto } from "./dto/create-reservation.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* Serialized assets, the stock ledger, and reservations — Master Prompt
|
||||
* §6.8, ADR-0007. Full cycle modeled here: reserve (generic or a specific
|
||||
* serial) → install/comodato → return. Every mutation appends a
|
||||
* stock_movement (ledger, never an editable balance column — see
|
||||
* PENDING_inventory.cjs's header comment) and an audit_log event.
|
||||
*/
|
||||
@ApiTags("inventory")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/inventory")
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventory: InventoryService) {}
|
||||
|
||||
@Get("assets")
|
||||
@RequirePermission("inventory", "view")
|
||||
listAssets(
|
||||
@Query("status") status?: string,
|
||||
@Query("warehouseId") warehouseId?: string,
|
||||
@Query("productId") productId?: string,
|
||||
) {
|
||||
return this.inventory.listAssets(status, warehouseId, productId);
|
||||
}
|
||||
|
||||
@Get("assets/:id")
|
||||
@RequirePermission("inventory", "view")
|
||||
getAsset(@Param("id") id: string) {
|
||||
return this.inventory.getAsset(id);
|
||||
}
|
||||
|
||||
@Post("assets")
|
||||
@RequirePermission("inventory", "edit")
|
||||
createAsset(@Body() dto: CreateAssetDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.inventory.createAsset(dto, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves this SPECIFIC serialized asset (quantity is always 1 —
|
||||
* the serial has already been picked). For the generic product+quantity
|
||||
* reservation described in the Master Prompt as the step BEFORE a
|
||||
* serial is picked, see POST /v1/inventory/reservations instead.
|
||||
*/
|
||||
@Post("assets/:id/reserve")
|
||||
@RequirePermission("inventory", "edit")
|
||||
reserveAsset(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ReserveAssetDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.inventory.reserveAsset(id, dto, user);
|
||||
}
|
||||
|
||||
@Post("assets/:id/install")
|
||||
@RequirePermission("inventory", "edit")
|
||||
installAsset(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: InstallAssetDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.inventory.installAsset(id, dto, user);
|
||||
}
|
||||
|
||||
@Post("assets/:id/return")
|
||||
@RequirePermission("inventory", "edit")
|
||||
returnAsset(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ReturnAssetDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.inventory.returnAsset(id, dto, user);
|
||||
}
|
||||
|
||||
@Get("movements")
|
||||
@RequirePermission("inventory", "view")
|
||||
listMovements(
|
||||
@Query("warehouseId") warehouseId?: string,
|
||||
@Query("productId") productId?: string,
|
||||
@Query("referenceType") referenceType?: string,
|
||||
@Query("referenceId") referenceId?: string,
|
||||
) {
|
||||
return this.inventory.listMovements(warehouseId, productId, referenceType, referenceId);
|
||||
}
|
||||
|
||||
/** Generic quantity reservation (product+warehouse, no serial picked yet) — the first step of the Master Prompt §6.8 flow. */
|
||||
@Get("reservations")
|
||||
@RequirePermission("inventory", "view")
|
||||
listReservations(
|
||||
@Query("status") status?: string,
|
||||
@Query("warehouseId") warehouseId?: string,
|
||||
@Query("productId") productId?: string,
|
||||
) {
|
||||
return this.inventory.listReservations(status, warehouseId, productId);
|
||||
}
|
||||
|
||||
@Post("reservations")
|
||||
@RequirePermission("inventory", "edit")
|
||||
createReservation(@Body() dto: CreateReservationDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.inventory.createReservation(dto, user);
|
||||
}
|
||||
|
||||
@Post("reservations/:id/release")
|
||||
@RequirePermission("inventory", "edit")
|
||||
releaseReservation(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.inventory.releaseReservation(id, user);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/inventory/inventory.module.ts
Normal file
15
apps/api/src/inventory/inventory.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { WarehousesController } from "./warehouses.controller.js";
|
||||
import { WarehousesService } from "./warehouses.service.js";
|
||||
import { InventoryController } from "./inventory.controller.js";
|
||||
import { InventoryService } from "./inventory.service.js";
|
||||
|
||||
/** Warehouses/Assets/Stock — Master Prompt §6.8, ADR-0007. Not yet imported by app.module.ts (integration step left to the merge, see .claude/agents/eden-inventory.md). */
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [WarehousesController, InventoryController],
|
||||
providers: [WarehousesService, InventoryService],
|
||||
exports: [WarehousesService, InventoryService],
|
||||
})
|
||||
export class InventoryModule {}
|
||||
268
apps/api/src/inventory/inventory.service.ts
Normal file
268
apps/api/src/inventory/inventory.service.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
inventoryRepo,
|
||||
productsRepo,
|
||||
type SerializedAsset,
|
||||
type StockMovement,
|
||||
type StockReservation,
|
||||
} from "@eden/database";
|
||||
import type { CreateAssetDto } from "./dto/create-asset.dto.js";
|
||||
import type { ReserveAssetDto } from "./dto/reserve-asset.dto.js";
|
||||
import type { InstallAssetDto } from "./dto/install-asset.dto.js";
|
||||
import type { ReturnAssetDto } from "./dto/return-asset.dto.js";
|
||||
import type { CreateReservationDto } from "./dto/create-reservation.dto.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
const {
|
||||
AssetNotFoundError,
|
||||
InvalidAssetTransitionError,
|
||||
NoOpenAssignmentError,
|
||||
ReservationNotFoundError,
|
||||
InvalidReservationTransitionError,
|
||||
} = inventoryRepo;
|
||||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
// ---------------------------------------------------------------------
|
||||
// serialized_assets
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
listAssets(status?: string, warehouseId?: string, productId?: string): Promise<SerializedAsset[]> {
|
||||
return inventoryRepo.listSerializedAssets({
|
||||
status: status as SerializedAsset["status"] | undefined,
|
||||
warehouseId,
|
||||
productId,
|
||||
});
|
||||
}
|
||||
|
||||
async getAsset(id: string): Promise<SerializedAsset> {
|
||||
const asset = await inventoryRepo.getSerializedAssetById(id);
|
||||
if (!asset) throw new NotFoundException("Ativo não encontrado");
|
||||
return asset;
|
||||
}
|
||||
|
||||
async createAsset(dto: CreateAssetDto, user: EdenAuthenticatedUser): Promise<SerializedAsset> {
|
||||
if (!dto.serial && !dto.patrimonyTag) {
|
||||
throw new BadRequestException("Informe ao menos serial ou patrimonyTag");
|
||||
}
|
||||
|
||||
const product = await productsRepo.getProductById(dto.productId);
|
||||
if (!product) throw new BadRequestException(`Produto ${dto.productId} não encontrado`);
|
||||
|
||||
const warehouse = await inventoryRepo.getWarehouseById(dto.warehouseId);
|
||||
if (!warehouse) throw new BadRequestException(`Armazém ${dto.warehouseId} não encontrado`);
|
||||
|
||||
let result: Awaited<ReturnType<typeof inventoryRepo.createAssetWithPurchaseMovement>>;
|
||||
try {
|
||||
result = await inventoryRepo.createAssetWithPurchaseMovement({
|
||||
productId: dto.productId,
|
||||
warehouseId: dto.warehouseId,
|
||||
serial: dto.serial ?? null,
|
||||
patrimonyTag: dto.patrimonyTag ?? null,
|
||||
macAddress: dto.macAddress ?? null,
|
||||
warrantyExpiresAt: dto.warrantyExpiresAt ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
createdBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
// 23505 = unique_violation — the DB-enforced "serial/patrimony/MAC
|
||||
// never active twice" invariant (see migration).
|
||||
if (isUniqueViolation(err)) {
|
||||
throw new ConflictException(
|
||||
"Serial, patrimônio ou MAC já está em uso por outro ativo ativo",
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "inventory.asset_created",
|
||||
resourceType: "serialized_asset",
|
||||
resourceId: result.asset.id,
|
||||
metadata: { productId: dto.productId, warehouseId: dto.warehouseId, serial: dto.serial ?? null },
|
||||
});
|
||||
|
||||
return result.asset;
|
||||
}
|
||||
|
||||
async reserveAsset(id: string, dto: ReserveAssetDto, user: EdenAuthenticatedUser): Promise<SerializedAsset> {
|
||||
try {
|
||||
const result = await inventoryRepo.reserveAsset(id, {
|
||||
referenceType: dto.referenceType ?? null,
|
||||
referenceId: dto.referenceId ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "inventory.asset_reserved",
|
||||
resourceType: "serialized_asset",
|
||||
resourceId: id,
|
||||
metadata: { reservationId: result.reservation.id },
|
||||
});
|
||||
|
||||
return result.asset;
|
||||
} catch (err) {
|
||||
if (err instanceof AssetNotFoundError) throw new NotFoundException("Ativo não encontrado");
|
||||
if (err instanceof InvalidAssetTransitionError) {
|
||||
throw new ConflictException(
|
||||
`Ativo com status '${err.fromStatus}' não pode ser reservado (esperado: in_stock)`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async installAsset(id: string, dto: InstallAssetDto, user: EdenAuthenticatedUser): Promise<SerializedAsset> {
|
||||
try {
|
||||
const result = await inventoryRepo.installAsset(id, {
|
||||
contractId: dto.contractId,
|
||||
assignmentType: dto.assignmentType,
|
||||
notes: dto.notes ?? null,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "inventory.asset_installed",
|
||||
resourceType: "serialized_asset",
|
||||
resourceId: id,
|
||||
metadata: { contractId: dto.contractId, assignmentType: dto.assignmentType },
|
||||
});
|
||||
|
||||
return result.asset;
|
||||
} catch (err) {
|
||||
if (err instanceof AssetNotFoundError) throw new NotFoundException("Ativo não encontrado");
|
||||
if (err instanceof InvalidAssetTransitionError) {
|
||||
throw new ConflictException(
|
||||
`Ativo com status '${err.fromStatus}' não pode ser instalado/comodato (esperado: in_stock ou reserved)`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async returnAsset(id: string, dto: ReturnAssetDto, user: EdenAuthenticatedUser): Promise<SerializedAsset> {
|
||||
const warehouse = await inventoryRepo.getWarehouseById(dto.warehouseId);
|
||||
if (!warehouse) throw new BadRequestException(`Armazém ${dto.warehouseId} não encontrado`);
|
||||
|
||||
try {
|
||||
const result = await inventoryRepo.returnAsset(id, {
|
||||
warehouseId: dto.warehouseId,
|
||||
notes: dto.notes ?? null,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "inventory.asset_returned",
|
||||
resourceType: "serialized_asset",
|
||||
resourceId: id,
|
||||
metadata: { warehouseId: dto.warehouseId },
|
||||
});
|
||||
|
||||
return result.asset;
|
||||
} catch (err) {
|
||||
if (err instanceof AssetNotFoundError) throw new NotFoundException("Ativo não encontrado");
|
||||
if (err instanceof InvalidAssetTransitionError) {
|
||||
throw new ConflictException(
|
||||
`Ativo com status '${err.fromStatus}' não pode ser devolvido (esperado: installed ou comodato)`,
|
||||
);
|
||||
}
|
||||
if (err instanceof NoOpenAssignmentError) {
|
||||
throw new ConflictException("Ativo não tem vínculo aberto para encerrar");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// stock_movements
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
listMovements(
|
||||
warehouseId?: string,
|
||||
productId?: string,
|
||||
referenceType?: string,
|
||||
referenceId?: string,
|
||||
): Promise<StockMovement[]> {
|
||||
return inventoryRepo.listStockMovements({ warehouseId, productId, referenceType, referenceId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// stock_reservations
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
listReservations(status?: string, warehouseId?: string, productId?: string): Promise<StockReservation[]> {
|
||||
return inventoryRepo.listStockReservations({
|
||||
status: status as StockReservation["status"] | undefined,
|
||||
warehouseId,
|
||||
productId,
|
||||
});
|
||||
}
|
||||
|
||||
async createReservation(dto: CreateReservationDto, user: EdenAuthenticatedUser): Promise<StockReservation> {
|
||||
const product = await productsRepo.getProductById(dto.productId);
|
||||
if (!product) throw new BadRequestException(`Produto ${dto.productId} não encontrado`);
|
||||
|
||||
const warehouse = await inventoryRepo.getWarehouseById(dto.warehouseId);
|
||||
if (!warehouse) throw new BadRequestException(`Armazém ${dto.warehouseId} não encontrado`);
|
||||
|
||||
const result = await inventoryRepo.createGenericReservation({
|
||||
warehouseId: dto.warehouseId,
|
||||
productId: dto.productId,
|
||||
quantity: dto.quantity,
|
||||
referenceType: dto.referenceType ?? null,
|
||||
referenceId: dto.referenceId ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "inventory.reservation_created",
|
||||
resourceType: "stock_reservation",
|
||||
resourceId: result.reservation.id,
|
||||
metadata: { productId: dto.productId, warehouseId: dto.warehouseId, quantity: dto.quantity },
|
||||
});
|
||||
|
||||
return result.reservation;
|
||||
}
|
||||
|
||||
async releaseReservation(id: string, user: EdenAuthenticatedUser): Promise<StockReservation> {
|
||||
try {
|
||||
const result = await inventoryRepo.releaseReservation(id, user.id);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "inventory.reservation_released",
|
||||
resourceType: "stock_reservation",
|
||||
resourceId: id,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
return result.reservation;
|
||||
} catch (err) {
|
||||
if (err instanceof ReservationNotFoundError) throw new NotFoundException("Reserva não encontrada");
|
||||
if (err instanceof InvalidReservationTransitionError) {
|
||||
throw new ConflictException(
|
||||
`Reserva com status '${err.fromStatus}' não pode ser liberada (esperado: active)`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && (err as { code?: string }).code === "23505";
|
||||
}
|
||||
54
apps/api/src/inventory/warehouses.controller.ts
Normal file
54
apps/api/src/inventory/warehouses.controller.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { WarehousesService } from "./warehouses.service.js";
|
||||
import { CreateWarehouseDto } from "./dto/create-warehouse.dto.js";
|
||||
import { UpdateWarehouseDto } from "./dto/update-warehouse.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* Warehouses/Assets/Stock — Master Prompt §6.8, ADR-0007. Read is gated by
|
||||
* inventory:view / write by inventory:edit for everything in this module
|
||||
* (warehouses included) — no reseller-style scope filtering here, mirrors
|
||||
* the simpler products/companies pattern rather than contracts' scoped
|
||||
* one: warehouses/stock are an internal operational concern, not
|
||||
* reseller-partitioned data.
|
||||
*/
|
||||
@ApiTags("inventory")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/warehouses")
|
||||
export class WarehousesController {
|
||||
constructor(private readonly warehouses: WarehousesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission("inventory", "view")
|
||||
list(@Query("includeInactive") includeInactive?: string) {
|
||||
return this.warehouses.list(includeInactive === "true");
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("inventory", "view")
|
||||
get(@Param("id") id: string) {
|
||||
return this.warehouses.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("inventory", "edit")
|
||||
create(@Body() dto: CreateWarehouseDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.warehouses.create(dto, user);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequirePermission("inventory", "edit")
|
||||
update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateWarehouseDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.warehouses.update(id, dto, user);
|
||||
}
|
||||
}
|
||||
64
apps/api/src/inventory/warehouses.service.ts
Normal file
64
apps/api/src/inventory/warehouses.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { auditRepo, companiesRepo, inventoryRepo, type Warehouse } from "@eden/database";
|
||||
import type { CreateWarehouseDto } from "./dto/create-warehouse.dto.js";
|
||||
import type { UpdateWarehouseDto } from "./dto/update-warehouse.dto.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
@Injectable()
|
||||
export class WarehousesService {
|
||||
list(includeInactive: boolean): Promise<Warehouse[]> {
|
||||
return inventoryRepo.listWarehouses(includeInactive);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Warehouse> {
|
||||
const warehouse = await inventoryRepo.getWarehouseById(id);
|
||||
if (!warehouse) throw new NotFoundException("Armazém não encontrado");
|
||||
return warehouse;
|
||||
}
|
||||
|
||||
async create(dto: CreateWarehouseDto, user: EdenAuthenticatedUser): Promise<Warehouse> {
|
||||
const company = await companiesRepo.getCompanyById(dto.companyId);
|
||||
if (!company) throw new NotFoundException("Empresa (legal entity) não encontrada");
|
||||
|
||||
const warehouse = await inventoryRepo.createWarehouse({
|
||||
companyId: dto.companyId,
|
||||
name: dto.name,
|
||||
code: dto.code,
|
||||
isActive: dto.isActive,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "warehouse.created",
|
||||
resourceType: "warehouse",
|
||||
resourceId: warehouse.id,
|
||||
metadata: { code: warehouse.code, companyId: warehouse.company_id },
|
||||
});
|
||||
|
||||
return warehouse;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseDto, user: EdenAuthenticatedUser): Promise<Warehouse> {
|
||||
await this.get(id); // 404 before attempting the update
|
||||
|
||||
const warehouse = await inventoryRepo.updateWarehouse(id, {
|
||||
name: dto.name,
|
||||
code: dto.code,
|
||||
isActive: dto.isActive,
|
||||
});
|
||||
if (!warehouse) throw new NotFoundException("Armazém não encontrado");
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "warehouse.updated",
|
||||
resourceType: "warehouse",
|
||||
resourceId: warehouse.id,
|
||||
metadata: { name: dto.name, code: dto.code, isActive: dto.isActive },
|
||||
});
|
||||
|
||||
return warehouse;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import { loadEnv } from "@eden/config";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -9,6 +10,17 @@ async function bootstrap() {
|
||||
|
||||
app.setGlobalPrefix("api", { exclude: ["health/live", "health/ready"] });
|
||||
|
||||
// The 3 frontends (core-web/reseller-web/subscriber-web) each run on a
|
||||
// distinct origin/port (ADR-0002) and call this API cross-origin — never
|
||||
// a wildcard, since requests carry bearer tokens (see env-schema.ts).
|
||||
const corsOrigins = (loadEnv().EDEN_CORS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
if (corsOrigins.length > 0) {
|
||||
app.enableCors({ origin: corsOrigins, credentials: true });
|
||||
}
|
||||
|
||||
// Server-side validation is the authority — never trust that a client
|
||||
// already validated (Master Prompt §12). Strips unknown fields instead
|
||||
// of accepting them silently (mass-assignment protection).
|
||||
|
||||
10
apps/api/src/support/dto/create-ticket-comment.dto.ts
Normal file
10
apps/api/src/support/dto/create-ticket-comment.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class CreateTicketCommentDto {
|
||||
@IsString() @MinLength(1) body!: string;
|
||||
|
||||
// Defaults to false (public comment) — is_internal=true is never
|
||||
// visible to the subscriber/reseller portals (enforced in
|
||||
// support.service.ts::get, not just by UI convention).
|
||||
@IsOptional() @IsBoolean() isInternal?: boolean;
|
||||
}
|
||||
24
apps/api/src/support/dto/create-ticket.dto.ts
Normal file
24
apps/api/src/support/dto/create-ticket.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IsIn, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
|
||||
|
||||
export class CreateTicketDto {
|
||||
@IsString() @MinLength(3) title!: string;
|
||||
@IsString() @MinLength(3) description!: string;
|
||||
@IsString() category!: string;
|
||||
|
||||
@IsOptional() @IsString() subcategory?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["low", "medium", "high", "critical"])
|
||||
priority?: "low" | "medium" | "high" | "critical";
|
||||
|
||||
// resellerId is only honored for an actor with 'all' scope on
|
||||
// support-tickets:edit (core staff) — a reseller-scoped actor always
|
||||
// gets their own resellerId forced server-side in support.service.ts,
|
||||
// never trusting this field for authorization (CLAUDE.md: never trust
|
||||
// a client-supplied reseller_id).
|
||||
@IsOptional() @IsUUID() resellerId?: string;
|
||||
|
||||
@IsOptional() @IsUUID() clientRegistrationId?: string;
|
||||
@IsOptional() @IsUUID() contractId?: string;
|
||||
@IsOptional() @IsUUID() ownerUserId?: string;
|
||||
}
|
||||
31
apps/api/src/support/dto/update-ticket-status.dto.ts
Normal file
31
apps/api/src/support/dto/update-ticket-status.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateTicketStatusDto {
|
||||
@IsIn([
|
||||
"new",
|
||||
"triage",
|
||||
"in_progress",
|
||||
"waiting_customer",
|
||||
"waiting_third_party",
|
||||
"resolved",
|
||||
"closed",
|
||||
"cancelled",
|
||||
])
|
||||
toStatus!:
|
||||
| "new"
|
||||
| "triage"
|
||||
| "in_progress"
|
||||
| "waiting_customer"
|
||||
| "waiting_third_party"
|
||||
| "resolved"
|
||||
| "closed"
|
||||
| "cancelled";
|
||||
|
||||
@IsOptional() @IsString() reason?: string;
|
||||
|
||||
// Only persisted by the service layer on a transition into 'closed' —
|
||||
// see support.service.ts::updateStatus and the root_cause column
|
||||
// comment in PENDING_support.cjs.
|
||||
@IsOptional() @IsString() rootCause?: string;
|
||||
@IsOptional() @IsString() resolutionNotes?: string;
|
||||
}
|
||||
76
apps/api/src/support/sla.ts
Normal file
76
apps/api/src/support/sla.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Support/Service Desk SLA engine — Master Prompt §6.13. Pure, DB-free
|
||||
* functions (mirrors apps/api/src/quotes/pricing.ts: business logic lives
|
||||
* here, not scattered across the repository/service layers, and is
|
||||
* trivially unit-testable without a database).
|
||||
*
|
||||
* SLA policy for this MVP is deliberately a single flat default
|
||||
* (first response in 4h, resolution in 24h) applied to every ticket
|
||||
* regardless of category/priority. A configurable, per-category SLA
|
||||
* policy (and a calendar-aware "business hours only" due-date engine,
|
||||
* instead of naive wall-clock offsets) is explicitly DEFERRED — see the
|
||||
* migration header comment in PENDING_support.cjs.
|
||||
*/
|
||||
|
||||
/** The subset of a sla_timers row this module needs — duck-typed so callers can pass the full DB row. */
|
||||
export interface SlaTimerPauseState {
|
||||
paused_since: Date | null;
|
||||
accumulated_pause_seconds: number;
|
||||
}
|
||||
|
||||
export interface DefaultSlaDueDates {
|
||||
firstResponseDueAt: Date;
|
||||
resolutionDueAt: Date;
|
||||
}
|
||||
|
||||
const DEFAULT_FIRST_RESPONSE_HOURS = 4;
|
||||
const DEFAULT_RESOLUTION_HOURS = 24;
|
||||
|
||||
/**
|
||||
* The example SLA policy this MVP ships with: first_response_due_at =
|
||||
* now + 4h, resolution_due_at = now + 24h, for every ticket regardless of
|
||||
* priority/category. Called once at ticket creation
|
||||
* (support.service.ts::create) — never recomputed afterwards.
|
||||
*/
|
||||
export function computeDefaultSlaDueDates(now: Date): DefaultSlaDueDates {
|
||||
return {
|
||||
firstResponseDueAt: new Date(now.getTime() + DEFAULT_FIRST_RESPONSE_HOURS * 3_600_000),
|
||||
resolutionDueAt: new Date(now.getTime() + DEFAULT_RESOLUTION_HOURS * 3_600_000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Total time (in seconds) the timer has spent paused, up to `now` —
|
||||
* accumulated_pause_seconds from past pause/resume cycles, PLUS the
|
||||
* duration of the current pause if the timer is still paused right now
|
||||
* (paused_since is non-null). This is exactly the amount that must be
|
||||
* subtracted from wall-clock elapsed time to get the SLA-relevant
|
||||
* "elapsed" time — a ticket sitting in waiting_customer does not burn
|
||||
* SLA time by default (eden-support persona spec).
|
||||
*
|
||||
* Never negative even if `now` is (pathologically) earlier than
|
||||
* paused_since — clamped at 0 for that slice.
|
||||
*/
|
||||
export function computeElapsedSeconds(timer: SlaTimerPauseState, now: Date): number {
|
||||
let pausedSeconds = timer.accumulated_pause_seconds;
|
||||
if (timer.paused_since) {
|
||||
const currentPauseSeconds = Math.floor((now.getTime() - timer.paused_since.getTime()) / 1000);
|
||||
pausedSeconds += Math.max(0, currentPauseSeconds);
|
||||
}
|
||||
return pausedSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given SLA due date has been breached as of `now`, once the
|
||||
* paused time (computeElapsedSeconds) is excluded from the elapsed
|
||||
* wall-clock time. Works for either due date (first_response_due_at or
|
||||
* resolution_due_at) — pass whichever is being checked.
|
||||
*
|
||||
* adjustedNow = now - totalPausedSeconds
|
||||
* breached = adjustedNow > dueAt
|
||||
*/
|
||||
export function isSlaBreached(timer: SlaTimerPauseState, dueAt: Date, now: Date): boolean {
|
||||
const pausedSeconds = computeElapsedSeconds(timer, now);
|
||||
const adjustedNowMs = now.getTime() - pausedSeconds * 1000;
|
||||
return adjustedNowMs > dueAt.getTime();
|
||||
}
|
||||
64
apps/api/src/support/support.controller.ts
Normal file
64
apps/api/src/support/support.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { SupportService } from "./support.service.js";
|
||||
import { CreateTicketDto } from "./dto/create-ticket.dto.js";
|
||||
import { UpdateTicketStatusDto } from "./dto/update-ticket-status.dto.js";
|
||||
import { CreateTicketCommentDto } from "./dto/create-ticket-comment.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* Support/Service Desk — Master Prompt §6.13. Greenfield MVP: tickets,
|
||||
* status machine + SLA timers (see apps/api/src/support/sla.ts),
|
||||
* public/internal comments. DEFERRED (see PENDING_support.cjs header):
|
||||
* real S3 attachments, CSAT, dedicated work-order entity, routing
|
||||
* queues, configurable attendance calendar.
|
||||
*/
|
||||
@ApiTags("support")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/support/tickets")
|
||||
export class SupportController {
|
||||
constructor(private readonly support: SupportService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission("support-tickets", "view")
|
||||
list(@CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.support.list(user);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("support-tickets", "view")
|
||||
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.support.get(id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("support-tickets", "edit")
|
||||
create(@Body() dto: CreateTicketDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.support.create(dto, user);
|
||||
}
|
||||
|
||||
@Patch(":id/status")
|
||||
@RequirePermission("support-tickets", "edit")
|
||||
updateStatus(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateTicketStatusDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.support.updateStatus(id, dto, user);
|
||||
}
|
||||
|
||||
@Post(":id/comments")
|
||||
@RequirePermission("support-tickets", "edit")
|
||||
addComment(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: CreateTicketCommentDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.support.addComment(id, dto, user);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/support/support.module.ts
Normal file
12
apps/api/src/support/support.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { SupportController } from "./support.controller.js";
|
||||
import { SupportService } from "./support.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService],
|
||||
exports: [SupportService],
|
||||
})
|
||||
export class SupportModule {}
|
||||
176
apps/api/src/support/support.service.ts
Normal file
176
apps/api/src/support/support.service.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
supportTicketsRepo,
|
||||
type SupportTicket,
|
||||
type TicketComment,
|
||||
type TicketWithDetails,
|
||||
} from "@eden/database";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import { maxGrantedScope } from "../auth/permissions.js";
|
||||
import { computeDefaultSlaDueDates, isSlaBreached } from "./sla.js";
|
||||
import type { CreateTicketDto } from "./dto/create-ticket.dto.js";
|
||||
import type { UpdateTicketStatusDto } from "./dto/update-ticket-status.dto.js";
|
||||
import type { CreateTicketCommentDto } from "./dto/create-ticket-comment.dto.js";
|
||||
|
||||
const { TicketNotFoundError, InvalidTicketTransitionError } = supportTicketsRepo;
|
||||
|
||||
const RESOURCE = "support-tickets";
|
||||
|
||||
@Injectable()
|
||||
export class SupportService {
|
||||
async list(user: EdenAuthenticatedUser): Promise<SupportTicket[]> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
|
||||
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'support-tickets'");
|
||||
if (scope === "all") return supportTicketsRepo.listTickets({ scope: "all" });
|
||||
return supportTicketsRepo.listTickets({ scope: "reseller", resellerId: user.resellerId });
|
||||
}
|
||||
|
||||
async get(id: string, user: EdenAuthenticatedUser): Promise<TicketWithDetails> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
|
||||
const ticket = await supportTicketsRepo.getTicketById(id);
|
||||
if (!ticket || !scope) throw new NotFoundException("Ticket não encontrado");
|
||||
if (scope !== "all" && ticket.reseller_id !== user.resellerId) {
|
||||
throw new NotFoundException("Ticket não encontrado");
|
||||
}
|
||||
|
||||
// is_internal comments are never visible outside 'all' scope (core
|
||||
// staff) — enforced here, in the query layer, not left to the UI.
|
||||
// Reinforces the eden-support persona invariant: "comentário interno
|
||||
// nunca visível ao cliente/revenda". A dedicated 'own' scope for the
|
||||
// subscriber portal isn't wired up in this MVP (mirrors contracts.ts,
|
||||
// which also only distinguishes all/reseller) — when it lands, it
|
||||
// must fall into this same filtered branch.
|
||||
if (scope !== "all") {
|
||||
return { ...ticket, comments: ticket.comments.filter((c) => !c.is_internal) };
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async create(dto: CreateTicketDto, user: EdenAuthenticatedUser): Promise<TicketWithDetails> {
|
||||
const editScope = maxGrantedScope(user.permissions, RESOURCE, "edit");
|
||||
if (!editScope) throw new ForbiddenException("Sem permissão para 'edit' em 'support-tickets'");
|
||||
|
||||
// Never trust a client-supplied reseller_id for a reseller-scoped
|
||||
// actor (CLAUDE.md: authorization never trusts a client field) — it's
|
||||
// always forced to the caller's own reseller. Only an 'all'-scope
|
||||
// actor (core staff) may open a ticket on behalf of an arbitrary
|
||||
// reseller.
|
||||
const resellerId = editScope === "all" ? (dto.resellerId ?? null) : user.resellerId;
|
||||
|
||||
const now = new Date();
|
||||
const { firstResponseDueAt, resolutionDueAt } = computeDefaultSlaDueDates(now);
|
||||
|
||||
const ticket = await supportTicketsRepo.createTicket({
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
category: dto.category,
|
||||
subcategory: dto.subcategory ?? null,
|
||||
priority: dto.priority ?? "medium",
|
||||
resellerId,
|
||||
clientRegistrationId: dto.clientRegistrationId ?? null,
|
||||
contractId: dto.contractId ?? null,
|
||||
ownerUserId: dto.ownerUserId ?? null,
|
||||
openedByUserId: user.id,
|
||||
createdBy: user.id,
|
||||
firstResponseDueAt,
|
||||
resolutionDueAt,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "support_ticket.created",
|
||||
resourceType: "support_ticket",
|
||||
resourceId: ticket.id,
|
||||
metadata: { protocolNumber: ticket.protocol_number, priority: ticket.priority, category: ticket.category },
|
||||
});
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
dto: UpdateTicketStatusDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<SupportTicket> {
|
||||
await this.get(id, user); // scope check — 404 before attempting the transition
|
||||
|
||||
try {
|
||||
const { ticket, slaTimer } = await supportTicketsRepo.updateTicketStatus(
|
||||
id,
|
||||
dto.toStatus,
|
||||
dto.reason ?? null,
|
||||
user.id,
|
||||
{ rootCause: dto.rootCause ?? null, resolutionNotes: dto.resolutionNotes ?? null },
|
||||
);
|
||||
|
||||
// Only reachable when entering 'resolved' from 'in_progress' (see
|
||||
// TICKET_STATUS_TRANSITIONS), so the timer returned here is
|
||||
// guaranteed unpaused — safe to evaluate the breach directly
|
||||
// against resolution_due_at.
|
||||
if (dto.toStatus === "resolved") {
|
||||
const now = new Date();
|
||||
const met = !isSlaBreached(slaTimer, slaTimer.resolution_due_at, now);
|
||||
await supportTicketsRepo.recordResolutionMet(id, met);
|
||||
}
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "support_ticket.status_changed",
|
||||
resourceType: "support_ticket",
|
||||
resourceId: id,
|
||||
metadata: { toStatus: dto.toStatus, reason: dto.reason ?? null },
|
||||
});
|
||||
|
||||
return ticket;
|
||||
} catch (err) {
|
||||
if (err instanceof TicketNotFoundError) throw new NotFoundException("Ticket não encontrado");
|
||||
if (err instanceof InvalidTicketTransitionError) {
|
||||
throw new ConflictException(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async addComment(
|
||||
id: string,
|
||||
dto: CreateTicketCommentDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<TicketComment> {
|
||||
const ticket = await this.get(id, user);
|
||||
const isInternal = dto.isInternal ?? false;
|
||||
|
||||
const comment = await supportTicketsRepo.addTicketComment(id, {
|
||||
authorUserId: user.id,
|
||||
body: dto.body,
|
||||
isInternal,
|
||||
});
|
||||
|
||||
// First public response starts/settles first_response_at + the SLA
|
||||
// first_response_met flag — only once, never recomputed afterwards
|
||||
// (see recordFirstResponse's idempotent WHERE ... IS NULL guards).
|
||||
if (!isInternal && ticket.first_response_at === null && ticket.slaTimer) {
|
||||
const now = new Date();
|
||||
const met = !isSlaBreached(ticket.slaTimer, ticket.slaTimer.first_response_due_at, now);
|
||||
await supportTicketsRepo.recordFirstResponse(id, met, now);
|
||||
}
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "support_ticket.comment_added",
|
||||
resourceType: "support_ticket",
|
||||
resourceId: id,
|
||||
metadata: { isInternal },
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
}
|
||||
117
apps/api/test/afd-parser.spec.ts
Normal file
117
apps/api/test/afd-parser.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { crc16Hex, crc16Kermit, normalizeAfdCpf, parseAfdFile, parseAfdLine } from "../src/hr-timeclock/afd-parser.js";
|
||||
|
||||
/**
|
||||
* Pure-function unit tests for the AFD parser — no DB, no Nest app, unlike
|
||||
* every other spec in this directory (which are DB-backed integration
|
||||
* tests per vitest.config.ts's own doc comment). Placed here anyway
|
||||
* because `include: ["test/**\/*.spec.ts"]` is the only pattern vitest
|
||||
* picks up for @eden/api; these tests just happen not to need Postgres.
|
||||
*/
|
||||
describe("crc16Kermit", () => {
|
||||
it("matches the known CRC-16/KERMIT test vector for ASCII '123456789'", () => {
|
||||
expect(crc16Kermit("123456789")).toBe(0x2189);
|
||||
expect(crc16Hex("123456789")).toBe("2189");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAfdCpf", () => {
|
||||
it("takes the last 11 digits of a wider raw field", () => {
|
||||
expect(normalizeAfdCpf("01234567890X")).toBe("01234567890");
|
||||
expect(normalizeAfdCpf(" 123.456.789-01 ")).toBe("12345678901");
|
||||
});
|
||||
|
||||
it("never invents padding for a short field", () => {
|
||||
expect(normalizeAfdCpf("123")).toBe("123");
|
||||
expect(normalizeAfdCpf(null)).toBeNull();
|
||||
expect(normalizeAfdCpf("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function buildType3Line(nsr: string, dh: string, cpf: string): string {
|
||||
const dataPart = `${nsr}3${dh}${cpf}`;
|
||||
return dataPart + crc16Hex(dataPart);
|
||||
}
|
||||
|
||||
describe("parseAfdLine — type 3 (marcação REP-C/REP-A)", () => {
|
||||
it("parses a well-formed, CRC-valid punch line", () => {
|
||||
const line = buildType3Line("000000001", "2026-08-06T08:30:00-0300", "01234567890X");
|
||||
const record = parseAfdLine(line, 1);
|
||||
|
||||
expect(record.nsr).toBe(1);
|
||||
expect(record.recordType).toBe("3");
|
||||
expect(record.crcValid).toBe("VALID");
|
||||
expect(record.validationStatus).toBe("VALID");
|
||||
expect(record.employeeCpfRaw).toBe("01234567890X");
|
||||
expect(record.eventDatetime?.toISOString()).toBe(new Date("2026-08-06T08:30:00-03:00").toISOString());
|
||||
});
|
||||
|
||||
it("flags a tampered CRC as INVALID but still preserves the raw line", () => {
|
||||
const line = buildType3Line("000000002", "2026-08-06T08:30:00-0300", "01234567890X");
|
||||
const tampered = line.slice(0, -1) + (line.at(-1) === "0" ? "1" : "0");
|
||||
const record = parseAfdLine(tampered, 2);
|
||||
|
||||
expect(record.crcValid).toBe("INVALID");
|
||||
expect(record.validationStatus).toBe("INVALID");
|
||||
expect(record.rawLine).toBe(tampered);
|
||||
expect(record.validationNotes).toMatch(/CRC divergente/);
|
||||
});
|
||||
|
||||
it("never invents an NSR — a non-numeric NSR field stays null", () => {
|
||||
const line = buildType3Line("ABCDEFGHI", "2026-08-06T08:30:00-0300", "01234567890X");
|
||||
const record = parseAfdLine(line, 3);
|
||||
|
||||
expect(record.nsr).toBeNull();
|
||||
expect(record.nsrRaw).toBe("ABCDEFGHI");
|
||||
expect(record.validationStatus).toBe("INVALID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAfdLine — type 7 (marcação REP-P)", () => {
|
||||
it("never recomputes the type-7 hash chain — crcValid is always NOT_APPLICABLE", () => {
|
||||
const dh = "2026-08-06T08:30:00-0300";
|
||||
const line = "0000000037" + dh + "01234567890X" + dh + "01" + "O" + "a".repeat(64);
|
||||
const record = parseAfdLine(line, 1);
|
||||
|
||||
expect(record.recordType).toBe("7");
|
||||
expect(record.crcValid).toBe("NOT_APPLICABLE");
|
||||
expect(record.crcCalculated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAfdLine — unsupported/malformed", () => {
|
||||
it("captures an unrecognized record type as UNSUPPORTED_LAYOUT, never dropped", () => {
|
||||
const record = parseAfdLine("000000005X" + "resto-nao-documentado", 5);
|
||||
expect(record.validationStatus).toBe("UNSUPPORTED_LAYOUT");
|
||||
expect(record.parsedData.raw_tail).toBe("resto-nao-documentado");
|
||||
});
|
||||
|
||||
it("preserves a too-short line instead of throwing", () => {
|
||||
const record = parseAfdLine("12345", 6);
|
||||
expect(record.validationStatus).toBe("INVALID");
|
||||
expect(record.rawLine).toBe("12345");
|
||||
expect(record.nsr).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAfdFile — partial-error policy", () => {
|
||||
it("keeps parsing all lines even when some are malformed, never dropping raw data", () => {
|
||||
const good = buildType3Line("000000001", "2026-08-06T08:30:00-0300", "01234567890X");
|
||||
const bad = "short";
|
||||
const content = [good, bad].join("\r\n");
|
||||
|
||||
const result = parseAfdFile(content);
|
||||
expect(result.records).toHaveLength(2);
|
||||
expect(result.failures).toHaveLength(0); // "bad" is malformed-but-handled, not an unexpected exception
|
||||
expect(result.summary.byValidationStatus.VALID).toBe(1);
|
||||
expect(result.summary.byValidationStatus.INVALID).toBe(1);
|
||||
expect(result.summary.firstNsr).toBe(1);
|
||||
expect(result.summary.lastNsr).toBe(1);
|
||||
});
|
||||
|
||||
it("skips blank trailing lines without generating a phantom record", () => {
|
||||
const good = buildType3Line("000000001", "2026-08-06T08:30:00-0300", "01234567890X");
|
||||
const result = parseAfdFile(good + "\r\n");
|
||||
expect(result.records).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
148
apps/api/test/mfa.spec.ts
Normal file
148
apps/api/test/mfa.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { generate } from "otplib";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agent,
|
||||
bearer,
|
||||
closeTestApp,
|
||||
createTestApp,
|
||||
loginSuperAdmin,
|
||||
qaEmail,
|
||||
type AuthedTokens,
|
||||
} from "./setup.js";
|
||||
|
||||
/**
|
||||
* Protects the MFA/TOTP invariants added in apps/api/src/auth/mfa.service.ts
|
||||
* and the login-flow branch in auth.service.ts. Boots the real app against
|
||||
* the real Postgres (same pattern as auth.spec.ts) — codes are generated
|
||||
* for real via `otplib.generate` against the plaintext secret returned by
|
||||
* the real enroll endpoint, never faked.
|
||||
*/
|
||||
describe("auth — MFA/TOTP (login step-up)", () => {
|
||||
let app: INestApplication;
|
||||
let admin: AuthedTokens;
|
||||
let fixtureEmail: string;
|
||||
let fixtureUserId: string;
|
||||
let fixturePassword: string;
|
||||
let fixtureAccessToken: string;
|
||||
let secret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
admin = await loginSuperAdmin(app);
|
||||
|
||||
fixtureEmail = qaEmail("mfa");
|
||||
const invite = await agent(app)
|
||||
.post("/api/v1/users/invite")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ email: fixtureEmail, roleKey: "user", applications: ["core"] })
|
||||
.expect(201);
|
||||
fixtureUserId = invite.body.user.id;
|
||||
fixturePassword = invite.body.tempPassword;
|
||||
|
||||
const login = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
|
||||
.expect(200);
|
||||
fixtureAccessToken = login.body.accessToken as string;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await agent(app)
|
||||
.patch(`/api/v1/users/${fixtureUserId}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ isActive: false })
|
||||
.expect(200);
|
||||
await closeTestApp(app);
|
||||
});
|
||||
|
||||
it("login before enrollment returns tokens directly (no mfaRequired)", async () => {
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
|
||||
.expect(200);
|
||||
expect(res.body.accessToken).toBeTypeOf("string");
|
||||
expect(res.body.mfaRequired).toBeUndefined();
|
||||
});
|
||||
|
||||
it("confirming enrollment with a wrong code is rejected and does not enable MFA", async () => {
|
||||
const enroll = await agent(app)
|
||||
.post("/api/v1/auth/mfa/enroll")
|
||||
.set(...bearer(fixtureAccessToken))
|
||||
.expect(201);
|
||||
secret = enroll.body.secret as string;
|
||||
expect(enroll.body.otpauthUrl).toContain("otpauth://totp/");
|
||||
|
||||
await agent(app)
|
||||
.post("/api/v1/auth/mfa/confirm")
|
||||
.set(...bearer(fixtureAccessToken))
|
||||
.send({ code: "000000" })
|
||||
.expect(400);
|
||||
|
||||
// MFA must still be off — login without a code still returns tokens directly.
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
|
||||
.expect(200);
|
||||
expect(res.body.mfaRequired).toBeUndefined();
|
||||
});
|
||||
|
||||
it("confirming enrollment with the real current code enables MFA and step-up login works end to end", async () => {
|
||||
await agent(app)
|
||||
.post("/api/v1/auth/mfa/confirm")
|
||||
.set(...bearer(fixtureAccessToken))
|
||||
.send({ code: await generate({ secret }) })
|
||||
.expect(204);
|
||||
|
||||
// Password alone is no longer enough — login returns a challenge, not tokens.
|
||||
const challenge = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
|
||||
.expect(200);
|
||||
expect(challenge.body.mfaRequired).toBe(true);
|
||||
expect(challenge.body.accessToken).toBeUndefined();
|
||||
const mfaChallenge = challenge.body.mfaChallenge as string;
|
||||
|
||||
// Regression: the challenge token must NEVER work as a bearer access
|
||||
// token — it's signed with the same secret as a real access token, so
|
||||
// without an explicit `purpose` check it would grant full API access
|
||||
// without ever verifying the second factor (caught live against the
|
||||
// real running stack, not a hypothetical).
|
||||
await agent(app).get("/api/v1/auth/me").set(...bearer(mfaChallenge)).expect(401);
|
||||
|
||||
// Wrong TOTP code is rejected.
|
||||
await agent(app)
|
||||
.post("/api/v1/auth/mfa/challenge")
|
||||
.send({ mfaChallenge, code: "000000" })
|
||||
.expect(401);
|
||||
|
||||
// Correct TOTP code completes login with real tokens.
|
||||
const done = await agent(app)
|
||||
.post("/api/v1/auth/mfa/challenge")
|
||||
.send({ mfaChallenge, code: await generate({ secret }) })
|
||||
.expect(200);
|
||||
expect(done.body.accessToken).toBeTypeOf("string");
|
||||
expect(done.body.refreshToken).toBeTypeOf("string");
|
||||
});
|
||||
|
||||
it("disabling MFA requires a valid current code, then login goes back to returning tokens directly", async () => {
|
||||
await agent(app)
|
||||
.post("/api/v1/auth/mfa/disable")
|
||||
.set(...bearer(fixtureAccessToken))
|
||||
.send({ code: "000000" })
|
||||
.expect(401);
|
||||
|
||||
await agent(app)
|
||||
.post("/api/v1/auth/mfa/disable")
|
||||
.set(...bearer(fixtureAccessToken))
|
||||
.send({ code: await generate({ secret }) })
|
||||
.expect(204);
|
||||
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
|
||||
.expect(200);
|
||||
expect(res.body.mfaRequired).toBeUndefined();
|
||||
expect(res.body.accessToken).toBeTypeOf("string");
|
||||
});
|
||||
});
|
||||
4
apps/core-web/.env.example
Normal file
4
apps/core-web/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copiar para `.env` neste diretório (apps/core-web/.env) para desenvolvimento
|
||||
# local com `pnpm --filter @eden/core-web dev` — Vite só lê .env do root do
|
||||
# próprio app, não do root do monorepo (ver .env.example na raiz do repo).
|
||||
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||
@@ -11,8 +11,10 @@
|
||||
"test": "echo 'no tests yet'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eden/ui": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
|
||||
BIN
apps/core-web/public/eden-logo-horizontal.png
Normal file
BIN
apps/core-web/public/eden-logo-horizontal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 444 KiB |
@@ -1,12 +1,23 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./lib/auth/AuthContext.js";
|
||||
import { AuthenticatedLayout } from "./routes/AuthenticatedLayout.js";
|
||||
import { DashboardPage } from "./routes/DashboardPage.js";
|
||||
import { LoginPage } from "./routes/LoginPage.js";
|
||||
import { ProtectedRoute } from "./routes/ProtectedRoute.js";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold">EDEN Core</h1>
|
||||
<p className="mt-2 text-slate-400">
|
||||
Bootstrap da Fase 1 — Design System ainda não portado do tema DreamsERP.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AuthenticatedLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
67
apps/core-web/src/lib/api/auth.ts
Normal file
67
apps/core-web/src/lib/api/auth.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Client for `apps/api` `/v1/auth/*` (see apps/api/src/auth/auth.controller.ts
|
||||
* and auth.service.ts for the source of truth — shapes below are mirrored,
|
||||
* not invented, and were smoke-tested against the real running API).
|
||||
*/
|
||||
import { apiRequest } from "./http.js";
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
roleKey: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
refreshToken: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
export interface MfaRequiredResult {
|
||||
mfaRequired: true;
|
||||
mfaChallenge: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export type LoginResult = AuthResult | MfaRequiredResult;
|
||||
|
||||
export function isMfaRequired(result: LoginResult): result is MfaRequiredResult {
|
||||
return "mfaRequired" in result && result.mfaRequired === true;
|
||||
}
|
||||
|
||||
export type PermissionGrant = { resource: string; action: string; scope: string };
|
||||
|
||||
export interface MeResult {
|
||||
user: PublicUser;
|
||||
permissions: "ALL" | PermissionGrant[];
|
||||
applications: Array<"core" | "reseller" | "subscriber">;
|
||||
}
|
||||
|
||||
const APPLICATION_KEY = "core" as const;
|
||||
|
||||
export function login(email: string, password: string): Promise<LoginResult> {
|
||||
return apiRequest<LoginResult>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password, applicationKey: APPLICATION_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function completeMfaLogin(mfaChallenge: string, code: string): Promise<AuthResult> {
|
||||
return apiRequest<AuthResult>("/auth/mfa/challenge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mfaChallenge, code }),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchMe(accessToken: string): Promise<MeResult> {
|
||||
return apiRequest<MeResult>("/auth/me", { method: "GET" }, { accessToken });
|
||||
}
|
||||
|
||||
export async function logout(refreshToken: string): Promise<void> {
|
||||
await apiRequest<void>("/auth/logout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
}
|
||||
73
apps/core-web/src/lib/api/http.ts
Normal file
73
apps/core-web/src/lib/api/http.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Minimal fetch wrapper for the EDEN API. Base URL comes from
|
||||
* `VITE_API_BASE_URL` (set in apps/core-web/.env — see .env.example),
|
||||
* falling back to the documented local dev default so `pnpm dev` works
|
||||
* out of the box against `docker compose up eden-postgres` + a locally
|
||||
* run `apps/api`.
|
||||
*/
|
||||
const DEFAULT_BASE_URL = "http://localhost:8080/api/v1";
|
||||
|
||||
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? DEFAULT_BASE_URL;
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiErrorBody {
|
||||
message?: string | string[];
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
function extractErrorMessage(body: unknown, fallback: string): string {
|
||||
const parsed = body as ApiErrorBody | null;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||
if (typeof parsed.message === "string") return parsed.message;
|
||||
if (typeof parsed.error === "string") return parsed.error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export async function apiRequest<TResponse>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
options?: RequestOptions,
|
||||
): Promise<TResponse> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options?.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "Não foi possível conectar à API. Verifique sua conexão e tente novamente.");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as TResponse;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const body = text ? JSON.parse(text) : undefined;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, extractErrorMessage(body, "Ocorreu um erro inesperado."));
|
||||
}
|
||||
|
||||
return body as TResponse;
|
||||
}
|
||||
109
apps/core-web/src/lib/auth/AuthContext.tsx
Normal file
109
apps/core-web/src/lib/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
completeMfaLogin,
|
||||
fetchMe,
|
||||
isMfaRequired,
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
} from "../api/auth.js";
|
||||
import type { PublicUser } from "../api/auth.js";
|
||||
import { clearSession, readSession, writeSession } from "./session.js";
|
||||
|
||||
export type AuthStatus = "loading" | "authenticated" | "anonymous";
|
||||
|
||||
/** Result of `login()` when the account has MFA enabled — caller must render a code field and call `submitMfa`. */
|
||||
export interface MfaPending {
|
||||
mfaRequired: true;
|
||||
mfaChallenge: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: PublicUser | null;
|
||||
accessToken: string | null;
|
||||
/** Resolves to `{ mfaRequired: true, mfaChallenge }` when a TOTP code is needed next, otherwise logs in fully. */
|
||||
login: (email: string, password: string) => Promise<MfaPending | { mfaRequired: false }>;
|
||||
submitMfa: (mfaChallenge: string, code: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readSession();
|
||||
if (!stored) {
|
||||
setStatus("anonymous");
|
||||
return;
|
||||
}
|
||||
// Validate the persisted token against the API rather than trusting
|
||||
// localStorage blindly — it may have expired since the last visit.
|
||||
// No refresh-token rotation in this leva: an expired access token here
|
||||
// just sends the user back to /login.
|
||||
fetchMe(stored.accessToken)
|
||||
.then((me) => {
|
||||
setUser(me.user);
|
||||
setAccessToken(stored.accessToken);
|
||||
setRefreshToken(stored.refreshToken);
|
||||
setStatus("authenticated");
|
||||
})
|
||||
.catch(() => {
|
||||
clearSession();
|
||||
setStatus("anonymous");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const result = await apiLogin(email, password);
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true as const, mfaChallenge: result.mfaChallenge };
|
||||
}
|
||||
writeSession({ accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user });
|
||||
setUser(result.user);
|
||||
setAccessToken(result.accessToken);
|
||||
setRefreshToken(result.refreshToken);
|
||||
setStatus("authenticated");
|
||||
return { mfaRequired: false as const };
|
||||
}, []);
|
||||
|
||||
const submitMfa = useCallback(async (mfaChallenge: string, code: string) => {
|
||||
const result = await completeMfaLogin(mfaChallenge, code);
|
||||
writeSession({ accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user });
|
||||
setUser(result.user);
|
||||
setAccessToken(result.accessToken);
|
||||
setRefreshToken(result.refreshToken);
|
||||
setStatus("authenticated");
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (refreshToken) {
|
||||
// Best-effort — even if the API call fails (e.g. session already
|
||||
// expired server-side), the local session must still be cleared.
|
||||
await apiLogout(refreshToken).catch(() => undefined);
|
||||
}
|
||||
clearSession();
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
setRefreshToken(null);
|
||||
setStatus("anonymous");
|
||||
}, [refreshToken]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ status, user, accessToken, login, submitMfa, logout }),
|
||||
[status, user, accessToken, login, submitMfa, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
34
apps/core-web/src/lib/auth/session.ts
Normal file
34
apps/core-web/src/lib/auth/session.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { PublicUser } from "../api/auth.js";
|
||||
|
||||
const STORAGE_KEY = "eden.core.session";
|
||||
|
||||
export interface StoredSession {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic session persistence (localStorage) so a page refresh doesn't log
|
||||
* the user out. Deliberately NOT rotating the refresh token automatically
|
||||
* on expiry in this leva — see apps/core-web/src/lib/auth/AuthContext.tsx.
|
||||
*/
|
||||
export function readSession(): StoredSession | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as StoredSession;
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.user) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSession(session: StoredSession): void {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
35
apps/core-web/src/routes/AuthenticatedLayout.tsx
Normal file
35
apps/core-web/src/routes/AuthenticatedLayout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, Outlet } from "react-router-dom";
|
||||
import { AppShell } from "@eden/ui";
|
||||
import type { AppShellNavItem } from "@eden/ui";
|
||||
import { useAuth } from "../lib/auth/AuthContext.js";
|
||||
|
||||
const NAV_ITEMS: AppShellNavItem[] = [{ key: "dashboard", label: "Painel", href: "/", active: true }];
|
||||
|
||||
export function AuthenticatedLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
appName="EDEN Core"
|
||||
logoSrc="/eden-logo-horizontal.png"
|
||||
navItems={NAV_ITEMS}
|
||||
userName={user?.fullName ?? user?.email}
|
||||
userRole={user?.roleKey}
|
||||
onLogout={handleLogout}
|
||||
LinkComponent={({ href, className, children }) => (
|
||||
<Link to={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
23
apps/core-web/src/routes/DashboardPage.tsx
Normal file
23
apps/core-web/src/routes/DashboardPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Card, PageHeader } from "@eden/ui";
|
||||
import { useAuth } from "../lib/auth/AuthContext.js";
|
||||
|
||||
/**
|
||||
* Placeholder landing page — out of scope for this leva to build real
|
||||
* business screens (Quotes/Contracts/etc.). This just proves the
|
||||
* authenticated shell renders with real session data end to end.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title={`Bem-vindo, ${user?.fullName ?? user?.email ?? ""}`} description="EDEN Core — Fase 1" />
|
||||
<Card>
|
||||
<p className="text-sm text-default">
|
||||
O shell autenticado está funcionando. As telas de negócio (orçamentos, contratos, etc.) chegam nas
|
||||
próximas levas.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
apps/core-web/src/routes/LoginPage.tsx
Normal file
132
apps/core-web/src/routes/LoginPage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input } from "@eden/ui";
|
||||
import { ApiError } from "../lib/api/http.js";
|
||||
import { useAuth } from "../lib/auth/AuthContext.js";
|
||||
|
||||
export function LoginPage() {
|
||||
const { status, login, submitMfa } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [mfaChallenge, setMfaChallenge] = useState<string | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
if (status === "authenticated") {
|
||||
const redirectTo = (location.state as { from?: string } | null)?.from ?? "/";
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
}
|
||||
|
||||
async function handleCredentialsSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await login(email, password);
|
||||
if (result.mfaRequired) {
|
||||
setMfaChallenge(result.mfaChallenge);
|
||||
} else {
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível entrar. Tente novamente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMfaSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!mfaChallenge) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitMfa(mfaChallenge, mfaCode);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Código inválido. Tente novamente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-light p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="mb-6 text-center">
|
||||
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto mb-4 h-10" />
|
||||
<h1 className="!text-xl font-bold text-title">
|
||||
{mfaChallenge ? "Verificação em duas etapas" : "Bem-vindo de volta"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-default">
|
||||
{mfaChallenge ? "Informe o código do seu aplicativo autenticador." : "Entre com sua conta EDEN Core."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{mfaChallenge ? (
|
||||
<form className="space-y-4" onSubmit={handleMfaSubmit}>
|
||||
<Input
|
||||
label="Código de verificação"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={submitting}>
|
||||
Confirmar
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-center text-xs text-default hover:text-primary"
|
||||
onClick={() => {
|
||||
setMfaChallenge(null);
|
||||
setMfaCode("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Voltar
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleCredentialsSubmit}>
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label="Senha"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={submitting}>
|
||||
Entrar
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
24
apps/core-web/src/routes/ProtectedRoute.tsx
Normal file
24
apps/core-web/src/routes/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth/AuthContext.js";
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { status } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-light">
|
||||
<span
|
||||
aria-label="Carregando"
|
||||
className="size-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "anonymous") {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
9
apps/core-web/src/vite-env.d.ts
vendored
Normal file
9
apps/core-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import edenPreset from "@eden/ui/tailwind-preset";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
presets: [edenPreset],
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
// Design-system components live in packages/ui and ship as source (not
|
||||
// pre-built CSS) — Tailwind's JIT scanner needs to see them too, or
|
||||
// classes only used inside @eden/ui get purged from core-web's build.
|
||||
"../../packages/ui/src/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
19
compose.yaml
19
compose.yaml
@@ -50,6 +50,7 @@ services:
|
||||
EDEN_FIELD_ENCRYPTION_KEY: ${EDEN_FIELD_ENCRYPTION_KEY}
|
||||
EDEN_SUPERADMIN_EMAIL: ${EDEN_SUPERADMIN_EMAIL}
|
||||
EDEN_SUPERADMIN_PASSWORD: ${EDEN_SUPERADMIN_PASSWORD}
|
||||
EDEN_CORS_ORIGINS: ${EDEN_CORS_ORIGINS}
|
||||
SMTP_HOST: ${SMTP_HOST}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
@@ -95,6 +96,12 @@ services:
|
||||
dockerfile: infra/docker/Dockerfile.web
|
||||
args:
|
||||
APP_NAME: core-web
|
||||
# Sempre relativo aqui (não ${VITE_API_BASE_URL} do .env — esse é
|
||||
# para `pnpm dev` local fora do Docker, valor diferente e não deve
|
||||
# vazar para este build arg). A API nunca tem domínio público
|
||||
# próprio; o proxy externo roteia este path para eden-api na rede
|
||||
# interna — ver comentário em .env.example.
|
||||
VITE_API_BASE_URL: /api/v1
|
||||
container_name: eden-core
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -115,6 +122,12 @@ services:
|
||||
dockerfile: infra/docker/Dockerfile.web
|
||||
args:
|
||||
APP_NAME: reseller-web
|
||||
# Sempre relativo aqui (não ${VITE_API_BASE_URL} do .env — esse é
|
||||
# para `pnpm dev` local fora do Docker, valor diferente e não deve
|
||||
# vazar para este build arg). A API nunca tem domínio público
|
||||
# próprio; o proxy externo roteia este path para eden-api na rede
|
||||
# interna — ver comentário em .env.example.
|
||||
VITE_API_BASE_URL: /api/v1
|
||||
container_name: eden-parceiros
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -135,6 +148,12 @@ services:
|
||||
dockerfile: infra/docker/Dockerfile.web
|
||||
args:
|
||||
APP_NAME: subscriber-web
|
||||
# Sempre relativo aqui (não ${VITE_API_BASE_URL} do .env — esse é
|
||||
# para `pnpm dev` local fora do Docker, valor diferente e não deve
|
||||
# vazar para este build arg). A API nunca tem domínio público
|
||||
# próprio; o proxy externo roteia este path para eden-api na rede
|
||||
# interna — ver comentário em .env.example.
|
||||
VITE_API_BASE_URL: /api/v1
|
||||
container_name: eden-assinante
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
119
docs/progress.md
119
docs/progress.md
@@ -115,7 +115,7 @@ Vitest + supertest + `@nestjs/testing` (bootstrap real do Nest, sem mock) contra
|
||||
6 subagentes no total (4 + 1 QA + Users/Roles feito diretamente), rodando em `git worktree` isolados, revisados e mesclados manualmente um a um (nunca merge automático sem verificação). **4 bugs reais** encontrados só porque cada módulo foi testado contra a stack viva antes do commit — nenhum deles teria aparecido só com typecheck/build, que todos os agentes já reportavam verde. Padrão validado: delegar implementação para agentes especializados funciona bem para módulos CRUD bem especificados, mas a integração final (merge, wiring, teste ao vivo, revisão) precisa ficar com quem tem visão do sistema inteiro.
|
||||
|
||||
### Pendências da Fase 1 (não iniciadas)
|
||||
- MFA/TOTP (schema já existe — `users.mfa_*` — endpoint ainda não implementado).
|
||||
- ~~MFA/TOTP~~ — implementado na leva seguinte (ver Fase 4+ abaixo).
|
||||
- Extração do Design System a partir do tema DreamsERP (`packages/ui` ainda é placeholder).
|
||||
- Wiring dos adapters S3/SMTP em algum módulo consumidor real (hoje só existem como biblioteca em `packages/integrations`, nada os chama ainda).
|
||||
- Nota de dado de teste: a empresa "Handix Telecom LTDA" e a revenda "Handix (revenda própria)" criadas durante os testes usam dados de exemplo, não os dados reais da Handix — substituir/remover antes de qualquer ambiente que não seja dev local.
|
||||
@@ -150,6 +150,121 @@ Wiring final feito manualmente: barrel (`packages/database/src/index.ts`) e `app
|
||||
2. `packages/database/src/repositories/types.ts` — a interface `User` estava sem `reseller_id`/`can_approve_discount`, mesmo essas colunas já existindo no banco desde as migrations de Resellers/Quotes (ninguém tinha atualizado o tipo compartilhado). Descoberto ao ligar o uso desses campos em Quotes; corrigido no `types.ts` e propagado para `EdenAuthenticatedUser` em `jwt-auth.guard.ts`.
|
||||
|
||||
### Pendências deste lote
|
||||
- FKs cruzadas entre as 3 tabelas ainda não existem (ficaram como UUID nullable simples, documentado em comentário nas migrations): `quotes.client_registration_id → client_registrations(id)`, `client_registrations.quote_id → quotes(id)`, `contracts.quote_id → quotes(id)`, `contracts.client_registration_id → client_registrations(id)`.
|
||||
- ~~FKs cruzadas entre as 3 tabelas~~ — fechadas na leva seguinte (ver abaixo).
|
||||
- Fluxo de autoatendimento público (token) de Cadastro de Cliente, upload de anexo, integração CNPJ/CEP — todos fora do escopo desta MVP.
|
||||
- 7 das 11 tabelas do ADR-0006 de Contratos (aditivos, faturas, documentos/e-signature, etc.) ainda não criadas.
|
||||
|
||||
---
|
||||
|
||||
## Fase 4/9 — MFA/TOTP, Estoque (Inventory), Suporte (Service Desk) e fundação de Ponto Eletrônico (HR/Timeclock)
|
||||
|
||||
Data: 2026-09-03.
|
||||
|
||||
Pedido do usuário: "pode seguir na criacao do app ao menos mais 20 etapas use subagentes". Estratégia: fechei a pendência de MFA/TOTP da Fase 1 diretamente (autenticação é sensível demais para delegar) e lancei 3 subagentes em paralelo (`eden-inventory`, `eden-support`, `eden-hr-timeclock`) em worktrees isolados, seguindo o mesmo padrão validado nas levas anteriores. Além das FKs cruzadas de Quotes/Contracts/ClientRegistrations, deixadas pendentes na leva anterior.
|
||||
|
||||
### FKs cruzadas (pendência da leva anterior) — fechada
|
||||
|
||||
Migration `1788450538883_cross-reference-fks.cjs`: `quotes.client_registration_id → client_registrations(id)`, `client_registrations.quote_id → quotes(id)`, `contracts.quote_id → quotes(id)`, `contracts.client_registration_id → client_registrations(id)`, todas `ON DELETE RESTRICT` (nenhum desses agregados tem rota DELETE hoje; perder o vínculo silenciosamente numa exclusão não relacionada seria pior que bloquear a exclusão). Verificado zero órfãos antes de aplicar.
|
||||
|
||||
### MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `field-encryption.service.ts`) — implementado diretamente
|
||||
|
||||
- `FieldEncryptionService` (`apps/api/src/crypto/`): criptografia reversível genérica AES-256-GCM versionada (ADR-0005), formato `v1:iv:tag:ciphertext` base64, chave de `EDEN_FIELD_ENCRYPTION_KEY` (hasheada para 32 bytes via SHA-256). Primeira implementação real desse padrão no projeto — vai ser reaproveitada por outros segredos reversíveis (ex.: credencial de equipamento Control iD, feito na mesma leva).
|
||||
- Fluxo de login em duas etapas quando `mfa_enabled=true`: `POST /auth/login` retorna `{mfaRequired: true, mfaChallenge}` (JWT de propósito único, 2 min, claim `purpose` distintivo) em vez de tokens; `POST /auth/mfa/challenge` troca o desafio + código TOTP pelos tokens reais. `POST /auth/mfa/enroll` (gera secret + `otpauthUrl`, não habilita ainda) → `POST /auth/mfa/confirm` (primeiro código correto habilita) → `POST /auth/mfa/disable` (exige código válido, não só sessão autenticada).
|
||||
- Biblioteca `otplib` v13 (API funcional `generateSecret`/`generateURI`/`verify`, não mais o antigo `authenticator` singleton de v12).
|
||||
|
||||
**Bug de segurança real encontrado e corrigido em teste ao vivo (não hipotético — explorado de verdade contra a stack rodando):** o token de desafio MFA (`mfaChallenge`, emitido logo após a senha, antes do código TOTP) era aceito pelo `JwtAuthGuard` como um access token comum — `verifyAccessToken()` só validava assinatura/expiração, nunca checava a claim `purpose`. Resultado: um atacante que só tivesse a senha (sem o segundo fator) conseguia acesso total à API por 2 minutos usando o token de desafio como Bearer normal — MFA completamente contornável. Corrigido em `token.service.ts::verifyAccessToken` rejeitando qualquer token com claim `purpose` presente. Teste de regressão adicionado em `test/mfa.spec.ts` (`GET /auth/me` com o challenge token como Bearer → 401). Explorado e depois fechado ao vivo via curl contra o container real antes de seguir adiante.
|
||||
|
||||
### Estoque / Inventory (`apps/api/src/inventory/` + migration `inventory`) — subagente `eden-inventory`
|
||||
|
||||
5 tabelas (de 15 possíveis no Master Prompt §6.8, mesmo approach de MVP-coerente do módulo Contracts): `warehouses`, `stock_movements` (ledger append-only, saldo sempre derivado via SUM, nunca coluna editável — reforçado por trigger que bloqueia UPDATE/DELETE sem exceção), `serialized_assets`, `asset_assignments`, `stock_reservations`. Invariante "mesmo serial/patrimônio/MAC nunca duas vezes ativo" (Master Prompt §15.3) reforçada por **3 índices únicos parciais no banco**, não só validação de aplicação. Fluxo completo reserva→instalação(comodato)→devolução implementado com `SELECT...FOR UPDATE` + transação, erros tipados mapeados para 404/409.
|
||||
|
||||
Subagente entregou trabalho de excelente qualidade na primeira tentativa — nenhum bug encontrado na revisão nem no teste ao vivo.
|
||||
|
||||
### Suporte / Service Desk (`apps/api/src/support/` + migration `support`) — subagente `eden-support`
|
||||
|
||||
4 tabelas: `support_tickets` (protocolo humano `TCK-2026-000001`, mesmo padrão de `quote_number`/`contract_number`), `ticket_comments` (com `is_internal`, nunca exposto fora do escopo `all`), `ticket_status_history`, `sla_timers` (1:1). Máquina de estados `new→triage→in_progress⇄{waiting_customer,waiting_third_party}→resolved→closed` (+`cancelled`), mesmo padrão de `CONTRACT_STATUS_TRANSITIONS`. Motor de SLA implementado como funções puras testáveis (`apps/api/src/support/sla.ts`) — pausa/despausa atomicamente em SQL na transição de status, cálculo de violação subtrai corretamente o tempo pausado (incluindo a pausa corrente). `root_cause`/`resolution_notes` só persistidos na transição para `closed`, não `resolved` (decisão documentada, confirmada em teste ao vivo).
|
||||
|
||||
Também de excelente qualidade — nenhum bug encontrado.
|
||||
|
||||
### Fundação de Ponto Eletrônico / HR-Timeclock (`apps/api/src/hr-timeclock/` + migration `hr-timeclock`) — subagente `eden-hr-timeclock`
|
||||
|
||||
Escopo desta leva deliberadamente limitado a fundação (modelo de dados + Control iD device + parser/import de AFD com CRC-16/KERMIT) — apuração, fechamento de período, ajustes, banco de horas e relatórios (eden.md §5-10) explicitamente adiados. 4 tabelas: `timeclock_devices` (senha do equipamento cifrada via `FieldEncryptionService`, nunca retornada em nenhum GET — só `hasCredential:boolean`), `timeclock_employees` (cadastro separado de `users`, decisão documentada: o Control iD identifica por CPF, e `users` não tem CPF), `afd_import_batches`, `afd_records`. `afd_records` é **imutável sem exceção, nem para super_admin** — trigger que bloqueia UPDATE/DELETE sem nenhuma válvula de escape (diferente de `audit_log`, que tem bypass controlado via `SET LOCAL`). Idempotência em duas camadas: hash SHA-256 do arquivo inteiro (retorna `alreadyImported:true` em reimportação) e `UNIQUE(device_id, nsr)` por registro. NSR ausente/não-numérico nunca vira 0 — fica `null`, preservando o dado bruto mesmo inválido.
|
||||
|
||||
Parser CRC-16/KERMIT (poly 0x1021 refletido/0x8408, init 0x0000, sem XOR final) verificado contra o vetor de teste conhecido ("123456789" → 0x2189) — confirmado tanto no script ad-hoc do agente quanto na suíte permanente `test/afd-parser.spec.ts` (11 testes).
|
||||
|
||||
**1 bug real encontrado e corrigido antes da integração:** a migration `PENDING_hr-timeclock.cjs` tinha uma crase (`` ` ``) solta dentro de um comentário SQL (`-- separate from \`users\``) — como a migration inteira é um template literal JS (`` pgm.sql(`...`) ``), essa crase fechava o literal prematuramente e quebrava o resto do SQL em código JS inválido, causando `SyntaxError: missing ) after argument list` ao tentar rodar `pnpm db:migrate`. Corrigido trocando a crase por aspas simples no comentário. Encontrado e corrigido antes de qualquer teste ao vivo, na primeira tentativa de aplicar a migration.
|
||||
|
||||
### Padrão de salvamento seletivo ao integrar (novo, deste lote)
|
||||
|
||||
Diferente da leva anterior (onde os 2 agentes falharam por limite de gasto e o trabalho foi resgatado de worktrees não commitados), desta vez os 3 agentes **completaram normalmente**, mas todos os 3 worktrees foram criados a partir do commit único de bootstrap (antes de Auth/Companies/Products/etc. existirem nesse checkout isolado) — sem acesso ao código real de `apps/api/src/auth/`, `packages/database/src/repositories/{audit,users,roles,types}.ts` etc. Cada agente relatou isso com transparência e, para conseguir cumprir a etapa de verificação (`pnpm typecheck` limpo), recriou stubs/placeholders type-compatíveis desses arquivos dentro do próprio worktree — e dois deles (`eden-support`, `eden-hr-timeclock`) deixaram esses placeholders (`audit.ts`, `users.ts`, `roles.ts`, `types.ts`, config, guards de auth) como arquivos genuínos no diff final, não revertidos, por acreditarem (corretamente, do ponto de vista deles, isolados no worktree) que essa infraestrutura ainda não existia.
|
||||
|
||||
Na integração, copiei seletivamente **só os arquivos de domínio real** de cada agente (migration, repositório específico do módulo, camada de API do módulo) e **descartei os placeholders duplicados de Auth/Config/Crypto/audit/users/roles**, mantendo as versões reais já existentes em `main`. Wiring do barrel (`packages/database/src/index.ts`) e `app.module.ts` feito manualmente para os 3 módulos, como nas levas anteriores.
|
||||
|
||||
### Verificação
|
||||
|
||||
`pnpm turbo run typecheck build` verde em cada integração incremental; suíte de testes cresceu de 15 para **30 testes, todos verdes** (`auth.spec.ts`, `mfa.spec.ts` novo, `afd-parser.spec.ts` novo com 11 testes, `role-weight.spec.ts`, `reseller-assignment.spec.ts`, `companies.spec.ts`, `mass-assignment.spec.ts`). Container `eden-api` reconstruído e todo o lote testado ao vivo via curl contra a stack real:
|
||||
- MFA: enroll→confirm com código TOTP real gerado via `otplib`→login exige desafio→código errado rejeitado→código certo completa login→disable exige código válido; **exploração do bug de segurança repetida após o fix, confirmando 401**.
|
||||
- Inventory: armazém→entrada de ativo→serial duplicado rejeitado (409, não 500)→reserva→instalação (comodato)→instalação duplicada rejeitada (409)→devolução→ledger conferido linha a linha (`purchase_in`, `reservation`, `comodato_out`, `return_in`).
|
||||
- Support: ticket criado com SLA (+4h/+24h)→transições válidas→comentário público marca `first_response_met`→`waiting_customer` pausa o timer→retorno a `in_progress` acumula o tempo pausado→`resolved`→`closed` (root_cause só persiste aqui)→transição inválida rejeitada (409).
|
||||
- HR-Timeclock: device criado com senha cifrada (confirmado no Postgres, formato `v1:iv:tag:ciphertext`, nunca no GET)→import de arquivo AFD válido (2 linhas, CRC correto)→reimport do mesmo arquivo retorna `alreadyImported:true` sem duplicar→`UPDATE` direto em `afd_records` via SQL bloqueado pelo trigger mesmo fora da API.
|
||||
- Trilha de auditoria (`audit_log`) conferida no Postgres com a sequência completa de todos os eventos do lote.
|
||||
|
||||
### Pendências deste lote
|
||||
- Inventory: `warehouse_locations`, `stock_items` (cache de saldo), `stock_lots`, `inventory_counts`, `transfers`/`receipts`/`issues`/`returns` formais, `rma`, `asset_maintenance` — deferidos, tipos de movimento já modelados no vocabulário do ledger para não exigir nova migration.
|
||||
- Support: anexos reais (S3), CSAT, OS dedicada, filas com roteamento automático, calendário de atendimento configurável, SLA por categoria — hoje é uma política única fixa (4h/24h).
|
||||
- HR-Timeclock: apuração, fechamento de período, ajustes manuais, banco de horas, relatórios, sync ao vivo com o Control iD, upload multipart real (hoje aceita o conteúdo como string JSON no body).
|
||||
- MFA: sem rate-limiting/bloqueio de tentativas no `mfa/challenge` (mesma lacuna que já existe no login por senha hoje — nenhum endpoint de auth tem rate limit ainda, ficou registrado como débito técnico geral, não específico do MFA).
|
||||
|
||||
---
|
||||
|
||||
## Fase 5/6 + Design System — Billing, Fiscal e primeira tela real (login + shell autenticado)
|
||||
|
||||
Data: 2026-09-03.
|
||||
|
||||
Continuação do mesmo pedido do usuário ("pelo menos mais 20 etapas, use subagentes"). 3 subagentes em paralelo (`eden-finance`, `eden-fiscal`, `eden-frontend`), mesmo padrão de worktree isolado + integração seletiva manual.
|
||||
|
||||
### Billing / Faturamento recorrente (`apps/api/src/billing/` + migration `billing`) — subagente `eden-finance`
|
||||
|
||||
MVP de 4 tabelas (ADR-0008 lista 9; billing_accounts/billing_cycles/charge_components/usage_charges/invoice_adjustments/billing_run_logs deferidos, documentado): `billing_subscriptions` (derivada de contrato, `monthly_value` é snapshot — nunca recalculado automaticamente), `billing_runs`, `invoices`, `invoice_items`. Fluxo `subscription→billing_run→invoice` completo e idempotente: rodar o mesmo run duas vezes em `draft` substitui as invoices (não duplica) — reforçado por índice único parcial `WHERE status='draft'` (no máximo um run em rascunho por período); depois de `consolidate`, run e invoices ficam imutáveis pela aplicação (409 em qualquer tentativa de gerar/alterar). ADR-0008 respeitado à risca: Billing só calcula o que é devido, nunca toca em AR/AP/boleto/cobrança (Finance) nem em documento fiscal (Fiscal) — bounded contexts continuam sendo desenvolvidos separadamente.
|
||||
|
||||
### Fiscal (`apps/api/src/fiscal/` + migration `fiscal`) — subagente `eden-fiscal`
|
||||
|
||||
eden.md módulo 5 tem ~19 tabelas de catálogo + importadores automáticos (Siscomex/IBGE) + importador manual de arquivo — MVP portou 5 tabelas fielmente ao shape do legado: `fiscal_ncm`/`fiscal_cfop` (catálogo simples), `fiscal_municipality` (shape distinto, sem `valid_from`/`valid_until`), `fiscal_operation_nature` (seed fixo com as 11 naturezas do legado), `product_fiscal_profiles` (liga produto à classificação fiscal). Invariante do eden.md §2.5 implementada de verdade: **um produto tem no máximo um profile ativo por `billing_component`** — índice único parcial + camada de aplicação que inativa o profile anterior antes de criar o novo (nunca update destrutivo, histórico preservado). Todo o importador automático/manual e as ~14 outras tabelas de catálogo ficam deferidos (documentado com a seção exata do eden.md para cada item).
|
||||
|
||||
### Design System + login real (`packages/ui`, `apps/core-web`) — subagente `eden-frontend`
|
||||
|
||||
Primeira leva de frontend do projeto. Achado que mudou o escopo para melhor: o `tema_do_Eden.zip` (comprado) não é só Angular — tem uma versão pronta em **React 18 + Vite + Tailwind** dentro (`react.zip`, 42.5MB), a mesma stack que o EDEN já usa. Em vez de portar de Angular, o agente extraiu os tokens visuais reais (cores, tipografia, sombras, border-radius, larguras de layout) desse React/Tailwind — confirmando inclusive uma divergência da nossa própria documentação: `docs/project-inventory.md` citava fonte Nunito/Poppins, a fonte real do tema é **Geist**.
|
||||
|
||||
- `packages/ui` deixou de ser placeholder: preset Tailwind exportável (`@eden/ui/tailwind-preset`) com os tokens reais extraídos, mais componentes React reais (não stub) — `Button`, `Input`, `Card`, `Badge`, `Alert`, `PageHeader`, `AppShell` (sidebar+topbar+conteúdo).
|
||||
- `apps/core-web` (EDEN Core — prioridade, os outros 2 apps ficam para leva futura): trocou a tela estática "Bootstrap da Fase 1" por um fluxo real com `react-router-dom` — página de login chamando de verdade `POST /api/v1/auth/login` (com suporte ao fluxo `mfaRequired`/`POST /mfa/challenge` da leva de MFA), sessão em `localStorage`, rota protegida, e um shell autenticado (`AppShell` real, nome do usuário via `GET /api/v1/auth/me`, logout).
|
||||
|
||||
**Achado real fora do escopo do agente, corrigido por mim em seguida**: a API não tinha CORS configurado (`app.enableCors` ausente em `main.ts`) — um browser real rodando `core-web` num domínio/porta diferente da API teria toda chamada de login bloqueada no preflight, mesmo com tudo implementado corretamente nos dois lados. Corrigido: novo `EDEN_CORS_ORIGINS` (env, lista de origens separadas por vírgula, nunca wildcard — a API emite bearer token) em `packages/config/src/env-schema.ts`, `app.enableCors({origin, credentials:true})` em `main.ts`, propagado em `compose.yaml`/`.env`/`.env.example` já com os 3 domínios públicos reais (`dev-edencore`/`dev-parceirohandix`/`dev-portalassinate.falehandix.com.br`) mais as portas de dev locais. Testado ao vivo: preflight de `https://dev-edencore.falehandix.com.br` aceito com os headers corretos; origem não autorizada (`evil.example.com`) corretamente sem `Access-Control-Allow-Origin` na resposta; login completo simulando o `Origin` real de browser, 200 com o header de CORS certo.
|
||||
|
||||
**Pendência de infra identificada, não resolvida (decisão do operador, não técnica)**: os 3 domínios públicos hoje só apontam para os containers de frontend (3001/3002/3003) — não há um domínio público equivalente para a API. `VITE_API_BASE_URL` do build de produção dos 3 apps ainda aponta para `localhost:8080` (só funciona em dev local); falta decidir e implementar como o browser em produção alcança a API (domínio próprio para a API? proxy reverso roteando um path tipo `/api` para o container `eden-api` no mesmo domínio do frontend?) antes do login funcionar nos domínios públicos de verdade.
|
||||
|
||||
### Verificação
|
||||
|
||||
`pnpm turbo run typecheck build` (28/28) e `pnpm --filter @eden/api test` (30/30, sem regressão) verdes após cada integração. Containers `eden-api`/`eden-core` reconstruídos; testado ao vivo via curl:
|
||||
- Fiscal: naturezas de operação semeadas (11), NCM criado, **invariante de profile único ativo por componente confirmada** (segundo profile RECURRING criado → primeiro automaticamente `active:false`).
|
||||
- Billing: subscription criada → billing run gerado (invoice de R$499,90, vencimento no `billing_day`) → regeneração do mesmo período reaproveita o mesmo run (idempotente, sem duplicar invoice) → consolidação → segunda consolidação rejeitada (409) → regeneração de período já consolidado rejeitada (409).
|
||||
- CORS: preflight aceito do domínio público real, rejeitado de origem não cadastrada, login completo com `Origin` de browser real.
|
||||
- `core-web`: build de produção servido pelo container real não contém mais o texto "Bootstrap da Fase 1" — contém o formulário de login real (`E-mail`/`Senha`/`Entrar`).
|
||||
- Trilha de auditoria conferida no Postgres com a sequência completa do lote.
|
||||
|
||||
### Pendências deste lote
|
||||
- Billing: `billing_accounts`, `billing_cycles` como tabela própria, consumo/telefonia (`charge_components`/`usage_charges`), `invoice_adjustments`, pró-rata — tudo Finance/AR-AP (títulos, boletos, dunning, conciliação) fica para quando esse bounded context for iniciado, por decisão da ADR-0008.
|
||||
- Fiscal: as ~14 tabelas de catálogo restantes, `fiscal_catalog_sync`, importador automático (Siscomex/IBGE) e manual (CSV/XLSX/JSON com proteção de inativação em massa), `fiscal_rules` (schema pronto mas sem consumo mesmo no legado).
|
||||
- Frontend: `reseller-web`/`subscriber-web` ainda no bootstrap estático; telas de negócio (Quotes/Contracts/Inventory/etc.) no `core-web`; rotação automática de refresh token; fluxo de ENROLL de MFA na UI (só login com MFA já habilitado está implementado).
|
||||
|
||||
### Decisão do operador sobre topologia de API pública + 3 bugs de infra encontrados e corrigidos na sequência
|
||||
|
||||
Usuário decidiu: a API **nunca** tem domínio público próprio, só IP/nome interno — o browser sempre chama a API pelo mesmo domínio do frontend (caminho relativo), e o proxy externo (gerenciado pelo operador, fora deste repo) roteia esse caminho internamente para `eden-api` (por nome de serviço Docker, `/etc/hosts` ou IP interno — detalhe de infra do operador, não deste código).
|
||||
|
||||
Implementado: `infra/docker/Dockerfile.web` agora aceita `ARG VITE_API_BASE_URL` (default `/api/v1`, Vite bake em build-time); `compose.yaml` passa esse build arg fixo (`/api/v1`) para os 3 serviços web; `EDEN_CORS_ORIGINS` simplificado para só as portas de dev local (o fluxo de produção é same-origin via proxy, não precisa de CORS).
|
||||
|
||||
**3 bugs reais de infraestrutura encontrados e corrigidos nessa verificação (nenhum era o objetivo original, todos achados testando o resultado de ponta a ponta, não só lendo o código):**
|
||||
1. **`turbo.json` não declarava `VITE_API_BASE_URL` como input da task `build`** — o Turborepo cacheava o build do Vite sem saber que essa env var afeta o output, então reaproveitava um bundle antigo (com a URL absoluta de dev) mesmo depois de mudar a env var. Corrigido adicionando `"env": ["VITE_*"]` à task `build` em `turbo.json`.
|
||||
2. **`compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`** para o build arg do Docker — parecia razoável, mas colidia com a variável de mesmo nome no `.env` (usada para `pnpm dev` local, valor absoluto `http://localhost:8080/api/v1`) que o `docker compose` carrega automaticamente; o "default" nunca era usado porque a variável já estava setada. Corrigido fixando o valor diretamente (`/api/v1`, sem interpolação de env) nos 3 serviços web, já que essa é uma decisão arquitetural fixa, não uma configuração por ambiente.
|
||||
3. **Não existia `.dockerignore`** no projeto — todo `COPY . .` do `Dockerfile.web`/`Dockerfile.node` estava copiando `node_modules/` (315MB), `.git/` (5.8MB) e `.claude/worktrees/` (345MB, os worktrees de subagentes desta mesma sessão) para dentro do contexto de build a cada vez. Além do desperdício óbvio, isso é parte de por que o bug nº1 passou despercebido no primeiro teste: caches `.turbo/` de dentro de cada pacote também eram copiados para dentro da imagem. Corrigido criando `.dockerignore` (exclui `node_modules`, `.git`, `dist`, `.turbo`, `.claude/worktrees`, `.tmp`, `.env*`, o zip do tema).
|
||||
|
||||
Cada correção foi testada isoladamente e a última confirmada abrindo o JS de produção servido pelo container real (`docker compose build --no-cache`) e conferindo que o literal `"/api/v1"` (não mais `"http://localhost:8080/api/v1"`) está no bundle.
|
||||
|
||||
@@ -8,6 +8,15 @@ WORKDIR /repo
|
||||
COPY . .
|
||||
RUN pnpm install --frozen-lockfile
|
||||
ARG APP_NAME
|
||||
# Vite bakes VITE_-prefixed vars into the bundle at build time (no runtime
|
||||
# env in a static nginx-served SPA) — see docs/adr/0002 + compose.yaml's
|
||||
# build.args for each of the 3 frontends. Default is same-origin relative
|
||||
# path: the API is never exposed on its own public domain (operator
|
||||
# decision), so in production the reverse proxy in front of each public
|
||||
# frontend domain must forward this path (e.g. /api/*) to eden-api on the
|
||||
# internal network — see the comment on EDEN_CORS_ORIGINS in .env.example.
|
||||
ARG VITE_API_BASE_URL=/api/v1
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
RUN pnpm turbo run build --filter=@eden/${APP_NAME}...
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
@@ -36,6 +36,19 @@ export const envSchema = z.object({
|
||||
S3_BUCKET: z.string().optional(),
|
||||
|
||||
PUBLIC_BASE_URL: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Comma-separated list of allowed browser origins for CORS. The API has
|
||||
* no public domain of its own (operator decision) — production traffic
|
||||
* reaches it same-origin through each frontend's reverse proxy, so this
|
||||
* only matters for local dev, where the Vite dev server (localhost:3001+)
|
||||
* and the API (localhost:8080) are different origins. Optional — when
|
||||
* unset, CORS stays effectively disabled (no browser origin is allowed
|
||||
* cross-origin). Never a wildcard: the API issues bearer tokens, and a
|
||||
* wildcard origin combined with credentialed requests is a known
|
||||
* CSRF-adjacent misconfiguration.
|
||||
*/
|
||||
EDEN_CORS_ORIGINS: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EdenEnv = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Adds the cross-reference foreign keys between Quotes, Client
|
||||
* Registrations and Contracts that were deliberately left as plain
|
||||
* nullable UUIDs when each table was created in parallel (see the header
|
||||
* comments in 1788445280742_quotes.cjs, 1788447739229_client-registrations.cjs
|
||||
* and 1788447802216_contracts.cjs). All three tables now exist on `main`,
|
||||
* so this closes that gap.
|
||||
*
|
||||
* ON DELETE RESTRICT everywhere: none of these aggregates has a DELETE
|
||||
* route today (quotes/client_registrations/contracts are only ever
|
||||
* status-transitioned, never removed), and a business document silently
|
||||
* losing its link to another business document on an unrelated delete
|
||||
* would be a worse failure mode than the delete itself being blocked.
|
||||
*/
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.addConstraint("quotes", "fk_quotes_client_registration", {
|
||||
foreignKeys: {
|
||||
columns: "client_registration_id",
|
||||
references: "client_registrations(id)",
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
});
|
||||
|
||||
pgm.addConstraint("client_registrations", "fk_client_registrations_quote", {
|
||||
foreignKeys: {
|
||||
columns: "quote_id",
|
||||
references: "quotes(id)",
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
});
|
||||
|
||||
pgm.addConstraint("contracts", "fk_contracts_quote", {
|
||||
foreignKeys: {
|
||||
columns: "quote_id",
|
||||
references: "quotes(id)",
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
});
|
||||
|
||||
pgm.addConstraint("contracts", "fk_contracts_client_registration", {
|
||||
foreignKeys: {
|
||||
columns: "client_registration_id",
|
||||
references: "client_registrations(id)",
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.dropConstraint("contracts", "fk_contracts_client_registration");
|
||||
pgm.dropConstraint("contracts", "fk_contracts_quote");
|
||||
pgm.dropConstraint("client_registrations", "fk_client_registrations_quote");
|
||||
pgm.dropConstraint("quotes", "fk_quotes_client_registration");
|
||||
};
|
||||
264
packages/database/migrations/1788451164762_inventory.cjs
Normal file
264
packages/database/migrations/1788451164762_inventory.cjs
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Inventory/Warehouse/Assets bounded context — Master Prompt §6.8, ADR-0007.
|
||||
* Greenfield: the legacy OrçaFácil has no real stock module, only the
|
||||
* requisições cited in the Master Prompt, so there is no eden.md section
|
||||
* to port faithfully here — this schema is designed from the Master
|
||||
* Prompt §6.8 requirements directly.
|
||||
*
|
||||
* Master Prompt §6.8 lists 15 possible entities. This migration builds
|
||||
* the 5 that form one coherent, end-to-end-testable slice (reserve →
|
||||
* select serial → install/comodato → return), mirroring the
|
||||
* 4-of-11-tables MVP approach taken by the Contracts module
|
||||
* (docs/adr/0006, see 1788447802216_contracts.cjs's header comment):
|
||||
*
|
||||
* - warehouses (this migration)
|
||||
* - stock_movements (this migration)
|
||||
* - serialized_assets (this migration)
|
||||
* - asset_assignments (this migration)
|
||||
* - stock_reservations (this migration)
|
||||
*
|
||||
* Explicitly DEFERRED, not forgotten — each needs a module/workflow that
|
||||
* hasn't landed yet:
|
||||
* - warehouse_locations — bin/shelf-level location inside a warehouse;
|
||||
* needs a UX for put-away/picking before a bare table is useful.
|
||||
* - stock_items — a per-(warehouse,product) balance cache row;
|
||||
* deliberately NOT built — this MVP's invariant is that on-hand
|
||||
* balance is ALWAYS derived from stock_movements (SUM by sign), never
|
||||
* a stored/editable column (see stock_movements' comment). A
|
||||
* materialized cache can be added later purely as a performance
|
||||
* optimization, refreshed FROM the ledger, never the other way round.
|
||||
* - stock_lots — batch/lot tracking (expiry, supplier batch);
|
||||
* no product in the current catalog needs it yet.
|
||||
* - inventory_counts — physical count / cycle-count workflow with
|
||||
* its own reconciliation UX; a count's variance would itself need to
|
||||
* post `adjustment_in`/`adjustment_out` movements once built.
|
||||
* - transfers — a formal multi-step inter-warehouse transfer
|
||||
* (request → in-transit → receive) with its own approval workflow;
|
||||
* `transfer_out`/`transfer_in` movement types are already modeled in
|
||||
* the ledger vocabulary below so this can be layered on top later
|
||||
* without a ledger migration.
|
||||
* - receipts / issues — formal goods-receipt-note / issue-note
|
||||
* documents (e.g. tied to a purchase order); `purchase_in` already
|
||||
* covers the ledger side for this MVP without the PO workflow.
|
||||
* - returns — a formal customer-return workflow distinct
|
||||
* from the simple `/assets/:id/return` endpoint this MVP ships
|
||||
* (which only closes the assignment and posts `return_in`); needed
|
||||
* later for e.g. partial/quantity-based non-serialized returns.
|
||||
* - rma — formal vendor RMA case tracking (tracking
|
||||
* number, vendor, resolution); `rma_out` is already modeled in the
|
||||
* ledger vocabulary below so the case-tracking table can be added
|
||||
* later without a ledger migration.
|
||||
* - asset_maintenance — service/repair history for a serialized
|
||||
* asset; needs its own workflow design, not just a bare log table.
|
||||
*
|
||||
* No FK to `contracts` yet: `serialized_assets.current_contract_id` and
|
||||
* `asset_assignments.contract_id` are deliberately nullable/plain UUID
|
||||
* with NO REFERENCES clause — same adopted pattern as Quotes/Contracts/
|
||||
* ClientRegistrations' cross-module FKs while modules land in parallel
|
||||
* worktrees. Wiring those FKs is integration work for the merge step.
|
||||
*
|
||||
* ON DELETE: no table in this migration ever CASCADEs a delete from
|
||||
* warehouses/products/serialized_assets — an asset or warehouse with
|
||||
* movement/assignment history is never hard-deleted, only inactivated
|
||||
* (warehouses.is_active) or status-transitioned (serialized_assets.status
|
||||
* = 'written_off'). This is a deliberate invariant per the Inventory
|
||||
* agent's mandate, not an oversight.
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- =====================================================================
|
||||
-- warehouses — armazém/depósito físico, pertence a uma legal entity
|
||||
-- =====================================================================
|
||||
CREATE TABLE warehouses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
name TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
UNIQUE (code)
|
||||
);
|
||||
COMMENT ON TABLE warehouses IS
|
||||
'Armazém/depósito físico — Master Prompt §6.8. Pertence a uma company (legal entity, companies.id). code é único globalmente (não só por company) para servir como identificador humano curto e inequívoco em toda a UI/relatórios. Nunca deletado fisicamente — um armazém desativado é is_active=false, preservando o histórico de stock_movements/serialized_assets que apontam para ele (ver comentário de topo da migration).';
|
||||
|
||||
CREATE TRIGGER trg_warehouses_updated_at BEFORE UPDATE ON warehouses
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE INDEX idx_warehouses_company ON warehouses(company_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- stock_movements — ledger append-only. Saldo de estoque é SEMPRE
|
||||
-- derivado daqui (SUM com sinal implícito pelo tipo), nunca uma coluna
|
||||
-- de saldo editável em nenhuma outra tabela.
|
||||
-- =====================================================================
|
||||
CREATE TABLE stock_movements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
warehouse_id UUID NOT NULL REFERENCES warehouses(id),
|
||||
product_id UUID NOT NULL REFERENCES products(id),
|
||||
movement_type TEXT NOT NULL CHECK (movement_type IN (
|
||||
'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'
|
||||
)),
|
||||
quantity NUMERIC(14,3) NOT NULL CHECK (quantity > 0),
|
||||
reference_type TEXT,
|
||||
reference_id UUID,
|
||||
notes TEXT
|
||||
);
|
||||
COMMENT ON TABLE stock_movements IS
|
||||
'Ledger append-only de estoque — Master Prompt §6.8. quantity é SEMPRE positivo; o sinal/direção é implícito pelo movement_type (ver COMMENT de movement_type). Uma correção de um movimento passado é sempre um NOVO movimento de estorno (ex.: um purchase_in lançado errado é corrigido com um adjustment_out equivalente), nunca um UPDATE/DELETE da linha original — daí os triggers trg_stock_movements_no_update/no_delete abaixo, mesmo padrão de audit_log (ver baseline-schema). reference_type/reference_id são polimórficos e opcionais (sem FK, por natureza) — ex.: reference_type=''contract''/reference_id=<contracts.id> num install_out, ou reference_type=''serialized_asset''/reference_id=<serialized_assets.id> numa reservation de um ativo específico.';
|
||||
COMMENT ON COLUMN stock_movements.movement_type IS
|
||||
'Direção implícita por tipo (usada para calcular o saldo on-hand de um warehouse+product via SUM):
|
||||
ENTRADA (+ saldo on-hand): purchase_in, adjustment_in, transfer_in, return_in
|
||||
SAÍDA (- saldo on-hand): adjustment_out, transfer_out, sale_out, install_out, comodato_out, rma_out, write_off
|
||||
NEUTRO para saldo on-hand, afeta apenas "disponível para prometer"
|
||||
(on_hand - reservas ativas), não o saldo físico: reservation, reservation_release
|
||||
Saldo on-hand de um (warehouse_id, product_id) = SUM(quantity) das entradas − SUM(quantity) das saídas, sempre calculado em tempo de consulta a partir desta tabela — nunca cacheado numa coluna editável (ver header da migration, item stock_items deferido).';
|
||||
|
||||
CREATE OR REPLACE FUNCTION prevent_stock_movement_mutation() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF current_setting('eden.allow_stock_movement_mutation', true) IS DISTINCT FROM 'true' THEN
|
||||
RAISE EXCEPTION 'stock_movements é append-only — correções são novos movimentos de estorno, nunca UPDATE/DELETE (ver Master Prompt §6.8 / .claude/agents/eden-inventory.md)';
|
||||
END IF;
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_stock_movements_no_update BEFORE UPDATE ON stock_movements
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_stock_movement_mutation();
|
||||
CREATE TRIGGER trg_stock_movements_no_delete BEFORE DELETE ON stock_movements
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_stock_movement_mutation();
|
||||
|
||||
CREATE INDEX idx_stock_movements_warehouse_product ON stock_movements(warehouse_id, product_id);
|
||||
CREATE INDEX idx_stock_movements_reference ON stock_movements(reference_type, reference_id);
|
||||
CREATE INDEX idx_stock_movements_created_at ON stock_movements(created_at);
|
||||
|
||||
-- =====================================================================
|
||||
-- serialized_assets — um ativo físico rastreado individualmente
|
||||
-- =====================================================================
|
||||
CREATE TABLE serialized_assets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
updated_by UUID REFERENCES users(id),
|
||||
product_id UUID NOT NULL REFERENCES products(id),
|
||||
serial TEXT,
|
||||
patrimony_tag TEXT,
|
||||
mac_address TEXT,
|
||||
warehouse_id UUID REFERENCES warehouses(id),
|
||||
status TEXT NOT NULL DEFAULT 'in_stock' CHECK (status IN (
|
||||
'in_stock', 'reserved', 'installed', 'comodato', 'returned', 'written_off'
|
||||
)),
|
||||
current_contract_id UUID,
|
||||
warranty_expires_at TIMESTAMPTZ,
|
||||
CONSTRAINT chk_serialized_assets_has_identifier CHECK (serial IS NOT NULL OR patrimony_tag IS NOT NULL)
|
||||
);
|
||||
COMMENT ON TABLE serialized_assets IS
|
||||
'Ativo físico rastreado individualmente (serial/patrimônio/MAC) — Master Prompt §6.8. Ao menos um identificador (serial OU patrimony_tag) é obrigatório; produtos que só rastreiam patrimônio (ex.: mobiliário) podem não ter serial de fabricante. warehouse_id é a localização atual e é NULL quando o ativo está instalado/comodato no cliente (status IN (''installed'',''comodato'')) — current_contract_id aponta pra onde ele está nesse caso. current_contract_id NÃO tem FK para contracts ainda (contracts está sendo construído em paralelo por outro agente neste worktree) — mesmo padrão de FK cruzada adiada de Quotes/Contracts/ClientRegistrations; a FK real é trabalho de integração no merge. Nunca deletado fisicamente: um ativo dado de baixa vira status=''written_off'' (ver header da migration).';
|
||||
COMMENT ON COLUMN serialized_assets.mac_address IS
|
||||
'MVP: uma única coluna de MAC primário (nullable), não um array nem uma tabela filha. Decisão documentada: o índice único parcial que garante "MAC nunca duplicado entre ativos simultaneamente ativos" (ver abaixo) precisa colocalizar mac_address com status na MESMA tabela — um índice parcial não pode referenciar uma condição de uma tabela filha via JOIN. Se um produto real precisar rastrear múltiplos MACs (ex.: CPE dual-band com MAC de LAN e de WLAN), promover para uma tabela filha então, com a invariante reforçada por trigger em vez de índice parcial — deferido conscientemente, não esquecido.';
|
||||
COMMENT ON COLUMN serialized_assets.status IS
|
||||
'in_stock/reserved/installed/comodato contam como "ativo" para a invariante de não-duplicação de identificador (ver índices únicos parciais abaixo). returned/written_off NÃO contam — liberam o serial/patrimônio/MAC para reuso por uma linha nova, ex.: uma correção de cadastro. Nota: o endpoint POST /assets/:id/return desta MVP leva o ativo direto de volta a ''in_stock'' (mesma linha) — o status ''returned'' fica reservado para um fluxo futuro de RMA/inspeção pós-devolução (ainda não implementado, ver header da migration) em que o ativo chega mas ainda não foi conferido/restocado.';
|
||||
COMMENT ON COLUMN serialized_assets.current_contract_id IS
|
||||
'Sem FK para contracts.id ainda — deliberado, ver COMMENT da tabela. Preenchido em /assets/:id/install, limpo em /assets/:id/return.';
|
||||
|
||||
CREATE TRIGGER trg_serialized_assets_updated_at BEFORE UPDATE ON serialized_assets
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- Invariante obrigatória (Master Prompt §15.3 / §6.8: "nunca reutilizar
|
||||
-- o mesmo serial/MAC/patrimônio simultaneamente em dois ativos
|
||||
-- ativos") — reforçada em DB, não só em validação de aplicação.
|
||||
CREATE UNIQUE INDEX idx_serialized_assets_serial_active ON serialized_assets(serial)
|
||||
WHERE serial IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato');
|
||||
CREATE UNIQUE INDEX idx_serialized_assets_patrimony_active ON serialized_assets(patrimony_tag)
|
||||
WHERE patrimony_tag IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato');
|
||||
CREATE UNIQUE INDEX idx_serialized_assets_mac_active ON serialized_assets(mac_address)
|
||||
WHERE mac_address IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato');
|
||||
|
||||
CREATE INDEX idx_serialized_assets_product ON serialized_assets(product_id);
|
||||
CREATE INDEX idx_serialized_assets_warehouse ON serialized_assets(warehouse_id);
|
||||
CREATE INDEX idx_serialized_assets_status ON serialized_assets(status);
|
||||
CREATE INDEX idx_serialized_assets_contract ON serialized_assets(current_contract_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- asset_assignments — histórico de vínculo de um serialized_asset a um
|
||||
-- contrato/cliente ao longo do tempo
|
||||
-- =====================================================================
|
||||
CREATE TABLE asset_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
asset_id UUID NOT NULL REFERENCES serialized_assets(id),
|
||||
contract_id UUID NOT NULL,
|
||||
assignment_type TEXT NOT NULL CHECK (assignment_type IN ('installed', 'comodato')),
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
unassigned_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE asset_assignments IS
|
||||
'Histórico de vínculo de um ativo serializado a um contrato/cliente — Master Prompt §6.8. contract_id sem FK ainda (mesmo motivo/padrão de serialized_assets.current_contract_id). Nunca UPDATE de um assignment fechado (unassigned_at já preenchido): encerrar um vínculo é preencher unassigned_at UMA vez (camada de repositório só permite esse UPDATE específico, guardado pelo índice único parcial abaixo que impede reabrir/duplicar um vínculo aberto); um novo vínculo é sempre uma linha nova.';
|
||||
|
||||
-- Reforça em DB que um ativo nunca tem mais de um vínculo ABERTO
|
||||
-- simultâneo (unassigned_at IS NULL) — mesma invariante de
|
||||
-- "não alocado duas vezes" do Master Prompt §15.3, vista pelo lado do
|
||||
-- histórico de vínculos em vez do status do ativo.
|
||||
CREATE UNIQUE INDEX idx_asset_assignments_open ON asset_assignments(asset_id)
|
||||
WHERE unassigned_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_asset_assignments_asset ON asset_assignments(asset_id);
|
||||
CREATE INDEX idx_asset_assignments_contract ON asset_assignments(contract_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- stock_reservations — reserva de quantidade de um produto num
|
||||
-- warehouse, com ou sem um serial específico já selecionado
|
||||
-- =====================================================================
|
||||
CREATE TABLE stock_reservations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
warehouse_id UUID NOT NULL REFERENCES warehouses(id),
|
||||
product_id UUID NOT NULL REFERENCES products(id),
|
||||
quantity NUMERIC(14,3) NOT NULL CHECK (quantity > 0),
|
||||
reference_type TEXT,
|
||||
reference_id UUID,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'released', 'fulfilled')),
|
||||
notes TEXT
|
||||
);
|
||||
COMMENT ON TABLE stock_reservations IS
|
||||
'Reserva de quantidade de um produto num warehouse, ANTES (ou no lugar) da seleção de um serial específico — Master Prompt §6.8, primeiro passo do fluxo reservar→selecionar serial→instalar. Duas formas de uso, ambas suportadas por esta mesma tabela (sem coluna asset_id — decisão documentada):
|
||||
1. Reserva GENÉRICA (produto+quantidade, ainda sem serial escolhido): reference_type/reference_id apontam, por exemplo, para a quote_id ou contract_id que originou a necessidade. Caso descrito no Master Prompt como o primeiro passo do fluxo.
|
||||
2. Reserva de um ATIVO ESPECÍFICO (POST /assets/:id/reserve): quantity=1, reference_type=''serialized_asset'', reference_id=<serialized_assets.id> — rastreável ao ativo exato sem precisar de uma coluna dedicada.
|
||||
status=''fulfilled'' quando a reserva efetivamente vira uma saída (ex.: o ativo reservado é instalado); ''released'' quando cancelada sem uso. Sem FK em reference_id (polimórfico).';
|
||||
|
||||
CREATE TRIGGER trg_stock_reservations_updated_at BEFORE UPDATE ON stock_reservations
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE INDEX idx_stock_reservations_warehouse_product ON stock_reservations(warehouse_id, product_id);
|
||||
CREATE INDEX idx_stock_reservations_status ON stock_reservations(status);
|
||||
CREATE INDEX idx_stock_reservations_reference ON stock_reservations(reference_type, reference_id);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS stock_reservations;
|
||||
DROP TABLE IF EXISTS asset_assignments;
|
||||
DROP TABLE IF EXISTS serialized_assets;
|
||||
DROP TABLE IF EXISTS stock_movements;
|
||||
DROP FUNCTION IF EXISTS prevent_stock_movement_mutation();
|
||||
DROP TABLE IF EXISTS warehouses;
|
||||
`);
|
||||
};
|
||||
164
packages/database/migrations/1788451374900_support.cjs
Normal file
164
packages/database/migrations/1788451374900_support.cjs
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Support/Service Desk bounded context (Master Prompt §6.13). Greenfield —
|
||||
* no legacy OrçaFácil module to port, unlike most other EDEN modules.
|
||||
*
|
||||
* This MVP builds the 4 tables needed for a working ticket lifecycle with
|
||||
* real SLA timers:
|
||||
* - support_tickets (this migration)
|
||||
* - ticket_comments (this migration)
|
||||
* - ticket_status_history (this migration)
|
||||
* - sla_timers (this migration, 1:1 with support_tickets)
|
||||
*
|
||||
* Explicitly DEFERRED, not forgotten:
|
||||
* - Real attachments (S3) — ticket_comments/support_tickets have no
|
||||
* attachment table yet; needs the Documents/S3 integration adapter
|
||||
* (packages/integrations) to land first, same reasoning as
|
||||
* contract_documents in the Contracts migration.
|
||||
* - CSAT (customer satisfaction survey after closing) — needs a survey
|
||||
* delivery mechanism (email/portal) that doesn't exist yet.
|
||||
* - Dedicated work order (OS) entity distinct from the ticket itself —
|
||||
* this MVP treats the ticket as also carrying the OS-equivalent
|
||||
* fields (root_cause/resolution_notes); a first-class `work_orders`
|
||||
* table with its own scheduling/dispatch workflow is future work.
|
||||
* - Queue model with automatic routing rules — there is no `queues`
|
||||
* table yet; tickets are assigned via owner_user_id only (manual).
|
||||
* - Configurable attendance calendar (business hours/holidays) for SLA
|
||||
* due-date math — `sla_timers` due dates are computed with naive
|
||||
* wall-clock offsets (see apps/api/src/support/sla.ts), not a
|
||||
* calendar-aware engine. Category-specific SLA policy is also
|
||||
* deferred — this MVP uses one flat default policy for every ticket
|
||||
* regardless of category/priority (see sla.ts comment).
|
||||
*
|
||||
* `support_tickets.client_registration_id` and `.contract_id` are
|
||||
* deliberately nullable with NO foreign key, same adopted pattern as
|
||||
* `contracts.client_registration_id`/`contracts.quote_id`: those modules
|
||||
* are being built in parallel by other agents. Wiring the FKs is
|
||||
* integration-step work, not this migration's.
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- =====================================================================
|
||||
-- support_tickets — the core aggregate
|
||||
-- =====================================================================
|
||||
CREATE SEQUENCE support_tickets_protocol_number_seq START 1;
|
||||
|
||||
CREATE TABLE support_tickets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
updated_by UUID REFERENCES users(id),
|
||||
protocol_number TEXT NOT NULL UNIQUE
|
||||
DEFAULT ('TCK-' || to_char(now(), 'YYYY') || '-' || lpad(nextval('support_tickets_protocol_number_seq')::text, 6, '0')),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT,
|
||||
priority TEXT NOT NULL DEFAULT 'medium'
|
||||
CHECK (priority IN ('low', 'medium', 'high', 'critical')),
|
||||
status TEXT NOT NULL DEFAULT 'new'
|
||||
CHECK (status IN (
|
||||
'new', 'triage', 'in_progress', 'waiting_customer',
|
||||
'waiting_third_party', 'resolved', 'closed', 'cancelled'
|
||||
)),
|
||||
reseller_id UUID REFERENCES resellers(id),
|
||||
client_registration_id UUID,
|
||||
contract_id UUID,
|
||||
owner_user_id UUID REFERENCES users(id),
|
||||
opened_by_user_id UUID NOT NULL REFERENCES users(id),
|
||||
root_cause TEXT,
|
||||
resolution_notes TEXT,
|
||||
first_response_at TIMESTAMPTZ,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
closed_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE support_tickets IS
|
||||
'Ticket de atendimento (Service Desk, Master Prompt §6.13) — módulo greenfield, sem equivalente no legado OrçaFácil. protocol_number é gerado inline via support_tickets_protocol_number_seq + to_char, mesmo padrão de quotes.quote_number (ver migration 1788445280742_quotes.cjs), formato TCK-{ano}-{sequência de 6 dígitos}. client_registration_id/contract_id são nullable SEM FK — client_registrations e contracts existem em outras migrations mas a FK cruzada é trabalho de integração, não desta migration (mesmo padrão adotado por contracts.client_registration_id/contracts.quote_id).';
|
||||
COMMENT ON COLUMN support_tickets.root_cause IS
|
||||
'Causa raiz — só tem sentido preenchido no fechamento do ticket (transição para ''closed''), mas não há CHECK de banco forçando isso: a camada de aplicação (support.service.ts) só aceita esse campo no payload da transição para ''closed''.';
|
||||
COMMENT ON COLUMN support_tickets.first_response_at IS
|
||||
'Quando a primeira resposta pública (não-interna) ao ticket ocorreu. Setado uma única vez (nunca sobrescrito) pela camada de aplicação ao adicionar o primeiro ticket_comments.is_internal=false — ver apps/api/src/support/support.service.ts e sla_timers.first_response_met.';
|
||||
COMMENT ON COLUMN support_tickets.resolved_at IS
|
||||
'Quando o status se tornou ''resolved'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE, mesmo padrão de contracts.activated_at).';
|
||||
COMMENT ON COLUMN support_tickets.closed_at IS
|
||||
'Quando o status se tornou ''closed'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE, mesmo padrão de contracts.activated_at).';
|
||||
|
||||
CREATE TRIGGER trg_support_tickets_updated_at BEFORE UPDATE ON support_tickets
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE INDEX idx_support_tickets_status ON support_tickets(status);
|
||||
CREATE INDEX idx_support_tickets_reseller ON support_tickets(reseller_id);
|
||||
CREATE INDEX idx_support_tickets_owner ON support_tickets(owner_user_id);
|
||||
CREATE INDEX idx_support_tickets_client_registration ON support_tickets(client_registration_id);
|
||||
CREATE INDEX idx_support_tickets_contract ON support_tickets(contract_id);
|
||||
|
||||
ALTER SEQUENCE support_tickets_protocol_number_seq OWNED BY support_tickets.protocol_number;
|
||||
|
||||
-- =====================================================================
|
||||
-- ticket_comments — public/internal comment thread
|
||||
-- =====================================================================
|
||||
CREATE TABLE ticket_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||
author_user_id UUID NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL,
|
||||
is_internal BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE ticket_comments IS
|
||||
'Thread de comentários do ticket. is_internal=true nunca pode ser exposto ao portal do assinante ou da revenda — reforçado na camada de query/serviço (apps/api/src/support/support.service.ts), não apenas por convenção de UI, mesma disciplina de isolamento cross-tenant do módulo comercial (Master Prompt, eden-support persona).';
|
||||
CREATE INDEX idx_ticket_comments_ticket ON ticket_comments(ticket_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- ticket_status_history — append-only UX-facing lifecycle log,
|
||||
-- distinct from (and complementary to) the generic audit_log
|
||||
-- =====================================================================
|
||||
CREATE TABLE ticket_status_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||
from_status TEXT,
|
||||
to_status TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
changed_by UUID REFERENCES users(id),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE ticket_status_history IS
|
||||
'Log de transições de status do ticket — append-only por convenção da CAMADA DE APLICAÇÃO (nenhuma rota de UPDATE/DELETE é exposta), mesmo padrão e mesma justificativa de coexistência com audit_log de contract_status_history (ver migration 1788447802216_contracts.cjs).';
|
||||
CREATE INDEX idx_ticket_status_history_ticket ON ticket_status_history(ticket_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- sla_timers — 1:1 with support_tickets (ticket_id is both PK and FK)
|
||||
-- =====================================================================
|
||||
CREATE TABLE sla_timers (
|
||||
ticket_id UUID PRIMARY KEY REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
first_response_due_at TIMESTAMPTZ NOT NULL,
|
||||
resolution_due_at TIMESTAMPTZ NOT NULL,
|
||||
first_response_met BOOLEAN,
|
||||
resolution_met BOOLEAN,
|
||||
paused_since TIMESTAMPTZ,
|
||||
accumulated_pause_seconds INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
COMMENT ON TABLE sla_timers IS
|
||||
'Timer de SLA, uma linha por ticket (ticket_id é PK e FK ao mesmo tempo — força o 1:1). Regra de pausa: ao entrar em waiting_customer/waiting_third_party, paused_since = now(); ao voltar para in_progress, accumulated_pause_seconds soma o intervalo pausado e paused_since volta a NULL — implementado atomicamente em packages/database/src/repositories/support-tickets.ts::updateTicketStatus (mesmo padrão SELECT...FOR UPDATE + withTransaction de contracts.ts::updateContractStatus). O cálculo de violação de SLA (subtraindo o tempo pausado, incluindo a pausa corrente se ainda pausado) é feito pelas funções puras computeElapsedSeconds/isSlaBreached em apps/api/src/support/sla.ts — nunca reimplementado ad-hoc. first_response_met/resolution_met só são preenchidos no momento em que o evento correspondente (primeira resposta pública / entrada em resolved) de fato ocorre — permanecem NULL até lá, não são recalculados retroativamente.';
|
||||
COMMENT ON COLUMN sla_timers.paused_since IS
|
||||
'Quando não-nulo, o timer está pausado desde este instante — todo o tempo entre paused_since e now() ainda NÃO está refletido em accumulated_pause_seconds e deve ser somado no cálculo de "tempo decorrido" (ver computeElapsedSeconds em sla.ts).';
|
||||
|
||||
CREATE TRIGGER trg_sla_timers_updated_at BEFORE UPDATE ON sla_timers
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS sla_timers;
|
||||
DROP TABLE IF EXISTS ticket_status_history;
|
||||
DROP TABLE IF EXISTS ticket_comments;
|
||||
DROP TABLE IF EXISTS support_tickets;
|
||||
DROP SEQUENCE IF EXISTS support_tickets_protocol_number_seq;
|
||||
`);
|
||||
};
|
||||
245
packages/database/migrations/1788452136875_hr-timeclock.cjs
Normal file
245
packages/database/migrations/1788452136875_hr-timeclock.cjs
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* HR/Timeclock (Ponto Eletrônico) — eden.md módulo 6, foundation leva.
|
||||
* Master Prompt §6.14. Ported faithfully from the legacy OrçaFácil
|
||||
* (`eden.md` módulo 6 §1 for the data model, §2 for the Control iD
|
||||
* integration shape, §4 for the AFD format) — this is dense legal/
|
||||
* compliance logic already validated in production, not redesigned here.
|
||||
*
|
||||
* SCOPE OF THIS LEVA (deliberately narrow — see the task brief):
|
||||
* - timeclock_devices — Control iD REP iDClass device registry
|
||||
* (data model only; no live sync yet)
|
||||
* - timeclock_employees — separate cadastro from `users` (see note
|
||||
* below on why)
|
||||
* - afd_import_batches — one row per AFD file import (manual upload)
|
||||
* - afd_records — one row per AFD file LINE, IMMUTABLE
|
||||
*
|
||||
* Explicitly DEFERRED to a future leva, not forgotten (eden.md §5-§10):
|
||||
* - timeclock_employee_devices — per-device sync/biometry status link
|
||||
* table (eden.md §1.3); needs the live-sync workflow to be meaningful.
|
||||
* - timeclock_work_schedules / timeclock_work_schedule_days (§1.6-1.7)
|
||||
* - timeclock_holidays (§1.8)
|
||||
* - timeclock_punch_adjustments (§1.9) — administrative treatments
|
||||
* - timeclock_daily_calculations (§1.10) — apuração engine output
|
||||
* - timeclock_time_bank_entries (§1.11) — banco de horas ledger
|
||||
* - timeclock_period_closures (§1.12) — fechamento de período
|
||||
* - time_audit_logs (§1.13) — this leva uses the shared `audit_log`
|
||||
* hash-chain (via auditRepo.appendAuditEvent) instead; the module-
|
||||
* specific structured log is deferred with the workflows that need it
|
||||
* (approvals, reopenings) to render nicely.
|
||||
*
|
||||
* ── employees vs. users (explicit decision, per task brief) ─────────────
|
||||
* eden.md §1.2 is unambiguous: `timeclock_employees` is a cadastro
|
||||
* SEPARATE from `users` (with an optional `user_id` link), because (a) a
|
||||
* punch-clock employee often has no ERP login at all, and (b) the
|
||||
* Control iD device identifies a person BY CPF (eden.md §1.2's "Importante
|
||||
* — identidade no relógio": "no modo 671 da API Control iD, o CPF é o
|
||||
* próprio identificador do usuário no equipamento"), and `users` has no
|
||||
* CPF column. Reusing `users` would mean inventing a CPF column on a
|
||||
* table owned by Identity/Auth, and forcing every punch-clock employee to
|
||||
* have an ERP login they may not need. So: `timeclock_employees` it is,
|
||||
* ported faithfully.
|
||||
*
|
||||
* ── naming deviation from eden.md, per task brief ────────────────────────
|
||||
* The task brief names the import-batch table `afd_import_batches`
|
||||
* (this migration) rather than eden.md's `afd_imports`. Kept as briefed.
|
||||
* Also simplified vs. eden.md's richer version: no S3 storage of the
|
||||
* original file bytes (the file is never persisted outside afd_records'
|
||||
* raw_line reconstruction — see afd-imports.controller.ts (CreateAfdImportDto) for why: this
|
||||
* leva's import endpoint accepts the file as a JSON string, not a
|
||||
* multipart upload, so there's no original file artifact worth archiving
|
||||
* separately from the parsed lines themselves; a real multipart upload +
|
||||
* S3 archival, matching eden.md's `file_s3_key`, is future work), and no
|
||||
* `source` distinction (`manual_upload` only — `live_sync` is deferred
|
||||
* along with the Control iD live-communication layer, eden.md §2-3).
|
||||
*
|
||||
* ── crc_valid / validation_status as TEXT enums, not a plain boolean ────
|
||||
* The task brief's own summary suggested a boolean `crc_valid`, but
|
||||
* eden.md §1.5/§4.2 documents a 4-value enum (VALID/INVALID/
|
||||
* NOT_APPLICABLE/NOT_VALIDATED) because "no CRC field for this record
|
||||
* type" (types 1/5/6/9) and "CRC field present but not checked" are
|
||||
* different, useful facts from a real CRC mismatch (type 7's own hash
|
||||
* chain) — collapsing that into a boolean would lose information the
|
||||
* legacy explicitly modeled. Followed eden.md here per this task's own
|
||||
* instruction to prefer the full read over the summary when they diverge.
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- =====================================================================
|
||||
-- timeclock_devices — Control iD REP iDClass equipment registry
|
||||
-- =====================================================================
|
||||
CREATE TABLE timeclock_devices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
updated_by UUID REFERENCES users(id),
|
||||
company_id UUID,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
branch TEXT,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443 CHECK (port > 0 AND port <= 65535),
|
||||
use_https BOOLEAN NOT NULL DEFAULT true,
|
||||
username TEXT NOT NULL,
|
||||
password_encrypted TEXT NOT NULL,
|
||||
serial_number TEXT,
|
||||
model TEXT,
|
||||
firmware_version TEXT,
|
||||
timezone TEXT NOT NULL DEFAULT 'America/Sao_Paulo',
|
||||
status TEXT NOT NULL DEFAULT 'UNKNOWN' CHECK (status IN ('UNKNOWN', 'ONLINE', 'OFFLINE', 'ERROR')),
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
last_sync_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
last_afd_nsr BIGINT,
|
||||
active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
COMMENT ON TABLE timeclock_devices IS
|
||||
'Relógios de ponto Control iD REP iDClass — eden.md módulo 6 §1.1/§2. company_id nullable = equipamento "sem empresa" (compartilhado). DELIBERADAMENTE sem FK para companies: essa tabela (Organization bounded context) não existe neste worktree isolado no momento em que esta migration foi escrita — mesma situação de contracts.cjs (contracts.quote_id/client_registration_id) neste monorepo. A FK é trabalho de integração/merge, não desta migration.';
|
||||
COMMENT ON COLUMN timeclock_devices.password_encrypted IS
|
||||
'AES-256-GCM reversível via FieldEncryptionService (apps/api/src/crypto/field-encryption.service.ts, formato v1:iv:tag:ciphertext) — NUNCA hash, porque a aplicação precisa autenticar no equipamento sob demanda a cada operação (eden.md §2.2/§2.3). NUNCA retornada em texto puro por nenhuma rota GET — a API expõe apenas um flag hasCredential.';
|
||||
COMMENT ON COLUMN timeclock_devices.timezone IS
|
||||
'Metadado informativo do cadastro do equipamento. A interpretação de negócio das datas/horas do AFD SEMPRE usa o offset literal embutido no próprio campo de data/hora do arquivo (fixo -03:00 na prática, América/São_Paulo sem horário de verão desde 2019) — esta coluna não é consultada pelo parser (afd-parser.ts), que nunca assume o timezone do servidor nem de nenhuma configuração.';
|
||||
COMMENT ON COLUMN timeclock_devices.last_afd_nsr IS
|
||||
'Cursor de sincronização incremental do AFD (eden.md §3) — reservado para a leva de sync ao vivo; não é escrito por esta leva (só import manual de arquivo completo).';
|
||||
|
||||
CREATE TRIGGER trg_timeclock_devices_updated_at BEFORE UPDATE ON timeclock_devices
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
CREATE INDEX idx_timeclock_devices_company ON timeclock_devices(company_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- timeclock_employees — separate from 'users' (see migration header)
|
||||
-- =====================================================================
|
||||
CREATE TABLE timeclock_employees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
updated_by UUID REFERENCES users(id),
|
||||
company_id UUID,
|
||||
user_id UUID REFERENCES users(id),
|
||||
full_name TEXT NOT NULL,
|
||||
cpf TEXT NOT NULL,
|
||||
registration TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
UNIQUE (company_id, cpf)
|
||||
);
|
||||
COMMENT ON TABLE timeclock_employees IS
|
||||
'Cadastro de funcionários do módulo de ponto, SEPARADO de users (eden.md §1.2) — vínculo opcional via user_id. CPF (11 dígitos, sem máscara) é o identificador do usuário no equipamento Control iD no modo 671 — não existe um "controlid_user_id" separado. CPF único por empresa (company_id, cpf), não globalmente. work_schedule_id (jornada vinculada) é DEFERRADO para a leva de apuração — não existe ainda tabela timeclock_work_schedules.';
|
||||
COMMENT ON COLUMN timeclock_employees.cpf IS
|
||||
'11 dígitos, sem máscara. Validação de dígito verificador é responsabilidade da camada de aplicação (mesma convenção de contract_parties.document/companies.cnpj neste monorepo) — sem CHECK de formato no banco nesta leva (nenhuma rota de CRUD de employees foi implementada ainda; a coluna existe para afd_records.employee_id ter o que referenciar e para a resolução de CPF do parser).';
|
||||
|
||||
CREATE TRIGGER trg_timeclock_employees_updated_at BEFORE UPDATE ON timeclock_employees
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
CREATE INDEX idx_timeclock_employees_company ON timeclock_employees(company_id);
|
||||
CREATE INDEX idx_timeclock_employees_user ON timeclock_employees(user_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- afd_import_batches — one row per AFD file import (manual upload only
|
||||
-- this leva; live_sync source is deferred with the Control iD live
|
||||
-- communication layer, eden.md §2-3)
|
||||
-- =====================================================================
|
||||
CREATE TABLE afd_import_batches (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
device_id UUID NOT NULL REFERENCES timeclock_devices(id),
|
||||
company_id UUID,
|
||||
source TEXT NOT NULL DEFAULT 'manual_upload' CHECK (source = 'manual_upload'),
|
||||
file_hash TEXT NOT NULL UNIQUE,
|
||||
file_size INTEGER NOT NULL,
|
||||
record_count INTEGER NOT NULL DEFAULT 0,
|
||||
inserted_count INTEGER NOT NULL DEFAULT 0,
|
||||
valid_count INTEGER NOT NULL DEFAULT 0,
|
||||
warning_count INTEGER NOT NULL DEFAULT 0,
|
||||
invalid_count INTEGER NOT NULL DEFAULT 0,
|
||||
unsupported_count INTEGER NOT NULL DEFAULT 0,
|
||||
parse_failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK (status IN ('VALID', 'WARNING', 'INVALID')),
|
||||
first_nsr BIGINT,
|
||||
last_nsr BIGINT,
|
||||
summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
imported_by UUID REFERENCES users(id),
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE afd_import_batches IS
|
||||
'Metadados de cada importação de arquivo AFD (eden.md §1.4, nomeada afd_import_batches por instrução desta leva em vez de afd_imports do legado). file_hash é a idempotência de arquivo inteiro (SHA-256) — UNIQUE garante que reimportar o mesmo conteúdo retorna already_imported em vez de duplicar. Simplificação desta leva: nenhum S3/armazenamento do arquivo original bruto (o transporte é um JSON string, não multipart — ver afd-imports.controller.ts (CreateAfdImportDto)); o dado legal é preservado integralmente em afd_records.raw_line, uma linha por registro.';
|
||||
COMMENT ON COLUMN afd_import_batches.file_hash IS
|
||||
'SHA-256 do CONTEÚDO recebido pela API (a string JSON do body, decodificada como texto) — não necessariamente idêntico byte-a-byte ao SHA-256 de um arquivo .afd bruto em disco, já que o transporte desta leva é string JSON, não upload multipart de bytes crus. Suficiente para o propósito de idempotência desta leva; ver nota de simplificação no cabeçalho da migration.';
|
||||
COMMENT ON COLUMN afd_import_batches.record_count IS
|
||||
'Total de linhas parseadas do arquivo (inclui INVALID/UNSUPPORTED_LAYOUT — nunca descartadas). inserted_count pode ser menor que record_count quando ON CONFLICT (device_id, nsr) DO NOTHING pulou registros já existentes de uma importação anterior (idempotência por registro, eden.md §1.5).';
|
||||
|
||||
CREATE INDEX idx_afd_import_batches_device ON afd_import_batches(device_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- afd_records — one row per AFD file LINE. IMMUTABLE: no application
|
||||
-- route may ever UPDATE/DELETE this table, at any privilege level,
|
||||
-- not even super_admin (eden.md módulo 6 §12) — enforced here again at
|
||||
-- the DB level via trigger, same pattern as audit_log's
|
||||
-- prevent_audit_log_mutation, but WITHOUT that trigger's escape-hatch
|
||||
-- GUC (audit_log allows a controlled internal bypass; afd_records
|
||||
-- deliberately does not, per this leva's explicit non-negotiable
|
||||
-- instruction: "em nenhum nível de privilégio, nem para super_admin").
|
||||
-- =====================================================================
|
||||
CREATE TABLE afd_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
afd_import_batch_id UUID NOT NULL REFERENCES afd_import_batches(id),
|
||||
device_id UUID NOT NULL REFERENCES timeclock_devices(id),
|
||||
company_id UUID,
|
||||
nsr BIGINT,
|
||||
record_type TEXT NOT NULL,
|
||||
event_datetime TIMESTAMPTZ,
|
||||
event_datetime_raw TEXT,
|
||||
employee_cpf_raw TEXT,
|
||||
employee_id UUID REFERENCES timeclock_employees(id),
|
||||
raw_line TEXT NOT NULL,
|
||||
crc_received TEXT,
|
||||
crc_calculated TEXT,
|
||||
crc_valid TEXT NOT NULL DEFAULT 'NOT_APPLICABLE' CHECK (crc_valid IN ('VALID', 'INVALID', 'NOT_APPLICABLE', 'NOT_VALIDATED')),
|
||||
parsed_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
validation_status TEXT NOT NULL CHECK (validation_status IN ('VALID', 'WARNING', 'INVALID', 'UNSUPPORTED_LAYOUT')),
|
||||
validation_notes TEXT,
|
||||
UNIQUE (device_id, nsr)
|
||||
);
|
||||
COMMENT ON TABLE afd_records IS
|
||||
'Dado bruto de ponto, eden.md módulo 6 §1.5/§12 — IMUTÁVEL por design. Nenhuma rota de UPDATE/DELETE existe na camada de aplicação (apps/api/src/hr-timeclock/afd-records.controller.ts expõe apenas GET) e o banco reforça isso via trigger (trg_afd_records_no_update/no_delete abaixo) SEM nenhuma válvula de escape — nem para super_admin, diferente de audit_log. Preservado mesmo quando inválido: nsr é nullable de propósito (NSR ausente/não-numérico nunca vira 0/autoincrement), e todo registro tem raw_line completa, mesmo os classificados INVALID/UNSUPPORTED_LAYOUT.';
|
||||
COMMENT ON COLUMN afd_records.nsr IS
|
||||
'Número Sequencial de Registro. NULLABLE DE PROPÓSITO — NSR ausente/não-numérico é inválido mas precisa ser preservado, nunca substituído por 0/autoincrement (eden.md §12). UNIQUE (device_id, nsr): múltiplas linhas com nsr NULL do mesmo device são permitidas (semântica padrão de NULL em índice único do Postgres) — a idempotência por registro só se aplica a NSRs realmente numéricos.';
|
||||
COMMENT ON COLUMN afd_records.crc_valid IS
|
||||
'VALID/INVALID = CRC-16/KERMIT verificado (tipos 2/3/4). NOT_APPLICABLE = tipo 7, que usa cadeia de hash própria NUNCA recalculada pelo EDEN. NOT_VALIDATED = tipos 1/5/6/9, sem campo de CRC. Enum de 4 valores, não boolean — ver nota no cabeçalho da migration sobre por que.';
|
||||
COMMENT ON COLUMN afd_records.parsed_data IS
|
||||
'Campos decodificados específicos do tipo de registro, com posição EXATA documentada em eden.md §4.1 — qualquer trecho sem posição documentada vai para a chave "raw_tail" dentro deste JSONB, nunca é inventado/adivinhado (ver apps/api/src/hr-timeclock/afd-parser.ts).';
|
||||
COMMENT ON COLUMN afd_records.employee_cpf_raw IS
|
||||
'CPF exatamente como veio no campo do AFD (12 caracteres de largura documentada para tipos 3/7, 11 para o cpf_resp do tipo 4) — sempre string, nunca number, nunca perde zero à esquerda. employee_id é resolvido a partir daqui via normalizeAfdCpf (últimos 11 dígitos numéricos), escopado por company_id quando o device tem uma; NULL quando órfão (sem match) — nunca um match é inventado/forçado.';
|
||||
|
||||
CREATE INDEX idx_afd_records_employee ON afd_records(employee_id);
|
||||
CREATE INDEX idx_afd_records_company ON afd_records(company_id);
|
||||
CREATE INDEX idx_afd_records_type ON afd_records(record_type);
|
||||
CREATE INDEX idx_afd_records_event_datetime ON afd_records(event_datetime);
|
||||
CREATE INDEX idx_afd_records_batch ON afd_records(afd_import_batch_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION prevent_afd_records_mutation() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'afd_records é imutável (dado bruto de ponto, eden.md módulo 6 §12) — UPDATE/DELETE bloqueados, sem exceção, mesmo para super_admin';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_afd_records_no_update BEFORE UPDATE ON afd_records
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_afd_records_mutation();
|
||||
CREATE TRIGGER trg_afd_records_no_delete BEFORE DELETE ON afd_records
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_afd_records_mutation();
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TRIGGER IF EXISTS trg_afd_records_no_delete ON afd_records;
|
||||
DROP TRIGGER IF EXISTS trg_afd_records_no_update ON afd_records;
|
||||
DROP FUNCTION IF EXISTS prevent_afd_records_mutation();
|
||||
DROP TABLE IF EXISTS afd_records;
|
||||
DROP TABLE IF EXISTS afd_import_batches;
|
||||
DROP TABLE IF EXISTS timeclock_employees;
|
||||
DROP TABLE IF EXISTS timeclock_devices;
|
||||
`);
|
||||
};
|
||||
231
packages/database/migrations/1788457368744_fiscal.cjs
Normal file
231
packages/database/migrations/1788457368744_fiscal.cjs
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Fiscal bounded context (Master Prompt §6.11, ADR-0011, eden.md módulo 5
|
||||
* "Módulo Fiscal (NCM, CFOP, Municípios)"). The legacy OrçaFácil has a
|
||||
* mature ~19-table catalog layer + automatic (Siscomex/IBGE fetch) and
|
||||
* manual (CSV/XLSX/JSON upload) importers + a sync log (`fiscal_catalog_sync`)
|
||||
* + `product_fiscal_profiles` linking `products` to fiscal classification.
|
||||
*
|
||||
* This MVP builds a coherent, faithfully-shaped SUBSET, end-to-end:
|
||||
* - fiscal_ncm (this migration) — "simple catalog" shape, eden.md §2.2
|
||||
* - fiscal_cfop (this migration) — simple catalog + direction/scope, eden.md §2.3/§4
|
||||
* - fiscal_municipality (this migration) — distinct shape, eden.md §2.3
|
||||
* - fiscal_operation_nature (this migration) — internal enum-like catalog, eden.md §2.3, seeded
|
||||
* - product_fiscal_profiles (this migration) — eden.md §2.5, reduced to the
|
||||
* 4 catalogs actually created here (ncm/cfop/municipality/operation_nature)
|
||||
*
|
||||
* Explicitly DEFERRED, not forgotten:
|
||||
* - The other ~9 "simple catalog" tables (eden.md §2.2): fiscal_product_origin,
|
||||
* fiscal_icms_cst, fiscal_icms_csosn, fiscal_pis_cst, fiscal_cofins_cst,
|
||||
* fiscal_ipi_cst, fiscal_lc116_service, fiscal_nbs, fiscal_ibs_cbs_cst,
|
||||
* fiscal_ibs_cbs_credit_class — same shape as fiscal_ncm/fiscal_cfop, can
|
||||
* be added later without a redesign.
|
||||
* - The "extended catalog" tables (eden.md §2.3): fiscal_cest, fiscal_cbenef,
|
||||
* fiscal_nfcom_cclass, fiscal_nfse_trib_nacional, fiscal_nfse_trib_municipal,
|
||||
* fiscal_ibs_cbs_classification, fiscal_ibs_cbs_standard_rate.
|
||||
* - `fiscal_catalog_sync` (append-only sync log, eden.md §2.4) and the
|
||||
* automatic importers (NCM/Siscomex, Municipality/IBGE — eden.md §3/§5)
|
||||
* + the generic manual file importer with dry-run and the mass-
|
||||
* inactivation safety threshold (eden.md §7) — substantial, isolated
|
||||
* work (network fetch adapters, checksum/threshold logic, file parsing),
|
||||
* deferred to its own leva.
|
||||
* - `fiscal_rules` (eden.md §2.6) — schema-ready-but-unconsumed even in the
|
||||
* legacy itself ("estrutura pronta, sem UI/consumo ainda"); not recreated
|
||||
* here, stays future work until the fiscal resolution engine is built.
|
||||
* - `products.ixc_product_code` / `products.product_code` (eden.md §6) —
|
||||
* belong to the Products migration, not this one.
|
||||
* - product_fiscal_profiles columns for catalogs not yet created here:
|
||||
* nfcom_cclass_id, nfse_trib_nacional_id, nfse_trib_municipal_id,
|
||||
* lc116_service_id, nbs_id, cest_id, product_origin_id, ibs_cbs_cst_id,
|
||||
* ibs_cbs_classification_id, ibs_cbs_credit_class_id (eden.md §2.5) —
|
||||
* added when their respective catalogs land.
|
||||
*
|
||||
* `product_fiscal_profiles.product_id` is deliberately nullable-FK-free:
|
||||
* NO `REFERENCES products(id)`, same cross-module "FK cruzada adiada"
|
||||
* pattern used by `contracts.quote_id`/`contracts.client_registration_id`
|
||||
* (see 1788447802216_contracts.cjs header) and
|
||||
* `support_tickets.client_registration_id`/`.contract_id`
|
||||
* (see 1788451374900_support.cjs header) — this worktree may not always
|
||||
* carry every other module's migration in the same merge order; wiring the
|
||||
* FK is integration-step work at merge time, not this migration's.
|
||||
*
|
||||
* Naming convention: all fiscal tables use `created_at`/`updated_at`
|
||||
* (not `created_date`/`updated_date`) — this is already EDEN's own
|
||||
* repo-wide convention (see every other migration in this directory), so
|
||||
* there is no deviation to reconcile here. eden.md §2.1 calls this "a
|
||||
* deliberate deviation" only relative to the *legacy* OrçaFácil schema,
|
||||
* not relative to EDEN.
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- =====================================================================
|
||||
-- fiscal_ncm — Nomenclatura Comum do Mercosul. "Simple catalog" shape,
|
||||
-- eden.md §2.2. Source: Receita Federal / Siscomex (import deferred).
|
||||
-- =====================================================================
|
||||
CREATE TABLE fiscal_ncm (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
valid_from DATE,
|
||||
valid_until DATE,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
source TEXT,
|
||||
source_version TEXT,
|
||||
source_updated_at TIMESTAMPTZ,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE fiscal_ncm IS
|
||||
'Nomenclatura Comum do Mercosul (eden.md §2.2, §3). "Catálogo simples" — mesmo shape reutilizável para as ~9 outras tabelas de catálogo simples ainda não portadas (fiscal_product_origin, ICMS CST/CSOSN, PIS/COFINS/IPI CST, LC116, NBS, IBS-CBS CST/credit_class). Importação automática (Siscomex, checksum, trava de volume mínimo, upsert com inactivateMissing=true) é trabalho DEFERIDO — ver cabeçalho da migration.';
|
||||
|
||||
CREATE UNIQUE INDEX idx_fiscal_ncm_code ON fiscal_ncm(code);
|
||||
CREATE INDEX idx_fiscal_ncm_active ON fiscal_ncm(active);
|
||||
|
||||
CREATE TRIGGER trg_fiscal_ncm_updated_at BEFORE UPDATE ON fiscal_ncm
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- =====================================================================
|
||||
-- fiscal_cfop — Código Fiscal de Operações e Prestações. "Simple
|
||||
-- catalog" shape + direction/scope (eden.md §2.3, §4). Fonte MANUAL
|
||||
-- (sem endpoint estruturado confiável testado no legado).
|
||||
-- =====================================================================
|
||||
CREATE TABLE fiscal_cfop (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
direction TEXT,
|
||||
scope TEXT,
|
||||
valid_from DATE,
|
||||
valid_until DATE,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
source TEXT,
|
||||
source_version TEXT,
|
||||
source_updated_at TIMESTAMPTZ,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE fiscal_cfop IS
|
||||
'Código Fiscal de Operações e Prestações (eden.md §2.3, §4). "Catálogo simples" + direction/scope. Fonte declarada "Portal Nacional NF-e" mas tipo MANUAL no legado (sem endpoint estruturado confiável testado) — cadastro por upload de arquivo ou tela, ambos DEFERIDOS nesta leva (ver cabeçalho da migration).';
|
||||
COMMENT ON COLUMN fiscal_cfop.direction IS
|
||||
'Direção da operação (entrada/saída) — TEXT livre, sem CHECK/enum, exatamente como o legado (eden.md §4). O 1º dígito do código CFOP codifica direção+âmbito na prática brasileira, mas isso NUNCA é derivado automaticamente do código no legado nem aqui — direction/scope são só campos preenchidos manualmente/via importação.';
|
||||
COMMENT ON COLUMN fiscal_cfop.scope IS
|
||||
'Âmbito da operação (estadual/interestadual/exterior) — TEXT livre, sem CHECK/enum, mesma razão de direction (eden.md §4).';
|
||||
|
||||
CREATE UNIQUE INDEX idx_fiscal_cfop_code ON fiscal_cfop(code);
|
||||
CREATE INDEX idx_fiscal_cfop_active ON fiscal_cfop(active);
|
||||
|
||||
CREATE TRIGGER trg_fiscal_cfop_updated_at BEFORE UPDATE ON fiscal_cfop
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- =====================================================================
|
||||
-- fiscal_municipality — shape DIFERENTE do catálogo simples: sem
|
||||
-- valid_from/valid_until/metadata ("não faz sentido um município
|
||||
-- expirar", eden.md §2.3). Fonte: IBGE (import deferido).
|
||||
-- =====================================================================
|
||||
CREATE TABLE fiscal_municipality (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
source TEXT,
|
||||
source_version TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE fiscal_municipality IS
|
||||
'Municípios/IBGE (eden.md §2.3, §5) — base para ISS/NFS-e. Shape deliberadamente distinto do catálogo simples: sem valid_from/valid_until/metadata ("não faz sentido um município expirar"). code = código IBGE, state = UF. Importação automática (IBGE, checksum, trava de volume mínimo de 5000 linhas) é DEFERIDA — ver cabeçalho da migration.';
|
||||
|
||||
CREATE UNIQUE INDEX idx_fiscal_municipality_code ON fiscal_municipality(code);
|
||||
CREATE INDEX idx_fiscal_municipality_state ON fiscal_municipality(state);
|
||||
|
||||
CREATE TRIGGER trg_fiscal_municipality_updated_at BEFORE UPDATE ON fiscal_municipality
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- =====================================================================
|
||||
-- fiscal_operation_nature — cadastro interno, sem fonte externa
|
||||
-- (eden.md §2.3). Seed fixo via ON CONFLICT DO NOTHING.
|
||||
-- =====================================================================
|
||||
CREATE TABLE fiscal_operation_nature (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE fiscal_operation_nature IS
|
||||
'Naturezas de operação (eden.md §2.3) — cadastro interno de negócio, não uma fonte externa sincronizável. Seed fixo abaixo, único endpoint da API para este catálogo é leitura (GET /v1/fiscal/operation-natures) — sem criação via API nesta leva.';
|
||||
|
||||
CREATE TRIGGER trg_fiscal_operation_nature_updated_at BEFORE UPDATE ON fiscal_operation_nature
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
INSERT INTO fiscal_operation_nature (code, name) VALUES
|
||||
('VENDA', 'Venda'),
|
||||
('REMESSA_LOCACAO', 'Remessa em locação'),
|
||||
('RETORNO_LOCACAO', 'Retorno de locação'),
|
||||
('REMESSA_MANUTENCAO', 'Remessa para manutenção'),
|
||||
('RETORNO_MANUTENCAO', 'Retorno de manutenção'),
|
||||
('STFC_RECORRENTE', 'STFC recorrente'),
|
||||
('SVA_RECORRENTE', 'SVA recorrente'),
|
||||
('PABX_RECORRENTE', 'PABX recorrente'),
|
||||
('PABX_IMPLANTACAO', 'PABX implantação'),
|
||||
('SOFTWARE_RECORRENTE', 'Software recorrente'),
|
||||
('SOFTWARE_IMPLANTACAO', 'Software implantação')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- =====================================================================
|
||||
-- product_fiscal_profiles — liga products à classificação fiscal
|
||||
-- (eden.md §2.5). Reduzida aos 4 catálogos criados nesta leva.
|
||||
-- =====================================================================
|
||||
CREATE TABLE product_fiscal_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
product_id UUID NOT NULL,
|
||||
billing_component TEXT NOT NULL CHECK (billing_component IN ('RECURRING', 'IMPLEMENTATION')),
|
||||
document_type TEXT NOT NULL DEFAULT 'NONE' CHECK (document_type IN ('NFCOM', 'NFSE', 'NFE', 'NONE')),
|
||||
ncm_id UUID REFERENCES fiscal_ncm(id),
|
||||
cfop_id UUID REFERENCES fiscal_cfop(id),
|
||||
municipality_id UUID REFERENCES fiscal_municipality(id),
|
||||
operation_nature_id UUID REFERENCES fiscal_operation_nature(id),
|
||||
valid_from DATE,
|
||||
valid_until DATE,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
updated_by UUID REFERENCES users(id)
|
||||
);
|
||||
COMMENT ON TABLE product_fiscal_profiles IS
|
||||
'Configuração fiscal de produto (eden.md §2.5) — deliberadamente separada de products, nunca duplica campos comerciais (price_*, impl_unit_price, metered, tariff_rates, has_ldi continuam só em products). product_id SEM FK (products pode não existir neste worktree isolado — ver cabeçalho da migration, padrão "FK cruzada adiada" de contracts.cjs/support.cjs). Colunas de referência a catálogos ainda não portados (nfcom_cclass_id, nfse_trib_nacional_id, nfse_trib_municipal_id, lc116_service_id, nbs_id, cest_id, product_origin_id, ibs_cbs_cst_id, ibs_cbs_classification_id, ibs_cbs_credit_class_id) ficam para quando os catálogos correspondentes existirem. Sem backfill automático nesta leva (eden.md §2.5 backfill dependia de products.service_type/impl_unit_price já populados em produção real) — profiles são criados sob demanda via API.';
|
||||
COMMENT ON COLUMN product_fiscal_profiles.product_id IS
|
||||
'Sem REFERENCES products(id) nesta leva — products pode não existir neste worktree isolado. FK cruzada é trabalho de integração no merge (mesmo padrão de contracts.quote_id/client_registration_id e support_tickets.client_registration_id/contract_id).';
|
||||
|
||||
-- Invariante central (eden.md §2.5): um produto tem no máximo um
|
||||
-- profile ATIVO por billing_component. Nunca DELETE/UPDATE destrutivo
|
||||
-- de um profile antigo para "trocar" a classificação — cria-se um novo
|
||||
-- profile ativo e inativa-se o anterior (active=false), preservando
|
||||
-- histórico.
|
||||
CREATE UNIQUE INDEX idx_product_fiscal_profiles_active_component
|
||||
ON product_fiscal_profiles(product_id, billing_component) WHERE active = true;
|
||||
|
||||
CREATE INDEX idx_product_fiscal_profiles_product ON product_fiscal_profiles(product_id);
|
||||
|
||||
CREATE TRIGGER trg_product_fiscal_profiles_updated_at BEFORE UPDATE ON product_fiscal_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS product_fiscal_profiles;
|
||||
DROP TABLE IF EXISTS fiscal_operation_nature;
|
||||
DROP TABLE IF EXISTS fiscal_municipality;
|
||||
DROP TABLE IF EXISTS fiscal_cfop;
|
||||
DROP TABLE IF EXISTS fiscal_ncm;
|
||||
`);
|
||||
};
|
||||
185
packages/database/migrations/1788457629203_billing.cjs
Normal file
185
packages/database/migrations/1788457629203_billing.cjs
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Billing bounded context (recurring billing engine) — Master Prompt
|
||||
* §6.10, docs/adr/0008-billing-finance-fiscal-separation.md. ADR-0008 is
|
||||
* explicit: Billing (this module) calculates *what is owed* and produces
|
||||
* an internal invoice; it is strictly separate from Finance/AR-AP
|
||||
* (collecting/paying — títulos, boletos, dunning, conciliação) and from
|
||||
* Fiscal (documento fiscal). Neither of those is built here.
|
||||
*
|
||||
* ADR-0008 lists nine Billing tables (billing_accounts, billing_cycles,
|
||||
* subscriptions/services, charge_components, usage_charges, invoices,
|
||||
* invoice_items, invoice_adjustments, billing_runs). This migration
|
||||
* builds the coherent MVP subset needed for a working, testable
|
||||
* subscription -> billing_run -> invoice flow:
|
||||
*
|
||||
* - billing_subscriptions (this migration)
|
||||
* - billing_runs (this migration)
|
||||
* - invoices (this migration)
|
||||
* - invoice_items (this migration)
|
||||
*
|
||||
* Explicitly DEFERRED, not forgotten:
|
||||
* - billing_accounts — this MVP hangs subscriptions/invoices directly
|
||||
* off `reseller_id`; a billing_account (grouping multiple subscriptions
|
||||
* under one consolidated bill) is deferred until there's a real need
|
||||
* to bill more than one subscription together.
|
||||
* - billing_cycles as its own table — this MVP derives the cycle from
|
||||
* the (billing_day, reference_year, reference_month) triple on the
|
||||
* run/subscription rather than a calendar table of cycle instances.
|
||||
* - charge_components / usage_charges — consumption/telephony billing;
|
||||
* needs the usage-rating module (out of scope), so every subscription
|
||||
* in this MVP bills a single flat `monthly_value` with no metered
|
||||
* component.
|
||||
* - invoice_adjustments / credit_debit_adjustments — the sanctioned way
|
||||
* to correct a *consolidated* invoice (never a silent edit); deferred,
|
||||
* so today a consolidated billing_run's invoices are simply immutable
|
||||
* from the application layer (see billing.ts's assertRunIsMutable).
|
||||
* - billing_run_logs — this MVP uses the shared `audit_log`
|
||||
* (auditRepo.appendAuditEvent) for every billing_run/invoice mutation
|
||||
* instead of a dedicated structured log table.
|
||||
* - proration — every subscription always bills the full monthly_value;
|
||||
* no day-proportional calculation for a subscription that started or
|
||||
* was cancelled mid-cycle. `invoice_items.item_type` already reserves
|
||||
* the 'proration' vocabulary value for when this lands, so it won't
|
||||
* need a migration of its own.
|
||||
*
|
||||
* Deferred cross-context foreign keys (no REFERENCES, plain UUID, same
|
||||
* pattern used by Quotes/Contracts/ClientRegistrations for tables owned by
|
||||
* a module built in a different worktree — see contracts' migration
|
||||
* header for precedent):
|
||||
* - billing_subscriptions.contract_id — `contracts` (Commercial/§6.6) is
|
||||
* not guaranteed to exist in every worktree this batch might land on.
|
||||
* - billing_subscriptions.reseller_id / invoices.reseller_id —
|
||||
* `resellers` is not present in THIS worktree's migration set either
|
||||
* (bootstrap-only checkout). Both FKs are integration work for
|
||||
* whoever merges this alongside the resellers/contracts migrations.
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- =====================================================================
|
||||
-- billing_subscriptions — a recurring billing subscription derived
|
||||
-- from an active contract
|
||||
-- =====================================================================
|
||||
CREATE TABLE billing_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
updated_by UUID REFERENCES users(id),
|
||||
contract_id UUID NOT NULL,
|
||||
reseller_id UUID,
|
||||
monthly_value NUMERIC(14,2) NOT NULL CHECK (monthly_value >= 0),
|
||||
billing_day INTEGER NOT NULL CHECK (billing_day BETWEEN 1 AND 28),
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'suspended', 'cancelled')),
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cancelled_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE billing_subscriptions IS
|
||||
'Assinatura de cobrança recorrente derivada de um contrato ativo (Master Prompt §6.10). contract_id e reseller_id são UUID simples sem REFERENCES — mesmo padrão de FK cruzada adiada de Quotes/Contracts/ClientRegistrations: contracts/resellers não existem garantidamente neste worktree (checkout de bootstrap). A FK real é trabalho de integração central, não desta migration.';
|
||||
COMMENT ON COLUMN billing_subscriptions.monthly_value IS
|
||||
'Snapshot do valor mensal no momento da criação da subscription — NUNCA recalculado automaticamente se o contrato de origem mudar depois (isso seria uma reversão silenciosa de fatura, proibida pelo Master Prompt §24 invariante nº4). Uma mudança de valor exige cancelar e recriar a subscription (ou, quando existir, um endpoint explícito de reajuste) — nesta leva não há reajuste, só criação/cancelamento.';
|
||||
COMMENT ON COLUMN billing_subscriptions.billing_day IS
|
||||
'Dia do mês do ciclo de cobrança, limitado a 1-28 deliberadamente para nunca cair em um mês sem esse dia (evita a ambiguidade de dia 29-31 em fevereiro/meses de 30 dias) — não há regra de "último dia do mês" nesta leva.';
|
||||
COMMENT ON COLUMN billing_subscriptions.cancelled_at IS
|
||||
'Setado uma única vez, quando status transiciona para cancelled (camada de repositório) — nunca sobrescrito depois.';
|
||||
CREATE INDEX idx_billing_subscriptions_reseller ON billing_subscriptions(reseller_id);
|
||||
CREATE INDEX idx_billing_subscriptions_status_day ON billing_subscriptions(status, billing_day);
|
||||
CREATE INDEX idx_billing_subscriptions_contract ON billing_subscriptions(contract_id);
|
||||
|
||||
CREATE TRIGGER trg_billing_subscriptions_updated_at BEFORE UPDATE ON billing_subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- =====================================================================
|
||||
-- billing_runs — one billing-close execution for a
|
||||
-- (reference_year, reference_month, billing_day) period
|
||||
-- =====================================================================
|
||||
CREATE TABLE billing_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reference_year INTEGER NOT NULL CHECK (reference_year BETWEEN 2000 AND 2100),
|
||||
reference_month INTEGER NOT NULL CHECK (reference_month BETWEEN 1 AND 12),
|
||||
billing_day INTEGER NOT NULL CHECK (billing_day BETWEEN 1 AND 28),
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'consolidated', 'failed')),
|
||||
consolidated_at TIMESTAMPTZ,
|
||||
triggered_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE billing_runs IS
|
||||
'Execução de fechamento de billing para um período + billing_day (Master Prompt §6.10). draft é reexecutável: rodar de novo com os mesmos (reference_year, reference_month, billing_day) sobre um run ainda draft SUBSTITUI (delete+reinsert, mesma transação) as invoices geradas em vez de duplicar — implementado em packages/database/src/repositories/billing.ts, generateOrRegenerateBillingRun(). consolidated é terminal e imutável pela aplicação (ver invoices.status e assertRunIsMutable). failed é reservado para quando esta leva ganhar um passo que possa falhar de forma parcial (ex. rating de consumo) — nada nesta leva efetivamente produz failed.';
|
||||
COMMENT ON COLUMN billing_runs.triggered_by IS
|
||||
'Ator (usuário) que disparou a criação/regeneração deste run — atualizado a cada regeneração em draft para refletir quem rodou por último.';
|
||||
CREATE INDEX idx_billing_runs_period ON billing_runs(reference_year, reference_month, billing_day);
|
||||
|
||||
-- Idempotência: no máximo um run 'draft' por (ano, mês, billing_day) —
|
||||
-- índice único parcial em vez de UNIQUE(reference_year, reference_month,
|
||||
-- billing_day, status) puro, porque este último permitiria dois runs
|
||||
-- 'consolidated' distintos para o mesmo período sem impedir a corrida
|
||||
-- de dois 'draft' concorrentes ao mesmo tempo (o problema real que essa
|
||||
-- invariante precisa resolver). A regra "não criar novo run para um
|
||||
-- período já consolidado" (refaturamento controlado é deferido) é
|
||||
-- reforçada na camada de aplicação, não aqui, porque múltiplos runs
|
||||
-- 'consolidated' históricos para o mesmo período não são logicamente
|
||||
-- impossíveis no schema (só indesejados pelo fluxo atual).
|
||||
CREATE UNIQUE INDEX uq_billing_runs_one_draft_per_period
|
||||
ON billing_runs(reference_year, reference_month, billing_day)
|
||||
WHERE status = 'draft';
|
||||
|
||||
-- =====================================================================
|
||||
-- invoices — internal invoice generated by a billing_run for one
|
||||
-- billing_subscription
|
||||
-- =====================================================================
|
||||
CREATE TABLE invoices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
billing_run_id UUID NOT NULL REFERENCES billing_runs(id) ON DELETE CASCADE,
|
||||
billing_subscription_id UUID NOT NULL REFERENCES billing_subscriptions(id),
|
||||
reseller_id UUID,
|
||||
amount NUMERIC(14,2) NOT NULL CHECK (amount >= 0),
|
||||
reference_year INTEGER NOT NULL,
|
||||
reference_month INTEGER NOT NULL CHECK (reference_month BETWEEN 1 AND 12),
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'consolidated', 'cancelled')),
|
||||
due_date DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (billing_run_id, billing_subscription_id)
|
||||
);
|
||||
COMMENT ON TABLE invoices IS
|
||||
'Fatura interna (Billing) gerada por um billing_run — DISTINTA de um título de AR/AP (Finance) e de um documento fiscal (Fiscal), ADR-0008. status espelha o status do run no momento da geração (draft -> pode ser apagada/regerada enquanto o run ainda é draft; consolidated -> imutável pela aplicação, ver invariante em billing.ts). UNIQUE(billing_run_id, billing_subscription_id) é uma segunda barreira de idempotência a nível de banco: o mesmo run nunca pode gerar duas invoices para a mesma subscription.';
|
||||
COMMENT ON COLUMN invoices.reseller_id IS
|
||||
'Denormalizado a partir de billing_subscriptions.reseller_id no momento da geração — evita join obrigatório para filtrar por revenda em listagens/relatórios; nunca é a fonte da verdade (a subscription é). Sem REFERENCES pelo mesmo motivo de billing_subscriptions.reseller_id (ver cabeçalho da migration).';
|
||||
COMMENT ON COLUMN invoices.due_date IS
|
||||
'Nesta leva, due_date = dia billing_day do mês de referência (reference_year/reference_month) — regra de vencimento mais sofisticada (ex. carência após o fechamento) é responsabilidade do módulo Finance/AR-AP (ADR-0008), fora de escopo aqui.';
|
||||
CREATE INDEX idx_invoices_run ON invoices(billing_run_id);
|
||||
CREATE INDEX idx_invoices_subscription ON invoices(billing_subscription_id);
|
||||
CREATE INDEX idx_invoices_reseller_status ON invoices(reseller_id, status);
|
||||
CREATE INDEX idx_invoices_period ON invoices(reference_year, reference_month);
|
||||
|
||||
-- =====================================================================
|
||||
-- invoice_items — invoice line items (generic vocabulary; this MVP
|
||||
-- only ever generates 'recurring' automatically)
|
||||
-- =====================================================================
|
||||
CREATE TABLE invoice_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
description TEXT NOT NULL,
|
||||
amount NUMERIC(14,2) NOT NULL,
|
||||
item_type TEXT NOT NULL
|
||||
CHECK (item_type IN ('recurring', 'proration', 'implementation', 'adjustment')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE invoice_items IS
|
||||
'Linhas da fatura, modeladas de forma genérica para não exigir migration nova quando proration/implementation/adjustment passarem a ser gerados automaticamente — nesta leva, a camada de aplicação só cria itens item_type=recurring (um por invoice, espelhando billing_subscriptions.monthly_value no momento da geração do run).';
|
||||
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS invoice_items;
|
||||
DROP TABLE IF EXISTS invoices;
|
||||
DROP TABLE IF EXISTS billing_runs;
|
||||
DROP TABLE IF EXISTS billing_subscriptions;
|
||||
`);
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
{
|
||||
"name": "@eden/ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./tailwind-preset": {
|
||||
"types": "./dist/tailwind-preset.d.ts",
|
||||
"default": "./dist/tailwind-preset.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
@@ -15,6 +25,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
29
packages/ui/src/components/Alert.tsx
Normal file
29
packages/ui/src/components/Alert.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export type AlertVariant = "info" | "success" | "warning" | "error";
|
||||
|
||||
const variantClasses: Record<AlertVariant, string> = {
|
||||
info: "bg-info-transparent border-info-200 text-info-900",
|
||||
success: "bg-success-transparent border-success-200 text-success-900",
|
||||
warning: "bg-warning-transparent border-warning-200 text-warning-900",
|
||||
error: "bg-danger-transparent border-danger-200 text-danger-900",
|
||||
};
|
||||
|
||||
export interface AlertProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
variant?: AlertVariant;
|
||||
title?: ReactNode;
|
||||
}
|
||||
|
||||
export function Alert({ variant = "info", title, className, children, role, ...rest }: AlertProps) {
|
||||
return (
|
||||
<div
|
||||
role={role ?? (variant === "error" ? "alert" : "status")}
|
||||
className={cn("rounded-md border px-4 py-3 text-sm", variantClasses[variant], className)}
|
||||
{...rest}
|
||||
>
|
||||
{title ? <p className="mb-0.5 font-semibold">{title}</p> : null}
|
||||
{children ? <div>{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
packages/ui/src/components/AppShell.tsx
Normal file
120
packages/ui/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export interface AppShellNavItem {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: ReactNode;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface NavLinkRenderProps {
|
||||
href: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface AppShellProps {
|
||||
/** e.g. "EDEN Core" — shown next to the logo and used as the img alt text. */
|
||||
appName: string;
|
||||
logoSrc?: string;
|
||||
navItems: AppShellNavItem[];
|
||||
userName?: string;
|
||||
userRole?: string;
|
||||
onLogout?: () => void;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Router-agnostic navigation: packages/ui doesn't depend on react-router.
|
||||
* Defaults to a plain `<a>` (full page reload); pass e.g.
|
||||
* `({ href, className, children }) => <Link to={href} className={className}>{children}</Link>`
|
||||
* from the consuming app to get client-side routing.
|
||||
*/
|
||||
LinkComponent?: ComponentType<NavLinkRenderProps>;
|
||||
}
|
||||
|
||||
function DefaultLink({ href, className, children }: NavLinkRenderProps) {
|
||||
return (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visually inspired by the DreamsERP theme's sidebar/topbar
|
||||
* (react/src/components/common-sidebar, common-header) — sidebar with
|
||||
* logo + dark active nav item, white topbar with a bordered bottom edge —
|
||||
* without importing any of the theme's Redux/routing code.
|
||||
*/
|
||||
export function AppShell({
|
||||
appName,
|
||||
logoSrc,
|
||||
navItems,
|
||||
userName,
|
||||
userRole,
|
||||
onLogout,
|
||||
children,
|
||||
LinkComponent = DefaultLink,
|
||||
}: AppShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-light">
|
||||
<aside className="hidden w-sidebar shrink-0 flex-col border-r border-border-color bg-white lg:flex">
|
||||
<div className="flex h-topbar items-center gap-2 border-b border-border-color px-4">
|
||||
{logoSrc ? <img src={logoSrc} alt={appName} className="h-7 w-auto" /> : null}
|
||||
<span className="text-sm font-bold text-title">{appName}</span>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-3" aria-label="Navegação principal">
|
||||
{navItems.map((item) => (
|
||||
<LinkComponent
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150",
|
||||
item.active
|
||||
? "bg-dark text-white"
|
||||
: "text-gray-900 hover:bg-light-700 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</LinkComponent>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-topbar items-center justify-between gap-3 border-b border-border-color bg-white px-4">
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
{logoSrc ? <img src={logoSrc} alt={appName} className="h-6 w-auto" /> : null}
|
||||
<span className="text-sm font-bold text-title">{appName}</span>
|
||||
</div>
|
||||
<div className="ms-auto flex items-center gap-3">
|
||||
{userName ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border-color px-2 py-1">
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-dark text-xs font-semibold text-white">
|
||||
{userName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="hidden text-left leading-tight sm:block">
|
||||
<p className="text-xs font-semibold text-title">{userName}</p>
|
||||
{userRole ? <p className="text-[11px] text-default">{userRole}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{onLogout ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-default hover:bg-light hover:text-danger"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
packages/ui/src/components/Badge.tsx
Normal file
33
packages/ui/src/components/Badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export type BadgeVariant = "primary" | "secondary" | "success" | "warning" | "danger" | "info" | "neutral";
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
primary: "bg-primary-transparent text-primary-900",
|
||||
secondary: "bg-secondary-transparent text-secondary-900",
|
||||
success: "bg-success-transparent text-success-900",
|
||||
warning: "bg-warning-transparent text-warning-900",
|
||||
danger: "bg-danger-transparent text-danger-900",
|
||||
info: "bg-info-transparent text-info-900",
|
||||
neutral: "bg-light text-gray-900",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
export function Badge({ variant = "neutral", className, children, ...rest }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-semibold",
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
54
packages/ui/src/components/Button.tsx
Normal file
54
packages/ui/src/components/Button.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
export type ButtonSize = "sm" | "md";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
/** Shows an inline spinner and disables the button — for in-flight submits (e.g. login). */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
// Theme source (login.tsx) uses a dark CTA that hovers to the primary-hover
|
||||
// shade, not a flat primary fill — kept here as-is.
|
||||
primary: "bg-dark text-white hover:bg-primary-hover",
|
||||
secondary: "bg-white text-title border border-border-color hover:bg-light",
|
||||
danger: "bg-danger text-white hover:bg-danger-hover",
|
||||
ghost: "bg-transparent text-default hover:bg-light",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: "text-xs px-3 py-1.5",
|
||||
md: "text-sm px-4 py-2.5",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = "primary", size = "md", loading = false, disabled, className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md font-semibold cursor-pointer transition-colors duration-200 ease-in-out disabled:cursor-not-allowed disabled:opacity-60",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
22
packages/ui/src/components/Card.tsx
Normal file
22
packages/ui/src/components/Card.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
title?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */
|
||||
export function Card({ title, actions, className, children, ...rest }: CardProps) {
|
||||
return (
|
||||
<div className={cn("rounded-md border border-border-color bg-white shadow-sm", className)} {...rest}>
|
||||
{title || actions ? (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border-color px-5 py-4">
|
||||
{title ? <h3 className="!text-base font-semibold text-title">{title}</h3> : <span />}
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
packages/ui/src/components/Input.tsx
Normal file
50
packages/ui/src/components/Input.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { forwardRef, useId } from "react";
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
/** Validation/server error message — shown below the field, field border turns danger. */
|
||||
error?: string;
|
||||
/** Helper text shown below the field when there's no error. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ label, error, hint, id, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const generatedId = useId();
|
||||
const inputId = id ?? generatedId;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label ? (
|
||||
<label htmlFor={inputId} className="text-sm font-semibold text-gray-900">
|
||||
{label}
|
||||
</label>
|
||||
) : null}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||
className={cn(
|
||||
"w-full rounded-md border bg-white px-3 py-2.5 text-sm text-title placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30",
|
||||
error ? "border-danger" : "border-border-color",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p id={`${inputId}-error`} className="text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
) : hint ? (
|
||||
<p id={`${inputId}-hint`} className="text-xs text-default">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
20
packages/ui/src/components/PageHeader.tsx
Normal file
20
packages/ui/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "../utils/cn.js";
|
||||
|
||||
export interface PageHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, actions, className, ...rest }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-start justify-between gap-3 pb-4", className)} {...rest}>
|
||||
<div>
|
||||
<h1 className="!text-xl font-bold text-title">{title}</h1>
|
||||
{description ? <p className="mt-1 text-sm text-default">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user