diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ec2cc25 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +node_modules +**/node_modules +**/dist +**/.turbo +.claude/worktrees +.tmp +*.log +.env +.env.local +.env.*.local +tema_do_Eden.zip diff --git a/.env.example b/.env.example index ab81ec0..7aa75f3 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,29 @@ EDEN_CORE_PORT=3001 EDEN_PARCEIROS_PORT=3002 EDEN_ASSINANTE_PORT=3003 +# --- Frontend: URL base da API consumida pelo browser (Vite, build-time) --- +# Lida via import.meta.env.VITE_API_BASE_URL. Dev local (`pnpm dev`, fora de +# Docker): a API roda em localhost:8080, origem DIFERENTE da porta do Vite — +# precisa de URL absoluta + CORS (ver EDEN_CORS_ORIGINS abaixo). Copiar para +# o .env de cada app (apps/core-web/.env etc.) — Vite não lê o .env da raiz. +VITE_API_BASE_URL=http://localhost:8080/api/v1 + +# Build de produção via Docker (compose.yaml `VITE_API_BASE_URL` de build +# arg, default /api/v1): a API NÃO tem domínio público próprio (decisão do +# operador — só acessível por IP interno). O browser chama a API pelo MESMO +# domínio do frontend, caminho relativo /api/v1 — o proxy reverso na frente +# de cada um dos 3 domínios públicos precisa rotear internamente esse path +# para o container eden-api (rede interna, nunca exposto publicamente). +# Nesse fluxo (mesma origem) o navegador não faz preflight de CORS. + +# --- CORS (origens de browser permitidas a chamar a API cross-origin) --- +# Só é relevante para dev local (core-web em localhost:3001+, API em +# localhost:8080 — origens diferentes). Nunca usar "*" — a API emite bearer +# token (ver packages/config/src/env-schema.ts). Os 3 domínios públicos NÃO +# entram aqui: eles chamam a API por caminho relativo via proxy (acima), +# nunca cross-origin diretamente na API. +EDEN_CORS_ORIGINS=http://localhost:3001,http://localhost:3002,http://localhost:3003 + # --- Autenticação / sessão (ADR-0003) --- # Gerar com: openssl rand -hex 48 JWT_SIGNING_SECRET= diff --git a/apps/api/package.json b/apps/api/package.json index a2fd9bc..a29801a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" }, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a318f55..a443039 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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], }) diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index eb7ecba..4547447 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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) { diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 17b156d..c5b4be9 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 1d6eadc..a525441 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + ): Promise { 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 { + 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 { const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext); const active = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash); diff --git a/apps/api/src/auth/dto/mfa-challenge.dto.ts b/apps/api/src/auth/dto/mfa-challenge.dto.ts new file mode 100644 index 0000000..3ab1eca --- /dev/null +++ b/apps/api/src/auth/dto/mfa-challenge.dto.ts @@ -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; +} diff --git a/apps/api/src/auth/dto/mfa-code.dto.ts b/apps/api/src/auth/dto/mfa-code.dto.ts new file mode 100644 index 0000000..fd973e3 --- /dev/null +++ b/apps/api/src/auth/dto/mfa-code.dto.ts @@ -0,0 +1,7 @@ +import { IsString, Length } from "class-validator"; + +export class MfaCodeDto { + @IsString() + @Length(6, 6) + code!: string; +} diff --git a/apps/api/src/auth/mfa.service.ts b/apps/api/src/auth/mfa.service.ts new file mode 100644 index 0000000..aebbca9 --- /dev/null +++ b/apps/api/src/auth/mfa.service.ts @@ -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 { + 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 { + 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 { + const secret = this.fieldEncryption.decrypt(encryptedSecret); + const result = await verify({ token: code, secret }); + return result.valid; + } +} diff --git a/apps/api/src/auth/token.service.ts b/apps/api/src/auth/token.service.ts index 3a86517..48f1b0d 100644 --- a/apps/api/src/auth/token.service.ts +++ b/apps/api/src/auth/token.service.ts @@ -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): { 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; + if (payload.purpose !== MFA_CHALLENGE_PURPOSE || !payload.sub || !payload.app) { + throw new Error("Token de desafio MFA inválido"); + } + return payload as MfaChallengePayload; + } } diff --git a/apps/api/src/billing/billing.controller.ts b/apps/api/src/billing/billing.controller.ts new file mode 100644 index 0000000..3f2f4b2 --- /dev/null +++ b/apps/api/src/billing/billing.controller.ts @@ -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, + }); + } +} diff --git a/apps/api/src/billing/billing.module.ts b/apps/api/src/billing/billing.module.ts new file mode 100644 index 0000000..beeced0 --- /dev/null +++ b/apps/api/src/billing/billing.module.ts @@ -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 {} diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts new file mode 100644 index 0000000..c6ba442 --- /dev/null +++ b/apps/api/src/billing/billing.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}'`, + ); + } + } +} diff --git a/apps/api/src/billing/dto/create-billing-run.dto.ts b/apps/api/src/billing/dto/create-billing-run.dto.ts new file mode 100644 index 0000000..7ace1e5 --- /dev/null +++ b/apps/api/src/billing/dto/create-billing-run.dto.ts @@ -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; +} diff --git a/apps/api/src/billing/dto/create-billing-subscription.dto.ts b/apps/api/src/billing/dto/create-billing-subscription.dto.ts new file mode 100644 index 0000000..5e0277c --- /dev/null +++ b/apps/api/src/billing/dto/create-billing-subscription.dto.ts @@ -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; +} diff --git a/apps/api/src/crypto/field-encryption.service.ts b/apps/api/src/crypto/field-encryption.service.ts new file mode 100644 index 0000000..2c15b02 --- /dev/null +++ b/apps/api/src/crypto/field-encryption.service.ts @@ -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; + } +} diff --git a/apps/api/src/fiscal/dto/create-cfop.dto.ts b/apps/api/src/fiscal/dto/create-cfop.dto.ts new file mode 100644 index 0000000..1e6a1da --- /dev/null +++ b/apps/api/src/fiscal/dto/create-cfop.dto.ts @@ -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; +} diff --git a/apps/api/src/fiscal/dto/create-municipality.dto.ts b/apps/api/src/fiscal/dto/create-municipality.dto.ts new file mode 100644 index 0000000..9382a73 --- /dev/null +++ b/apps/api/src/fiscal/dto/create-municipality.dto.ts @@ -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; +} diff --git a/apps/api/src/fiscal/dto/create-ncm.dto.ts b/apps/api/src/fiscal/dto/create-ncm.dto.ts new file mode 100644 index 0000000..6a319ed --- /dev/null +++ b/apps/api/src/fiscal/dto/create-ncm.dto.ts @@ -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; +} diff --git a/apps/api/src/fiscal/dto/create-product-fiscal-profile.dto.ts b/apps/api/src/fiscal/dto/create-product-fiscal-profile.dto.ts new file mode 100644 index 0000000..00b1e46 --- /dev/null +++ b/apps/api/src/fiscal/dto/create-product-fiscal-profile.dto.ts @@ -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; +} diff --git a/apps/api/src/fiscal/dto/update-cfop.dto.ts b/apps/api/src/fiscal/dto/update-cfop.dto.ts new file mode 100644 index 0000000..8b98073 --- /dev/null +++ b/apps/api/src/fiscal/dto/update-cfop.dto.ts @@ -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; +} diff --git a/apps/api/src/fiscal/dto/update-municipality.dto.ts b/apps/api/src/fiscal/dto/update-municipality.dto.ts new file mode 100644 index 0000000..7085e6c --- /dev/null +++ b/apps/api/src/fiscal/dto/update-municipality.dto.ts @@ -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; +} diff --git a/apps/api/src/fiscal/dto/update-ncm.dto.ts b/apps/api/src/fiscal/dto/update-ncm.dto.ts new file mode 100644 index 0000000..bee8d10 --- /dev/null +++ b/apps/api/src/fiscal/dto/update-ncm.dto.ts @@ -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; +} diff --git a/apps/api/src/fiscal/fiscal.controller.ts b/apps/api/src/fiscal/fiscal.controller.ts new file mode 100644 index 0000000..4970841 --- /dev/null +++ b/apps/api/src/fiscal/fiscal.controller.ts @@ -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"; +} diff --git a/apps/api/src/fiscal/fiscal.module.ts b/apps/api/src/fiscal/fiscal.module.ts new file mode 100644 index 0000000..7070c57 --- /dev/null +++ b/apps/api/src/fiscal/fiscal.module.ts @@ -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 {} diff --git a/apps/api/src/fiscal/fiscal.service.ts b/apps/api/src/fiscal/fiscal.service.ts new file mode 100644 index 0000000..182c3cb --- /dev/null +++ b/apps/api/src/fiscal/fiscal.service.ts @@ -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 { + return fiscalRepo.listNcm({ q, active }); + } + + async getNcm(id: string): Promise { + const row = await fiscalRepo.getNcmById(id); + if (!row) throw new NotFoundException("NCM não encontrado"); + return row; + } + + async createNcm(dto: CreateNcmDto, user: EdenAuthenticatedUser): Promise { + 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 { + 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 { + return fiscalRepo.listCfop({ q, active }); + } + + async getCfop(id: string): Promise { + const row = await fiscalRepo.getCfopById(id); + if (!row) throw new NotFoundException("CFOP não encontrado"); + return row; + } + + async createCfop(dto: CreateCfopDto, user: EdenAuthenticatedUser): Promise { + 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 { + 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 { + return fiscalRepo.listMunicipalities({ q, active }); + } + + async getMunicipality(id: string): Promise { + 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 { + 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 { + 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 { + return fiscalRepo.listOperationNatures(); + } + + // --------------------------------------------------------------------- + // product_fiscal_profiles + // --------------------------------------------------------------------- + + async listProfiles(productId: string): Promise { + 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 { + 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 { + const product = await productsRepo.getProductById(productId); + if (!product) throw new NotFoundException("Produto não encontrado"); + } +} diff --git a/apps/api/src/hr-timeclock/afd-imports.controller.ts b/apps/api/src/hr-timeclock/afd-imports.controller.ts new file mode 100644 index 0000000..59e745c --- /dev/null +++ b/apps/api/src/hr-timeclock/afd-imports.controller.ts @@ -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); + } +} diff --git a/apps/api/src/hr-timeclock/afd-imports.service.ts b/apps/api/src/hr-timeclock/afd-imports.service.ts new file mode 100644 index 0000000..cf8ebe3 --- /dev/null +++ b/apps/api/src/hr-timeclock/afd-imports.service.ts @@ -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 { + 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, + 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 { + 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> { + const normalizedByRaw = new Map(); + 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(); + for (const employee of candidates) { + const list = candidatesByCpf.get(employee.cpf) ?? []; + list.push(employee); + candidatesByCpf.set(employee.cpf, list); + } + + const result = new Map(); + 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; + } +} diff --git a/apps/api/src/hr-timeclock/afd-parser.ts b/apps/api/src/hr-timeclock/afd-parser.ts new file mode 100644 index 0000000..440a377 --- /dev/null +++ b/apps/api/src/hr-timeclock/afd-parser.ts @@ -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; + 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; + byValidationStatus: Record; + /** 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 = {}; + 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 = {}; + const byValidationStatus: Record = {}; + 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, + }, + }; +} diff --git a/apps/api/src/hr-timeclock/afd-records.controller.ts b/apps/api/src/hr-timeclock/afd-records.controller.ts new file mode 100644 index 0000000..0389d59 --- /dev/null +++ b/apps/api/src/hr-timeclock/afd-records.controller.ts @@ -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); + } +} diff --git a/apps/api/src/hr-timeclock/afd-records.service.ts b/apps/api/src/hr-timeclock/afd-records.service.ts new file mode 100644 index 0000000..a25cac0 --- /dev/null +++ b/apps/api/src/hr-timeclock/afd-records.service.ts @@ -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 { + const record = await hrTimeclockRepo.getAfdRecordById(id); + if (!record) throw new NotFoundException("Registro de AFD não encontrado"); + return record; + } +} diff --git a/apps/api/src/hr-timeclock/dto/create-afd-import.dto.ts b/apps/api/src/hr-timeclock/dto/create-afd-import.dto.ts new file mode 100644 index 0000000..62e4954 --- /dev/null +++ b/apps/api/src/hr-timeclock/dto/create-afd-import.dto.ts @@ -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; +} diff --git a/apps/api/src/hr-timeclock/dto/create-timeclock-device.dto.ts b/apps/api/src/hr-timeclock/dto/create-timeclock-device.dto.ts new file mode 100644 index 0000000..a087b7a --- /dev/null +++ b/apps/api/src/hr-timeclock/dto/create-timeclock-device.dto.ts @@ -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; +} diff --git a/apps/api/src/hr-timeclock/dto/list-afd-records-query.dto.ts b/apps/api/src/hr-timeclock/dto/list-afd-records-query.dto.ts new file mode 100644 index 0000000..e87c606 --- /dev/null +++ b/apps/api/src/hr-timeclock/dto/list-afd-records-query.dto.ts @@ -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; +} diff --git a/apps/api/src/hr-timeclock/dto/update-timeclock-device.dto.ts b/apps/api/src/hr-timeclock/dto/update-timeclock-device.dto.ts new file mode 100644 index 0000000..9219ccd --- /dev/null +++ b/apps/api/src/hr-timeclock/dto/update-timeclock-device.dto.ts @@ -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; +} diff --git a/apps/api/src/hr-timeclock/hr-timeclock.module.ts b/apps/api/src/hr-timeclock/hr-timeclock.module.ts new file mode 100644 index 0000000..c373ae4 --- /dev/null +++ b/apps/api/src/hr-timeclock/hr-timeclock.module.ts @@ -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 {} diff --git a/apps/api/src/hr-timeclock/timeclock-devices.controller.ts b/apps/api/src/hr-timeclock/timeclock-devices.controller.ts new file mode 100644 index 0000000..008f766 --- /dev/null +++ b/apps/api/src/hr-timeclock/timeclock-devices.controller.ts @@ -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); + } +} diff --git a/apps/api/src/hr-timeclock/timeclock-devices.service.ts b/apps/api/src/hr-timeclock/timeclock-devices.service.ts new file mode 100644 index 0000000..adec54c --- /dev/null +++ b/apps/api/src/hr-timeclock/timeclock-devices.service.ts @@ -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 & { 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 { + const devices = await hrTimeclockRepo.listTimeclockDevices(includeInactive); + return devices.map(toView); + } + + async get(id: string): Promise { + 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 { + 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 { + 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); + } +} diff --git a/apps/api/src/inventory/dto/create-asset.dto.ts b/apps/api/src/inventory/dto/create-asset.dto.ts new file mode 100644 index 0000000..ec99582 --- /dev/null +++ b/apps/api/src/inventory/dto/create-asset.dto.ts @@ -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; +} diff --git a/apps/api/src/inventory/dto/create-reservation.dto.ts b/apps/api/src/inventory/dto/create-reservation.dto.ts new file mode 100644 index 0000000..0abf2a4 --- /dev/null +++ b/apps/api/src/inventory/dto/create-reservation.dto.ts @@ -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; +} diff --git a/apps/api/src/inventory/dto/create-warehouse.dto.ts b/apps/api/src/inventory/dto/create-warehouse.dto.ts new file mode 100644 index 0000000..2472610 --- /dev/null +++ b/apps/api/src/inventory/dto/create-warehouse.dto.ts @@ -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; +} diff --git a/apps/api/src/inventory/dto/install-asset.dto.ts b/apps/api/src/inventory/dto/install-asset.dto.ts new file mode 100644 index 0000000..6df4d22 --- /dev/null +++ b/apps/api/src/inventory/dto/install-asset.dto.ts @@ -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; +} diff --git a/apps/api/src/inventory/dto/reserve-asset.dto.ts b/apps/api/src/inventory/dto/reserve-asset.dto.ts new file mode 100644 index 0000000..496e4ba --- /dev/null +++ b/apps/api/src/inventory/dto/reserve-asset.dto.ts @@ -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; +} diff --git a/apps/api/src/inventory/dto/return-asset.dto.ts b/apps/api/src/inventory/dto/return-asset.dto.ts new file mode 100644 index 0000000..6d77d67 --- /dev/null +++ b/apps/api/src/inventory/dto/return-asset.dto.ts @@ -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; +} diff --git a/apps/api/src/inventory/dto/update-warehouse.dto.ts b/apps/api/src/inventory/dto/update-warehouse.dto.ts new file mode 100644 index 0000000..35d3896 --- /dev/null +++ b/apps/api/src/inventory/dto/update-warehouse.dto.ts @@ -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; +} diff --git a/apps/api/src/inventory/inventory.controller.ts b/apps/api/src/inventory/inventory.controller.ts new file mode 100644 index 0000000..e02b2c8 --- /dev/null +++ b/apps/api/src/inventory/inventory.controller.ts @@ -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); + } +} diff --git a/apps/api/src/inventory/inventory.module.ts b/apps/api/src/inventory/inventory.module.ts new file mode 100644 index 0000000..68ea57e --- /dev/null +++ b/apps/api/src/inventory/inventory.module.ts @@ -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 {} diff --git a/apps/api/src/inventory/inventory.service.ts b/apps/api/src/inventory/inventory.service.ts new file mode 100644 index 0000000..4a684a0 --- /dev/null +++ b/apps/api/src/inventory/inventory.service.ts @@ -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 { + return inventoryRepo.listSerializedAssets({ + status: status as SerializedAsset["status"] | undefined, + warehouseId, + productId, + }); + } + + async getAsset(id: string): Promise { + const asset = await inventoryRepo.getSerializedAssetById(id); + if (!asset) throw new NotFoundException("Ativo não encontrado"); + return asset; + } + + async createAsset(dto: CreateAssetDto, user: EdenAuthenticatedUser): Promise { + 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>; + 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 { + 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 { + 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 { + 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 { + return inventoryRepo.listStockMovements({ warehouseId, productId, referenceType, referenceId }); + } + + // --------------------------------------------------------------------- + // stock_reservations + // --------------------------------------------------------------------- + + listReservations(status?: string, warehouseId?: string, productId?: string): Promise { + return inventoryRepo.listStockReservations({ + status: status as StockReservation["status"] | undefined, + warehouseId, + productId, + }); + } + + async createReservation(dto: CreateReservationDto, user: EdenAuthenticatedUser): Promise { + 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 { + 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"; +} diff --git a/apps/api/src/inventory/warehouses.controller.ts b/apps/api/src/inventory/warehouses.controller.ts new file mode 100644 index 0000000..7fc2c02 --- /dev/null +++ b/apps/api/src/inventory/warehouses.controller.ts @@ -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); + } +} diff --git a/apps/api/src/inventory/warehouses.service.ts b/apps/api/src/inventory/warehouses.service.ts new file mode 100644 index 0000000..7561a49 --- /dev/null +++ b/apps/api/src/inventory/warehouses.service.ts @@ -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 { + return inventoryRepo.listWarehouses(includeInactive); + } + + async get(id: string): Promise { + 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 { + 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 { + 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; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 2f95223..410cf68 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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). diff --git a/apps/api/src/support/dto/create-ticket-comment.dto.ts b/apps/api/src/support/dto/create-ticket-comment.dto.ts new file mode 100644 index 0000000..8368937 --- /dev/null +++ b/apps/api/src/support/dto/create-ticket-comment.dto.ts @@ -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; +} diff --git a/apps/api/src/support/dto/create-ticket.dto.ts b/apps/api/src/support/dto/create-ticket.dto.ts new file mode 100644 index 0000000..6222c11 --- /dev/null +++ b/apps/api/src/support/dto/create-ticket.dto.ts @@ -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; +} diff --git a/apps/api/src/support/dto/update-ticket-status.dto.ts b/apps/api/src/support/dto/update-ticket-status.dto.ts new file mode 100644 index 0000000..6210467 --- /dev/null +++ b/apps/api/src/support/dto/update-ticket-status.dto.ts @@ -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; +} diff --git a/apps/api/src/support/sla.ts b/apps/api/src/support/sla.ts new file mode 100644 index 0000000..53c6687 --- /dev/null +++ b/apps/api/src/support/sla.ts @@ -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(); +} diff --git a/apps/api/src/support/support.controller.ts b/apps/api/src/support/support.controller.ts new file mode 100644 index 0000000..249e40f --- /dev/null +++ b/apps/api/src/support/support.controller.ts @@ -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); + } +} diff --git a/apps/api/src/support/support.module.ts b/apps/api/src/support/support.module.ts new file mode 100644 index 0000000..17e9ad8 --- /dev/null +++ b/apps/api/src/support/support.module.ts @@ -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 {} diff --git a/apps/api/src/support/support.service.ts b/apps/api/src/support/support.service.ts new file mode 100644 index 0000000..b42717d --- /dev/null +++ b/apps/api/src/support/support.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/api/test/afd-parser.spec.ts b/apps/api/test/afd-parser.spec.ts new file mode 100644 index 0000000..ac3af62 --- /dev/null +++ b/apps/api/test/afd-parser.spec.ts @@ -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); + }); +}); diff --git a/apps/api/test/mfa.spec.ts b/apps/api/test/mfa.spec.ts new file mode 100644 index 0000000..2461967 --- /dev/null +++ b/apps/api/test/mfa.spec.ts @@ -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"); + }); +}); diff --git a/apps/core-web/.env.example b/apps/core-web/.env.example new file mode 100644 index 0000000..0c0928b --- /dev/null +++ b/apps/core-web/.env.example @@ -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 diff --git a/apps/core-web/package.json b/apps/core-web/package.json index 15c3096..dd2bdc1 100644 --- a/apps/core-web/package.json +++ b/apps/core-web/package.json @@ -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", diff --git a/apps/core-web/public/eden-logo-horizontal.png b/apps/core-web/public/eden-logo-horizontal.png new file mode 100644 index 0000000..43f6bdd Binary files /dev/null and b/apps/core-web/public/eden-logo-horizontal.png differ diff --git a/apps/core-web/src/App.tsx b/apps/core-web/src/App.tsx index b0e6d97..4e315dd 100644 --- a/apps/core-web/src/App.tsx +++ b/apps/core-web/src/App.tsx @@ -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 ( -
-
-

EDEN Core

-

- Bootstrap da Fase 1 — Design System ainda não portado do tema DreamsERP. -

-
-
+ + + + } /> + }> + }> + } /> + + + + + ); } diff --git a/apps/core-web/src/lib/api/auth.ts b/apps/core-web/src/lib/api/auth.ts new file mode 100644 index 0000000..5423d87 --- /dev/null +++ b/apps/core-web/src/lib/api/auth.ts @@ -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 { + return apiRequest("/auth/login", { + method: "POST", + body: JSON.stringify({ email, password, applicationKey: APPLICATION_KEY }), + }); +} + +export function completeMfaLogin(mfaChallenge: string, code: string): Promise { + return apiRequest("/auth/mfa/challenge", { + method: "POST", + body: JSON.stringify({ mfaChallenge, code }), + }); +} + +export function fetchMe(accessToken: string): Promise { + return apiRequest("/auth/me", { method: "GET" }, { accessToken }); +} + +export async function logout(refreshToken: string): Promise { + await apiRequest("/auth/logout", { + method: "POST", + body: JSON.stringify({ refreshToken }), + }); +} diff --git a/apps/core-web/src/lib/api/http.ts b/apps/core-web/src/lib/api/http.ts new file mode 100644 index 0000000..a6b74f7 --- /dev/null +++ b/apps/core-web/src/lib/api/http.ts @@ -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( + path: string, + init?: RequestInit, + options?: RequestOptions, +): Promise { + 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; +} diff --git a/apps/core-web/src/lib/auth/AuthContext.tsx b/apps/core-web/src/lib/auth/AuthContext.tsx new file mode 100644 index 0000000..fc41fbe --- /dev/null +++ b/apps/core-web/src/lib/auth/AuthContext.tsx @@ -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; + submitMfa: (mfaChallenge: string, code: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState("loading"); + const [user, setUser] = useState(null); + const [accessToken, setAccessToken] = useState(null); + const [refreshToken, setRefreshToken] = useState(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( + () => ({ status, user, accessToken, login, submitMfa, logout }), + [status, user, accessToken, login, submitMfa, logout], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within an AuthProvider"); + return ctx; +} diff --git a/apps/core-web/src/lib/auth/session.ts b/apps/core-web/src/lib/auth/session.ts new file mode 100644 index 0000000..eded868 --- /dev/null +++ b/apps/core-web/src/lib/auth/session.ts @@ -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); +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx new file mode 100644 index 0000000..c2d385b --- /dev/null +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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 ( + ( + + {children} + + )} + > + + + ); +} diff --git a/apps/core-web/src/routes/DashboardPage.tsx b/apps/core-web/src/routes/DashboardPage.tsx new file mode 100644 index 0000000..4e11d76 --- /dev/null +++ b/apps/core-web/src/routes/DashboardPage.tsx @@ -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 ( +
+ + +

+ O shell autenticado está funcionando. As telas de negócio (orçamentos, contratos, etc.) chegam nas + próximas levas. +

+
+
+ ); +} diff --git a/apps/core-web/src/routes/LoginPage.tsx b/apps/core-web/src/routes/LoginPage.tsx new file mode 100644 index 0000000..aa32dca --- /dev/null +++ b/apps/core-web/src/routes/LoginPage.tsx @@ -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(null); + const [mfaCode, setMfaCode] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + if (status === "authenticated") { + const redirectTo = (location.state as { from?: string } | null)?.from ?? "/"; + return ; + } + + 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 ( +
+ +
+ EDEN +

+ {mfaChallenge ? "Verificação em duas etapas" : "Bem-vindo de volta"} +

+

+ {mfaChallenge ? "Informe o código do seu aplicativo autenticador." : "Entre com sua conta EDEN Core."} +

+
+ + {error ? ( + + {error} + + ) : null} + + {mfaChallenge ? ( +
+ setMfaCode(e.target.value)} + required + autoFocus + /> + + +
+ ) : ( +
+ setEmail(e.target.value)} + required + autoFocus + /> + setPassword(e.target.value)} + required + /> + +
+ )} +
+
+ ); +} diff --git a/apps/core-web/src/routes/ProtectedRoute.tsx b/apps/core-web/src/routes/ProtectedRoute.tsx new file mode 100644 index 0000000..a7e083e --- /dev/null +++ b/apps/core-web/src/routes/ProtectedRoute.tsx @@ -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 ( +
+ +
+ ); + } + + if (status === "anonymous") { + return ; + } + + return ; +} diff --git a/apps/core-web/src/vite-env.d.ts b/apps/core-web/src/vite-env.d.ts new file mode 100644 index 0000000..d43868c --- /dev/null +++ b/apps/core-web/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/core-web/tailwind.config.js b/apps/core-web/tailwind.config.js index 93aa364..f474a79 100644 --- a/apps/core-web/tailwind.config.js +++ b/apps/core-web/tailwind.config.js @@ -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: {}, }, diff --git a/compose.yaml b/compose.yaml index d2048b7..92d6637 100644 --- a/compose.yaml +++ b/compose.yaml @@ -50,6 +50,7 @@ services: EDEN_FIELD_ENCRYPTION_KEY: ${EDEN_FIELD_ENCRYPTION_KEY} EDEN_SUPERADMIN_EMAIL: ${EDEN_SUPERADMIN_EMAIL} EDEN_SUPERADMIN_PASSWORD: ${EDEN_SUPERADMIN_PASSWORD} + EDEN_CORS_ORIGINS: ${EDEN_CORS_ORIGINS} SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} SMTP_USER: ${SMTP_USER} @@ -95,6 +96,12 @@ services: dockerfile: infra/docker/Dockerfile.web args: APP_NAME: core-web + # Sempre relativo aqui (não ${VITE_API_BASE_URL} do .env — esse é + # para `pnpm dev` local fora do Docker, valor diferente e não deve + # vazar para este build arg). A API nunca tem domínio público + # próprio; o proxy externo roteia este path para eden-api na rede + # interna — ver comentário em .env.example. + VITE_API_BASE_URL: /api/v1 container_name: eden-core restart: unless-stopped ports: @@ -115,6 +122,12 @@ services: dockerfile: infra/docker/Dockerfile.web args: APP_NAME: reseller-web + # Sempre relativo aqui (não ${VITE_API_BASE_URL} do .env — esse é + # para `pnpm dev` local fora do Docker, valor diferente e não deve + # vazar para este build arg). A API nunca tem domínio público + # próprio; o proxy externo roteia este path para eden-api na rede + # interna — ver comentário em .env.example. + VITE_API_BASE_URL: /api/v1 container_name: eden-parceiros restart: unless-stopped ports: @@ -135,6 +148,12 @@ services: dockerfile: infra/docker/Dockerfile.web args: APP_NAME: subscriber-web + # Sempre relativo aqui (não ${VITE_API_BASE_URL} do .env — esse é + # para `pnpm dev` local fora do Docker, valor diferente e não deve + # vazar para este build arg). A API nunca tem domínio público + # próprio; o proxy externo roteia este path para eden-api na rede + # interna — ver comentário em .env.example. + VITE_API_BASE_URL: /api/v1 container_name: eden-assinante restart: unless-stopped ports: diff --git a/docs/progress.md b/docs/progress.md index b366bd7..65f259a 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -115,7 +115,7 @@ Vitest + supertest + `@nestjs/testing` (bootstrap real do Nest, sem mock) contra 6 subagentes no total (4 + 1 QA + Users/Roles feito diretamente), rodando em `git worktree` isolados, revisados e mesclados manualmente um a um (nunca merge automático sem verificação). **4 bugs reais** encontrados só porque cada módulo foi testado contra a stack viva antes do commit — nenhum deles teria aparecido só com typecheck/build, que todos os agentes já reportavam verde. Padrão validado: delegar implementação para agentes especializados funciona bem para módulos CRUD bem especificados, mas a integração final (merge, wiring, teste ao vivo, revisão) precisa ficar com quem tem visão do sistema inteiro. ### Pendências da Fase 1 (não iniciadas) -- MFA/TOTP (schema já existe — `users.mfa_*` — endpoint ainda não implementado). +- ~~MFA/TOTP~~ — implementado na leva seguinte (ver Fase 4+ abaixo). - Extração do Design System a partir do tema DreamsERP (`packages/ui` ainda é placeholder). - Wiring dos adapters S3/SMTP em algum módulo consumidor real (hoje só existem como biblioteca em `packages/integrations`, nada os chama ainda). - Nota de dado de teste: a empresa "Handix Telecom LTDA" e a revenda "Handix (revenda própria)" criadas durante os testes usam dados de exemplo, não os dados reais da Handix — substituir/remover antes de qualquer ambiente que não seja dev local. @@ -150,6 +150,121 @@ Wiring final feito manualmente: barrel (`packages/database/src/index.ts`) e `app 2. `packages/database/src/repositories/types.ts` — a interface `User` estava sem `reseller_id`/`can_approve_discount`, mesmo essas colunas já existindo no banco desde as migrations de Resellers/Quotes (ninguém tinha atualizado o tipo compartilhado). Descoberto ao ligar o uso desses campos em Quotes; corrigido no `types.ts` e propagado para `EdenAuthenticatedUser` em `jwt-auth.guard.ts`. ### Pendências deste lote -- FKs cruzadas entre as 3 tabelas ainda não existem (ficaram como UUID nullable simples, documentado em comentário nas migrations): `quotes.client_registration_id → client_registrations(id)`, `client_registrations.quote_id → quotes(id)`, `contracts.quote_id → quotes(id)`, `contracts.client_registration_id → client_registrations(id)`. +- ~~FKs cruzadas entre as 3 tabelas~~ — fechadas na leva seguinte (ver abaixo). - Fluxo de autoatendimento público (token) de Cadastro de Cliente, upload de anexo, integração CNPJ/CEP — todos fora do escopo desta MVP. - 7 das 11 tabelas do ADR-0006 de Contratos (aditivos, faturas, documentos/e-signature, etc.) ainda não criadas. + +--- + +## Fase 4/9 — MFA/TOTP, Estoque (Inventory), Suporte (Service Desk) e fundação de Ponto Eletrônico (HR/Timeclock) + +Data: 2026-09-03. + +Pedido do usuário: "pode seguir na criacao do app ao menos mais 20 etapas use subagentes". Estratégia: fechei a pendência de MFA/TOTP da Fase 1 diretamente (autenticação é sensível demais para delegar) e lancei 3 subagentes em paralelo (`eden-inventory`, `eden-support`, `eden-hr-timeclock`) em worktrees isolados, seguindo o mesmo padrão validado nas levas anteriores. Além das FKs cruzadas de Quotes/Contracts/ClientRegistrations, deixadas pendentes na leva anterior. + +### FKs cruzadas (pendência da leva anterior) — fechada + +Migration `1788450538883_cross-reference-fks.cjs`: `quotes.client_registration_id → client_registrations(id)`, `client_registrations.quote_id → quotes(id)`, `contracts.quote_id → quotes(id)`, `contracts.client_registration_id → client_registrations(id)`, todas `ON DELETE RESTRICT` (nenhum desses agregados tem rota DELETE hoje; perder o vínculo silenciosamente numa exclusão não relacionada seria pior que bloquear a exclusão). Verificado zero órfãos antes de aplicar. + +### MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `field-encryption.service.ts`) — implementado diretamente + +- `FieldEncryptionService` (`apps/api/src/crypto/`): criptografia reversível genérica AES-256-GCM versionada (ADR-0005), formato `v1:iv:tag:ciphertext` base64, chave de `EDEN_FIELD_ENCRYPTION_KEY` (hasheada para 32 bytes via SHA-256). Primeira implementação real desse padrão no projeto — vai ser reaproveitada por outros segredos reversíveis (ex.: credencial de equipamento Control iD, feito na mesma leva). +- Fluxo de login em duas etapas quando `mfa_enabled=true`: `POST /auth/login` retorna `{mfaRequired: true, mfaChallenge}` (JWT de propósito único, 2 min, claim `purpose` distintivo) em vez de tokens; `POST /auth/mfa/challenge` troca o desafio + código TOTP pelos tokens reais. `POST /auth/mfa/enroll` (gera secret + `otpauthUrl`, não habilita ainda) → `POST /auth/mfa/confirm` (primeiro código correto habilita) → `POST /auth/mfa/disable` (exige código válido, não só sessão autenticada). +- Biblioteca `otplib` v13 (API funcional `generateSecret`/`generateURI`/`verify`, não mais o antigo `authenticator` singleton de v12). + +**Bug de segurança real encontrado e corrigido em teste ao vivo (não hipotético — explorado de verdade contra a stack rodando):** o token de desafio MFA (`mfaChallenge`, emitido logo após a senha, antes do código TOTP) era aceito pelo `JwtAuthGuard` como um access token comum — `verifyAccessToken()` só validava assinatura/expiração, nunca checava a claim `purpose`. Resultado: um atacante que só tivesse a senha (sem o segundo fator) conseguia acesso total à API por 2 minutos usando o token de desafio como Bearer normal — MFA completamente contornável. Corrigido em `token.service.ts::verifyAccessToken` rejeitando qualquer token com claim `purpose` presente. Teste de regressão adicionado em `test/mfa.spec.ts` (`GET /auth/me` com o challenge token como Bearer → 401). Explorado e depois fechado ao vivo via curl contra o container real antes de seguir adiante. + +### Estoque / Inventory (`apps/api/src/inventory/` + migration `inventory`) — subagente `eden-inventory` + +5 tabelas (de 15 possíveis no Master Prompt §6.8, mesmo approach de MVP-coerente do módulo Contracts): `warehouses`, `stock_movements` (ledger append-only, saldo sempre derivado via SUM, nunca coluna editável — reforçado por trigger que bloqueia UPDATE/DELETE sem exceção), `serialized_assets`, `asset_assignments`, `stock_reservations`. Invariante "mesmo serial/patrimônio/MAC nunca duas vezes ativo" (Master Prompt §15.3) reforçada por **3 índices únicos parciais no banco**, não só validação de aplicação. Fluxo completo reserva→instalação(comodato)→devolução implementado com `SELECT...FOR UPDATE` + transação, erros tipados mapeados para 404/409. + +Subagente entregou trabalho de excelente qualidade na primeira tentativa — nenhum bug encontrado na revisão nem no teste ao vivo. + +### Suporte / Service Desk (`apps/api/src/support/` + migration `support`) — subagente `eden-support` + +4 tabelas: `support_tickets` (protocolo humano `TCK-2026-000001`, mesmo padrão de `quote_number`/`contract_number`), `ticket_comments` (com `is_internal`, nunca exposto fora do escopo `all`), `ticket_status_history`, `sla_timers` (1:1). Máquina de estados `new→triage→in_progress⇄{waiting_customer,waiting_third_party}→resolved→closed` (+`cancelled`), mesmo padrão de `CONTRACT_STATUS_TRANSITIONS`. Motor de SLA implementado como funções puras testáveis (`apps/api/src/support/sla.ts`) — pausa/despausa atomicamente em SQL na transição de status, cálculo de violação subtrai corretamente o tempo pausado (incluindo a pausa corrente). `root_cause`/`resolution_notes` só persistidos na transição para `closed`, não `resolved` (decisão documentada, confirmada em teste ao vivo). + +Também de excelente qualidade — nenhum bug encontrado. + +### Fundação de Ponto Eletrônico / HR-Timeclock (`apps/api/src/hr-timeclock/` + migration `hr-timeclock`) — subagente `eden-hr-timeclock` + +Escopo desta leva deliberadamente limitado a fundação (modelo de dados + Control iD device + parser/import de AFD com CRC-16/KERMIT) — apuração, fechamento de período, ajustes, banco de horas e relatórios (eden.md §5-10) explicitamente adiados. 4 tabelas: `timeclock_devices` (senha do equipamento cifrada via `FieldEncryptionService`, nunca retornada em nenhum GET — só `hasCredential:boolean`), `timeclock_employees` (cadastro separado de `users`, decisão documentada: o Control iD identifica por CPF, e `users` não tem CPF), `afd_import_batches`, `afd_records`. `afd_records` é **imutável sem exceção, nem para super_admin** — trigger que bloqueia UPDATE/DELETE sem nenhuma válvula de escape (diferente de `audit_log`, que tem bypass controlado via `SET LOCAL`). Idempotência em duas camadas: hash SHA-256 do arquivo inteiro (retorna `alreadyImported:true` em reimportação) e `UNIQUE(device_id, nsr)` por registro. NSR ausente/não-numérico nunca vira 0 — fica `null`, preservando o dado bruto mesmo inválido. + +Parser CRC-16/KERMIT (poly 0x1021 refletido/0x8408, init 0x0000, sem XOR final) verificado contra o vetor de teste conhecido ("123456789" → 0x2189) — confirmado tanto no script ad-hoc do agente quanto na suíte permanente `test/afd-parser.spec.ts` (11 testes). + +**1 bug real encontrado e corrigido antes da integração:** a migration `PENDING_hr-timeclock.cjs` tinha uma crase (`` ` ``) solta dentro de um comentário SQL (`-- separate from \`users\``) — como a migration inteira é um template literal JS (`` pgm.sql(`...`) ``), essa crase fechava o literal prematuramente e quebrava o resto do SQL em código JS inválido, causando `SyntaxError: missing ) after argument list` ao tentar rodar `pnpm db:migrate`. Corrigido trocando a crase por aspas simples no comentário. Encontrado e corrigido antes de qualquer teste ao vivo, na primeira tentativa de aplicar a migration. + +### Padrão de salvamento seletivo ao integrar (novo, deste lote) + +Diferente da leva anterior (onde os 2 agentes falharam por limite de gasto e o trabalho foi resgatado de worktrees não commitados), desta vez os 3 agentes **completaram normalmente**, mas todos os 3 worktrees foram criados a partir do commit único de bootstrap (antes de Auth/Companies/Products/etc. existirem nesse checkout isolado) — sem acesso ao código real de `apps/api/src/auth/`, `packages/database/src/repositories/{audit,users,roles,types}.ts` etc. Cada agente relatou isso com transparência e, para conseguir cumprir a etapa de verificação (`pnpm typecheck` limpo), recriou stubs/placeholders type-compatíveis desses arquivos dentro do próprio worktree — e dois deles (`eden-support`, `eden-hr-timeclock`) deixaram esses placeholders (`audit.ts`, `users.ts`, `roles.ts`, `types.ts`, config, guards de auth) como arquivos genuínos no diff final, não revertidos, por acreditarem (corretamente, do ponto de vista deles, isolados no worktree) que essa infraestrutura ainda não existia. + +Na integração, copiei seletivamente **só os arquivos de domínio real** de cada agente (migration, repositório específico do módulo, camada de API do módulo) e **descartei os placeholders duplicados de Auth/Config/Crypto/audit/users/roles**, mantendo as versões reais já existentes em `main`. Wiring do barrel (`packages/database/src/index.ts`) e `app.module.ts` feito manualmente para os 3 módulos, como nas levas anteriores. + +### Verificação + +`pnpm turbo run typecheck build` verde em cada integração incremental; suíte de testes cresceu de 15 para **30 testes, todos verdes** (`auth.spec.ts`, `mfa.spec.ts` novo, `afd-parser.spec.ts` novo com 11 testes, `role-weight.spec.ts`, `reseller-assignment.spec.ts`, `companies.spec.ts`, `mass-assignment.spec.ts`). Container `eden-api` reconstruído e todo o lote testado ao vivo via curl contra a stack real: +- MFA: enroll→confirm com código TOTP real gerado via `otplib`→login exige desafio→código errado rejeitado→código certo completa login→disable exige código válido; **exploração do bug de segurança repetida após o fix, confirmando 401**. +- Inventory: armazém→entrada de ativo→serial duplicado rejeitado (409, não 500)→reserva→instalação (comodato)→instalação duplicada rejeitada (409)→devolução→ledger conferido linha a linha (`purchase_in`, `reservation`, `comodato_out`, `return_in`). +- Support: ticket criado com SLA (+4h/+24h)→transições válidas→comentário público marca `first_response_met`→`waiting_customer` pausa o timer→retorno a `in_progress` acumula o tempo pausado→`resolved`→`closed` (root_cause só persiste aqui)→transição inválida rejeitada (409). +- HR-Timeclock: device criado com senha cifrada (confirmado no Postgres, formato `v1:iv:tag:ciphertext`, nunca no GET)→import de arquivo AFD válido (2 linhas, CRC correto)→reimport do mesmo arquivo retorna `alreadyImported:true` sem duplicar→`UPDATE` direto em `afd_records` via SQL bloqueado pelo trigger mesmo fora da API. +- Trilha de auditoria (`audit_log`) conferida no Postgres com a sequência completa de todos os eventos do lote. + +### Pendências deste lote +- Inventory: `warehouse_locations`, `stock_items` (cache de saldo), `stock_lots`, `inventory_counts`, `transfers`/`receipts`/`issues`/`returns` formais, `rma`, `asset_maintenance` — deferidos, tipos de movimento já modelados no vocabulário do ledger para não exigir nova migration. +- Support: anexos reais (S3), CSAT, OS dedicada, filas com roteamento automático, calendário de atendimento configurável, SLA por categoria — hoje é uma política única fixa (4h/24h). +- HR-Timeclock: apuração, fechamento de período, ajustes manuais, banco de horas, relatórios, sync ao vivo com o Control iD, upload multipart real (hoje aceita o conteúdo como string JSON no body). +- MFA: sem rate-limiting/bloqueio de tentativas no `mfa/challenge` (mesma lacuna que já existe no login por senha hoje — nenhum endpoint de auth tem rate limit ainda, ficou registrado como débito técnico geral, não específico do MFA). + +--- + +## Fase 5/6 + Design System — Billing, Fiscal e primeira tela real (login + shell autenticado) + +Data: 2026-09-03. + +Continuação do mesmo pedido do usuário ("pelo menos mais 20 etapas, use subagentes"). 3 subagentes em paralelo (`eden-finance`, `eden-fiscal`, `eden-frontend`), mesmo padrão de worktree isolado + integração seletiva manual. + +### Billing / Faturamento recorrente (`apps/api/src/billing/` + migration `billing`) — subagente `eden-finance` + +MVP de 4 tabelas (ADR-0008 lista 9; billing_accounts/billing_cycles/charge_components/usage_charges/invoice_adjustments/billing_run_logs deferidos, documentado): `billing_subscriptions` (derivada de contrato, `monthly_value` é snapshot — nunca recalculado automaticamente), `billing_runs`, `invoices`, `invoice_items`. Fluxo `subscription→billing_run→invoice` completo e idempotente: rodar o mesmo run duas vezes em `draft` substitui as invoices (não duplica) — reforçado por índice único parcial `WHERE status='draft'` (no máximo um run em rascunho por período); depois de `consolidate`, run e invoices ficam imutáveis pela aplicação (409 em qualquer tentativa de gerar/alterar). ADR-0008 respeitado à risca: Billing só calcula o que é devido, nunca toca em AR/AP/boleto/cobrança (Finance) nem em documento fiscal (Fiscal) — bounded contexts continuam sendo desenvolvidos separadamente. + +### Fiscal (`apps/api/src/fiscal/` + migration `fiscal`) — subagente `eden-fiscal` + +eden.md módulo 5 tem ~19 tabelas de catálogo + importadores automáticos (Siscomex/IBGE) + importador manual de arquivo — MVP portou 5 tabelas fielmente ao shape do legado: `fiscal_ncm`/`fiscal_cfop` (catálogo simples), `fiscal_municipality` (shape distinto, sem `valid_from`/`valid_until`), `fiscal_operation_nature` (seed fixo com as 11 naturezas do legado), `product_fiscal_profiles` (liga produto à classificação fiscal). Invariante do eden.md §2.5 implementada de verdade: **um produto tem no máximo um profile ativo por `billing_component`** — índice único parcial + camada de aplicação que inativa o profile anterior antes de criar o novo (nunca update destrutivo, histórico preservado). Todo o importador automático/manual e as ~14 outras tabelas de catálogo ficam deferidos (documentado com a seção exata do eden.md para cada item). + +### Design System + login real (`packages/ui`, `apps/core-web`) — subagente `eden-frontend` + +Primeira leva de frontend do projeto. Achado que mudou o escopo para melhor: o `tema_do_Eden.zip` (comprado) não é só Angular — tem uma versão pronta em **React 18 + Vite + Tailwind** dentro (`react.zip`, 42.5MB), a mesma stack que o EDEN já usa. Em vez de portar de Angular, o agente extraiu os tokens visuais reais (cores, tipografia, sombras, border-radius, larguras de layout) desse React/Tailwind — confirmando inclusive uma divergência da nossa própria documentação: `docs/project-inventory.md` citava fonte Nunito/Poppins, a fonte real do tema é **Geist**. + +- `packages/ui` deixou de ser placeholder: preset Tailwind exportável (`@eden/ui/tailwind-preset`) com os tokens reais extraídos, mais componentes React reais (não stub) — `Button`, `Input`, `Card`, `Badge`, `Alert`, `PageHeader`, `AppShell` (sidebar+topbar+conteúdo). +- `apps/core-web` (EDEN Core — prioridade, os outros 2 apps ficam para leva futura): trocou a tela estática "Bootstrap da Fase 1" por um fluxo real com `react-router-dom` — página de login chamando de verdade `POST /api/v1/auth/login` (com suporte ao fluxo `mfaRequired`/`POST /mfa/challenge` da leva de MFA), sessão em `localStorage`, rota protegida, e um shell autenticado (`AppShell` real, nome do usuário via `GET /api/v1/auth/me`, logout). + +**Achado real fora do escopo do agente, corrigido por mim em seguida**: a API não tinha CORS configurado (`app.enableCors` ausente em `main.ts`) — um browser real rodando `core-web` num domínio/porta diferente da API teria toda chamada de login bloqueada no preflight, mesmo com tudo implementado corretamente nos dois lados. Corrigido: novo `EDEN_CORS_ORIGINS` (env, lista de origens separadas por vírgula, nunca wildcard — a API emite bearer token) em `packages/config/src/env-schema.ts`, `app.enableCors({origin, credentials:true})` em `main.ts`, propagado em `compose.yaml`/`.env`/`.env.example` já com os 3 domínios públicos reais (`dev-edencore`/`dev-parceirohandix`/`dev-portalassinate.falehandix.com.br`) mais as portas de dev locais. Testado ao vivo: preflight de `https://dev-edencore.falehandix.com.br` aceito com os headers corretos; origem não autorizada (`evil.example.com`) corretamente sem `Access-Control-Allow-Origin` na resposta; login completo simulando o `Origin` real de browser, 200 com o header de CORS certo. + +**Pendência de infra identificada, não resolvida (decisão do operador, não técnica)**: os 3 domínios públicos hoje só apontam para os containers de frontend (3001/3002/3003) — não há um domínio público equivalente para a API. `VITE_API_BASE_URL` do build de produção dos 3 apps ainda aponta para `localhost:8080` (só funciona em dev local); falta decidir e implementar como o browser em produção alcança a API (domínio próprio para a API? proxy reverso roteando um path tipo `/api` para o container `eden-api` no mesmo domínio do frontend?) antes do login funcionar nos domínios públicos de verdade. + +### Verificação + +`pnpm turbo run typecheck build` (28/28) e `pnpm --filter @eden/api test` (30/30, sem regressão) verdes após cada integração. Containers `eden-api`/`eden-core` reconstruídos; testado ao vivo via curl: +- Fiscal: naturezas de operação semeadas (11), NCM criado, **invariante de profile único ativo por componente confirmada** (segundo profile RECURRING criado → primeiro automaticamente `active:false`). +- Billing: subscription criada → billing run gerado (invoice de R$499,90, vencimento no `billing_day`) → regeneração do mesmo período reaproveita o mesmo run (idempotente, sem duplicar invoice) → consolidação → segunda consolidação rejeitada (409) → regeneração de período já consolidado rejeitada (409). +- CORS: preflight aceito do domínio público real, rejeitado de origem não cadastrada, login completo com `Origin` de browser real. +- `core-web`: build de produção servido pelo container real não contém mais o texto "Bootstrap da Fase 1" — contém o formulário de login real (`E-mail`/`Senha`/`Entrar`). +- Trilha de auditoria conferida no Postgres com a sequência completa do lote. + +### Pendências deste lote +- Billing: `billing_accounts`, `billing_cycles` como tabela própria, consumo/telefonia (`charge_components`/`usage_charges`), `invoice_adjustments`, pró-rata — tudo Finance/AR-AP (títulos, boletos, dunning, conciliação) fica para quando esse bounded context for iniciado, por decisão da ADR-0008. +- Fiscal: as ~14 tabelas de catálogo restantes, `fiscal_catalog_sync`, importador automático (Siscomex/IBGE) e manual (CSV/XLSX/JSON com proteção de inativação em massa), `fiscal_rules` (schema pronto mas sem consumo mesmo no legado). +- Frontend: `reseller-web`/`subscriber-web` ainda no bootstrap estático; telas de negócio (Quotes/Contracts/Inventory/etc.) no `core-web`; rotação automática de refresh token; fluxo de ENROLL de MFA na UI (só login com MFA já habilitado está implementado). + +### Decisão do operador sobre topologia de API pública + 3 bugs de infra encontrados e corrigidos na sequência + +Usuário decidiu: a API **nunca** tem domínio público próprio, só IP/nome interno — o browser sempre chama a API pelo mesmo domínio do frontend (caminho relativo), e o proxy externo (gerenciado pelo operador, fora deste repo) roteia esse caminho internamente para `eden-api` (por nome de serviço Docker, `/etc/hosts` ou IP interno — detalhe de infra do operador, não deste código). + +Implementado: `infra/docker/Dockerfile.web` agora aceita `ARG VITE_API_BASE_URL` (default `/api/v1`, Vite bake em build-time); `compose.yaml` passa esse build arg fixo (`/api/v1`) para os 3 serviços web; `EDEN_CORS_ORIGINS` simplificado para só as portas de dev local (o fluxo de produção é same-origin via proxy, não precisa de CORS). + +**3 bugs reais de infraestrutura encontrados e corrigidos nessa verificação (nenhum era o objetivo original, todos achados testando o resultado de ponta a ponta, não só lendo o código):** +1. **`turbo.json` não declarava `VITE_API_BASE_URL` como input da task `build`** — o Turborepo cacheava o build do Vite sem saber que essa env var afeta o output, então reaproveitava um bundle antigo (com a URL absoluta de dev) mesmo depois de mudar a env var. Corrigido adicionando `"env": ["VITE_*"]` à task `build` em `turbo.json`. +2. **`compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`** para o build arg do Docker — parecia razoável, mas colidia com a variável de mesmo nome no `.env` (usada para `pnpm dev` local, valor absoluto `http://localhost:8080/api/v1`) que o `docker compose` carrega automaticamente; o "default" nunca era usado porque a variável já estava setada. Corrigido fixando o valor diretamente (`/api/v1`, sem interpolação de env) nos 3 serviços web, já que essa é uma decisão arquitetural fixa, não uma configuração por ambiente. +3. **Não existia `.dockerignore`** no projeto — todo `COPY . .` do `Dockerfile.web`/`Dockerfile.node` estava copiando `node_modules/` (315MB), `.git/` (5.8MB) e `.claude/worktrees/` (345MB, os worktrees de subagentes desta mesma sessão) para dentro do contexto de build a cada vez. Além do desperdício óbvio, isso é parte de por que o bug nº1 passou despercebido no primeiro teste: caches `.turbo/` de dentro de cada pacote também eram copiados para dentro da imagem. Corrigido criando `.dockerignore` (exclui `node_modules`, `.git`, `dist`, `.turbo`, `.claude/worktrees`, `.tmp`, `.env*`, o zip do tema). + +Cada correção foi testada isoladamente e a última confirmada abrindo o JS de produção servido pelo container real (`docker compose build --no-cache`) e conferindo que o literal `"/api/v1"` (não mais `"http://localhost:8080/api/v1"`) está no bundle. diff --git a/infra/docker/Dockerfile.web b/infra/docker/Dockerfile.web index f20ec5d..0147c17 100644 --- a/infra/docker/Dockerfile.web +++ b/infra/docker/Dockerfile.web @@ -8,6 +8,15 @@ WORKDIR /repo COPY . . RUN pnpm install --frozen-lockfile ARG APP_NAME +# Vite bakes VITE_-prefixed vars into the bundle at build time (no runtime +# env in a static nginx-served SPA) — see docs/adr/0002 + compose.yaml's +# build.args for each of the 3 frontends. Default is same-origin relative +# path: the API is never exposed on its own public domain (operator +# decision), so in production the reverse proxy in front of each public +# frontend domain must forward this path (e.g. /api/*) to eden-api on the +# internal network — see the comment on EDEN_CORS_ORIGINS in .env.example. +ARG VITE_API_BASE_URL=/api/v1 +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} RUN pnpm turbo run build --filter=@eden/${APP_NAME}... FROM nginx:1.27-alpine AS runtime diff --git a/packages/config/src/env-schema.ts b/packages/config/src/env-schema.ts index f1e84d5..685aa14 100644 --- a/packages/config/src/env-schema.ts +++ b/packages/config/src/env-schema.ts @@ -36,6 +36,19 @@ export const envSchema = z.object({ S3_BUCKET: z.string().optional(), PUBLIC_BASE_URL: z.string().optional(), + + /** + * Comma-separated list of allowed browser origins for CORS. The API has + * no public domain of its own (operator decision) — production traffic + * reaches it same-origin through each frontend's reverse proxy, so this + * only matters for local dev, where the Vite dev server (localhost:3001+) + * and the API (localhost:8080) are different origins. Optional — when + * unset, CORS stays effectively disabled (no browser origin is allowed + * cross-origin). Never a wildcard: the API issues bearer tokens, and a + * wildcard origin combined with credentialed requests is a known + * CSRF-adjacent misconfiguration. + */ + EDEN_CORS_ORIGINS: z.string().optional(), }); export type EdenEnv = z.infer; diff --git a/packages/database/migrations/1788450538883_cross-reference-fks.cjs b/packages/database/migrations/1788450538883_cross-reference-fks.cjs new file mode 100644 index 0000000..9f467a7 --- /dev/null +++ b/packages/database/migrations/1788450538883_cross-reference-fks.cjs @@ -0,0 +1,55 @@ +/** + * Adds the cross-reference foreign keys between Quotes, Client + * Registrations and Contracts that were deliberately left as plain + * nullable UUIDs when each table was created in parallel (see the header + * comments in 1788445280742_quotes.cjs, 1788447739229_client-registrations.cjs + * and 1788447802216_contracts.cjs). All three tables now exist on `main`, + * so this closes that gap. + * + * ON DELETE RESTRICT everywhere: none of these aggregates has a DELETE + * route today (quotes/client_registrations/contracts are only ever + * status-transitioned, never removed), and a business document silently + * losing its link to another business document on an unrelated delete + * would be a worse failure mode than the delete itself being blocked. + */ + +exports.up = (pgm) => { + pgm.addConstraint("quotes", "fk_quotes_client_registration", { + foreignKeys: { + columns: "client_registration_id", + references: "client_registrations(id)", + onDelete: "RESTRICT", + }, + }); + + pgm.addConstraint("client_registrations", "fk_client_registrations_quote", { + foreignKeys: { + columns: "quote_id", + references: "quotes(id)", + onDelete: "RESTRICT", + }, + }); + + pgm.addConstraint("contracts", "fk_contracts_quote", { + foreignKeys: { + columns: "quote_id", + references: "quotes(id)", + onDelete: "RESTRICT", + }, + }); + + pgm.addConstraint("contracts", "fk_contracts_client_registration", { + foreignKeys: { + columns: "client_registration_id", + references: "client_registrations(id)", + onDelete: "RESTRICT", + }, + }); +}; + +exports.down = (pgm) => { + pgm.dropConstraint("contracts", "fk_contracts_client_registration"); + pgm.dropConstraint("contracts", "fk_contracts_quote"); + pgm.dropConstraint("client_registrations", "fk_client_registrations_quote"); + pgm.dropConstraint("quotes", "fk_quotes_client_registration"); +}; diff --git a/packages/database/migrations/1788451164762_inventory.cjs b/packages/database/migrations/1788451164762_inventory.cjs new file mode 100644 index 0000000..602190a --- /dev/null +++ b/packages/database/migrations/1788451164762_inventory.cjs @@ -0,0 +1,264 @@ +/** + * Inventory/Warehouse/Assets bounded context — Master Prompt §6.8, ADR-0007. + * Greenfield: the legacy OrçaFácil has no real stock module, only the + * requisições cited in the Master Prompt, so there is no eden.md section + * to port faithfully here — this schema is designed from the Master + * Prompt §6.8 requirements directly. + * + * Master Prompt §6.8 lists 15 possible entities. This migration builds + * the 5 that form one coherent, end-to-end-testable slice (reserve → + * select serial → install/comodato → return), mirroring the + * 4-of-11-tables MVP approach taken by the Contracts module + * (docs/adr/0006, see 1788447802216_contracts.cjs's header comment): + * + * - warehouses (this migration) + * - stock_movements (this migration) + * - serialized_assets (this migration) + * - asset_assignments (this migration) + * - stock_reservations (this migration) + * + * Explicitly DEFERRED, not forgotten — each needs a module/workflow that + * hasn't landed yet: + * - warehouse_locations — bin/shelf-level location inside a warehouse; + * needs a UX for put-away/picking before a bare table is useful. + * - stock_items — a per-(warehouse,product) balance cache row; + * deliberately NOT built — this MVP's invariant is that on-hand + * balance is ALWAYS derived from stock_movements (SUM by sign), never + * a stored/editable column (see stock_movements' comment). A + * materialized cache can be added later purely as a performance + * optimization, refreshed FROM the ledger, never the other way round. + * - stock_lots — batch/lot tracking (expiry, supplier batch); + * no product in the current catalog needs it yet. + * - inventory_counts — physical count / cycle-count workflow with + * its own reconciliation UX; a count's variance would itself need to + * post `adjustment_in`/`adjustment_out` movements once built. + * - transfers — a formal multi-step inter-warehouse transfer + * (request → in-transit → receive) with its own approval workflow; + * `transfer_out`/`transfer_in` movement types are already modeled in + * the ledger vocabulary below so this can be layered on top later + * without a ledger migration. + * - receipts / issues — formal goods-receipt-note / issue-note + * documents (e.g. tied to a purchase order); `purchase_in` already + * covers the ledger side for this MVP without the PO workflow. + * - returns — a formal customer-return workflow distinct + * from the simple `/assets/:id/return` endpoint this MVP ships + * (which only closes the assignment and posts `return_in`); needed + * later for e.g. partial/quantity-based non-serialized returns. + * - rma — formal vendor RMA case tracking (tracking + * number, vendor, resolution); `rma_out` is already modeled in the + * ledger vocabulary below so the case-tracking table can be added + * later without a ledger migration. + * - asset_maintenance — service/repair history for a serialized + * asset; needs its own workflow design, not just a bare log table. + * + * No FK to `contracts` yet: `serialized_assets.current_contract_id` and + * `asset_assignments.contract_id` are deliberately nullable/plain UUID + * with NO REFERENCES clause — same adopted pattern as Quotes/Contracts/ + * ClientRegistrations' cross-module FKs while modules land in parallel + * worktrees. Wiring those FKs is integration work for the merge step. + * + * ON DELETE: no table in this migration ever CASCADEs a delete from + * warehouses/products/serialized_assets — an asset or warehouse with + * movement/assignment history is never hard-deleted, only inactivated + * (warehouses.is_active) or status-transitioned (serialized_assets.status + * = 'written_off'). This is a deliberate invariant per the Inventory + * agent's mandate, not an oversight. + */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + -- ===================================================================== + -- warehouses — armazém/depósito físico, pertence a uma legal entity + -- ===================================================================== + CREATE TABLE warehouses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + company_id UUID NOT NULL REFERENCES companies(id), + name TEXT NOT NULL, + code TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + UNIQUE (code) + ); + COMMENT ON TABLE warehouses IS + 'Armazém/depósito físico — Master Prompt §6.8. Pertence a uma company (legal entity, companies.id). code é único globalmente (não só por company) para servir como identificador humano curto e inequívoco em toda a UI/relatórios. Nunca deletado fisicamente — um armazém desativado é is_active=false, preservando o histórico de stock_movements/serialized_assets que apontam para ele (ver comentário de topo da migration).'; + + CREATE TRIGGER trg_warehouses_updated_at BEFORE UPDATE ON warehouses + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + CREATE INDEX idx_warehouses_company ON warehouses(company_id); + + -- ===================================================================== + -- stock_movements — ledger append-only. Saldo de estoque é SEMPRE + -- derivado daqui (SUM com sinal implícito pelo tipo), nunca uma coluna + -- de saldo editável em nenhuma outra tabela. + -- ===================================================================== + CREATE TABLE stock_movements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + warehouse_id UUID NOT NULL REFERENCES warehouses(id), + product_id UUID NOT NULL REFERENCES products(id), + movement_type TEXT NOT NULL CHECK (movement_type IN ( + 'purchase_in', 'adjustment_in', 'adjustment_out', + 'transfer_out', 'transfer_in', + 'reservation', 'reservation_release', + 'sale_out', 'install_out', 'comodato_out', + 'return_in', 'rma_out', 'write_off' + )), + quantity NUMERIC(14,3) NOT NULL CHECK (quantity > 0), + reference_type TEXT, + reference_id UUID, + notes TEXT + ); + COMMENT ON TABLE stock_movements IS + 'Ledger append-only de estoque — Master Prompt §6.8. quantity é SEMPRE positivo; o sinal/direção é implícito pelo movement_type (ver COMMENT de movement_type). Uma correção de um movimento passado é sempre um NOVO movimento de estorno (ex.: um purchase_in lançado errado é corrigido com um adjustment_out equivalente), nunca um UPDATE/DELETE da linha original — daí os triggers trg_stock_movements_no_update/no_delete abaixo, mesmo padrão de audit_log (ver baseline-schema). reference_type/reference_id são polimórficos e opcionais (sem FK, por natureza) — ex.: reference_type=''contract''/reference_id= num install_out, ou reference_type=''serialized_asset''/reference_id= numa reservation de um ativo específico.'; + COMMENT ON COLUMN stock_movements.movement_type IS + 'Direção implícita por tipo (usada para calcular o saldo on-hand de um warehouse+product via SUM): + ENTRADA (+ saldo on-hand): purchase_in, adjustment_in, transfer_in, return_in + SAÍDA (- saldo on-hand): adjustment_out, transfer_out, sale_out, install_out, comodato_out, rma_out, write_off + NEUTRO para saldo on-hand, afeta apenas "disponível para prometer" + (on_hand - reservas ativas), não o saldo físico: reservation, reservation_release + Saldo on-hand de um (warehouse_id, product_id) = SUM(quantity) das entradas − SUM(quantity) das saídas, sempre calculado em tempo de consulta a partir desta tabela — nunca cacheado numa coluna editável (ver header da migration, item stock_items deferido).'; + + CREATE OR REPLACE FUNCTION prevent_stock_movement_mutation() RETURNS TRIGGER AS $$ + BEGIN + IF current_setting('eden.allow_stock_movement_mutation', true) IS DISTINCT FROM 'true' THEN + RAISE EXCEPTION 'stock_movements é append-only — correções são novos movimentos de estorno, nunca UPDATE/DELETE (ver Master Prompt §6.8 / .claude/agents/eden-inventory.md)'; + END IF; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER trg_stock_movements_no_update BEFORE UPDATE ON stock_movements + FOR EACH ROW EXECUTE FUNCTION prevent_stock_movement_mutation(); + CREATE TRIGGER trg_stock_movements_no_delete BEFORE DELETE ON stock_movements + FOR EACH ROW EXECUTE FUNCTION prevent_stock_movement_mutation(); + + CREATE INDEX idx_stock_movements_warehouse_product ON stock_movements(warehouse_id, product_id); + CREATE INDEX idx_stock_movements_reference ON stock_movements(reference_type, reference_id); + CREATE INDEX idx_stock_movements_created_at ON stock_movements(created_at); + + -- ===================================================================== + -- serialized_assets — um ativo físico rastreado individualmente + -- ===================================================================== + CREATE TABLE serialized_assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + updated_by UUID REFERENCES users(id), + product_id UUID NOT NULL REFERENCES products(id), + serial TEXT, + patrimony_tag TEXT, + mac_address TEXT, + warehouse_id UUID REFERENCES warehouses(id), + status TEXT NOT NULL DEFAULT 'in_stock' CHECK (status IN ( + 'in_stock', 'reserved', 'installed', 'comodato', 'returned', 'written_off' + )), + current_contract_id UUID, + warranty_expires_at TIMESTAMPTZ, + CONSTRAINT chk_serialized_assets_has_identifier CHECK (serial IS NOT NULL OR patrimony_tag IS NOT NULL) + ); + COMMENT ON TABLE serialized_assets IS + 'Ativo físico rastreado individualmente (serial/patrimônio/MAC) — Master Prompt §6.8. Ao menos um identificador (serial OU patrimony_tag) é obrigatório; produtos que só rastreiam patrimônio (ex.: mobiliário) podem não ter serial de fabricante. warehouse_id é a localização atual e é NULL quando o ativo está instalado/comodato no cliente (status IN (''installed'',''comodato'')) — current_contract_id aponta pra onde ele está nesse caso. current_contract_id NÃO tem FK para contracts ainda (contracts está sendo construído em paralelo por outro agente neste worktree) — mesmo padrão de FK cruzada adiada de Quotes/Contracts/ClientRegistrations; a FK real é trabalho de integração no merge. Nunca deletado fisicamente: um ativo dado de baixa vira status=''written_off'' (ver header da migration).'; + COMMENT ON COLUMN serialized_assets.mac_address IS + 'MVP: uma única coluna de MAC primário (nullable), não um array nem uma tabela filha. Decisão documentada: o índice único parcial que garante "MAC nunca duplicado entre ativos simultaneamente ativos" (ver abaixo) precisa colocalizar mac_address com status na MESMA tabela — um índice parcial não pode referenciar uma condição de uma tabela filha via JOIN. Se um produto real precisar rastrear múltiplos MACs (ex.: CPE dual-band com MAC de LAN e de WLAN), promover para uma tabela filha então, com a invariante reforçada por trigger em vez de índice parcial — deferido conscientemente, não esquecido.'; + COMMENT ON COLUMN serialized_assets.status IS + 'in_stock/reserved/installed/comodato contam como "ativo" para a invariante de não-duplicação de identificador (ver índices únicos parciais abaixo). returned/written_off NÃO contam — liberam o serial/patrimônio/MAC para reuso por uma linha nova, ex.: uma correção de cadastro. Nota: o endpoint POST /assets/:id/return desta MVP leva o ativo direto de volta a ''in_stock'' (mesma linha) — o status ''returned'' fica reservado para um fluxo futuro de RMA/inspeção pós-devolução (ainda não implementado, ver header da migration) em que o ativo chega mas ainda não foi conferido/restocado.'; + COMMENT ON COLUMN serialized_assets.current_contract_id IS + 'Sem FK para contracts.id ainda — deliberado, ver COMMENT da tabela. Preenchido em /assets/:id/install, limpo em /assets/:id/return.'; + + CREATE TRIGGER trg_serialized_assets_updated_at BEFORE UPDATE ON serialized_assets + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + -- Invariante obrigatória (Master Prompt §15.3 / §6.8: "nunca reutilizar + -- o mesmo serial/MAC/patrimônio simultaneamente em dois ativos + -- ativos") — reforçada em DB, não só em validação de aplicação. + CREATE UNIQUE INDEX idx_serialized_assets_serial_active ON serialized_assets(serial) + WHERE serial IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato'); + CREATE UNIQUE INDEX idx_serialized_assets_patrimony_active ON serialized_assets(patrimony_tag) + WHERE patrimony_tag IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato'); + CREATE UNIQUE INDEX idx_serialized_assets_mac_active ON serialized_assets(mac_address) + WHERE mac_address IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato'); + + CREATE INDEX idx_serialized_assets_product ON serialized_assets(product_id); + CREATE INDEX idx_serialized_assets_warehouse ON serialized_assets(warehouse_id); + CREATE INDEX idx_serialized_assets_status ON serialized_assets(status); + CREATE INDEX idx_serialized_assets_contract ON serialized_assets(current_contract_id); + + -- ===================================================================== + -- asset_assignments — histórico de vínculo de um serialized_asset a um + -- contrato/cliente ao longo do tempo + -- ===================================================================== + CREATE TABLE asset_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + asset_id UUID NOT NULL REFERENCES serialized_assets(id), + contract_id UUID NOT NULL, + assignment_type TEXT NOT NULL CHECK (assignment_type IN ('installed', 'comodato')), + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + unassigned_at TIMESTAMPTZ + ); + COMMENT ON TABLE asset_assignments IS + 'Histórico de vínculo de um ativo serializado a um contrato/cliente — Master Prompt §6.8. contract_id sem FK ainda (mesmo motivo/padrão de serialized_assets.current_contract_id). Nunca UPDATE de um assignment fechado (unassigned_at já preenchido): encerrar um vínculo é preencher unassigned_at UMA vez (camada de repositório só permite esse UPDATE específico, guardado pelo índice único parcial abaixo que impede reabrir/duplicar um vínculo aberto); um novo vínculo é sempre uma linha nova.'; + + -- Reforça em DB que um ativo nunca tem mais de um vínculo ABERTO + -- simultâneo (unassigned_at IS NULL) — mesma invariante de + -- "não alocado duas vezes" do Master Prompt §15.3, vista pelo lado do + -- histórico de vínculos em vez do status do ativo. + CREATE UNIQUE INDEX idx_asset_assignments_open ON asset_assignments(asset_id) + WHERE unassigned_at IS NULL; + + CREATE INDEX idx_asset_assignments_asset ON asset_assignments(asset_id); + CREATE INDEX idx_asset_assignments_contract ON asset_assignments(contract_id); + + -- ===================================================================== + -- stock_reservations — reserva de quantidade de um produto num + -- warehouse, com ou sem um serial específico já selecionado + -- ===================================================================== + CREATE TABLE stock_reservations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + warehouse_id UUID NOT NULL REFERENCES warehouses(id), + product_id UUID NOT NULL REFERENCES products(id), + quantity NUMERIC(14,3) NOT NULL CHECK (quantity > 0), + reference_type TEXT, + reference_id UUID, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'released', 'fulfilled')), + notes TEXT + ); + COMMENT ON TABLE stock_reservations IS + 'Reserva de quantidade de um produto num warehouse, ANTES (ou no lugar) da seleção de um serial específico — Master Prompt §6.8, primeiro passo do fluxo reservar→selecionar serial→instalar. Duas formas de uso, ambas suportadas por esta mesma tabela (sem coluna asset_id — decisão documentada): + 1. Reserva GENÉRICA (produto+quantidade, ainda sem serial escolhido): reference_type/reference_id apontam, por exemplo, para a quote_id ou contract_id que originou a necessidade. Caso descrito no Master Prompt como o primeiro passo do fluxo. + 2. Reserva de um ATIVO ESPECÍFICO (POST /assets/:id/reserve): quantity=1, reference_type=''serialized_asset'', reference_id= — rastreável ao ativo exato sem precisar de uma coluna dedicada. + status=''fulfilled'' quando a reserva efetivamente vira uma saída (ex.: o ativo reservado é instalado); ''released'' quando cancelada sem uso. Sem FK em reference_id (polimórfico).'; + + CREATE TRIGGER trg_stock_reservations_updated_at BEFORE UPDATE ON stock_reservations + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + CREATE INDEX idx_stock_reservations_warehouse_product ON stock_reservations(warehouse_id, product_id); + CREATE INDEX idx_stock_reservations_status ON stock_reservations(status); + CREATE INDEX idx_stock_reservations_reference ON stock_reservations(reference_type, reference_id); + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP TABLE IF EXISTS stock_reservations; + DROP TABLE IF EXISTS asset_assignments; + DROP TABLE IF EXISTS serialized_assets; + DROP TABLE IF EXISTS stock_movements; + DROP FUNCTION IF EXISTS prevent_stock_movement_mutation(); + DROP TABLE IF EXISTS warehouses; + `); +}; diff --git a/packages/database/migrations/1788451374900_support.cjs b/packages/database/migrations/1788451374900_support.cjs new file mode 100644 index 0000000..e424a37 --- /dev/null +++ b/packages/database/migrations/1788451374900_support.cjs @@ -0,0 +1,164 @@ +/** + * Support/Service Desk bounded context (Master Prompt §6.13). Greenfield — + * no legacy OrçaFácil module to port, unlike most other EDEN modules. + * + * This MVP builds the 4 tables needed for a working ticket lifecycle with + * real SLA timers: + * - support_tickets (this migration) + * - ticket_comments (this migration) + * - ticket_status_history (this migration) + * - sla_timers (this migration, 1:1 with support_tickets) + * + * Explicitly DEFERRED, not forgotten: + * - Real attachments (S3) — ticket_comments/support_tickets have no + * attachment table yet; needs the Documents/S3 integration adapter + * (packages/integrations) to land first, same reasoning as + * contract_documents in the Contracts migration. + * - CSAT (customer satisfaction survey after closing) — needs a survey + * delivery mechanism (email/portal) that doesn't exist yet. + * - Dedicated work order (OS) entity distinct from the ticket itself — + * this MVP treats the ticket as also carrying the OS-equivalent + * fields (root_cause/resolution_notes); a first-class `work_orders` + * table with its own scheduling/dispatch workflow is future work. + * - Queue model with automatic routing rules — there is no `queues` + * table yet; tickets are assigned via owner_user_id only (manual). + * - Configurable attendance calendar (business hours/holidays) for SLA + * due-date math — `sla_timers` due dates are computed with naive + * wall-clock offsets (see apps/api/src/support/sla.ts), not a + * calendar-aware engine. Category-specific SLA policy is also + * deferred — this MVP uses one flat default policy for every ticket + * regardless of category/priority (see sla.ts comment). + * + * `support_tickets.client_registration_id` and `.contract_id` are + * deliberately nullable with NO foreign key, same adopted pattern as + * `contracts.client_registration_id`/`contracts.quote_id`: those modules + * are being built in parallel by other agents. Wiring the FKs is + * integration-step work, not this migration's. + */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + -- ===================================================================== + -- support_tickets — the core aggregate + -- ===================================================================== + CREATE SEQUENCE support_tickets_protocol_number_seq START 1; + + CREATE TABLE support_tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + updated_by UUID REFERENCES users(id), + protocol_number TEXT NOT NULL UNIQUE + DEFAULT ('TCK-' || to_char(now(), 'YYYY') || '-' || lpad(nextval('support_tickets_protocol_number_seq')::text, 6, '0')), + title TEXT NOT NULL, + description TEXT NOT NULL, + category TEXT NOT NULL, + subcategory TEXT, + priority TEXT NOT NULL DEFAULT 'medium' + CHECK (priority IN ('low', 'medium', 'high', 'critical')), + status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ( + 'new', 'triage', 'in_progress', 'waiting_customer', + 'waiting_third_party', 'resolved', 'closed', 'cancelled' + )), + reseller_id UUID REFERENCES resellers(id), + client_registration_id UUID, + contract_id UUID, + owner_user_id UUID REFERENCES users(id), + opened_by_user_id UUID NOT NULL REFERENCES users(id), + root_cause TEXT, + resolution_notes TEXT, + first_response_at TIMESTAMPTZ, + resolved_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ + ); + COMMENT ON TABLE support_tickets IS + 'Ticket de atendimento (Service Desk, Master Prompt §6.13) — módulo greenfield, sem equivalente no legado OrçaFácil. protocol_number é gerado inline via support_tickets_protocol_number_seq + to_char, mesmo padrão de quotes.quote_number (ver migration 1788445280742_quotes.cjs), formato TCK-{ano}-{sequência de 6 dígitos}. client_registration_id/contract_id são nullable SEM FK — client_registrations e contracts existem em outras migrations mas a FK cruzada é trabalho de integração, não desta migration (mesmo padrão adotado por contracts.client_registration_id/contracts.quote_id).'; + COMMENT ON COLUMN support_tickets.root_cause IS + 'Causa raiz — só tem sentido preenchido no fechamento do ticket (transição para ''closed''), mas não há CHECK de banco forçando isso: a camada de aplicação (support.service.ts) só aceita esse campo no payload da transição para ''closed''.'; + COMMENT ON COLUMN support_tickets.first_response_at IS + 'Quando a primeira resposta pública (não-interna) ao ticket ocorreu. Setado uma única vez (nunca sobrescrito) pela camada de aplicação ao adicionar o primeiro ticket_comments.is_internal=false — ver apps/api/src/support/support.service.ts e sla_timers.first_response_met.'; + COMMENT ON COLUMN support_tickets.resolved_at IS + 'Quando o status se tornou ''resolved'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE, mesmo padrão de contracts.activated_at).'; + COMMENT ON COLUMN support_tickets.closed_at IS + 'Quando o status se tornou ''closed'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE, mesmo padrão de contracts.activated_at).'; + + CREATE TRIGGER trg_support_tickets_updated_at BEFORE UPDATE ON support_tickets + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + CREATE INDEX idx_support_tickets_status ON support_tickets(status); + CREATE INDEX idx_support_tickets_reseller ON support_tickets(reseller_id); + CREATE INDEX idx_support_tickets_owner ON support_tickets(owner_user_id); + CREATE INDEX idx_support_tickets_client_registration ON support_tickets(client_registration_id); + CREATE INDEX idx_support_tickets_contract ON support_tickets(contract_id); + + ALTER SEQUENCE support_tickets_protocol_number_seq OWNED BY support_tickets.protocol_number; + + -- ===================================================================== + -- ticket_comments — public/internal comment thread + -- ===================================================================== + CREATE TABLE ticket_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, + author_user_id UUID NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + is_internal BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + COMMENT ON TABLE ticket_comments IS + 'Thread de comentários do ticket. is_internal=true nunca pode ser exposto ao portal do assinante ou da revenda — reforçado na camada de query/serviço (apps/api/src/support/support.service.ts), não apenas por convenção de UI, mesma disciplina de isolamento cross-tenant do módulo comercial (Master Prompt, eden-support persona).'; + CREATE INDEX idx_ticket_comments_ticket ON ticket_comments(ticket_id); + + -- ===================================================================== + -- ticket_status_history — append-only UX-facing lifecycle log, + -- distinct from (and complementary to) the generic audit_log + -- ===================================================================== + CREATE TABLE ticket_status_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, + from_status TEXT, + to_status TEXT NOT NULL, + reason TEXT, + changed_by UUID REFERENCES users(id), + changed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + COMMENT ON TABLE ticket_status_history IS + 'Log de transições de status do ticket — append-only por convenção da CAMADA DE APLICAÇÃO (nenhuma rota de UPDATE/DELETE é exposta), mesmo padrão e mesma justificativa de coexistência com audit_log de contract_status_history (ver migration 1788447802216_contracts.cjs).'; + CREATE INDEX idx_ticket_status_history_ticket ON ticket_status_history(ticket_id); + + -- ===================================================================== + -- sla_timers — 1:1 with support_tickets (ticket_id is both PK and FK) + -- ===================================================================== + CREATE TABLE sla_timers ( + ticket_id UUID PRIMARY KEY REFERENCES support_tickets(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + first_response_due_at TIMESTAMPTZ NOT NULL, + resolution_due_at TIMESTAMPTZ NOT NULL, + first_response_met BOOLEAN, + resolution_met BOOLEAN, + paused_since TIMESTAMPTZ, + accumulated_pause_seconds INTEGER NOT NULL DEFAULT 0 + ); + COMMENT ON TABLE sla_timers IS + 'Timer de SLA, uma linha por ticket (ticket_id é PK e FK ao mesmo tempo — força o 1:1). Regra de pausa: ao entrar em waiting_customer/waiting_third_party, paused_since = now(); ao voltar para in_progress, accumulated_pause_seconds soma o intervalo pausado e paused_since volta a NULL — implementado atomicamente em packages/database/src/repositories/support-tickets.ts::updateTicketStatus (mesmo padrão SELECT...FOR UPDATE + withTransaction de contracts.ts::updateContractStatus). O cálculo de violação de SLA (subtraindo o tempo pausado, incluindo a pausa corrente se ainda pausado) é feito pelas funções puras computeElapsedSeconds/isSlaBreached em apps/api/src/support/sla.ts — nunca reimplementado ad-hoc. first_response_met/resolution_met só são preenchidos no momento em que o evento correspondente (primeira resposta pública / entrada em resolved) de fato ocorre — permanecem NULL até lá, não são recalculados retroativamente.'; + COMMENT ON COLUMN sla_timers.paused_since IS + 'Quando não-nulo, o timer está pausado desde este instante — todo o tempo entre paused_since e now() ainda NÃO está refletido em accumulated_pause_seconds e deve ser somado no cálculo de "tempo decorrido" (ver computeElapsedSeconds em sla.ts).'; + + CREATE TRIGGER trg_sla_timers_updated_at BEFORE UPDATE ON sla_timers + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP TABLE IF EXISTS sla_timers; + DROP TABLE IF EXISTS ticket_status_history; + DROP TABLE IF EXISTS ticket_comments; + DROP TABLE IF EXISTS support_tickets; + DROP SEQUENCE IF EXISTS support_tickets_protocol_number_seq; + `); +}; diff --git a/packages/database/migrations/1788452136875_hr-timeclock.cjs b/packages/database/migrations/1788452136875_hr-timeclock.cjs new file mode 100644 index 0000000..1ae2ca1 --- /dev/null +++ b/packages/database/migrations/1788452136875_hr-timeclock.cjs @@ -0,0 +1,245 @@ +/** + * HR/Timeclock (Ponto Eletrônico) — eden.md módulo 6, foundation leva. + * Master Prompt §6.14. Ported faithfully from the legacy OrçaFácil + * (`eden.md` módulo 6 §1 for the data model, §2 for the Control iD + * integration shape, §4 for the AFD format) — this is dense legal/ + * compliance logic already validated in production, not redesigned here. + * + * SCOPE OF THIS LEVA (deliberately narrow — see the task brief): + * - timeclock_devices — Control iD REP iDClass device registry + * (data model only; no live sync yet) + * - timeclock_employees — separate cadastro from `users` (see note + * below on why) + * - afd_import_batches — one row per AFD file import (manual upload) + * - afd_records — one row per AFD file LINE, IMMUTABLE + * + * Explicitly DEFERRED to a future leva, not forgotten (eden.md §5-§10): + * - timeclock_employee_devices — per-device sync/biometry status link + * table (eden.md §1.3); needs the live-sync workflow to be meaningful. + * - timeclock_work_schedules / timeclock_work_schedule_days (§1.6-1.7) + * - timeclock_holidays (§1.8) + * - timeclock_punch_adjustments (§1.9) — administrative treatments + * - timeclock_daily_calculations (§1.10) — apuração engine output + * - timeclock_time_bank_entries (§1.11) — banco de horas ledger + * - timeclock_period_closures (§1.12) — fechamento de período + * - time_audit_logs (§1.13) — this leva uses the shared `audit_log` + * hash-chain (via auditRepo.appendAuditEvent) instead; the module- + * specific structured log is deferred with the workflows that need it + * (approvals, reopenings) to render nicely. + * + * ── employees vs. users (explicit decision, per task brief) ───────────── + * eden.md §1.2 is unambiguous: `timeclock_employees` is a cadastro + * SEPARATE from `users` (with an optional `user_id` link), because (a) a + * punch-clock employee often has no ERP login at all, and (b) the + * Control iD device identifies a person BY CPF (eden.md §1.2's "Importante + * — identidade no relógio": "no modo 671 da API Control iD, o CPF é o + * próprio identificador do usuário no equipamento"), and `users` has no + * CPF column. Reusing `users` would mean inventing a CPF column on a + * table owned by Identity/Auth, and forcing every punch-clock employee to + * have an ERP login they may not need. So: `timeclock_employees` it is, + * ported faithfully. + * + * ── naming deviation from eden.md, per task brief ──────────────────────── + * The task brief names the import-batch table `afd_import_batches` + * (this migration) rather than eden.md's `afd_imports`. Kept as briefed. + * Also simplified vs. eden.md's richer version: no S3 storage of the + * original file bytes (the file is never persisted outside afd_records' + * raw_line reconstruction — see afd-imports.controller.ts (CreateAfdImportDto) for why: this + * leva's import endpoint accepts the file as a JSON string, not a + * multipart upload, so there's no original file artifact worth archiving + * separately from the parsed lines themselves; a real multipart upload + + * S3 archival, matching eden.md's `file_s3_key`, is future work), and no + * `source` distinction (`manual_upload` only — `live_sync` is deferred + * along with the Control iD live-communication layer, eden.md §2-3). + * + * ── crc_valid / validation_status as TEXT enums, not a plain boolean ──── + * The task brief's own summary suggested a boolean `crc_valid`, but + * eden.md §1.5/§4.2 documents a 4-value enum (VALID/INVALID/ + * NOT_APPLICABLE/NOT_VALIDATED) because "no CRC field for this record + * type" (types 1/5/6/9) and "CRC field present but not checked" are + * different, useful facts from a real CRC mismatch (type 7's own hash + * chain) — collapsing that into a boolean would lose information the + * legacy explicitly modeled. Followed eden.md here per this task's own + * instruction to prefer the full read over the summary when they diverge. + */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + -- ===================================================================== + -- timeclock_devices — Control iD REP iDClass equipment registry + -- ===================================================================== + CREATE TABLE timeclock_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + updated_by UUID REFERENCES users(id), + company_id UUID, + name TEXT NOT NULL, + description TEXT, + branch TEXT, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 443 CHECK (port > 0 AND port <= 65535), + use_https BOOLEAN NOT NULL DEFAULT true, + username TEXT NOT NULL, + password_encrypted TEXT NOT NULL, + serial_number TEXT, + model TEXT, + firmware_version TEXT, + timezone TEXT NOT NULL DEFAULT 'America/Sao_Paulo', + status TEXT NOT NULL DEFAULT 'UNKNOWN' CHECK (status IN ('UNKNOWN', 'ONLINE', 'OFFLINE', 'ERROR')), + last_seen_at TIMESTAMPTZ, + last_sync_at TIMESTAMPTZ, + last_error TEXT, + last_afd_nsr BIGINT, + active BOOLEAN NOT NULL DEFAULT true + ); + COMMENT ON TABLE timeclock_devices IS + 'Relógios de ponto Control iD REP iDClass — eden.md módulo 6 §1.1/§2. company_id nullable = equipamento "sem empresa" (compartilhado). DELIBERADAMENTE sem FK para companies: essa tabela (Organization bounded context) não existe neste worktree isolado no momento em que esta migration foi escrita — mesma situação de contracts.cjs (contracts.quote_id/client_registration_id) neste monorepo. A FK é trabalho de integração/merge, não desta migration.'; + COMMENT ON COLUMN timeclock_devices.password_encrypted IS + 'AES-256-GCM reversível via FieldEncryptionService (apps/api/src/crypto/field-encryption.service.ts, formato v1:iv:tag:ciphertext) — NUNCA hash, porque a aplicação precisa autenticar no equipamento sob demanda a cada operação (eden.md §2.2/§2.3). NUNCA retornada em texto puro por nenhuma rota GET — a API expõe apenas um flag hasCredential.'; + COMMENT ON COLUMN timeclock_devices.timezone IS + 'Metadado informativo do cadastro do equipamento. A interpretação de negócio das datas/horas do AFD SEMPRE usa o offset literal embutido no próprio campo de data/hora do arquivo (fixo -03:00 na prática, América/São_Paulo sem horário de verão desde 2019) — esta coluna não é consultada pelo parser (afd-parser.ts), que nunca assume o timezone do servidor nem de nenhuma configuração.'; + COMMENT ON COLUMN timeclock_devices.last_afd_nsr IS + 'Cursor de sincronização incremental do AFD (eden.md §3) — reservado para a leva de sync ao vivo; não é escrito por esta leva (só import manual de arquivo completo).'; + + CREATE TRIGGER trg_timeclock_devices_updated_at BEFORE UPDATE ON timeclock_devices + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE INDEX idx_timeclock_devices_company ON timeclock_devices(company_id); + + -- ===================================================================== + -- timeclock_employees — separate from 'users' (see migration header) + -- ===================================================================== + CREATE TABLE timeclock_employees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + updated_by UUID REFERENCES users(id), + company_id UUID, + user_id UUID REFERENCES users(id), + full_name TEXT NOT NULL, + cpf TEXT NOT NULL, + registration TEXT, + active BOOLEAN NOT NULL DEFAULT true, + UNIQUE (company_id, cpf) + ); + COMMENT ON TABLE timeclock_employees IS + 'Cadastro de funcionários do módulo de ponto, SEPARADO de users (eden.md §1.2) — vínculo opcional via user_id. CPF (11 dígitos, sem máscara) é o identificador do usuário no equipamento Control iD no modo 671 — não existe um "controlid_user_id" separado. CPF único por empresa (company_id, cpf), não globalmente. work_schedule_id (jornada vinculada) é DEFERRADO para a leva de apuração — não existe ainda tabela timeclock_work_schedules.'; + COMMENT ON COLUMN timeclock_employees.cpf IS + '11 dígitos, sem máscara. Validação de dígito verificador é responsabilidade da camada de aplicação (mesma convenção de contract_parties.document/companies.cnpj neste monorepo) — sem CHECK de formato no banco nesta leva (nenhuma rota de CRUD de employees foi implementada ainda; a coluna existe para afd_records.employee_id ter o que referenciar e para a resolução de CPF do parser).'; + + CREATE TRIGGER trg_timeclock_employees_updated_at BEFORE UPDATE ON timeclock_employees + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE INDEX idx_timeclock_employees_company ON timeclock_employees(company_id); + CREATE INDEX idx_timeclock_employees_user ON timeclock_employees(user_id); + + -- ===================================================================== + -- afd_import_batches — one row per AFD file import (manual upload only + -- this leva; live_sync source is deferred with the Control iD live + -- communication layer, eden.md §2-3) + -- ===================================================================== + CREATE TABLE afd_import_batches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id UUID NOT NULL REFERENCES timeclock_devices(id), + company_id UUID, + source TEXT NOT NULL DEFAULT 'manual_upload' CHECK (source = 'manual_upload'), + file_hash TEXT NOT NULL UNIQUE, + file_size INTEGER NOT NULL, + record_count INTEGER NOT NULL DEFAULT 0, + inserted_count INTEGER NOT NULL DEFAULT 0, + valid_count INTEGER NOT NULL DEFAULT 0, + warning_count INTEGER NOT NULL DEFAULT 0, + invalid_count INTEGER NOT NULL DEFAULT 0, + unsupported_count INTEGER NOT NULL DEFAULT 0, + parse_failure_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL CHECK (status IN ('VALID', 'WARNING', 'INVALID')), + first_nsr BIGINT, + last_nsr BIGINT, + summary JSONB NOT NULL DEFAULT '{}'::jsonb, + imported_by UUID REFERENCES users(id), + imported_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + COMMENT ON TABLE afd_import_batches IS + 'Metadados de cada importação de arquivo AFD (eden.md §1.4, nomeada afd_import_batches por instrução desta leva em vez de afd_imports do legado). file_hash é a idempotência de arquivo inteiro (SHA-256) — UNIQUE garante que reimportar o mesmo conteúdo retorna already_imported em vez de duplicar. Simplificação desta leva: nenhum S3/armazenamento do arquivo original bruto (o transporte é um JSON string, não multipart — ver afd-imports.controller.ts (CreateAfdImportDto)); o dado legal é preservado integralmente em afd_records.raw_line, uma linha por registro.'; + COMMENT ON COLUMN afd_import_batches.file_hash IS + 'SHA-256 do CONTEÚDO recebido pela API (a string JSON do body, decodificada como texto) — não necessariamente idêntico byte-a-byte ao SHA-256 de um arquivo .afd bruto em disco, já que o transporte desta leva é string JSON, não upload multipart de bytes crus. Suficiente para o propósito de idempotência desta leva; ver nota de simplificação no cabeçalho da migration.'; + COMMENT ON COLUMN afd_import_batches.record_count IS + 'Total de linhas parseadas do arquivo (inclui INVALID/UNSUPPORTED_LAYOUT — nunca descartadas). inserted_count pode ser menor que record_count quando ON CONFLICT (device_id, nsr) DO NOTHING pulou registros já existentes de uma importação anterior (idempotência por registro, eden.md §1.5).'; + + CREATE INDEX idx_afd_import_batches_device ON afd_import_batches(device_id); + + -- ===================================================================== + -- afd_records — one row per AFD file LINE. IMMUTABLE: no application + -- route may ever UPDATE/DELETE this table, at any privilege level, + -- not even super_admin (eden.md módulo 6 §12) — enforced here again at + -- the DB level via trigger, same pattern as audit_log's + -- prevent_audit_log_mutation, but WITHOUT that trigger's escape-hatch + -- GUC (audit_log allows a controlled internal bypass; afd_records + -- deliberately does not, per this leva's explicit non-negotiable + -- instruction: "em nenhum nível de privilégio, nem para super_admin"). + -- ===================================================================== + CREATE TABLE afd_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + afd_import_batch_id UUID NOT NULL REFERENCES afd_import_batches(id), + device_id UUID NOT NULL REFERENCES timeclock_devices(id), + company_id UUID, + nsr BIGINT, + record_type TEXT NOT NULL, + event_datetime TIMESTAMPTZ, + event_datetime_raw TEXT, + employee_cpf_raw TEXT, + employee_id UUID REFERENCES timeclock_employees(id), + raw_line TEXT NOT NULL, + crc_received TEXT, + crc_calculated TEXT, + crc_valid TEXT NOT NULL DEFAULT 'NOT_APPLICABLE' CHECK (crc_valid IN ('VALID', 'INVALID', 'NOT_APPLICABLE', 'NOT_VALIDATED')), + parsed_data JSONB NOT NULL DEFAULT '{}'::jsonb, + validation_status TEXT NOT NULL CHECK (validation_status IN ('VALID', 'WARNING', 'INVALID', 'UNSUPPORTED_LAYOUT')), + validation_notes TEXT, + UNIQUE (device_id, nsr) + ); + COMMENT ON TABLE afd_records IS + 'Dado bruto de ponto, eden.md módulo 6 §1.5/§12 — IMUTÁVEL por design. Nenhuma rota de UPDATE/DELETE existe na camada de aplicação (apps/api/src/hr-timeclock/afd-records.controller.ts expõe apenas GET) e o banco reforça isso via trigger (trg_afd_records_no_update/no_delete abaixo) SEM nenhuma válvula de escape — nem para super_admin, diferente de audit_log. Preservado mesmo quando inválido: nsr é nullable de propósito (NSR ausente/não-numérico nunca vira 0/autoincrement), e todo registro tem raw_line completa, mesmo os classificados INVALID/UNSUPPORTED_LAYOUT.'; + COMMENT ON COLUMN afd_records.nsr IS + 'Número Sequencial de Registro. NULLABLE DE PROPÓSITO — NSR ausente/não-numérico é inválido mas precisa ser preservado, nunca substituído por 0/autoincrement (eden.md §12). UNIQUE (device_id, nsr): múltiplas linhas com nsr NULL do mesmo device são permitidas (semântica padrão de NULL em índice único do Postgres) — a idempotência por registro só se aplica a NSRs realmente numéricos.'; + COMMENT ON COLUMN afd_records.crc_valid IS + 'VALID/INVALID = CRC-16/KERMIT verificado (tipos 2/3/4). NOT_APPLICABLE = tipo 7, que usa cadeia de hash própria NUNCA recalculada pelo EDEN. NOT_VALIDATED = tipos 1/5/6/9, sem campo de CRC. Enum de 4 valores, não boolean — ver nota no cabeçalho da migration sobre por que.'; + COMMENT ON COLUMN afd_records.parsed_data IS + 'Campos decodificados específicos do tipo de registro, com posição EXATA documentada em eden.md §4.1 — qualquer trecho sem posição documentada vai para a chave "raw_tail" dentro deste JSONB, nunca é inventado/adivinhado (ver apps/api/src/hr-timeclock/afd-parser.ts).'; + COMMENT ON COLUMN afd_records.employee_cpf_raw IS + 'CPF exatamente como veio no campo do AFD (12 caracteres de largura documentada para tipos 3/7, 11 para o cpf_resp do tipo 4) — sempre string, nunca number, nunca perde zero à esquerda. employee_id é resolvido a partir daqui via normalizeAfdCpf (últimos 11 dígitos numéricos), escopado por company_id quando o device tem uma; NULL quando órfão (sem match) — nunca um match é inventado/forçado.'; + + CREATE INDEX idx_afd_records_employee ON afd_records(employee_id); + CREATE INDEX idx_afd_records_company ON afd_records(company_id); + CREATE INDEX idx_afd_records_type ON afd_records(record_type); + CREATE INDEX idx_afd_records_event_datetime ON afd_records(event_datetime); + CREATE INDEX idx_afd_records_batch ON afd_records(afd_import_batch_id); + + CREATE OR REPLACE FUNCTION prevent_afd_records_mutation() RETURNS TRIGGER AS $$ + BEGIN + RAISE EXCEPTION 'afd_records é imutável (dado bruto de ponto, eden.md módulo 6 §12) — UPDATE/DELETE bloqueados, sem exceção, mesmo para super_admin'; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER trg_afd_records_no_update BEFORE UPDATE ON afd_records + FOR EACH ROW EXECUTE FUNCTION prevent_afd_records_mutation(); + CREATE TRIGGER trg_afd_records_no_delete BEFORE DELETE ON afd_records + FOR EACH ROW EXECUTE FUNCTION prevent_afd_records_mutation(); + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP TRIGGER IF EXISTS trg_afd_records_no_delete ON afd_records; + DROP TRIGGER IF EXISTS trg_afd_records_no_update ON afd_records; + DROP FUNCTION IF EXISTS prevent_afd_records_mutation(); + DROP TABLE IF EXISTS afd_records; + DROP TABLE IF EXISTS afd_import_batches; + DROP TABLE IF EXISTS timeclock_employees; + DROP TABLE IF EXISTS timeclock_devices; + `); +}; diff --git a/packages/database/migrations/1788457368744_fiscal.cjs b/packages/database/migrations/1788457368744_fiscal.cjs new file mode 100644 index 0000000..50940f2 --- /dev/null +++ b/packages/database/migrations/1788457368744_fiscal.cjs @@ -0,0 +1,231 @@ +/** + * Fiscal bounded context (Master Prompt §6.11, ADR-0011, eden.md módulo 5 + * "Módulo Fiscal (NCM, CFOP, Municípios)"). The legacy OrçaFácil has a + * mature ~19-table catalog layer + automatic (Siscomex/IBGE fetch) and + * manual (CSV/XLSX/JSON upload) importers + a sync log (`fiscal_catalog_sync`) + * + `product_fiscal_profiles` linking `products` to fiscal classification. + * + * This MVP builds a coherent, faithfully-shaped SUBSET, end-to-end: + * - fiscal_ncm (this migration) — "simple catalog" shape, eden.md §2.2 + * - fiscal_cfop (this migration) — simple catalog + direction/scope, eden.md §2.3/§4 + * - fiscal_municipality (this migration) — distinct shape, eden.md §2.3 + * - fiscal_operation_nature (this migration) — internal enum-like catalog, eden.md §2.3, seeded + * - product_fiscal_profiles (this migration) — eden.md §2.5, reduced to the + * 4 catalogs actually created here (ncm/cfop/municipality/operation_nature) + * + * Explicitly DEFERRED, not forgotten: + * - The other ~9 "simple catalog" tables (eden.md §2.2): fiscal_product_origin, + * fiscal_icms_cst, fiscal_icms_csosn, fiscal_pis_cst, fiscal_cofins_cst, + * fiscal_ipi_cst, fiscal_lc116_service, fiscal_nbs, fiscal_ibs_cbs_cst, + * fiscal_ibs_cbs_credit_class — same shape as fiscal_ncm/fiscal_cfop, can + * be added later without a redesign. + * - The "extended catalog" tables (eden.md §2.3): fiscal_cest, fiscal_cbenef, + * fiscal_nfcom_cclass, fiscal_nfse_trib_nacional, fiscal_nfse_trib_municipal, + * fiscal_ibs_cbs_classification, fiscal_ibs_cbs_standard_rate. + * - `fiscal_catalog_sync` (append-only sync log, eden.md §2.4) and the + * automatic importers (NCM/Siscomex, Municipality/IBGE — eden.md §3/§5) + * + the generic manual file importer with dry-run and the mass- + * inactivation safety threshold (eden.md §7) — substantial, isolated + * work (network fetch adapters, checksum/threshold logic, file parsing), + * deferred to its own leva. + * - `fiscal_rules` (eden.md §2.6) — schema-ready-but-unconsumed even in the + * legacy itself ("estrutura pronta, sem UI/consumo ainda"); not recreated + * here, stays future work until the fiscal resolution engine is built. + * - `products.ixc_product_code` / `products.product_code` (eden.md §6) — + * belong to the Products migration, not this one. + * - product_fiscal_profiles columns for catalogs not yet created here: + * nfcom_cclass_id, nfse_trib_nacional_id, nfse_trib_municipal_id, + * lc116_service_id, nbs_id, cest_id, product_origin_id, ibs_cbs_cst_id, + * ibs_cbs_classification_id, ibs_cbs_credit_class_id (eden.md §2.5) — + * added when their respective catalogs land. + * + * `product_fiscal_profiles.product_id` is deliberately nullable-FK-free: + * NO `REFERENCES products(id)`, same cross-module "FK cruzada adiada" + * pattern used by `contracts.quote_id`/`contracts.client_registration_id` + * (see 1788447802216_contracts.cjs header) and + * `support_tickets.client_registration_id`/`.contract_id` + * (see 1788451374900_support.cjs header) — this worktree may not always + * carry every other module's migration in the same merge order; wiring the + * FK is integration-step work at merge time, not this migration's. + * + * Naming convention: all fiscal tables use `created_at`/`updated_at` + * (not `created_date`/`updated_date`) — this is already EDEN's own + * repo-wide convention (see every other migration in this directory), so + * there is no deviation to reconcile here. eden.md §2.1 calls this "a + * deliberate deviation" only relative to the *legacy* OrçaFácil schema, + * not relative to EDEN. + */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + -- ===================================================================== + -- fiscal_ncm — Nomenclatura Comum do Mercosul. "Simple catalog" shape, + -- eden.md §2.2. Source: Receita Federal / Siscomex (import deferred). + -- ===================================================================== + CREATE TABLE fiscal_ncm ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL, + description TEXT NOT NULL, + valid_from DATE, + valid_until DATE, + active BOOLEAN NOT NULL DEFAULT true, + source TEXT, + source_version TEXT, + source_updated_at TIMESTAMPTZ, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + COMMENT ON TABLE fiscal_ncm IS + 'Nomenclatura Comum do Mercosul (eden.md §2.2, §3). "Catálogo simples" — mesmo shape reutilizável para as ~9 outras tabelas de catálogo simples ainda não portadas (fiscal_product_origin, ICMS CST/CSOSN, PIS/COFINS/IPI CST, LC116, NBS, IBS-CBS CST/credit_class). Importação automática (Siscomex, checksum, trava de volume mínimo, upsert com inactivateMissing=true) é trabalho DEFERIDO — ver cabeçalho da migration.'; + + CREATE UNIQUE INDEX idx_fiscal_ncm_code ON fiscal_ncm(code); + CREATE INDEX idx_fiscal_ncm_active ON fiscal_ncm(active); + + CREATE TRIGGER trg_fiscal_ncm_updated_at BEFORE UPDATE ON fiscal_ncm + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + -- ===================================================================== + -- fiscal_cfop — Código Fiscal de Operações e Prestações. "Simple + -- catalog" shape + direction/scope (eden.md §2.3, §4). Fonte MANUAL + -- (sem endpoint estruturado confiável testado no legado). + -- ===================================================================== + CREATE TABLE fiscal_cfop ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL, + description TEXT NOT NULL, + direction TEXT, + scope TEXT, + valid_from DATE, + valid_until DATE, + active BOOLEAN NOT NULL DEFAULT true, + source TEXT, + source_version TEXT, + source_updated_at TIMESTAMPTZ, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + COMMENT ON TABLE fiscal_cfop IS + 'Código Fiscal de Operações e Prestações (eden.md §2.3, §4). "Catálogo simples" + direction/scope. Fonte declarada "Portal Nacional NF-e" mas tipo MANUAL no legado (sem endpoint estruturado confiável testado) — cadastro por upload de arquivo ou tela, ambos DEFERIDOS nesta leva (ver cabeçalho da migration).'; + COMMENT ON COLUMN fiscal_cfop.direction IS + 'Direção da operação (entrada/saída) — TEXT livre, sem CHECK/enum, exatamente como o legado (eden.md §4). O 1º dígito do código CFOP codifica direção+âmbito na prática brasileira, mas isso NUNCA é derivado automaticamente do código no legado nem aqui — direction/scope são só campos preenchidos manualmente/via importação.'; + COMMENT ON COLUMN fiscal_cfop.scope IS + 'Âmbito da operação (estadual/interestadual/exterior) — TEXT livre, sem CHECK/enum, mesma razão de direction (eden.md §4).'; + + CREATE UNIQUE INDEX idx_fiscal_cfop_code ON fiscal_cfop(code); + CREATE INDEX idx_fiscal_cfop_active ON fiscal_cfop(active); + + CREATE TRIGGER trg_fiscal_cfop_updated_at BEFORE UPDATE ON fiscal_cfop + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + -- ===================================================================== + -- fiscal_municipality — shape DIFERENTE do catálogo simples: sem + -- valid_from/valid_until/metadata ("não faz sentido um município + -- expirar", eden.md §2.3). Fonte: IBGE (import deferido). + -- ===================================================================== + CREATE TABLE fiscal_municipality ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL, + name TEXT NOT NULL, + state TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + source TEXT, + source_version TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + COMMENT ON TABLE fiscal_municipality IS + 'Municípios/IBGE (eden.md §2.3, §5) — base para ISS/NFS-e. Shape deliberadamente distinto do catálogo simples: sem valid_from/valid_until/metadata ("não faz sentido um município expirar"). code = código IBGE, state = UF. Importação automática (IBGE, checksum, trava de volume mínimo de 5000 linhas) é DEFERIDA — ver cabeçalho da migration.'; + + CREATE UNIQUE INDEX idx_fiscal_municipality_code ON fiscal_municipality(code); + CREATE INDEX idx_fiscal_municipality_state ON fiscal_municipality(state); + + CREATE TRIGGER trg_fiscal_municipality_updated_at BEFORE UPDATE ON fiscal_municipality + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + -- ===================================================================== + -- fiscal_operation_nature — cadastro interno, sem fonte externa + -- (eden.md §2.3). Seed fixo via ON CONFLICT DO NOTHING. + -- ===================================================================== + CREATE TABLE fiscal_operation_nature ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + COMMENT ON TABLE fiscal_operation_nature IS + 'Naturezas de operação (eden.md §2.3) — cadastro interno de negócio, não uma fonte externa sincronizável. Seed fixo abaixo, único endpoint da API para este catálogo é leitura (GET /v1/fiscal/operation-natures) — sem criação via API nesta leva.'; + + CREATE TRIGGER trg_fiscal_operation_nature_updated_at BEFORE UPDATE ON fiscal_operation_nature + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + INSERT INTO fiscal_operation_nature (code, name) VALUES + ('VENDA', 'Venda'), + ('REMESSA_LOCACAO', 'Remessa em locação'), + ('RETORNO_LOCACAO', 'Retorno de locação'), + ('REMESSA_MANUTENCAO', 'Remessa para manutenção'), + ('RETORNO_MANUTENCAO', 'Retorno de manutenção'), + ('STFC_RECORRENTE', 'STFC recorrente'), + ('SVA_RECORRENTE', 'SVA recorrente'), + ('PABX_RECORRENTE', 'PABX recorrente'), + ('PABX_IMPLANTACAO', 'PABX implantação'), + ('SOFTWARE_RECORRENTE', 'Software recorrente'), + ('SOFTWARE_IMPLANTACAO', 'Software implantação') + ON CONFLICT (code) DO NOTHING; + + -- ===================================================================== + -- product_fiscal_profiles — liga products à classificação fiscal + -- (eden.md §2.5). Reduzida aos 4 catálogos criados nesta leva. + -- ===================================================================== + CREATE TABLE product_fiscal_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id UUID NOT NULL, + billing_component TEXT NOT NULL CHECK (billing_component IN ('RECURRING', 'IMPLEMENTATION')), + document_type TEXT NOT NULL DEFAULT 'NONE' CHECK (document_type IN ('NFCOM', 'NFSE', 'NFE', 'NONE')), + ncm_id UUID REFERENCES fiscal_ncm(id), + cfop_id UUID REFERENCES fiscal_cfop(id), + municipality_id UUID REFERENCES fiscal_municipality(id), + operation_nature_id UUID REFERENCES fiscal_operation_nature(id), + valid_from DATE, + valid_until DATE, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + updated_by UUID REFERENCES users(id) + ); + COMMENT ON TABLE product_fiscal_profiles IS + 'Configuração fiscal de produto (eden.md §2.5) — deliberadamente separada de products, nunca duplica campos comerciais (price_*, impl_unit_price, metered, tariff_rates, has_ldi continuam só em products). product_id SEM FK (products pode não existir neste worktree isolado — ver cabeçalho da migration, padrão "FK cruzada adiada" de contracts.cjs/support.cjs). Colunas de referência a catálogos ainda não portados (nfcom_cclass_id, nfse_trib_nacional_id, nfse_trib_municipal_id, lc116_service_id, nbs_id, cest_id, product_origin_id, ibs_cbs_cst_id, ibs_cbs_classification_id, ibs_cbs_credit_class_id) ficam para quando os catálogos correspondentes existirem. Sem backfill automático nesta leva (eden.md §2.5 backfill dependia de products.service_type/impl_unit_price já populados em produção real) — profiles são criados sob demanda via API.'; + COMMENT ON COLUMN product_fiscal_profiles.product_id IS + 'Sem REFERENCES products(id) nesta leva — products pode não existir neste worktree isolado. FK cruzada é trabalho de integração no merge (mesmo padrão de contracts.quote_id/client_registration_id e support_tickets.client_registration_id/contract_id).'; + + -- Invariante central (eden.md §2.5): um produto tem no máximo um + -- profile ATIVO por billing_component. Nunca DELETE/UPDATE destrutivo + -- de um profile antigo para "trocar" a classificação — cria-se um novo + -- profile ativo e inativa-se o anterior (active=false), preservando + -- histórico. + CREATE UNIQUE INDEX idx_product_fiscal_profiles_active_component + ON product_fiscal_profiles(product_id, billing_component) WHERE active = true; + + CREATE INDEX idx_product_fiscal_profiles_product ON product_fiscal_profiles(product_id); + + CREATE TRIGGER trg_product_fiscal_profiles_updated_at BEFORE UPDATE ON product_fiscal_profiles + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP TABLE IF EXISTS product_fiscal_profiles; + DROP TABLE IF EXISTS fiscal_operation_nature; + DROP TABLE IF EXISTS fiscal_municipality; + DROP TABLE IF EXISTS fiscal_cfop; + DROP TABLE IF EXISTS fiscal_ncm; + `); +}; diff --git a/packages/database/migrations/1788457629203_billing.cjs b/packages/database/migrations/1788457629203_billing.cjs new file mode 100644 index 0000000..236be36 --- /dev/null +++ b/packages/database/migrations/1788457629203_billing.cjs @@ -0,0 +1,185 @@ +/** + * Billing bounded context (recurring billing engine) — Master Prompt + * §6.10, docs/adr/0008-billing-finance-fiscal-separation.md. ADR-0008 is + * explicit: Billing (this module) calculates *what is owed* and produces + * an internal invoice; it is strictly separate from Finance/AR-AP + * (collecting/paying — títulos, boletos, dunning, conciliação) and from + * Fiscal (documento fiscal). Neither of those is built here. + * + * ADR-0008 lists nine Billing tables (billing_accounts, billing_cycles, + * subscriptions/services, charge_components, usage_charges, invoices, + * invoice_items, invoice_adjustments, billing_runs). This migration + * builds the coherent MVP subset needed for a working, testable + * subscription -> billing_run -> invoice flow: + * + * - billing_subscriptions (this migration) + * - billing_runs (this migration) + * - invoices (this migration) + * - invoice_items (this migration) + * + * Explicitly DEFERRED, not forgotten: + * - billing_accounts — this MVP hangs subscriptions/invoices directly + * off `reseller_id`; a billing_account (grouping multiple subscriptions + * under one consolidated bill) is deferred until there's a real need + * to bill more than one subscription together. + * - billing_cycles as its own table — this MVP derives the cycle from + * the (billing_day, reference_year, reference_month) triple on the + * run/subscription rather than a calendar table of cycle instances. + * - charge_components / usage_charges — consumption/telephony billing; + * needs the usage-rating module (out of scope), so every subscription + * in this MVP bills a single flat `monthly_value` with no metered + * component. + * - invoice_adjustments / credit_debit_adjustments — the sanctioned way + * to correct a *consolidated* invoice (never a silent edit); deferred, + * so today a consolidated billing_run's invoices are simply immutable + * from the application layer (see billing.ts's assertRunIsMutable). + * - billing_run_logs — this MVP uses the shared `audit_log` + * (auditRepo.appendAuditEvent) for every billing_run/invoice mutation + * instead of a dedicated structured log table. + * - proration — every subscription always bills the full monthly_value; + * no day-proportional calculation for a subscription that started or + * was cancelled mid-cycle. `invoice_items.item_type` already reserves + * the 'proration' vocabulary value for when this lands, so it won't + * need a migration of its own. + * + * Deferred cross-context foreign keys (no REFERENCES, plain UUID, same + * pattern used by Quotes/Contracts/ClientRegistrations for tables owned by + * a module built in a different worktree — see contracts' migration + * header for precedent): + * - billing_subscriptions.contract_id — `contracts` (Commercial/§6.6) is + * not guaranteed to exist in every worktree this batch might land on. + * - billing_subscriptions.reseller_id / invoices.reseller_id — + * `resellers` is not present in THIS worktree's migration set either + * (bootstrap-only checkout). Both FKs are integration work for + * whoever merges this alongside the resellers/contracts migrations. + */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + -- ===================================================================== + -- billing_subscriptions — a recurring billing subscription derived + -- from an active contract + -- ===================================================================== + CREATE TABLE billing_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + updated_by UUID REFERENCES users(id), + contract_id UUID NOT NULL, + reseller_id UUID, + monthly_value NUMERIC(14,2) NOT NULL CHECK (monthly_value >= 0), + billing_day INTEGER NOT NULL CHECK (billing_day BETWEEN 1 AND 28), + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'suspended', 'cancelled')), + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + cancelled_at TIMESTAMPTZ + ); + COMMENT ON TABLE billing_subscriptions IS + 'Assinatura de cobrança recorrente derivada de um contrato ativo (Master Prompt §6.10). contract_id e reseller_id são UUID simples sem REFERENCES — mesmo padrão de FK cruzada adiada de Quotes/Contracts/ClientRegistrations: contracts/resellers não existem garantidamente neste worktree (checkout de bootstrap). A FK real é trabalho de integração central, não desta migration.'; + COMMENT ON COLUMN billing_subscriptions.monthly_value IS + 'Snapshot do valor mensal no momento da criação da subscription — NUNCA recalculado automaticamente se o contrato de origem mudar depois (isso seria uma reversão silenciosa de fatura, proibida pelo Master Prompt §24 invariante nº4). Uma mudança de valor exige cancelar e recriar a subscription (ou, quando existir, um endpoint explícito de reajuste) — nesta leva não há reajuste, só criação/cancelamento.'; + COMMENT ON COLUMN billing_subscriptions.billing_day IS + 'Dia do mês do ciclo de cobrança, limitado a 1-28 deliberadamente para nunca cair em um mês sem esse dia (evita a ambiguidade de dia 29-31 em fevereiro/meses de 30 dias) — não há regra de "último dia do mês" nesta leva.'; + COMMENT ON COLUMN billing_subscriptions.cancelled_at IS + 'Setado uma única vez, quando status transiciona para cancelled (camada de repositório) — nunca sobrescrito depois.'; + CREATE INDEX idx_billing_subscriptions_reseller ON billing_subscriptions(reseller_id); + CREATE INDEX idx_billing_subscriptions_status_day ON billing_subscriptions(status, billing_day); + CREATE INDEX idx_billing_subscriptions_contract ON billing_subscriptions(contract_id); + + CREATE TRIGGER trg_billing_subscriptions_updated_at BEFORE UPDATE ON billing_subscriptions + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + -- ===================================================================== + -- billing_runs — one billing-close execution for a + -- (reference_year, reference_month, billing_day) period + -- ===================================================================== + CREATE TABLE billing_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + reference_year INTEGER NOT NULL CHECK (reference_year BETWEEN 2000 AND 2100), + reference_month INTEGER NOT NULL CHECK (reference_month BETWEEN 1 AND 12), + billing_day INTEGER NOT NULL CHECK (billing_day BETWEEN 1 AND 28), + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'consolidated', 'failed')), + consolidated_at TIMESTAMPTZ, + triggered_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + COMMENT ON TABLE billing_runs IS + 'Execução de fechamento de billing para um período + billing_day (Master Prompt §6.10). draft é reexecutável: rodar de novo com os mesmos (reference_year, reference_month, billing_day) sobre um run ainda draft SUBSTITUI (delete+reinsert, mesma transação) as invoices geradas em vez de duplicar — implementado em packages/database/src/repositories/billing.ts, generateOrRegenerateBillingRun(). consolidated é terminal e imutável pela aplicação (ver invoices.status e assertRunIsMutable). failed é reservado para quando esta leva ganhar um passo que possa falhar de forma parcial (ex. rating de consumo) — nada nesta leva efetivamente produz failed.'; + COMMENT ON COLUMN billing_runs.triggered_by IS + 'Ator (usuário) que disparou a criação/regeneração deste run — atualizado a cada regeneração em draft para refletir quem rodou por último.'; + CREATE INDEX idx_billing_runs_period ON billing_runs(reference_year, reference_month, billing_day); + + -- Idempotência: no máximo um run 'draft' por (ano, mês, billing_day) — + -- índice único parcial em vez de UNIQUE(reference_year, reference_month, + -- billing_day, status) puro, porque este último permitiria dois runs + -- 'consolidated' distintos para o mesmo período sem impedir a corrida + -- de dois 'draft' concorrentes ao mesmo tempo (o problema real que essa + -- invariante precisa resolver). A regra "não criar novo run para um + -- período já consolidado" (refaturamento controlado é deferido) é + -- reforçada na camada de aplicação, não aqui, porque múltiplos runs + -- 'consolidated' históricos para o mesmo período não são logicamente + -- impossíveis no schema (só indesejados pelo fluxo atual). + CREATE UNIQUE INDEX uq_billing_runs_one_draft_per_period + ON billing_runs(reference_year, reference_month, billing_day) + WHERE status = 'draft'; + + -- ===================================================================== + -- invoices — internal invoice generated by a billing_run for one + -- billing_subscription + -- ===================================================================== + CREATE TABLE invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + billing_run_id UUID NOT NULL REFERENCES billing_runs(id) ON DELETE CASCADE, + billing_subscription_id UUID NOT NULL REFERENCES billing_subscriptions(id), + reseller_id UUID, + amount NUMERIC(14,2) NOT NULL CHECK (amount >= 0), + reference_year INTEGER NOT NULL, + reference_month INTEGER NOT NULL CHECK (reference_month BETWEEN 1 AND 12), + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'consolidated', 'cancelled')), + due_date DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (billing_run_id, billing_subscription_id) + ); + COMMENT ON TABLE invoices IS + 'Fatura interna (Billing) gerada por um billing_run — DISTINTA de um título de AR/AP (Finance) e de um documento fiscal (Fiscal), ADR-0008. status espelha o status do run no momento da geração (draft -> pode ser apagada/regerada enquanto o run ainda é draft; consolidated -> imutável pela aplicação, ver invariante em billing.ts). UNIQUE(billing_run_id, billing_subscription_id) é uma segunda barreira de idempotência a nível de banco: o mesmo run nunca pode gerar duas invoices para a mesma subscription.'; + COMMENT ON COLUMN invoices.reseller_id IS + 'Denormalizado a partir de billing_subscriptions.reseller_id no momento da geração — evita join obrigatório para filtrar por revenda em listagens/relatórios; nunca é a fonte da verdade (a subscription é). Sem REFERENCES pelo mesmo motivo de billing_subscriptions.reseller_id (ver cabeçalho da migration).'; + COMMENT ON COLUMN invoices.due_date IS + 'Nesta leva, due_date = dia billing_day do mês de referência (reference_year/reference_month) — regra de vencimento mais sofisticada (ex. carência após o fechamento) é responsabilidade do módulo Finance/AR-AP (ADR-0008), fora de escopo aqui.'; + CREATE INDEX idx_invoices_run ON invoices(billing_run_id); + CREATE INDEX idx_invoices_subscription ON invoices(billing_subscription_id); + CREATE INDEX idx_invoices_reseller_status ON invoices(reseller_id, status); + CREATE INDEX idx_invoices_period ON invoices(reference_year, reference_month); + + -- ===================================================================== + -- invoice_items — invoice line items (generic vocabulary; this MVP + -- only ever generates 'recurring' automatically) + -- ===================================================================== + CREATE TABLE invoice_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + amount NUMERIC(14,2) NOT NULL, + item_type TEXT NOT NULL + CHECK (item_type IN ('recurring', 'proration', 'implementation', 'adjustment')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + COMMENT ON TABLE invoice_items IS + 'Linhas da fatura, modeladas de forma genérica para não exigir migration nova quando proration/implementation/adjustment passarem a ser gerados automaticamente — nesta leva, a camada de aplicação só cria itens item_type=recurring (um por invoice, espelhando billing_subscriptions.monthly_value no momento da geração do run).'; + CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id); + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP TABLE IF EXISTS invoice_items; + DROP TABLE IF EXISTS invoices; + DROP TABLE IF EXISTS billing_runs; + DROP TABLE IF EXISTS billing_subscriptions; + `); +}; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 4a0c3a9..5d19387 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -10,6 +10,11 @@ export * as quotesRepo from "./repositories/quotes.js"; export * as contractsRepo from "./repositories/contracts.js"; export * as clientRegistrationsRepo from "./repositories/client-registrations.js"; export * as clientRegistrationPartnersRepo from "./repositories/client-registration-partners.js"; +export * as inventoryRepo from "./repositories/inventory.js"; +export * as supportTicketsRepo from "./repositories/support-tickets.js"; +export * as hrTimeclockRepo from "./repositories/hr-timeclock.js"; +export * as fiscalRepo from "./repositories/fiscal.js"; +export * as billingRepo from "./repositories/billing.js"; export { listLeads, getLead, @@ -74,3 +79,87 @@ export type { CreatePartnerInput, UpdatePartnerInput, } from "./repositories/client-registration-partners.js"; +export type { + Warehouse, + CreateWarehouseInput, + UpdateWarehouseInput, + StockMovement, + StockMovementType, + ListStockMovementsFilter, + CreateStockMovementInput, + SerializedAsset, + SerializedAssetStatus, + ListSerializedAssetsFilter, + CreateAssetInput, + ReserveAssetInput, + InstallAssetInput, + ReturnAssetInput, + AssetAssignment, + StockReservation, + StockReservationStatus, + ListStockReservationsFilter, + CreateGenericReservationInput, +} from "./repositories/inventory.js"; +export type { + TicketStatus, + TicketPriority, + SupportTicket, + TicketComment, + TicketStatusHistoryEntry, + SlaTimer, + TicketWithDetails, + ListTicketsFilter, + CreateTicketInput, + CreateTicketCommentInput, + UpdateTicketStatusExtra, +} from "./repositories/support-tickets.js"; +export type { + TimeclockDeviceStatus, + TimeclockDevice, + CreateTimeclockDeviceInput, + UpdateTimeclockDeviceInput, + TimeclockEmployee, + AfdImportBatchStatus, + AfdCrcValidity, + AfdValidationStatus, + AfdImportBatch, + CreateAfdImportBatchInput, + AfdRecord, + AfdRecordInsertInput, + ImportAfdBatchInput, + ImportAfdBatchResult, + ListAfdRecordsFilter, +} from "./repositories/hr-timeclock.js"; +export type { + FiscalNcm, + CreateSimpleCatalogInput, + UpdateSimpleCatalogInput, + ListCatalogFilter, + FiscalCfop, + CreateCfopInput, + UpdateCfopInput, + FiscalMunicipality, + CreateMunicipalityInput, + UpdateMunicipalityInput, + FiscalOperationNature, + FiscalBillingComponent, + FiscalDocumentType, + ProductFiscalProfile, + CreateProductFiscalProfileInput, +} from "./repositories/fiscal.js"; +export type { + BillingSubscriptionStatus, + BillingSubscription, + BillingRunStatus, + BillingRun, + InvoiceStatus, + Invoice, + InvoiceItemType, + InvoiceItem, + BillingRunWithInvoices, + CreateBillingSubscriptionInput, + ListBillingSubscriptionsFilter, + GenerateBillingRunInput, + ListBillingRunsFilter, + ListInvoicesFilter, +} from "./repositories/billing.js"; diff --git a/packages/database/src/repositories/billing.ts b/packages/database/src/repositories/billing.ts new file mode 100644 index 0000000..c863f4d --- /dev/null +++ b/packages/database/src/repositories/billing.ts @@ -0,0 +1,471 @@ +import { query, withTransaction } from "../pool.js"; + +/** + * Billing bounded context (Master Prompt §6.10, ADR-0008) — recurring + * subscription -> billing_run -> invoice flow. See PENDING_billing.cjs's + * header comment for the full list of tables this MVP builds vs. defers + * (billing_accounts, billing_cycles, charge_components/usage_charges, + * invoice_adjustments, billing_run_logs, proration — all deferred). + * + * Mirrors contracts.ts's conventions: snake_case rows, withTransaction for + * multi-row writes that must commit/rollback together, typed domain + * errors mapped to HTTP status by the service layer (never here). + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type BillingSubscriptionStatus = "active" | "suspended" | "cancelled"; + +export interface BillingSubscription { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + updated_by: string | null; + contract_id: string; + reseller_id: string | null; + monthly_value: string; + billing_day: number; + status: BillingSubscriptionStatus; + started_at: Date; + cancelled_at: Date | null; +} + +export type BillingRunStatus = "draft" | "consolidated" | "failed"; + +export interface BillingRun { + id: string; + reference_year: number; + reference_month: number; + billing_day: number; + status: BillingRunStatus; + consolidated_at: Date | null; + triggered_by: string | null; + created_at: Date; +} + +export type InvoiceStatus = "draft" | "consolidated" | "cancelled"; + +export interface Invoice { + id: string; + billing_run_id: string; + billing_subscription_id: string; + reseller_id: string | null; + amount: string; + reference_year: number; + reference_month: number; + status: InvoiceStatus; + due_date: string | null; + created_at: Date; +} + +export type InvoiceItemType = "recurring" | "proration" | "implementation" | "adjustment"; + +export interface InvoiceItem { + id: string; + invoice_id: string; + description: string; + amount: string; + item_type: InvoiceItemType; + created_at: Date; +} + +export interface BillingRunWithInvoices extends BillingRun { + invoices: Invoice[]; + totalAmount: string; +} + +// --------------------------------------------------------------------------- +// Errors — mapped to HTTP status by the service layer, never here +// --------------------------------------------------------------------------- + +export class BillingSubscriptionNotFoundError extends Error { + constructor(public readonly subscriptionId: string) { + super(`Billing subscription ${subscriptionId} not found`); + this.name = "BillingSubscriptionNotFoundError"; + } +} + +export class BillingRunNotFoundError extends Error { + constructor(public readonly billingRunId: string) { + super(`Billing run ${billingRunId} not found`); + this.name = "BillingRunNotFoundError"; + } +} + +/** + * Thrown when an operation tries to write (regenerate invoices, alter + * items) against a billing_run that is already 'consolidated' — the + * central invariant of this module. The service layer maps this to 409. + */ +export class BillingRunConsolidatedError extends Error { + constructor(public readonly billingRunId: string) { + super(`Billing run ${billingRunId} is already consolidated and cannot be modified`); + this.name = "BillingRunConsolidatedError"; + } +} + +/** + * Thrown when creating/regenerating a run for a (year, month, billing_day) + * that already has a CONSOLIDATED run. Controlled re-billing (a new run + * deliberately superseding a consolidated one) is explicitly deferred + * (see migration header) — until it exists, this is a hard stop rather + * than a silent double-bill. + */ +export class BillingPeriodAlreadyConsolidatedError extends Error { + constructor( + public readonly referenceYear: number, + public readonly referenceMonth: number, + public readonly billingDay: number, + ) { + super( + `Billing run for ${referenceYear}-${referenceMonth}/day ${billingDay} is already consolidated`, + ); + this.name = "BillingPeriodAlreadyConsolidatedError"; + } +} + +// --------------------------------------------------------------------------- +// billing_subscriptions +// --------------------------------------------------------------------------- + +export interface CreateBillingSubscriptionInput { + contractId: string; + resellerId: string | null; + monthlyValue: number; + billingDay: number; + createdBy: string | null; +} + +export async function createBillingSubscription( + input: CreateBillingSubscriptionInput, +): Promise { + const { rows } = await query( + `INSERT INTO billing_subscriptions + (contract_id, reseller_id, monthly_value, billing_day, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $5) + RETURNING *`, + [input.contractId, input.resellerId, input.monthlyValue, input.billingDay, input.createdBy], + ); + const row = rows[0]; + if (!row) throw new Error("failed to create billing subscription"); + return row; +} + +export async function getBillingSubscriptionById(id: string): Promise { + const { rows } = await query( + "SELECT * FROM billing_subscriptions WHERE id = $1", + [id], + ); + return rows[0] ?? null; +} + +export interface ListBillingSubscriptionsFilter { + scope: "all" | "reseller"; + /** Required (and enforced by the caller) when scope === 'reseller'. */ + resellerId?: string | null; + status?: BillingSubscriptionStatus; +} + +export async function listBillingSubscriptions( + filter: ListBillingSubscriptionsFilter, +): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.scope === "reseller") { + // Deliberately never falls back to unfiltered: an actor with + // reseller-only scope and no resellerId sees nothing, not everything. + params.push(filter.resellerId ?? null); + conditions.push(`reseller_id = $${params.length}`); + } + if (filter.status) { + params.push(filter.status); + conditions.push(`status = $${params.length}`); + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM billing_subscriptions ${where} ORDER BY created_at DESC`, + params, + ); + return rows; +} + +/** + * Cancels a subscription — idempotent: cancelling an already-cancelled + * subscription is a no-op that returns the existing row rather than + * erroring, mirroring the "safe to retry" philosophy applied to billing + * runs elsewhere in this module. cancelled_at is set only the first time. + */ +export async function cancelBillingSubscription( + id: string, + actorId: string | null, +): Promise { + const { rows } = await query( + `UPDATE billing_subscriptions SET + status = 'cancelled', + cancelled_at = COALESCE(cancelled_at, now()), + updated_by = $2 + WHERE id = $1 + RETURNING *`, + [id, actorId], + ); + const row = rows[0]; + if (!row) throw new BillingSubscriptionNotFoundError(id); + return row; +} + +// --------------------------------------------------------------------------- +// billing_runs / invoices / invoice_items +// --------------------------------------------------------------------------- + +function dueDateFor(referenceYear: number, referenceMonth: number, billingDay: number): string { + const mm = String(referenceMonth).padStart(2, "0"); + const dd = String(billingDay).padStart(2, "0"); + return `${referenceYear}-${mm}-${dd}`; +} + +export interface GenerateBillingRunInput { + referenceYear: number; + referenceMonth: number; + billingDay: number; + triggeredBy: string | null; +} + +/** + * Idempotent billing close. Behavior for (referenceYear, referenceMonth, + * billingDay): + * - if a CONSOLIDATED run already exists for this exact period -> + * throws BillingPeriodAlreadyConsolidatedError (controlled re-billing + * is deferred, see migration header — this is a hard stop, not a + * silent double-bill). + * - if a DRAFT run already exists -> reused in place: its invoices (and + * their items) are deleted and regenerated from the current set of + * active subscriptions, all inside one transaction. Running this + * twice with the same parameters therefore never duplicates invoices + * — the second run replaces the first draft's invoices 1:1. + * - otherwise -> a new 'draft' run is created. + * + * One invoice per active billing_subscription whose billing_day matches, + * each carrying a single 'recurring' invoice_item for the full + * monthly_value (no proration in this MVP — see migration header). + */ +export async function generateOrRegenerateBillingRun( + input: GenerateBillingRunInput, +): Promise { + return withTransaction(async (client) => { + const { rows: existingRows } = await client.query( + `SELECT * FROM billing_runs + WHERE reference_year = $1 AND reference_month = $2 AND billing_day = $3 + AND status IN ('draft', 'consolidated') + FOR UPDATE`, + [input.referenceYear, input.referenceMonth, input.billingDay], + ); + + const consolidated = existingRows.find((r) => r.status === "consolidated"); + if (consolidated) { + throw new BillingPeriodAlreadyConsolidatedError( + input.referenceYear, + input.referenceMonth, + input.billingDay, + ); + } + + const existingDraft = existingRows.find((r) => r.status === "draft"); + let run: BillingRun; + + if (existingDraft) { + // Regeneration: wipe this draft's invoices (items cascade) and + // refresh triggered_by to reflect who ran it last. + await client.query( + `DELETE FROM invoices WHERE billing_run_id = $1`, + [existingDraft.id], + ); + const { rows: updatedRows } = await client.query( + `UPDATE billing_runs SET triggered_by = $2 WHERE id = $1 RETURNING *`, + [existingDraft.id, input.triggeredBy], + ); + run = updatedRows[0]!; + } else { + const { rows: createdRows } = await client.query( + `INSERT INTO billing_runs (reference_year, reference_month, billing_day, triggered_by) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [input.referenceYear, input.referenceMonth, input.billingDay, input.triggeredBy], + ); + run = createdRows[0]!; + } + + const { rows: subscriptions } = await client.query( + `SELECT * FROM billing_subscriptions WHERE status = 'active' AND billing_day = $1`, + [input.billingDay], + ); + + const dueDate = dueDateFor(input.referenceYear, input.referenceMonth, input.billingDay); + const invoices: Invoice[] = []; + + for (const subscription of subscriptions) { + const { rows: invoiceRows } = await client.query( + `INSERT INTO invoices + (billing_run_id, billing_subscription_id, reseller_id, amount, reference_year, reference_month, due_date) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + run.id, + subscription.id, + subscription.reseller_id, + subscription.monthly_value, + input.referenceYear, + input.referenceMonth, + dueDate, + ], + ); + const invoice = invoiceRows[0]!; + + await client.query( + `INSERT INTO invoice_items (invoice_id, description, amount, item_type) + VALUES ($1, $2, $3, 'recurring')`, + [invoice.id, "Mensalidade recorrente", subscription.monthly_value], + ); + + invoices.push(invoice); + } + + return { ...run, invoices, totalAmount: sumAmounts(invoices) }; + }); +} + +export interface ListBillingRunsFilter { + referenceYear?: number; + referenceMonth?: number; + status?: BillingRunStatus; +} + +export async function listBillingRuns(filter: ListBillingRunsFilter = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.referenceYear != null) { + params.push(filter.referenceYear); + conditions.push(`reference_year = $${params.length}`); + } + if (filter.referenceMonth != null) { + params.push(filter.referenceMonth); + conditions.push(`reference_month = $${params.length}`); + } + if (filter.status) { + params.push(filter.status); + conditions.push(`status = $${params.length}`); + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM billing_runs ${where} ORDER BY created_at DESC`, + params, + ); + return rows; +} + +function sumAmounts(invoices: Invoice[]): string { + const total = invoices.reduce((sum, inv) => sum + Number(inv.amount), 0); + return total.toFixed(2); +} + +export async function getBillingRunById(id: string): Promise { + const { rows } = await query("SELECT * FROM billing_runs WHERE id = $1", [id]); + const run = rows[0]; + if (!run) return null; + + const { rows: invoices } = await query( + "SELECT * FROM invoices WHERE billing_run_id = $1 ORDER BY created_at ASC", + [id], + ); + return { ...run, invoices, totalAmount: sumAmounts(invoices) }; +} + +export async function listInvoiceItems(invoiceId: string): Promise { + const { rows } = await query( + "SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY created_at ASC", + [invoiceId], + ); + return rows; +} + +/** + * Consolidates a billing_run: flips the run and every invoice it owns to + * 'consolidated' and stamps consolidated_at, all in one transaction. + * 409s (via BillingRunConsolidatedError) if already consolidated — + * consolidation is a one-way door in this MVP (no un-consolidate). + */ +export async function consolidateBillingRun( + id: string, + _actorId: string | null, +): Promise { + return withTransaction(async (client) => { + const { rows } = await client.query( + "SELECT * FROM billing_runs WHERE id = $1 FOR UPDATE", + [id], + ); + const run = rows[0]; + if (!run) throw new BillingRunNotFoundError(id); + if (run.status === "consolidated") throw new BillingRunConsolidatedError(id); + + const { rows: updatedRows } = await client.query( + `UPDATE billing_runs SET status = 'consolidated', consolidated_at = now() WHERE id = $1 RETURNING *`, + [id], + ); + const updated = updatedRows[0]!; + + await client.query( + `UPDATE invoices SET status = 'consolidated' WHERE billing_run_id = $1`, + [id], + ); + + const { rows: invoices } = await client.query( + "SELECT * FROM invoices WHERE billing_run_id = $1 ORDER BY created_at ASC", + [id], + ); + return { ...updated, invoices, totalAmount: sumAmounts(invoices) }; + }); +} + +export interface ListInvoicesFilter { + scope: "all" | "reseller"; + /** Required (and enforced by the caller) when scope === 'reseller'. */ + resellerId?: string | null; + status?: InvoiceStatus; + referenceYear?: number; + referenceMonth?: number; +} + +export async function listInvoices(filter: ListInvoicesFilter): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.scope === "reseller") { + params.push(filter.resellerId ?? null); + conditions.push(`reseller_id = $${params.length}`); + } + if (filter.status) { + params.push(filter.status); + conditions.push(`status = $${params.length}`); + } + if (filter.referenceYear != null) { + params.push(filter.referenceYear); + conditions.push(`reference_year = $${params.length}`); + } + if (filter.referenceMonth != null) { + params.push(filter.referenceMonth); + conditions.push(`reference_month = $${params.length}`); + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM invoices ${where} ORDER BY created_at DESC`, + params, + ); + return rows; +} diff --git a/packages/database/src/repositories/fiscal.ts b/packages/database/src/repositories/fiscal.ts new file mode 100644 index 0000000..7279312 --- /dev/null +++ b/packages/database/src/repositories/fiscal.ts @@ -0,0 +1,484 @@ +import { query, withTransaction } from "../pool.js"; + +/** + * Fiscal bounded context (Master Prompt §6.11, ADR-0011, eden.md módulo 5). + * See PENDING_fiscal.cjs migration header for full scope/deferral notes. + * This MVP covers 3 "catalog" tables (fiscal_ncm, fiscal_cfop, + * fiscal_municipality), the read-only fiscal_operation_nature seed, and + * product_fiscal_profiles — the piece that actually links a commercial + * product to its fiscal classification. + */ + +// =========================================================================== +// fiscal_ncm — "simple catalog" shape (eden.md §2.2) +// =========================================================================== + +export interface FiscalNcm { + id: string; + code: string; + description: string; + valid_from: string | null; + valid_until: string | null; + active: boolean; + source: string | null; + source_version: string | null; + source_updated_at: Date | null; + metadata: Record | null; + created_at: Date; + updated_at: Date; +} + +export interface CreateSimpleCatalogInput { + code: string; + description: string; + validFrom?: string | null; + validUntil?: string | null; + active?: boolean; + source?: string | null; + sourceVersion?: string | null; + sourceUpdatedAt?: string | null; + metadata?: Record | null; +} + +export interface UpdateSimpleCatalogInput { + code?: string; + description?: string; + validFrom?: string | null; + validUntil?: string | null; + active?: boolean; + source?: string | null; + sourceVersion?: string | null; + sourceUpdatedAt?: string | null; + metadata?: Record | null; +} + +export interface ListCatalogFilter { + q?: string; + active?: boolean; +} + +export async function listNcm(filter: ListCatalogFilter = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + if (filter.q) { + params.push(`%${filter.q}%`); + conditions.push(`(code ILIKE $${params.length} OR description ILIKE $${params.length})`); + } + if (filter.active !== undefined) { + params.push(filter.active); + conditions.push(`active = $${params.length}`); + } + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM fiscal_ncm ${where} ORDER BY code ASC NULLS LAST`, + params, + ); + return rows; +} + +export async function getNcmById(id: string): Promise { + const { rows } = await query("SELECT * FROM fiscal_ncm WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export async function createNcm(input: CreateSimpleCatalogInput): Promise { + const { rows } = await query( + `INSERT INTO fiscal_ncm (code, description, valid_from, valid_until, active, source, source_version, source_updated_at, metadata) + VALUES ($1,$2,$3,$4,COALESCE($5,true),$6,$7,$8,$9) + RETURNING *`, + [ + input.code, + input.description, + input.validFrom ?? null, + input.validUntil ?? null, + input.active ?? null, + input.source ?? null, + input.sourceVersion ?? null, + input.sourceUpdatedAt ?? null, + input.metadata ? JSON.stringify(input.metadata) : null, + ], + ); + const row = rows[0]; + if (!row) throw new Error("failed to create fiscal_ncm row"); + return row; +} + +export async function updateNcm(id: string, input: UpdateSimpleCatalogInput): Promise { + const { rows } = await query( + `UPDATE fiscal_ncm SET + code = COALESCE($2, code), + description = COALESCE($3, description), + valid_from = COALESCE($4, valid_from), + valid_until = COALESCE($5, valid_until), + active = COALESCE($6, active), + source = COALESCE($7, source), + source_version = COALESCE($8, source_version), + source_updated_at = COALESCE($9, source_updated_at), + metadata = COALESCE($10, metadata) + WHERE id = $1 + RETURNING *`, + [ + id, + input.code ?? null, + input.description ?? null, + input.validFrom ?? null, + input.validUntil ?? null, + input.active ?? null, + input.source ?? null, + input.sourceVersion ?? null, + input.sourceUpdatedAt ?? null, + input.metadata ? JSON.stringify(input.metadata) : null, + ], + ); + return rows[0] ?? null; +} + +// =========================================================================== +// fiscal_cfop — "simple catalog" shape + direction/scope (eden.md §2.3/§4) +// =========================================================================== + +export interface FiscalCfop { + id: string; + code: string; + description: string; + direction: string | null; + scope: string | null; + valid_from: string | null; + valid_until: string | null; + active: boolean; + source: string | null; + source_version: string | null; + source_updated_at: Date | null; + metadata: Record | null; + created_at: Date; + updated_at: Date; +} + +export interface CreateCfopInput extends CreateSimpleCatalogInput { + direction?: string | null; + scope?: string | null; +} + +export interface UpdateCfopInput extends UpdateSimpleCatalogInput { + direction?: string | null; + scope?: string | null; +} + +export async function listCfop(filter: ListCatalogFilter = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + if (filter.q) { + params.push(`%${filter.q}%`); + conditions.push(`(code ILIKE $${params.length} OR description ILIKE $${params.length})`); + } + if (filter.active !== undefined) { + params.push(filter.active); + conditions.push(`active = $${params.length}`); + } + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM fiscal_cfop ${where} ORDER BY code ASC NULLS LAST`, + params, + ); + return rows; +} + +export async function getCfopById(id: string): Promise { + const { rows } = await query("SELECT * FROM fiscal_cfop WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export async function createCfop(input: CreateCfopInput): Promise { + const { rows } = await query( + `INSERT INTO fiscal_cfop (code, description, direction, scope, valid_from, valid_until, active, source, source_version, source_updated_at, metadata) + VALUES ($1,$2,$3,$4,$5,$6,COALESCE($7,true),$8,$9,$10,$11) + RETURNING *`, + [ + input.code, + input.description, + input.direction ?? null, + input.scope ?? null, + input.validFrom ?? null, + input.validUntil ?? null, + input.active ?? null, + input.source ?? null, + input.sourceVersion ?? null, + input.sourceUpdatedAt ?? null, + input.metadata ? JSON.stringify(input.metadata) : null, + ], + ); + const row = rows[0]; + if (!row) throw new Error("failed to create fiscal_cfop row"); + return row; +} + +export async function updateCfop(id: string, input: UpdateCfopInput): Promise { + const { rows } = await query( + `UPDATE fiscal_cfop SET + code = COALESCE($2, code), + description = COALESCE($3, description), + direction = COALESCE($4, direction), + scope = COALESCE($5, scope), + valid_from = COALESCE($6, valid_from), + valid_until = COALESCE($7, valid_until), + active = COALESCE($8, active), + source = COALESCE($9, source), + source_version = COALESCE($10, source_version), + source_updated_at = COALESCE($11, source_updated_at), + metadata = COALESCE($12, metadata) + WHERE id = $1 + RETURNING *`, + [ + id, + input.code ?? null, + input.description ?? null, + input.direction ?? null, + input.scope ?? null, + input.validFrom ?? null, + input.validUntil ?? null, + input.active ?? null, + input.source ?? null, + input.sourceVersion ?? null, + input.sourceUpdatedAt ?? null, + input.metadata ? JSON.stringify(input.metadata) : null, + ], + ); + return rows[0] ?? null; +} + +// =========================================================================== +// fiscal_municipality — distinct shape, no valid_from/valid_until/metadata +// (eden.md §2.3) +// =========================================================================== + +export interface FiscalMunicipality { + id: string; + code: string; + name: string; + state: string; + active: boolean; + source: string | null; + source_version: string | null; + created_at: Date; + updated_at: Date; +} + +export interface CreateMunicipalityInput { + code: string; + name: string; + state: string; + active?: boolean; + source?: string | null; + sourceVersion?: string | null; +} + +export interface UpdateMunicipalityInput { + code?: string; + name?: string; + state?: string; + active?: boolean; + source?: string | null; + sourceVersion?: string | null; +} + +export async function listMunicipalities(filter: ListCatalogFilter = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + if (filter.q) { + params.push(`%${filter.q}%`); + conditions.push(`(code ILIKE $${params.length} OR name ILIKE $${params.length})`); + } + if (filter.active !== undefined) { + params.push(filter.active); + conditions.push(`active = $${params.length}`); + } + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM fiscal_municipality ${where} ORDER BY code ASC NULLS LAST`, + params, + ); + return rows; +} + +export async function getMunicipalityById(id: string): Promise { + const { rows } = await query("SELECT * FROM fiscal_municipality WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export async function createMunicipality(input: CreateMunicipalityInput): Promise { + const { rows } = await query( + `INSERT INTO fiscal_municipality (code, name, state, active, source, source_version) + VALUES ($1,$2,$3,COALESCE($4,true),$5,$6) + RETURNING *`, + [ + input.code, + input.name, + input.state, + input.active ?? null, + input.source ?? null, + input.sourceVersion ?? null, + ], + ); + const row = rows[0]; + if (!row) throw new Error("failed to create fiscal_municipality row"); + return row; +} + +export async function updateMunicipality( + id: string, + input: UpdateMunicipalityInput, +): Promise { + const { rows } = await query( + `UPDATE fiscal_municipality SET + code = COALESCE($2, code), + name = COALESCE($3, name), + state = COALESCE($4, state), + active = COALESCE($5, active), + source = COALESCE($6, source), + source_version = COALESCE($7, source_version) + WHERE id = $1 + RETURNING *`, + [ + id, + input.code ?? null, + input.name ?? null, + input.state ?? null, + input.active ?? null, + input.source ?? null, + input.sourceVersion ?? null, + ], + ); + return rows[0] ?? null; +} + +// =========================================================================== +// fiscal_operation_nature — internal enum-like catalog, read-only via API +// (eden.md §2.3) — seeded by the migration, no create/update in this MVP. +// =========================================================================== + +export interface FiscalOperationNature { + id: string; + code: string; + name: string; + description: string | null; + active: boolean; + created_at: Date; + updated_at: Date; +} + +export async function listOperationNatures(activeOnly = false): Promise { + const { rows } = await query( + activeOnly + ? "SELECT * FROM fiscal_operation_nature WHERE active = true ORDER BY code ASC" + : "SELECT * FROM fiscal_operation_nature ORDER BY code ASC", + ); + return rows; +} + +// =========================================================================== +// product_fiscal_profiles — links products to fiscal classification +// (eden.md §2.5). Reduced to the 4 catalogs created in this MVP. +// =========================================================================== + +export type FiscalBillingComponent = "RECURRING" | "IMPLEMENTATION"; +export type FiscalDocumentType = "NFCOM" | "NFSE" | "NFE" | "NONE"; + +export interface ProductFiscalProfile { + id: string; + product_id: string; + billing_component: FiscalBillingComponent; + document_type: FiscalDocumentType; + ncm_id: string | null; + cfop_id: string | null; + municipality_id: string | null; + operation_nature_id: string | null; + valid_from: string | null; + valid_until: string | null; + active: boolean; + created_at: Date; + updated_at: Date; + created_by: string | null; + updated_by: string | null; +} + +export async function listProductFiscalProfiles(productId: string): Promise { + const { rows } = await query( + `SELECT * FROM product_fiscal_profiles + WHERE product_id = $1 + ORDER BY billing_component ASC, created_at ASC`, + [productId], + ); + return rows; +} + +export async function getActiveProductFiscalProfile( + productId: string, + billingComponent: FiscalBillingComponent, +): Promise { + const { rows } = await query( + `SELECT * FROM product_fiscal_profiles + WHERE product_id = $1 AND billing_component = $2 AND active = true`, + [productId, billingComponent], + ); + return rows[0] ?? null; +} + +export interface CreateProductFiscalProfileInput { + productId: string; + billingComponent: FiscalBillingComponent; + documentType?: FiscalDocumentType; + ncmId?: string | null; + cfopId?: string | null; + municipalityId?: string | null; + operationNatureId?: string | null; + validFrom?: string | null; + validUntil?: string | null; + createdBy?: string | null; +} + +/** + * Creates a new ACTIVE profile for (productId, billingComponent), and — in + * the same transaction — inactivates whichever profile was previously + * active for that same pair (never a destructive UPDATE/DELETE of the old + * one, per eden.md §2.5's central invariant: at most one active profile per + * product per billing_component, enforced again at the DB level by + * idx_product_fiscal_profiles_active_component). If two concurrent callers + * raced here without the transaction ordering below, the partial unique + * index is the actual backstop — it would reject the second INSERT outright. + */ +export async function createActiveProductFiscalProfile( + input: CreateProductFiscalProfileInput, +): Promise { + return withTransaction(async (client) => { + await client.query( + `UPDATE product_fiscal_profiles + SET active = false, updated_by = $3 + WHERE product_id = $1 AND billing_component = $2 AND active = true`, + [input.productId, input.billingComponent, input.createdBy ?? null], + ); + + const { rows } = await client.query( + `INSERT INTO product_fiscal_profiles ( + product_id, billing_component, document_type, ncm_id, cfop_id, + municipality_id, operation_nature_id, valid_from, valid_until, + created_by, updated_by + ) VALUES ($1,$2,COALESCE($3,'NONE'),$4,$5,$6,$7,$8,$9,$10,$10) + RETURNING *`, + [ + input.productId, + input.billingComponent, + input.documentType ?? null, + input.ncmId ?? null, + input.cfopId ?? null, + input.municipalityId ?? null, + input.operationNatureId ?? null, + input.validFrom ?? null, + input.validUntil ?? null, + input.createdBy ?? null, + ], + ); + const row = rows[0]; + if (!row) throw new Error("failed to create product_fiscal_profiles row"); + return row; + }); +} diff --git a/packages/database/src/repositories/hr-timeclock.ts b/packages/database/src/repositories/hr-timeclock.ts new file mode 100644 index 0000000..9fea5e5 --- /dev/null +++ b/packages/database/src/repositories/hr-timeclock.ts @@ -0,0 +1,468 @@ +import { query, withTransaction } from "../pool.js"; +import type { PoolClient } from "pg"; + +/** + * HR/Timeclock (eden.md módulo 6) — foundation leva. See + * packages/database/migrations/PENDING_hr-timeclock.cjs for the full data + * model rationale (employees-vs-users decision, deferred tables, naming + * deviations from eden.md). This module deliberately does NOT expose any + * UPDATE/DELETE for afd_records — see importAfdBatch below and the + * afd-records.controller.ts (read-only) that consumes listAfdRecords. + */ + +// ───────────────────────────────────────────────────────────────────────── +// timeclock_devices +// ───────────────────────────────────────────────────────────────────────── + +export type TimeclockDeviceStatus = "UNKNOWN" | "ONLINE" | "OFFLINE" | "ERROR"; + +export interface TimeclockDevice { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + updated_by: string | null; + company_id: string | null; + name: string; + description: string | null; + branch: string | null; + host: string; + port: number; + use_https: boolean; + username: string; + password_encrypted: string; + serial_number: string | null; + model: string | null; + firmware_version: string | null; + timezone: string; + status: TimeclockDeviceStatus; + last_seen_at: Date | null; + last_sync_at: Date | null; + last_error: string | null; + last_afd_nsr: string | null; // BIGINT comes back as string from `pg` + active: boolean; +} + +export interface CreateTimeclockDeviceInput { + companyId?: string | null; + name: string; + description?: string | null; + branch?: string | null; + host: string; + port?: number; + useHttps?: boolean; + username: string; + /** Already encrypted (v1:iv:tag:ciphertext) by the caller via FieldEncryptionService — this repo never sees plaintext. */ + passwordEncrypted: string; + timezone?: string; + createdBy?: string | null; +} + +export async function listTimeclockDevices(includeInactive = false): Promise { + const { rows } = await query( + includeInactive + ? "SELECT * FROM timeclock_devices ORDER BY created_at DESC" + : "SELECT * FROM timeclock_devices WHERE active = true ORDER BY created_at DESC", + ); + return rows; +} + +export async function getTimeclockDeviceById(id: string): Promise { + const { rows } = await query("SELECT * FROM timeclock_devices WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export async function createTimeclockDevice(input: CreateTimeclockDeviceInput): Promise { + const { rows } = await query( + `INSERT INTO timeclock_devices ( + company_id, name, description, branch, host, port, use_https, username, + password_encrypted, timezone, created_by + ) VALUES ($1,$2,$3,$4,$5,COALESCE($6,443),COALESCE($7,true),$8,$9,COALESCE($10,'America/Sao_Paulo'),$11) + RETURNING *`, + [ + input.companyId ?? null, + input.name, + input.description ?? null, + input.branch ?? null, + input.host, + input.port ?? null, + input.useHttps ?? null, + input.username, + input.passwordEncrypted, + input.timezone ?? null, + input.createdBy ?? null, + ], + ); + const device = rows[0]; + if (!device) throw new Error("failed to create timeclock device"); + return device; +} + +export interface UpdateTimeclockDeviceInput { + name?: string; + description?: string | null; + branch?: string | null; + host?: string; + port?: number; + useHttps?: boolean; + username?: string; + /** Only re-encrypted/set when the caller actually supplied a new password — omit to leave unchanged. */ + passwordEncrypted?: string; + active?: boolean; + updatedBy?: string | null; +} + +export async function updateTimeclockDevice( + id: string, + input: UpdateTimeclockDeviceInput, +): Promise { + const { rows } = await query( + `UPDATE timeclock_devices SET + name = COALESCE($2, name), + description = COALESCE($3, description), + branch = COALESCE($4, branch), + host = COALESCE($5, host), + port = COALESCE($6, port), + use_https = COALESCE($7, use_https), + username = COALESCE($8, username), + password_encrypted = COALESCE($9, password_encrypted), + active = COALESCE($10, active), + updated_by = COALESCE($11, updated_by) + WHERE id = $1 + RETURNING *`, + [ + id, + input.name ?? null, + input.description ?? null, + input.branch ?? null, + input.host ?? null, + input.port ?? null, + input.useHttps ?? null, + input.username ?? null, + input.passwordEncrypted ?? null, + input.active ?? null, + input.updatedBy ?? null, + ], + ); + return rows[0] ?? null; +} + +// ───────────────────────────────────────────────────────────────────────── +// timeclock_employees — no CRUD in this leva (see migration header); only +// the CPF-resolution lookup that AFD import needs is implemented here. +// ───────────────────────────────────────────────────────────────────────── + +export interface TimeclockEmployee { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + updated_by: string | null; + company_id: string | null; + user_id: string | null; + full_name: string; + cpf: string; + registration: string | null; + active: boolean; +} + +/** + * Looks up employees by normalized CPF (11 digits) for AFD import + * resolution — never invents a match. Returns every row found; the + * caller (afd-imports.service.ts) is responsible for the "same CPF + * matched in more than one company and the device has no company" case, + * where it must leave employee_id unresolved rather than guess. + */ +export async function findTimeclockEmployeesByCpfs(cpfs: string[]): Promise { + if (cpfs.length === 0) return []; + const { rows } = await query( + "SELECT * FROM timeclock_employees WHERE cpf = ANY($1)", + [cpfs], + ); + return rows; +} + +// ───────────────────────────────────────────────────────────────────────── +// afd_import_batches / afd_records +// ───────────────────────────────────────────────────────────────────────── + +export type AfdImportBatchStatus = "VALID" | "WARNING" | "INVALID"; +export type AfdCrcValidity = "VALID" | "INVALID" | "NOT_APPLICABLE" | "NOT_VALIDATED"; +export type AfdValidationStatus = "VALID" | "WARNING" | "INVALID" | "UNSUPPORTED_LAYOUT"; + +export interface AfdImportBatch { + id: string; + device_id: string; + company_id: string | null; + source: "manual_upload"; + file_hash: string; + file_size: number; + record_count: number; + inserted_count: number; + valid_count: number; + warning_count: number; + invalid_count: number; + unsupported_count: number; + parse_failure_count: number; + status: AfdImportBatchStatus; + first_nsr: string | null; + last_nsr: string | null; + summary: Record; + imported_by: string | null; + imported_at: Date; +} + +export interface CreateAfdImportBatchInput { + deviceId: string; + companyId: string | null; + fileHash: string; + fileSize: number; + recordCount: number; + validCount: number; + warningCount: number; + invalidCount: number; + unsupportedCount: number; + parseFailureCount: number; + status: AfdImportBatchStatus; + firstNsr: number | null; + lastNsr: number | null; + summary: Record; + importedBy: string | null; +} + +export interface AfdRecord { + id: string; + created_at: Date; + afd_import_batch_id: string; + device_id: string; + company_id: string | null; + nsr: string | null; // BIGINT -> string + record_type: string; + event_datetime: Date | null; + event_datetime_raw: string | null; + employee_cpf_raw: string | null; + employee_id: string | null; + raw_line: string; + crc_received: string | null; + crc_calculated: string | null; + crc_valid: AfdCrcValidity; + parsed_data: Record; + validation_status: AfdValidationStatus; + validation_notes: string | null; +} + +export interface AfdRecordInsertInput { + nsr: number | null; + recordType: string; + eventDatetime: Date | null; + eventDatetimeRaw: string | null; + employeeCpfRaw: string | null; + employeeId: string | null; + rawLine: string; + crcReceived: string | null; + crcCalculated: string | null; + crcValid: AfdCrcValidity; + parsedData: Record; + validationStatus: AfdValidationStatus; + validationNotes: string | null; +} + +export class TimeclockDeviceNotFoundError extends Error { + constructor(public readonly deviceId: string) { + super(`Timeclock device ${deviceId} not found`); + this.name = "TimeclockDeviceNotFoundError"; + } +} + +export async function findAfdImportBatchByFileHash(fileHash: string): Promise { + const { rows } = await query("SELECT * FROM afd_import_batches WHERE file_hash = $1", [fileHash]); + return rows[0] ?? null; +} + +export async function getAfdImportBatchById(id: string): Promise { + const { rows } = await query("SELECT * FROM afd_import_batches WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export interface ImportAfdBatchInput { + batch: CreateAfdImportBatchInput; + records: AfdRecordInsertInput[]; +} + +export interface ImportAfdBatchResult { + batch: AfdImportBatch; + insertedCount: number; +} + +/** + * The whole AFD import write path, in one transaction: + * + * 1. `SELECT ... FOR UPDATE` locks the device row — this is what + * guarantees "never two concurrent imports for the same device" + * (eden.md módulo 6 §12's sequential-execution invariant for anything + * device-adjacent): a second concurrent import for the same device_id + * blocks here until the first one commits or rolls back. + * 2. Inserts the afd_import_batches row (needs to exist first — afd_records + * has a NOT NULL FK to it). + * 3. Inserts each afd_records row one at a time (matches this codebase's + * established multi-row-child pattern, see contracts.ts's item/party + * loops) with `ON CONFLICT (device_id, nsr) DO NOTHING` — the + * per-record idempotency layer (eden.md §12): a record whose + * (device_id, nsr) already exists from an earlier, different import is + * silently skipped, never duplicated, and never overwritten (afd_records + * has no UPDATE route at all — see the migration's trigger). + * 4. Updates the batch's `inserted_count` with how many rows actually + * landed (can be less than `records.length` because of step 3's + * ON CONFLICT skips). + * + * Whole-file idempotency (SHA-256 of the received content) is checked by + * the caller (afd-imports.service.ts) BEFORE calling this, via + * findAfdImportBatchByFileHash — this function does not re-check it, + * though the UNIQUE constraint on afd_import_batches.file_hash is a + * database-level backstop against a race between the two. + */ +export async function importAfdBatch(input: ImportAfdBatchInput): Promise { + return withTransaction(async (client: PoolClient) => { + const { rows: deviceRows } = await client.query<{ id: string }>( + "SELECT id FROM timeclock_devices WHERE id = $1 FOR UPDATE", + [input.batch.deviceId], + ); + if (!deviceRows[0]) { + throw new TimeclockDeviceNotFoundError(input.batch.deviceId); + } + + const { rows: batchRows } = await client.query( + `INSERT INTO afd_import_batches ( + device_id, company_id, file_hash, file_size, record_count, inserted_count, + valid_count, warning_count, invalid_count, unsupported_count, parse_failure_count, + status, first_nsr, last_nsr, summary, imported_by + ) VALUES ($1,$2,$3,$4,$5,0,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + RETURNING *`, + [ + input.batch.deviceId, + input.batch.companyId, + input.batch.fileHash, + input.batch.fileSize, + input.batch.recordCount, + input.batch.validCount, + input.batch.warningCount, + input.batch.invalidCount, + input.batch.unsupportedCount, + input.batch.parseFailureCount, + input.batch.status, + input.batch.firstNsr, + input.batch.lastNsr, + JSON.stringify(input.batch.summary), + input.batch.importedBy, + ], + ); + const batch = batchRows[0]; + if (!batch) throw new Error("failed to create afd_import_batch"); + + let insertedCount = 0; + for (const record of input.records) { + const { rowCount } = await client.query( + `INSERT INTO afd_records ( + afd_import_batch_id, device_id, company_id, nsr, record_type, + event_datetime, event_datetime_raw, employee_cpf_raw, employee_id, raw_line, + crc_received, crc_calculated, crc_valid, parsed_data, validation_status, validation_notes + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + ON CONFLICT (device_id, nsr) DO NOTHING`, + [ + batch.id, + input.batch.deviceId, + input.batch.companyId, + record.nsr, + record.recordType, + record.eventDatetime, + record.eventDatetimeRaw, + record.employeeCpfRaw, + record.employeeId, + record.rawLine, + record.crcReceived, + record.crcCalculated, + record.crcValid, + JSON.stringify(record.parsedData), + record.validationStatus, + record.validationNotes, + ], + ); + insertedCount += rowCount ?? 0; + } + + const { rows: updatedBatchRows } = await client.query( + "UPDATE afd_import_batches SET inserted_count = $2 WHERE id = $1 RETURNING *", + [batch.id, insertedCount], + ); + const updatedBatch = updatedBatchRows[0] ?? batch; + + return { batch: updatedBatch, insertedCount }; + }); +} + +export interface ListAfdRecordsFilter { + deviceId?: string; + employeeId?: string; + recordType?: string; + /** Inclusive lower bound on event_datetime. */ + dateFrom?: Date; + /** Exclusive upper bound on event_datetime (caller passes "day after dateTo" to include the whole end day). */ + dateTo?: Date; + limit?: number; + offset?: number; +} + +const DEFAULT_PAGE_SIZE = 50; + +/** + * Read-only, paginated. There is deliberately no equivalent write + * function for arbitrary fields — afd_records rows are only ever created + * via importAfdBatch's INSERT, never updated. + */ +export async function listAfdRecords(filter: ListAfdRecordsFilter): Promise<{ records: AfdRecord[]; total: number }> { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.deviceId) { + params.push(filter.deviceId); + conditions.push(`device_id = $${params.length}`); + } + if (filter.employeeId) { + params.push(filter.employeeId); + conditions.push(`employee_id = $${params.length}`); + } + if (filter.recordType) { + params.push(filter.recordType); + conditions.push(`record_type = $${params.length}`); + } + if (filter.dateFrom) { + params.push(filter.dateFrom); + conditions.push(`event_datetime >= $${params.length}`); + } + if (filter.dateTo) { + params.push(filter.dateTo); + conditions.push(`event_datetime < $${params.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const limit = filter.limit ?? DEFAULT_PAGE_SIZE; + const offset = filter.offset ?? 0; + + const { rows: countRows } = await query<{ count: string }>( + `SELECT count(*) FROM afd_records ${where}`, + params, + ); + const total = Number(countRows[0]?.count ?? 0); + + const { rows } = await query( + `SELECT * FROM afd_records ${where} ORDER BY event_datetime ASC NULLS LAST, created_at ASC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, + [...params, limit, offset], + ); + + return { records: rows, total }; +} + +export async function getAfdRecordById(id: string): Promise { + const { rows } = await query("SELECT * FROM afd_records WHERE id = $1", [id]); + return rows[0] ?? null; +} diff --git a/packages/database/src/repositories/inventory.ts b/packages/database/src/repositories/inventory.ts new file mode 100644 index 0000000..fd07f87 --- /dev/null +++ b/packages/database/src/repositories/inventory.ts @@ -0,0 +1,747 @@ +import { query, withTransaction } from "../pool.js"; +import type { PoolClient } from "pg"; + +/** + * Inventory/Warehouse/Assets bounded context — Master Prompt §6.8, + * ADR-0007. See the migration's header comment (PENDING_inventory.cjs) + * for the full list of possible entities and which 5 this MVP builds vs. + * explicitly deferred ones. + * + * Mirrors the conventions of companies.ts/products.ts (COALESCE-based + * partial writes, snake_case rows) and contracts.ts (withTransaction for + * multi-row writes that must commit/rollback together, custom error + * classes mapped to 404/409 by the service layer). + * + * NOT registered in the barrel (packages/database/src/index.ts) yet — + * that's deliberate integration work left to the merge step (see + * .claude/agents/eden-inventory.md task boundaries). + */ + +// =========================================================================== +// warehouses +// =========================================================================== + +export interface Warehouse { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + company_id: string; + name: string; + code: string; + is_active: boolean; +} + +export async function listWarehouses(includeInactive = false): Promise { + const { rows } = await query( + includeInactive + ? "SELECT * FROM warehouses ORDER BY created_at ASC" + : "SELECT * FROM warehouses WHERE is_active = true ORDER BY created_at ASC", + ); + return rows; +} + +export async function getWarehouseById(id: string): Promise { + const { rows } = await query("SELECT * FROM warehouses WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export interface CreateWarehouseInput { + companyId: string; + name: string; + code: string; + isActive?: boolean; + createdBy?: string | null; +} + +export async function createWarehouse(input: CreateWarehouseInput): Promise { + const { rows } = await query( + `INSERT INTO warehouses (company_id, name, code, is_active, created_by) + VALUES ($1,$2,$3,COALESCE($4,true),$5) + RETURNING *`, + [input.companyId, input.name, input.code, input.isActive ?? null, input.createdBy ?? null], + ); + const warehouse = rows[0]; + if (!warehouse) throw new Error("failed to create warehouse"); + return warehouse; +} + +export interface UpdateWarehouseInput { + name?: string; + code?: string; + isActive?: boolean; +} + +export async function updateWarehouse(id: string, input: UpdateWarehouseInput): Promise { + const { rows } = await query( + `UPDATE warehouses SET + name = COALESCE($2, name), + code = COALESCE($3, code), + is_active = COALESCE($4, is_active) + WHERE id = $1 + RETURNING *`, + [id, input.name ?? null, input.code ?? null, input.isActive ?? null], + ); + return rows[0] ?? null; +} + +// =========================================================================== +// stock_movements — append-only ledger +// =========================================================================== + +export type StockMovementType = + | "purchase_in" + | "adjustment_in" + | "adjustment_out" + | "transfer_out" + | "transfer_in" + | "reservation" + | "reservation_release" + | "sale_out" + | "install_out" + | "comodato_out" + | "return_in" + | "rma_out" + | "write_off"; + +export interface StockMovement { + id: string; + created_at: Date; + created_by: string | null; + warehouse_id: string; + product_id: string; + movement_type: StockMovementType; + quantity: string; + reference_type: string | null; + reference_id: string | null; + notes: string | null; +} + +export interface ListStockMovementsFilter { + warehouseId?: string; + productId?: string; + referenceType?: string; + referenceId?: string; +} + +export async function listStockMovements(filter: ListStockMovementsFilter = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.warehouseId) { + params.push(filter.warehouseId); + conditions.push(`warehouse_id = $${params.length}`); + } + if (filter.productId) { + params.push(filter.productId); + conditions.push(`product_id = $${params.length}`); + } + if (filter.referenceType) { + params.push(filter.referenceType); + conditions.push(`reference_type = $${params.length}`); + } + if (filter.referenceId) { + params.push(filter.referenceId); + conditions.push(`reference_id = $${params.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM stock_movements ${where} ORDER BY created_at DESC`, + params, + ); + return rows; +} + +export interface CreateStockMovementInput { + warehouseId: string; + productId: string; + movementType: StockMovementType; + quantity: number; + referenceType?: string | null; + referenceId?: string | null; + notes?: string | null; + createdBy?: string | null; +} + +/** + * Appends one movement. No update/edit function exists on purpose — a + * correction is always a new call to this function with the reversing + * type (also enforced at the DB level by trg_stock_movements_no_update/ + * no_delete). Accepts an optional `client` so callers that need this + * insert inside a larger transaction (asset entry/install/return) can + * pass the transaction's client instead of grabbing a new pool + * connection — used by future endpoints (adjustment/transfer/write_off) + * that aren't wired up yet in this MVP; the asset lifecycle functions + * below (createAssetWithPurchaseMovement, reserveAsset, installAsset, + * returnAsset) inline their own movement INSERTs instead of calling this, + * since they already hold the transaction's client for the surrounding + * asset row lock. + */ +export async function recordStockMovement( + input: CreateStockMovementInput, + client?: PoolClient, +): Promise { + const sql = `INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + RETURNING *`; + const params = [ + input.warehouseId, + input.productId, + input.movementType, + input.quantity, + input.referenceType ?? null, + input.referenceId ?? null, + input.notes ?? null, + input.createdBy ?? null, + ]; + const { rows } = client + ? await client.query(sql, params) + : await query(sql, params); + const row = rows[0]; + if (!row) throw new Error("failed to record stock movement"); + return row; +} + +// =========================================================================== +// serialized_assets +// =========================================================================== + +export type SerializedAssetStatus = + | "in_stock" + | "reserved" + | "installed" + | "comodato" + | "returned" + | "written_off"; + +export interface SerializedAsset { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + updated_by: string | null; + product_id: string; + serial: string | null; + patrimony_tag: string | null; + mac_address: string | null; + warehouse_id: string | null; + status: SerializedAssetStatus; + current_contract_id: string | null; + warranty_expires_at: Date | null; +} + +export interface ListSerializedAssetsFilter { + status?: SerializedAssetStatus; + warehouseId?: string; + productId?: string; +} + +export async function listSerializedAssets(filter: ListSerializedAssetsFilter = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.status) { + params.push(filter.status); + conditions.push(`status = $${params.length}`); + } + if (filter.warehouseId) { + params.push(filter.warehouseId); + conditions.push(`warehouse_id = $${params.length}`); + } + if (filter.productId) { + params.push(filter.productId); + conditions.push(`product_id = $${params.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM serialized_assets ${where} ORDER BY created_at DESC`, + params, + ); + return rows; +} + +export async function getSerializedAssetById(id: string): Promise { + const { rows } = await query("SELECT * FROM serialized_assets WHERE id = $1", [id]); + return rows[0] ?? null; +} + +async function getSerializedAssetByIdForUpdate(client: PoolClient, id: string): Promise { + const { rows } = await client.query( + "SELECT * FROM serialized_assets WHERE id = $1 FOR UPDATE", + [id], + ); + return rows[0] ?? null; +} + +export class AssetNotFoundError extends Error { + constructor(public readonly assetId: string) { + super(`Serialized asset ${assetId} not found`); + this.name = "AssetNotFoundError"; + } +} + +export class InvalidAssetTransitionError extends Error { + constructor( + public readonly fromStatus: SerializedAssetStatus, + public readonly action: string, + ) { + super(`Asset in status '${fromStatus}' cannot be ${action}`); + this.name = "InvalidAssetTransitionError"; + } +} + +export interface CreateAssetInput { + productId: string; + warehouseId: string; + serial?: string | null; + patrimonyTag?: string | null; + macAddress?: string | null; + warrantyExpiresAt?: Date | string | null; + notes?: string | null; + createdBy?: string | null; +} + +/** + * Entrada de um ativo novo — cria a linha em serialized_assets (status + * in_stock) e o stock_movement purchase_in correspondente numa única + * transação. Se o serial/patrimônio/MAC já estiver "ativo" em outro + * ativo, a constraint de banco (índice único parcial) rejeita o INSERT — + * o erro do Postgres (23505) é deixado subir; a camada de serviço decide + * como traduzir para 409. + */ +export async function createAssetWithPurchaseMovement(input: CreateAssetInput): Promise<{ + asset: SerializedAsset; + movement: StockMovement; +}> { + return withTransaction(async (client) => { + const { rows: assetRows } = await client.query( + `INSERT INTO serialized_assets ( + product_id, serial, patrimony_tag, mac_address, warehouse_id, status, + warranty_expires_at, created_by, updated_by + ) VALUES ($1,$2,$3,$4,$5,'in_stock',$6,$7,$7) + RETURNING *`, + [ + input.productId, + input.serial ?? null, + input.patrimonyTag ?? null, + input.macAddress ?? null, + input.warehouseId, + input.warrantyExpiresAt ?? null, + input.createdBy ?? null, + ], + ); + const asset = assetRows[0]; + if (!asset) throw new Error("failed to create serialized asset"); + + const { rows: movementRows } = await client.query( + `INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by) + VALUES ($1,$2,'purchase_in',1,'serialized_asset',$3,$4,$5) + RETURNING *`, + [input.warehouseId, input.productId, asset.id, input.notes ?? null, input.createdBy ?? null], + ); + const movement = movementRows[0]; + if (!movement) throw new Error("failed to record purchase_in movement"); + + return { asset, movement }; + }); +} + +export interface ReserveAssetInput { + referenceType?: string | null; + referenceId?: string | null; + notes?: string | null; + createdBy?: string | null; +} + +/** + * Reserva um ativo específico já em estoque: cria a stock_reservation + * (quantity=1, reference_type='serialized_asset', reference_id=asset.id) + * e move o ativo para status='reserved'. Só permitido a partir de + * 'in_stock' — throws InvalidAssetTransitionError caso contrário. + */ +export async function reserveAsset( + assetId: string, + input: ReserveAssetInput, +): Promise<{ asset: SerializedAsset; reservation: StockReservation; movement: StockMovement }> { + return withTransaction(async (client) => { + const asset = await getSerializedAssetByIdForUpdate(client, assetId); + if (!asset) throw new AssetNotFoundError(assetId); + if (asset.status !== "in_stock") throw new InvalidAssetTransitionError(asset.status, "reserved"); + if (!asset.warehouse_id) throw new Error(`asset ${assetId} has status in_stock but no warehouse_id`); + + const { rows: updatedRows } = await client.query( + `UPDATE serialized_assets SET status = 'reserved', updated_by = $2 WHERE id = $1 RETURNING *`, + [assetId, input.createdBy ?? null], + ); + const updated = updatedRows[0]; + if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`); + + const { rows: reservationRows } = await client.query( + `INSERT INTO stock_reservations (warehouse_id, product_id, quantity, reference_type, reference_id, status, notes, created_by) + VALUES ($1,$2,1,'serialized_asset',$3,'active',$4,$5) + RETURNING *`, + [asset.warehouse_id, asset.product_id, assetId, input.notes ?? null, input.createdBy ?? null], + ); + const reservation = reservationRows[0]; + if (!reservation) throw new Error("failed to create reservation"); + + const { rows: movementRows } = await client.query( + `INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by) + VALUES ($1,$2,'reservation',1,'serialized_asset',$3,$4,$5) + RETURNING *`, + [asset.warehouse_id, asset.product_id, assetId, input.notes ?? null, input.createdBy ?? null], + ); + const movement = movementRows[0]; + if (!movement) throw new Error("failed to record reservation movement"); + + return { asset: updated, reservation, movement }; + }); +} + +export interface InstallAssetInput { + contractId: string; + assignmentType: "installed" | "comodato"; + notes?: string | null; + createdBy?: string | null; +} + +/** + * Vincula um ativo (em 'in_stock' ou 'reserved') a um contrato: cria + * asset_assignments, move o ativo para status='installed'/'comodato' + * (limpando warehouse_id — a localização agora é "no cliente"), grava o + * stock_movement install_out/comodato_out, e — se havia uma reserva + * ATIVA para este ativo específico — marca-a 'fulfilled'. Tudo numa + * transação. Só permitido a partir de in_stock/reserved. + */ +export async function installAsset( + assetId: string, + input: InstallAssetInput, +): Promise<{ asset: SerializedAsset; assignment: AssetAssignment; movement: StockMovement }> { + return withTransaction(async (client) => { + const asset = await getSerializedAssetByIdForUpdate(client, assetId); + if (!asset) throw new AssetNotFoundError(assetId); + if (asset.status !== "in_stock" && asset.status !== "reserved") { + throw new InvalidAssetTransitionError(asset.status, input.assignmentType); + } + if (!asset.warehouse_id) throw new Error(`asset ${assetId} has no warehouse_id before install`); + + const originWarehouseId = asset.warehouse_id; + const movementType = input.assignmentType === "comodato" ? "comodato_out" : "install_out"; + + const { rows: updatedRows } = await client.query( + `UPDATE serialized_assets + SET status = $2, warehouse_id = NULL, current_contract_id = $3, updated_by = $4 + WHERE id = $1 + RETURNING *`, + [assetId, input.assignmentType, input.contractId, input.createdBy ?? null], + ); + const updated = updatedRows[0]; + if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`); + + const { rows: assignmentRows } = await client.query( + `INSERT INTO asset_assignments (asset_id, contract_id, assignment_type, created_by) + VALUES ($1,$2,$3,$4) + RETURNING *`, + [assetId, input.contractId, input.assignmentType, input.createdBy ?? null], + ); + const assignment = assignmentRows[0]; + if (!assignment) throw new Error("failed to create asset assignment"); + + const { rows: movementRows } = await client.query( + `INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by) + VALUES ($1,$2,$3,1,'contract',$4,$5,$6) + RETURNING *`, + [originWarehouseId, asset.product_id, movementType, input.contractId, input.notes ?? null, input.createdBy ?? null], + ); + const movement = movementRows[0]; + if (!movement) throw new Error("failed to record install/comodato movement"); + + // If there was an active asset-specific reservation, close it as fulfilled. + await client.query( + `UPDATE stock_reservations + SET status = 'fulfilled' + WHERE reference_type = 'serialized_asset' AND reference_id = $1 AND status = 'active'`, + [assetId], + ); + + return { asset: updated, assignment, movement }; + }); +} + +export interface ReturnAssetInput { + /** Warehouse receiving the physical unit back — not necessarily the one it left from (see migration comment on serialized_assets.warehouse_id). */ + warehouseId: string; + notes?: string | null; + createdBy?: string | null; +} + +export class NoOpenAssignmentError extends Error { + constructor(public readonly assetId: string) { + super(`Asset ${assetId} has no open assignment to close`); + this.name = "NoOpenAssignmentError"; + } +} + +/** + * Fecha o assignment aberto (unassigned_at = now()), grava o + * stock_movement return_in no warehouse de destino, e volta o ativo para + * status='in_stock' com current_contract_id limpo. Só permitido a partir + * de installed/comodato. + */ +export async function returnAsset( + assetId: string, + input: ReturnAssetInput, +): Promise<{ asset: SerializedAsset; movement: StockMovement }> { + return withTransaction(async (client) => { + const asset = await getSerializedAssetByIdForUpdate(client, assetId); + if (!asset) throw new AssetNotFoundError(assetId); + if (asset.status !== "installed" && asset.status !== "comodato") { + throw new InvalidAssetTransitionError(asset.status, "returned"); + } + + const { rows: assignmentRows } = await client.query( + `UPDATE asset_assignments SET unassigned_at = now() + WHERE asset_id = $1 AND unassigned_at IS NULL + RETURNING *`, + [assetId], + ); + if (!assignmentRows[0]) throw new NoOpenAssignmentError(assetId); + + const { rows: updatedRows } = await client.query( + `UPDATE serialized_assets + SET status = 'in_stock', warehouse_id = $2, current_contract_id = NULL, updated_by = $3 + WHERE id = $1 + RETURNING *`, + [assetId, input.warehouseId, input.createdBy ?? null], + ); + const updated = updatedRows[0]; + if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`); + + const { rows: movementRows } = await client.query( + `INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by) + VALUES ($1,$2,'return_in',1,'contract',$3,$4,$5) + RETURNING *`, + [input.warehouseId, asset.product_id, asset.current_contract_id, input.notes ?? null, input.createdBy ?? null], + ); + const movement = movementRows[0]; + if (!movement) throw new Error("failed to record return_in movement"); + + return { asset: updated, movement }; + }); +} + +// =========================================================================== +// asset_assignments +// =========================================================================== + +export interface AssetAssignment { + id: string; + created_at: Date; + created_by: string | null; + asset_id: string; + contract_id: string; + assignment_type: "installed" | "comodato"; + assigned_at: Date; + unassigned_at: Date | null; +} + +export async function listAssetAssignments(assetId: string): Promise { + const { rows } = await query( + "SELECT * FROM asset_assignments WHERE asset_id = $1 ORDER BY assigned_at DESC", + [assetId], + ); + return rows; +} + +// =========================================================================== +// stock_reservations +// =========================================================================== + +export type StockReservationStatus = "active" | "released" | "fulfilled"; + +export interface StockReservation { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + warehouse_id: string; + product_id: string; + quantity: string; + reference_type: string | null; + reference_id: string | null; + status: StockReservationStatus; + notes: string | null; +} + +export interface ListStockReservationsFilter { + status?: StockReservationStatus; + warehouseId?: string; + productId?: string; +} + +export async function listStockReservations( + filter: ListStockReservationsFilter = {}, +): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.status) { + params.push(filter.status); + conditions.push(`status = $${params.length}`); + } + if (filter.warehouseId) { + params.push(filter.warehouseId); + conditions.push(`warehouse_id = $${params.length}`); + } + if (filter.productId) { + params.push(filter.productId); + conditions.push(`product_id = $${params.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM stock_reservations ${where} ORDER BY created_at DESC`, + params, + ); + return rows; +} + +export async function getStockReservationById(id: string): Promise { + const { rows } = await query("SELECT * FROM stock_reservations WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export class ReservationNotFoundError extends Error { + constructor(public readonly reservationId: string) { + super(`Stock reservation ${reservationId} not found`); + this.name = "ReservationNotFoundError"; + } +} + +export class InvalidReservationTransitionError extends Error { + constructor( + public readonly fromStatus: StockReservationStatus, + public readonly action: string, + ) { + super(`Reservation in status '${fromStatus}' cannot be ${action}`); + this.name = "InvalidReservationTransitionError"; + } +} + +export interface CreateGenericReservationInput { + warehouseId: string; + productId: string; + quantity: number; + referenceType?: string | null; + referenceId?: string | null; + notes?: string | null; + createdBy?: string | null; +} + +/** + * Reserva GENÉRICA de quantidade (produto+warehouse), sem serial + * selecionado ainda — o primeiro passo do fluxo descrito no Master + * Prompt §6.8 antes de "posteriormente selecionar unidade serializada + * específica". Distinta de reserveAsset() (que reserva um serial já + * escolhido). + */ +export async function createGenericReservation( + input: CreateGenericReservationInput, +): Promise<{ reservation: StockReservation; movement: StockMovement }> { + return withTransaction(async (client) => { + const { rows: reservationRows } = await client.query( + `INSERT INTO stock_reservations (warehouse_id, product_id, quantity, reference_type, reference_id, status, notes, created_by) + VALUES ($1,$2,$3,$4,$5,'active',$6,$7) + RETURNING *`, + [ + input.warehouseId, + input.productId, + input.quantity, + input.referenceType ?? null, + input.referenceId ?? null, + input.notes ?? null, + input.createdBy ?? null, + ], + ); + const reservation = reservationRows[0]; + if (!reservation) throw new Error("failed to create reservation"); + + const { rows: movementRows } = await client.query( + `INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by) + VALUES ($1,$2,'reservation',$3,$4,$5,$6,$7) + RETURNING *`, + [ + input.warehouseId, + input.productId, + input.quantity, + input.referenceType ?? null, + input.referenceId ?? null, + input.notes ?? null, + input.createdBy ?? null, + ], + ); + const movement = movementRows[0]; + if (!movement) throw new Error("failed to record reservation movement"); + + return { reservation, movement }; + }); +} + +/** + * Cancela uma reserva ATIVA (status -> released) e grava o + * reservation_release correspondente. Se a reserva era de um ativo + * específico (reference_type='serialized_asset'), também devolve esse + * ativo para 'in_stock' — desfazendo o que reserveAsset() fez. + */ +export async function releaseReservation( + reservationId: string, + actorId: string | null, +): Promise<{ reservation: StockReservation; movement: StockMovement }> { + return withTransaction(async (client) => { + const { rows: currentRows } = await client.query( + "SELECT * FROM stock_reservations WHERE id = $1 FOR UPDATE", + [reservationId], + ); + const current = currentRows[0]; + if (!current) throw new ReservationNotFoundError(reservationId); + if (current.status !== "active") { + throw new InvalidReservationTransitionError(current.status, "released"); + } + + const { rows: updatedRows } = await client.query( + `UPDATE stock_reservations SET status = 'released' WHERE id = $1 RETURNING *`, + [reservationId], + ); + const updated = updatedRows[0]; + if (!updated) throw new Error(`reservation ${reservationId} disappeared mid-transaction`); + + const { rows: movementRows } = await client.query( + `INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by) + VALUES ($1,$2,'reservation_release',$3,$4,$5,$6,$7) + RETURNING *`, + [ + current.warehouse_id, + current.product_id, + current.quantity, + current.reference_type, + current.reference_id, + null, + actorId, + ], + ); + const movement = movementRows[0]; + if (!movement) throw new Error("failed to record reservation_release movement"); + + if (current.reference_type === "serialized_asset" && current.reference_id) { + await client.query( + `UPDATE serialized_assets SET status = 'in_stock', updated_by = $2 + WHERE id = $1 AND status = 'reserved'`, + [current.reference_id, actorId], + ); + } + + return { reservation: updated, movement }; + }); +} diff --git a/packages/database/src/repositories/support-tickets.ts b/packages/database/src/repositories/support-tickets.ts new file mode 100644 index 0000000..c922458 --- /dev/null +++ b/packages/database/src/repositories/support-tickets.ts @@ -0,0 +1,423 @@ +import { query, withTransaction } from "../pool.js"; + +/** + * Support/Service Desk bounded context (Master Prompt §6.13). Greenfield — + * no legacy module to port. See the migration's header comment + * (PENDING_support.cjs) for the full list of tables this MVP builds vs. + * explicitly deferred ones (real S3 attachments, CSAT, dedicated work + * order entity, routing queues, configurable attendance calendar). + * + * Mirrors contracts.ts's conventions closely: withTransaction + SELECT + * ... FOR UPDATE for the status-machine transition, an explicit + * transitions table, a NotFound/InvalidTransition error pair the service + * layer maps to 404/409, and a *WithDetails aggregate for the "get one" + * endpoint. + * + * SLA breach/elapsed-time math (computeElapsedSeconds/isSlaBreached) is + * deliberately NOT implemented in this file — it's pure, DB-independent + * business logic that lives in apps/api/src/support/sla.ts (this package + * must not depend on apps/api). This repository only performs the SQL + * side effect of pausing/resuming the timer (using the DB's own `now()`, + * to avoid app/DB clock skew for that specific bookkeeping) and persists + * whatever met/unmet flags the service layer has already computed. + */ + +export type TicketStatus = + | "new" + | "triage" + | "in_progress" + | "waiting_customer" + | "waiting_third_party" + | "resolved" + | "closed" + | "cancelled"; + +export type TicketPriority = "low" | "medium" | "high" | "critical"; + +export interface SupportTicket { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + updated_by: string | null; + protocol_number: string; + title: string; + description: string; + category: string; + subcategory: string | null; + priority: TicketPriority; + status: TicketStatus; + reseller_id: string | null; + client_registration_id: string | null; + contract_id: string | null; + owner_user_id: string | null; + opened_by_user_id: string; + root_cause: string | null; + resolution_notes: string | null; + first_response_at: Date | null; + resolved_at: Date | null; + closed_at: Date | null; +} + +export interface TicketComment { + id: string; + ticket_id: string; + author_user_id: string; + body: string; + is_internal: boolean; + created_at: Date; +} + +export interface TicketStatusHistoryEntry { + id: string; + ticket_id: string; + from_status: TicketStatus | null; + to_status: TicketStatus; + reason: string | null; + changed_by: string | null; + changed_at: Date; +} + +export interface SlaTimer { + ticket_id: string; + created_at: Date; + updated_at: Date; + first_response_due_at: Date; + resolution_due_at: Date; + first_response_met: boolean | null; + resolution_met: boolean | null; + paused_since: Date | null; + accumulated_pause_seconds: number; +} + +export interface TicketWithDetails extends SupportTicket { + comments: TicketComment[]; + statusHistory: TicketStatusHistoryEntry[]; + slaTimer: SlaTimer | null; +} + +/** + * MVP lifecycle (eden-support persona spec): new -> triage -> in_progress + * <-> {waiting_customer, waiting_third_party} -> resolved -> closed, plus + * cancelled reachable from any non-terminal state. closed/cancelled are + * terminal (no outgoing transitions modeled). + */ +export const TICKET_STATUS_TRANSITIONS: Record = { + new: ["triage", "cancelled"], + triage: ["in_progress", "cancelled"], + in_progress: ["waiting_customer", "waiting_third_party", "resolved", "cancelled"], + waiting_customer: ["in_progress", "cancelled"], + waiting_third_party: ["in_progress", "cancelled"], + resolved: ["closed", "cancelled"], + closed: [], + cancelled: [], +}; + +export class TicketNotFoundError extends Error { + constructor(public readonly ticketId: string) { + super(`Support ticket ${ticketId} not found`); + this.name = "TicketNotFoundError"; + } +} + +export class InvalidTicketTransitionError extends Error { + constructor( + public readonly fromStatus: TicketStatus, + public readonly toStatus: TicketStatus, + ) { + super(`Invalid ticket status transition: '${fromStatus}' -> '${toStatus}'`); + this.name = "InvalidTicketTransitionError"; + } +} + +export interface ListTicketsFilter { + scope: "all" | "reseller"; + /** Required (and enforced) when scope === 'reseller'. Ignored when scope === 'all'. */ + resellerId?: string | null; +} + +export async function listTickets(filter: ListTicketsFilter): Promise { + if (filter.scope === "reseller") { + // Deliberately never falls back to an unfiltered query — deny-by-default, + // same discipline as contracts.ts::listContracts. + const { rows } = await query( + "SELECT * FROM support_tickets WHERE reseller_id = $1 ORDER BY created_at DESC", + [filter.resellerId ?? null], + ); + return rows; + } + const { rows } = await query("SELECT * FROM support_tickets ORDER BY created_at DESC"); + return rows; +} + +async function listTicketComments(ticketId: string): Promise { + const { rows } = await query( + "SELECT * FROM ticket_comments WHERE ticket_id = $1 ORDER BY created_at ASC", + [ticketId], + ); + return rows; +} + +async function listTicketStatusHistory(ticketId: string): Promise { + const { rows } = await query( + "SELECT * FROM ticket_status_history WHERE ticket_id = $1 ORDER BY changed_at ASC", + [ticketId], + ); + return rows; +} + +async function getSlaTimer(ticketId: string): Promise { + const { rows } = await query("SELECT * FROM sla_timers WHERE ticket_id = $1", [ticketId]); + return rows[0] ?? null; +} + +export async function getTicketById(id: string): Promise { + const { rows } = await query("SELECT * FROM support_tickets WHERE id = $1", [id]); + const ticket = rows[0]; + if (!ticket) return null; + + const [comments, statusHistory, slaTimer] = await Promise.all([ + listTicketComments(id), + listTicketStatusHistory(id), + getSlaTimer(id), + ]); + + return { ...ticket, comments, statusHistory, slaTimer }; +} + +export interface CreateTicketInput { + title: string; + description: string; + category: string; + subcategory?: string | null; + priority: TicketPriority; + resellerId?: string | null; + clientRegistrationId?: string | null; + contractId?: string | null; + ownerUserId?: string | null; + openedByUserId: string; + createdBy?: string | null; + /** Server-computed by the caller (apps/api/src/support/sla.ts) — never accepted raw from a client. */ + firstResponseDueAt: Date; + /** Server-computed by the caller (apps/api/src/support/sla.ts) — never accepted raw from a client. */ + resolutionDueAt: Date; +} + +/** + * Creates the ticket plus its 1:1 sla_timers row in a single transaction + * (all-or-nothing) — mirrors contracts.ts::createContract. Status always + * starts 'new'; protocol_number is allocated by the column DEFAULT + * (support_tickets_protocol_number_seq), same pattern as quotes.quote_number. + */ +export async function createTicket(input: CreateTicketInput): Promise { + return withTransaction(async (client) => { + const { rows } = await client.query( + `INSERT INTO support_tickets ( + title, description, category, subcategory, priority, + reseller_id, client_registration_id, contract_id, + owner_user_id, opened_by_user_id, created_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + RETURNING *`, + [ + input.title, + input.description, + input.category, + input.subcategory ?? null, + input.priority, + input.resellerId ?? null, + input.clientRegistrationId ?? null, + input.contractId ?? null, + input.ownerUserId ?? null, + input.openedByUserId, + input.createdBy ?? null, + ], + ); + const ticket = rows[0]; + if (!ticket) throw new Error("failed to create support ticket"); + + const { rows: timerRows } = await client.query( + `INSERT INTO sla_timers (ticket_id, first_response_due_at, resolution_due_at) + VALUES ($1,$2,$3) + RETURNING *`, + [ticket.id, input.firstResponseDueAt, input.resolutionDueAt], + ); + const slaTimer = timerRows[0]; + if (!slaTimer) throw new Error("failed to create sla timer"); + + return { ...ticket, comments: [], statusHistory: [], slaTimer }; + }); +} + +export interface CreateTicketCommentInput { + authorUserId: string; + body: string; + isInternal: boolean; +} + +export async function addTicketComment( + ticketId: string, + input: CreateTicketCommentInput, +): Promise { + const { rows } = await query( + `INSERT INTO ticket_comments (ticket_id, author_user_id, body, is_internal) + VALUES ($1,$2,$3,$4) + RETURNING *`, + [ticketId, input.authorUserId, input.body, input.isInternal], + ); + const row = rows[0]; + if (!row) throw new Error("failed to add ticket comment"); + return row; +} + +/** + * Once the first public (non-internal) comment is added on a ticket whose + * first_response_at is still NULL, the service layer computes whether + * that response met the SLA (apps/api/src/support/sla.ts::isSlaBreached) + * and calls this to persist it. Idempotent by construction (WHERE ... IS + * NULL on both tables) — a second call after first_response_at is already + * set is a silent no-op, never overwrites. + */ +export async function recordFirstResponse( + ticketId: string, + met: boolean, + respondedAt: Date, +): Promise { + await query( + `UPDATE support_tickets SET first_response_at = $2 WHERE id = $1 AND first_response_at IS NULL`, + [ticketId, respondedAt], + ); + await query( + `UPDATE sla_timers SET first_response_met = $2, updated_at = now() + WHERE ticket_id = $1 AND first_response_met IS NULL`, + [ticketId, met], + ); +} + +/** + * Called by the service layer right after a transition into 'resolved', + * once it has computed the met/unmet flag from the (already-returned, + * necessarily-unpaused — see updateTicketStatus below) sla timer via + * sla.ts::isSlaBreached. + */ +export async function recordResolutionMet(ticketId: string, met: boolean): Promise { + await query(`UPDATE sla_timers SET resolution_met = $2, updated_at = now() WHERE ticket_id = $1`, [ + ticketId, + met, + ]); +} + +export interface UpdateTicketStatusExtra { + rootCause?: string | null; + resolutionNotes?: string | null; +} + +/** + * Validates the transition against TICKET_STATUS_TRANSITIONS and, only if + * valid, atomically: updates support_tickets.status, sets resolved_at/ + * closed_at via COALESCE the first time the ticket enters that state + * (never overwritten afterwards, same semantics as contracts.activated_at), + * accepts root_cause/resolution_notes only on the transition into + * 'closed', appends a ticket_status_history row, AND pauses/resumes the + * SLA timer: + * - entering waiting_customer/waiting_third_party -> paused_since = now() + * (COALESCE — a no-op if somehow already paused) + * - waiting_customer/waiting_third_party -> in_progress -> the elapsed + * pause is folded into accumulated_pause_seconds and paused_since is + * cleared + * All using the DB's own now() for this specific bookkeeping (single + * statement, no app/DB clock skew risk). Returns the updated ticket AND + * the post-transition sla timer row so the service layer can compute + * first_response_met/resolution_met with the pure functions in + * apps/api/src/support/sla.ts. Throws TicketNotFoundError / + * InvalidTicketTransitionError instead of silently no-oping — the service + * layer maps these to 404/409. + */ +export async function updateTicketStatus( + id: string, + toStatus: TicketStatus, + reason: string | null, + actorId: string | null, + extra?: UpdateTicketStatusExtra, +): Promise<{ ticket: SupportTicket; slaTimer: SlaTimer }> { + return withTransaction(async (client) => { + const { rows } = await client.query<{ status: TicketStatus }>( + "SELECT status FROM support_tickets WHERE id = $1 FOR UPDATE", + [id], + ); + const current = rows[0]; + if (!current) { + throw new TicketNotFoundError(id); + } + + const fromStatus = current.status; + const allowed = TICKET_STATUS_TRANSITIONS[fromStatus] ?? []; + if (!allowed.includes(toStatus)) { + throw new InvalidTicketTransitionError(fromStatus, toStatus); + } + + const enteringWaiting = toStatus === "waiting_customer" || toStatus === "waiting_third_party"; + const resumingFromWaiting = + (fromStatus === "waiting_customer" || fromStatus === "waiting_third_party") && toStatus === "in_progress"; + const enteringResolved = toStatus === "resolved"; + const enteringClosed = toStatus === "closed"; + + // Lock the timer row too (1:1, always exists once the ticket does) and + // apply the pause/resume side effect. All SET expressions in a single + // UPDATE see the row's OLD values, so referencing paused_since while + // also overwriting it in the same statement is safe standard SQL. + await client.query( + `UPDATE sla_timers SET + accumulated_pause_seconds = CASE + WHEN $3 AND paused_since IS NOT NULL + THEN accumulated_pause_seconds + GREATEST(0, EXTRACT(EPOCH FROM (now() - paused_since))::int) + ELSE accumulated_pause_seconds + END, + paused_since = CASE + WHEN $2 THEN COALESCE(paused_since, now()) + WHEN $3 THEN NULL + ELSE paused_since + END, + updated_at = now() + WHERE ticket_id = $1`, + [id, enteringWaiting, resumingFromWaiting], + ); + + const { rows: updatedRows } = await client.query( + `UPDATE support_tickets SET + status = $2, + resolved_at = CASE WHEN $3 THEN COALESCE(resolved_at, now()) ELSE resolved_at END, + closed_at = CASE WHEN $4 THEN COALESCE(closed_at, now()) ELSE closed_at END, + root_cause = CASE WHEN $4 THEN COALESCE($5, root_cause) ELSE root_cause END, + resolution_notes = CASE WHEN $4 THEN COALESCE($6, resolution_notes) ELSE resolution_notes END, + updated_by = $7 + WHERE id = $1 + RETURNING *`, + [ + id, + toStatus, + enteringResolved, + enteringClosed, + extra?.rootCause ?? null, + extra?.resolutionNotes ?? null, + actorId ?? null, + ], + ); + const ticket = updatedRows[0]; + if (!ticket) throw new Error(`support ticket ${id} disappeared mid-transaction`); + + await client.query( + `INSERT INTO ticket_status_history (ticket_id, from_status, to_status, reason, changed_by) + VALUES ($1,$2,$3,$4,$5)`, + [id, fromStatus, toStatus, reason ?? null, actorId ?? null], + ); + + const { rows: timerRows } = await client.query( + "SELECT * FROM sla_timers WHERE ticket_id = $1", + [id], + ); + const slaTimer = timerRows[0]; + if (!slaTimer) throw new Error(`sla timer for ticket ${id} missing`); + + return { ticket, slaTimer }; + }); +} diff --git a/packages/database/src/repositories/users.ts b/packages/database/src/repositories/users.ts index 1770ecf..aa7cd1b 100644 --- a/packages/database/src/repositories/users.ts +++ b/packages/database/src/repositories/users.ts @@ -142,3 +142,25 @@ export async function assignUserToReseller( ]); return (rowCount ?? 0) > 0; } + +/** + * Stages a TOTP secret without enabling MFA yet — `mfa_enabled` stays + * false until `confirmMfaEnrollment` verifies a code against it. Login + * only checks `mfa_enabled`, so an unconfirmed enrollment is inert. + */ +export async function setMfaSecret(userId: string, encryptedSecret: string): Promise { + await query("UPDATE users SET mfa_secret_encrypted = $2, mfa_enabled = false WHERE id = $1", [ + userId, + encryptedSecret, + ]); +} + +export async function confirmMfaEnrollment(userId: string): Promise { + await query("UPDATE users SET mfa_enabled = true WHERE id = $1", [userId]); +} + +export async function disableMfa(userId: string): Promise { + await query("UPDATE users SET mfa_enabled = false, mfa_secret_encrypted = null WHERE id = $1", [ + userId, + ]); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 417714c..5d401f8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,10 +1,20 @@ { "name": "@eden/ui", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./tailwind-preset": { + "types": "./dist/tailwind-preset.d.ts", + "default": "./dist/tailwind-preset.js" + } + }, "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", @@ -15,6 +25,7 @@ }, "devDependencies": { "@types/react": "^18.3.18", + "tailwindcss": "^3.4.17", "typescript": "^5.7.3" } } diff --git a/packages/ui/src/components/Alert.tsx b/packages/ui/src/components/Alert.tsx new file mode 100644 index 0000000..2518ff5 --- /dev/null +++ b/packages/ui/src/components/Alert.tsx @@ -0,0 +1,29 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export type AlertVariant = "info" | "success" | "warning" | "error"; + +const variantClasses: Record = { + info: "bg-info-transparent border-info-200 text-info-900", + success: "bg-success-transparent border-success-200 text-success-900", + warning: "bg-warning-transparent border-warning-200 text-warning-900", + error: "bg-danger-transparent border-danger-200 text-danger-900", +}; + +export interface AlertProps extends Omit, "title"> { + variant?: AlertVariant; + title?: ReactNode; +} + +export function Alert({ variant = "info", title, className, children, role, ...rest }: AlertProps) { + return ( +
+ {title ?

{title}

: null} + {children ?
{children}
: null} +
+ ); +} diff --git a/packages/ui/src/components/AppShell.tsx b/packages/ui/src/components/AppShell.tsx new file mode 100644 index 0000000..5511d75 --- /dev/null +++ b/packages/ui/src/components/AppShell.tsx @@ -0,0 +1,120 @@ +import type { ComponentType, ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface AppShellNavItem { + key: string; + label: string; + href: string; + icon?: ReactNode; + active?: boolean; +} + +interface NavLinkRenderProps { + href: string; + className?: string; + children: ReactNode; +} + +export interface AppShellProps { + /** e.g. "EDEN Core" — shown next to the logo and used as the img alt text. */ + appName: string; + logoSrc?: string; + navItems: AppShellNavItem[]; + userName?: string; + userRole?: string; + onLogout?: () => void; + children: ReactNode; + /** + * Router-agnostic navigation: packages/ui doesn't depend on react-router. + * Defaults to a plain `` (full page reload); pass e.g. + * `({ href, className, children }) => {children}` + * from the consuming app to get client-side routing. + */ + LinkComponent?: ComponentType; +} + +function DefaultLink({ href, className, children }: NavLinkRenderProps) { + return ( + + {children} + + ); +} + +/** + * Visually inspired by the DreamsERP theme's sidebar/topbar + * (react/src/components/common-sidebar, common-header) — sidebar with + * logo + dark active nav item, white topbar with a bordered bottom edge — + * without importing any of the theme's Redux/routing code. + */ +export function AppShell({ + appName, + logoSrc, + navItems, + userName, + userRole, + onLogout, + children, + LinkComponent = DefaultLink, +}: AppShellProps) { + return ( +
+ + +
+
+
+ {logoSrc ? {appName} : null} + {appName} +
+
+ {userName ? ( +
+ + {userName.charAt(0).toUpperCase()} + +
+

{userName}

+ {userRole ?

{userRole}

: null} +
+
+ ) : null} + {onLogout ? ( + + ) : null} +
+
+ +
{children}
+
+
+ ); +} diff --git a/packages/ui/src/components/Badge.tsx b/packages/ui/src/components/Badge.tsx new file mode 100644 index 0000000..69ef8a1 --- /dev/null +++ b/packages/ui/src/components/Badge.tsx @@ -0,0 +1,33 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export type BadgeVariant = "primary" | "secondary" | "success" | "warning" | "danger" | "info" | "neutral"; + +const variantClasses: Record = { + primary: "bg-primary-transparent text-primary-900", + secondary: "bg-secondary-transparent text-secondary-900", + success: "bg-success-transparent text-success-900", + warning: "bg-warning-transparent text-warning-900", + danger: "bg-danger-transparent text-danger-900", + info: "bg-info-transparent text-info-900", + neutral: "bg-light text-gray-900", +}; + +export interface BadgeProps extends HTMLAttributes { + variant?: BadgeVariant; +} + +export function Badge({ variant = "neutral", className, children, ...rest }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx new file mode 100644 index 0000000..d7fbfa2 --- /dev/null +++ b/packages/ui/src/components/Button.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; +import type { ButtonHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; +export type ButtonSize = "sm" | "md"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + /** Shows an inline spinner and disables the button — for in-flight submits (e.g. login). */ + loading?: boolean; +} + +const variantClasses: Record = { + // Theme source (login.tsx) uses a dark CTA that hovers to the primary-hover + // shade, not a flat primary fill — kept here as-is. + primary: "bg-dark text-white hover:bg-primary-hover", + secondary: "bg-white text-title border border-border-color hover:bg-light", + danger: "bg-danger text-white hover:bg-danger-hover", + ghost: "bg-transparent text-default hover:bg-light", +}; + +const sizeClasses: Record = { + sm: "text-xs px-3 py-1.5", + md: "text-sm px-4 py-2.5", +}; + +export const Button = forwardRef(function Button( + { variant = "primary", size = "md", loading = false, disabled, className, children, ...rest }, + ref, +) { + return ( + + ); +}); diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx new file mode 100644 index 0000000..18d3025 --- /dev/null +++ b/packages/ui/src/components/Card.tsx @@ -0,0 +1,22 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface CardProps extends Omit, "title"> { + title?: ReactNode; + actions?: ReactNode; +} + +/** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ +export function Card({ title, actions, className, children, ...rest }: CardProps) { + return ( +
+ {title || actions ? ( +
+ {title ?

{title}

: } + {actions ?
{actions}
: null} +
+ ) : null} +
{children}
+
+ ); +} diff --git a/packages/ui/src/components/Input.tsx b/packages/ui/src/components/Input.tsx new file mode 100644 index 0000000..618f17f --- /dev/null +++ b/packages/ui/src/components/Input.tsx @@ -0,0 +1,50 @@ +import { forwardRef, useId } from "react"; +import type { InputHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface InputProps extends InputHTMLAttributes { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; +} + +export const Input = forwardRef(function Input( + { label, error, hint, id, className, ...rest }, + ref, +) { + const generatedId = useId(); + const inputId = id ?? generatedId; + + return ( +
+ {label ? ( + + ) : null} + + {error ? ( +

+ {error} +

+ ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +}); diff --git a/packages/ui/src/components/PageHeader.tsx b/packages/ui/src/components/PageHeader.tsx new file mode 100644 index 0000000..ccc981e --- /dev/null +++ b/packages/ui/src/components/PageHeader.tsx @@ -0,0 +1,20 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface PageHeaderProps extends Omit, "title"> { + title: ReactNode; + description?: ReactNode; + actions?: ReactNode; +} + +export function PageHeader({ title, description, actions, className, ...rest }: PageHeaderProps) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d7b0a9d..d0966ae 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,7 +1,31 @@ /** * EDEN Design System, derived from the purchased DreamsERP theme - * (tema_do_Eden.zip) — no components ported yet. See + * (tema_do_Eden.zip → dreamserp-v1.0.1/react.zip). See * docs/project-inventory.md §2 and the eden-frontend agent - * (.claude/agents/eden-frontend.md) for the extraction plan. + * (.claude/agents/eden-frontend.md) for the extraction plan and + * `./tailwind-preset` for the extracted visual tokens. */ -export const UI_PACKAGE_VERSION = "0.1.0"; +export const UI_PACKAGE_VERSION = "0.2.0"; + +export { Button } from "./components/Button.js"; +export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button.js"; + +export { Input } from "./components/Input.js"; +export type { InputProps } from "./components/Input.js"; + +export { Card } from "./components/Card.js"; +export type { CardProps } from "./components/Card.js"; + +export { Badge } from "./components/Badge.js"; +export type { BadgeProps, BadgeVariant } from "./components/Badge.js"; + +export { Alert } from "./components/Alert.js"; +export type { AlertProps, AlertVariant } from "./components/Alert.js"; + +export { PageHeader } from "./components/PageHeader.js"; +export type { PageHeaderProps } from "./components/PageHeader.js"; + +export { AppShell } from "./components/AppShell.js"; +export type { AppShellProps, AppShellNavItem } from "./components/AppShell.js"; + +export { cn } from "./utils/cn.js"; diff --git a/packages/ui/src/tailwind-preset.ts b/packages/ui/src/tailwind-preset.ts new file mode 100644 index 0000000..df993e7 --- /dev/null +++ b/packages/ui/src/tailwind-preset.ts @@ -0,0 +1,231 @@ +/** + * EDEN Tailwind preset — visual tokens extracted from the purchased + * DreamsERP theme (tema_do_Eden.zip → dreamserp-v1.0.1/react.zip → + * react/src/style/css/style.css, Tailwind v4 `@theme` block). + * + * The theme itself ships as Tailwind v4 (CSS-first `@theme`), but our apps + * are on Tailwind v3, so the same token values are re-expressed here as a + * plain JS/TS preset consumed via `presets: [require("@eden/ui/tailwind-preset")]` + * (or the ESM equivalent) in each app's own tailwind.config.js. + * + * Do not hand-edit color hexes without re-checking the source file — these + * are copied verbatim, not invented. + */ +import type { Config } from "tailwindcss"; + +type PresetTheme = Config["theme"]; + +const withHover = (base: Record, hover: string) => ({ + ...base, + hover, +}); + +const primary = withHover( + { + DEFAULT: "#0F766E", + 100: "#D7E5E3", + 200: "#C3D8D5", + 300: "#AFCCC8", + 400: "#9BBFBA", + 500: "#88B3AD", + 600: "#74A6A0", + 700: "#5F9A93", + 800: "#4A8E87", + 900: "#32827A", + transparent: "#EBF2F1", + }, + "#0C6760", +); + +const secondary = withHover( + { + DEFAULT: "#2563EB", + 100: "#D6E4FF", + 200: "#C2D7FE", + 300: "#AEC9FD", + 400: "#9ABCFB", + 500: "#87AEF9", + 600: "#74A0F7", + 700: "#6092F5", + 800: "#4D83F2", + 900: "#3973EE", + transparent: "#EAF2FF", + }, + "#1F56CF", +); + +const success = withHover( + { + DEFAULT: "#059669", + 100: "#DAECE2", + 200: "#C7E2D4", + 300: "#B4D9C6", + 400: "#A1CFB8", + 500: "#8EC6AB", + 600: "#7BBC9D", + 700: "#66B390", + 800: "#50A983", + 900: "#36A076", + transparent: "#ECF5F1", + }, + "#04845C", +); + +const warning = withHover( + { + DEFAULT: "#D97706", + 100: "#FAE7D9", + 200: "#F8DBC6", + 300: "#F5CFB2", + 400: "#F1C29F", + 500: "#EEB68C", + 600: "#EAAA78", + 700: "#E69D64", + 800: "#E2914D", + 900: "#DE8434", + transparent: "#FDF3EC", + }, + "#BF6805", +); + +const danger = withHover( + { + DEFAULT: "#B91C1C", + 100: "#F8D9D5", + 200: "#F3C6C0", + 300: "#EEB4AC", + 400: "#EBA198", + 500: "#E28E84", + 600: "#DB7B70", + 700: "#D3675C", + 800: "#CB5248", + 900: "#C23B33", + transparent: "#FCECEA", + }, + "#A31717", +); + +const info = withHover( + { + DEFAULT: "#0EA5E9", + 100: "#DCEFFC", + 200: "#CBE7FB", + 300: "#B9DFF9", + 400: "#A7D7F7", + 500: "#94CFF5", + 600: "#81C7F3", + 700: "#6DBFF1", + 800: "#57B6EE", + 900: "#3CAEEC", + transparent: "#EEF7FE", + }, + "#0B91CD", +); + +const gray = withHover( + { + 100: "#D1D4D8", + 200: "#BBBFC5", + 300: "#A5AAB3", + 400: "#9096A1", + 500: "#7B838F", + 600: "#67707D", + 700: "#545D6C", + 800: "#414B5B", + 900: "#2F3A4B", + transparent: "#E8E9EC", + }, + "#192333", +); + +const light = withHover( + { + DEFAULT: "#F8F9FA", + 100: "#FAFBFD", + 200: "#F6F8FB", + 300: "#F3F4F6", + 400: "#F3F6F9", + 500: "#F1F4F7", + 600: "#EEF2F6", + 700: "#ECF1F6", + 800: "#E9EFF3", + 900: "#E7EDF2", + transparent: "#FDFDFE", + }, + "#DBDCDE", +); + +export const edenColors = { + primary, + secondary, + success, + warning, + danger, + info, + gray, + light, + dark: "#1E293B", + white: "#FFFFFF", + black: "#000000", + orange: { DEFAULT: "#E65100", transparent: "#FFF0EB" }, + purple: { DEFAULT: "#6A1B9A", transparent: "#F1EBF7" }, + pink: { DEFAULT: "#CC25B0", transparent: "#FDEDF8" }, + indigo: { DEFAULT: "#303F9F", transparent: "#EAEDF7" }, + cyan: { DEFAULT: "#0891B2", transparent: "#EBF3F6" }, + teal: { DEFAULT: "#0D9488", transparent: "#EBF2F1" }, + default: "#67707D", + title: "#2F3A4B", + "border-color": "#E8E9EC", + skeleton: "#E4E7EB", +} satisfies Record; + +/** Sidebar/topbar layout tokens (`--sidenav-width`, `--topbar-height`, …). */ +export const edenLayout = { + sidebarWidth: "242px", + sidebarMiniWidth: "56px", + sidebarExpandMiniWidth: "80px", + topbarHeight: "56px", +}; + +const edenTheme: PresetTheme = { + extend: { + colors: edenColors, + fontFamily: { + // Theme source imports Google Fonts "Geist" as --font-family-primary. + // (Older EDEN docs referenced Nunito/Poppins for a prior theme + // revision — confirmed against the actual shipped tailwind/style.css + // that this is the current value; update here if the theme changes.) + sans: ["Geist", "ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"], + }, + borderRadius: { + xs: "2px", + sm: "4px", + md: "8px", + xl: "12px", + }, + boxShadow: { + DEFAULT: "0px 1px 4px 0px rgba(0, 0, 0, 0.05)", + sm: "0px 4px 4px 0px rgba(219, 219, 219, 0.25)", + lg: "0px 4px 30px 0px rgba(20, 20, 20, 0.08)", + primary: "0px 4px 10px 0px rgba(46, 55, 164, 0.1)", + }, + spacing: { + sidebar: edenLayout.sidebarWidth, + "sidebar-mini": edenLayout.sidebarMiniWidth, + topbar: edenLayout.topbarHeight, + }, + backgroundImage: { + "primary-gradient": "linear-gradient(90deg, #0F766E 0%, #059669 100%)", + "dark-gradient": "linear-gradient(90deg, #1E293B 0%, #0F172A 100%)", + }, + }, +}; + +// A preset intentionally omits `content` (each consuming app supplies its +// own) — Tailwind's `Config` type marks it required, so we type this as a +// partial config rather than a full `Config`. +const edenPreset: Partial = { + theme: edenTheme, +}; + +export default edenPreset; diff --git a/packages/ui/src/utils/cn.ts b/packages/ui/src/utils/cn.ts new file mode 100644 index 0000000..991c355 --- /dev/null +++ b/packages/ui/src/utils/cn.ts @@ -0,0 +1,4 @@ +/** Joins class name fragments, skipping falsy values. No `clsx` dependency needed for this. */ +export function cn(...parts: Array): string { + return parts.filter(Boolean).join(" "); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94fa0b4..97bfa4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.3 + otplib: + specifier: ^13.5.0 + version: 13.5.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -99,12 +102,18 @@ importers: apps/core-web: dependencies: + '@eden/ui': + specifier: workspace:* + version: link:../../packages/ui react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.28.1 + version: 6.30.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/react': specifier: ^18.3.18 @@ -305,6 +314,9 @@ importers: '@types/react': specifier: ^18.3.18 version: 18.3.31 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.19(tsx@4.23.13) typescript: specifier: ^5.7.3 version: 5.9.3 @@ -1088,6 +1100,10 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1105,6 +1121,24 @@ packages: engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true + '@otplib/core@13.5.0': + resolution: {integrity: sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ==} + + '@otplib/hotp@13.5.0': + resolution: {integrity: sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw==} + + '@otplib/plugin-base32-scure@13.5.0': + resolution: {integrity: sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA==} + + '@otplib/plugin-crypto-noble@13.5.0': + resolution: {integrity: sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA==} + + '@otplib/totp@13.5.0': + resolution: {integrity: sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg==} + + '@otplib/uri@13.5.0': + resolution: {integrity: sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw==} + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -1112,6 +1146,10 @@ packages: resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} engines: {node: '>=10'} + '@remix-run/router@1.23.4': + resolution: {integrity: sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==} + engines: {node: '>=14.0.0'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1265,6 +1303,9 @@ packages: '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@scure/base@2.4.0': + resolution: {integrity: sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==} + '@smithy/core@3.33.3': resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} engines: {node: '>=18.0.0'} @@ -2286,6 +2327,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + otplib@13.5.0: + resolution: {integrity: sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -2461,6 +2505,19 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-router-dom@6.30.6: + resolution: {integrity: sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.6: + resolution: {integrity: sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -3551,6 +3608,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@noble/hashes@2.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3571,12 +3630,41 @@ snapshots: transitivePeerDependencies: - encoding + '@otplib/core@13.5.0': {} + + '@otplib/hotp@13.5.0': + dependencies: + '@otplib/core': 13.5.0 + '@otplib/uri': 13.5.0 + + '@otplib/plugin-base32-scure@13.5.0': + dependencies: + '@otplib/core': 13.5.0 + '@scure/base': 2.4.0 + + '@otplib/plugin-crypto-noble@13.5.0': + dependencies: + '@noble/hashes': 2.4.0 + '@otplib/core': 13.5.0 + + '@otplib/totp@13.5.0': + dependencies: + '@otplib/core': 13.5.0 + '@otplib/hotp': 13.5.0 + '@otplib/uri': 13.5.0 + + '@otplib/uri@13.5.0': + dependencies: + '@otplib/core': 13.5.0 + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 '@phc/format@1.0.0': {} + '@remix-run/router@1.23.4': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.4.0(rollup@4.63.1)': @@ -3664,6 +3752,8 @@ snapshots: '@scarf/scarf@1.4.0': {} + '@scure/base@2.4.0': {} + '@smithy/core@3.33.3': dependencies: '@smithy/types': 4.17.2 @@ -4773,6 +4863,15 @@ snapshots: dependencies: wrappy: 1.0.2 + otplib@13.5.0: + dependencies: + '@otplib/core': 13.5.0 + '@otplib/hotp': 13.5.0 + '@otplib/plugin-base32-scure': 13.5.0 + '@otplib/plugin-crypto-noble': 13.5.0 + '@otplib/totp': 13.5.0 + '@otplib/uri': 13.5.0 + package-json-from-dist@1.0.1: {} parseurl@1.3.3: {} @@ -4918,6 +5017,18 @@ snapshots: react-refresh@0.17.0: {} + react-router-dom@6.30.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.6(react@18.3.1) + + react-router@6.30.6(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.4 + react: 18.3.1 + react@18.3.1: dependencies: loose-envify: 1.4.0 diff --git a/turbo.json b/turbo.json index 267c280..5bf9535 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,8 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**"] + "outputs": ["dist/**"], + "env": ["VITE_*"] }, "dev": { "cache": false,