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:
2026-09-03 17:25:31 -03:00
parent 18838a9031
commit eff515b70d
105 changed files with 8875 additions and 21 deletions

View File

@@ -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"
},

View File

@@ -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],
})

View File

@@ -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) {

View File

@@ -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 {}

View File

@@ -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);

View 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;
}

View File

@@ -0,0 +1,7 @@
import { IsString, Length } from "class-validator";
export class MfaCodeDto {
@IsString()
@Length(6, 6)
code!: string;
}

View 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;
}
}

View File

@@ -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;
}
}

View 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,
});
}
}

View 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 {}

View 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}'`,
);
}
}
}

View 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;
}

View 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;
}

View 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;
}
}

View 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>;
}

View 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;
}

View 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>;
}

View 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;
}

View 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>;
}

View 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;
}

View 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>;
}

View 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";
}

View 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 {}

View 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");
}
}

View 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);
}
}

View 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;
}
}

View 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,
},
};
}

View 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);
}
}

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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 {}

View 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);
}
}

View 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);
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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";
}

View 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);
}
}

View 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;
}
}

View File

@@ -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).

View 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;
}

View 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;
}

View 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;
}

View 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();
}

View 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);
}
}

View 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 {}

View 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;
}
}

View 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
View 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");
});
});

View 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

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

View File

@@ -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>
);
}

View 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 }),
});
}

View 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;
}

View 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;
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -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: {},
},