Compare commits
53 Commits
44510bd019
...
developer
| Author | SHA1 | Date | |
|---|---|---|---|
| 398579fa82 | |||
| a1e020556e | |||
| 33efb9b9ca | |||
| b3b1e21c20 | |||
| 3785b32c5f | |||
| 1545666f17 | |||
| 553c0d9533 | |||
| 8ab18ababc | |||
| ef4591b8bc | |||
| 57f83598b6 | |||
| d81553fcfe | |||
| ec13dccee6 | |||
| 2ae61dba22 | |||
| 9e629bb394 | |||
| 205d83a5f5 | |||
| 93cd42b22e | |||
| 53c3031775 | |||
| 4e4de2c259 | |||
| 96200dc2ba | |||
| f38f2407ff | |||
| 9c14671e19 | |||
| 20b114a0bf | |||
| d46d44f01b | |||
| ccac936372 | |||
| 29430d0d96 | |||
| 4d23af3b23 | |||
| 012c00fa4d | |||
| 828e6e8ddb | |||
| 7021994d8e | |||
| 19e67dbe63 | |||
| abe056c8b6 | |||
| f96c465ee5 | |||
| 4d67fe5f1a | |||
| eff515b70d | |||
| 18838a9031 | |||
| e8014b4135 | |||
| 860c21df07 | |||
| 159a2964cc | |||
| cdafb7f853 | |||
| e7d3668154 | |||
| f56c3c9054 | |||
| a8f77e4068 | |||
| c9ba96f61f | |||
| 8d108e08ed | |||
| 1ff55afd89 | |||
| eeb6977e36 | |||
| dca6eb2766 | |||
| d94885a681 | |||
| 676806e1eb | |||
| 8f638f2743 | |||
| 95090ba24b | |||
| e5fd75c44a | |||
| 8d81cf506b |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.turbo
|
||||
.claude/worktrees
|
||||
.tmp
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
tema_do_Eden.zip
|
||||
35
.env.example
35
.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=
|
||||
@@ -45,6 +68,11 @@ SMTP_PASS=
|
||||
SMTP_FROM=
|
||||
|
||||
# --- S3 / storage de objetos (compatível MinIO ou AWS S3) ---
|
||||
# Usando o serviço eden-minio do compose.yaml (bundled, sobe junto com o
|
||||
# resto): endpoint é http://eden-minio:9000, e as credenciais abaixo viram
|
||||
# MINIO_ROOT_USER/MINIO_ROOT_PASSWORD desse container — gerar com
|
||||
# openssl rand -hex 12 / openssl rand -hex 24. Usando AWS S3 real: apagar
|
||||
# S3_ENDPOINT (deixar em branco) e usar as credenciais IAM normais.
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=us-east-1
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
@@ -55,6 +83,13 @@ S3_BUCKET=
|
||||
# --- URL pública base (usada para montar links absolutos em e-mails) ---
|
||||
PUBLIC_BASE_URL=http://localhost:3001
|
||||
|
||||
# --- Renderização de PDF (Chromium headless via Playwright) ---
|
||||
# Usado por documentos, PDF de oferta/orçamento (ANATEL) e certificado de
|
||||
# assinatura — packages/integrations/src/pdf/renderer.ts. Concorrência
|
||||
# limitada (cada aba do Chromium consome memória real) e timeout obrigatório.
|
||||
PDF_MAX_CONCURRENCY=3
|
||||
PDF_RENDER_TIMEOUT_SECONDS=15
|
||||
|
||||
# --- Integrações externas (preenchidas quando cada adapter for implementado) ---
|
||||
FOCUS_NFE_API_TOKEN=
|
||||
FOCUS_NFE_BASE_URL=
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -14,6 +14,7 @@ build/
|
||||
.env.local
|
||||
*.pem
|
||||
*.key
|
||||
CREDENCIAIS-INICIAIS.md
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -45,3 +46,10 @@ infra/docker/pgdata/
|
||||
|
||||
# Claude Code local overrides (personal, never shared)
|
||||
.claude/settings.local.json
|
||||
|
||||
# Temporary git worktrees created for parallel subagent work — each is its
|
||||
# own nested git checkout; never vendor these into the main repo history.
|
||||
.claude/worktrees/
|
||||
|
||||
# Session-local lock file for scheduled wakeups, not project content.
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
22
apps/api/.swcrc
Normal file
22
apps/api/.swcrc
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"decorators": true,
|
||||
"dynamicImport": true
|
||||
},
|
||||
"transform": {
|
||||
"legacyDecorator": true,
|
||||
"decoratorMetadata": true
|
||||
},
|
||||
"keepClassNames": true,
|
||||
"target": "es2022",
|
||||
"loose": false,
|
||||
"externalHelpers": false
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
@@ -6,24 +6,44 @@
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/main.js",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|
||||
"lint": "echo 'no linter configured yet'",
|
||||
"test": "echo 'no tests yet'"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eden/config": "workspace:*",
|
||||
"@eden/database": "workspace:*",
|
||||
"@eden/domain-shared": "workspace:*",
|
||||
"@eden/integrations": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/swagger": "^8.1.0",
|
||||
"@nestjs/terminus": "^10.2.3",
|
||||
"argon2": "^0.41.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"otplib": "^13.5.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@swc/core": "^1.10.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.7.3",
|
||||
"unplugin-swc": "^1.5.1",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,51 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TerminusModule } from "@nestjs/terminus";
|
||||
import { HealthController } from "./health/health.controller";
|
||||
import { HealthController } from "./health/health.controller.js";
|
||||
import { ConfigModule } from "./config/config.module.js";
|
||||
import { AuthModule } from "./auth/auth.module.js";
|
||||
import { BootstrapModule } from "./bootstrap/bootstrap.module.js";
|
||||
import { CompaniesModule } from "./companies/companies.module.js";
|
||||
import { UsersModule } from "./users/users.module.js";
|
||||
import { RolesModule } from "./roles/roles.module.js";
|
||||
import { ProductsModule } from "./products/products.module.js";
|
||||
import { ResellersModule } from "./resellers/resellers.module.js";
|
||||
import { LeadsModule } from "./leads/leads.module.js";
|
||||
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";
|
||||
import { DocumentsModule } from "./documents/documents.module.js";
|
||||
import { SignaturesModule } from "./signatures/signatures.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
imports: [
|
||||
ConfigModule,
|
||||
TerminusModule,
|
||||
AuthModule,
|
||||
BootstrapModule,
|
||||
CompaniesModule,
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
ProductsModule,
|
||||
ResellersModule,
|
||||
LeadsModule,
|
||||
OpportunitiesModule,
|
||||
QuotesModule,
|
||||
ClientRegistrationsModule,
|
||||
ContractsModule,
|
||||
InventoryModule,
|
||||
SupportModule,
|
||||
HrTimeclockModule,
|
||||
FiscalModule,
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
SignaturesModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
116
apps/api/src/auth/auth.controller.ts
Normal file
116
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Body, Controller, Get, HttpCode, Post, Req, UseGuards } from "@nestjs/common";
|
||||
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 { ForgotPasswordDto } from "./dto/forgot-password.dto.js";
|
||||
import { ResetPasswordDto } from "./dto/reset-password.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";
|
||||
import { RateLimitGuard } from "../common/rate-limit/rate-limit.guard.js";
|
||||
import { RateLimit } from "../common/rate-limit/rate-limit.decorator.js";
|
||||
import {
|
||||
AUTH_FORGOT_PASSWORD_RULE,
|
||||
AUTH_LOGIN_RULE,
|
||||
AUTH_MFA_CHALLENGE_RULE,
|
||||
AUTH_LOGOUT_RULE,
|
||||
AUTH_REFRESH_RULE,
|
||||
AUTH_RESET_PASSWORD_RULE,
|
||||
} from "../common/rate-limit/rate-limit-rules.js";
|
||||
|
||||
function requestContext(req: Request) {
|
||||
return {
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.headers["user-agent"] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller("v1/auth")
|
||||
@UseGuards(RateLimitGuard)
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly mfaService: MfaService,
|
||||
) {}
|
||||
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
@RateLimit(AUTH_LOGIN_RULE)
|
||||
async login(@Body() dto: LoginDto, @Req() req: Request) {
|
||||
return this.auth.login(dto.email, dto.password, dto.applicationKey, requestContext(req));
|
||||
}
|
||||
|
||||
@Post("mfa/challenge")
|
||||
@HttpCode(200)
|
||||
@RateLimit(AUTH_MFA_CHALLENGE_RULE)
|
||||
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("forgot-password")
|
||||
@HttpCode(200)
|
||||
@RateLimit(AUTH_FORGOT_PASSWORD_RULE)
|
||||
async forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: Request) {
|
||||
await this.auth.forgotPassword(dto.email, requestContext(req));
|
||||
// Always the same response regardless of whether the email is
|
||||
// registered — anti-enumeration (eden.md §4.1), mirrors login.
|
||||
return { message: "Se este e-mail estiver cadastrado, você receberá instruções para redefinir sua senha." };
|
||||
}
|
||||
|
||||
@Post("reset-password")
|
||||
@HttpCode(204)
|
||||
@RateLimit(AUTH_RESET_PASSWORD_RULE)
|
||||
async resetPassword(@Body() dto: ResetPasswordDto, @Req() req: Request) {
|
||||
await this.auth.resetPassword(dto.token, dto.newPassword, requestContext(req));
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@HttpCode(200)
|
||||
@RateLimit(AUTH_REFRESH_RULE)
|
||||
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {
|
||||
return this.auth.refresh(dto.refreshToken, requestContext(req));
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@HttpCode(204)
|
||||
@RateLimit(AUTH_LOGOUT_RULE)
|
||||
async logout(@Body() dto: RefreshDto, @Req() req: Request) {
|
||||
await this.auth.logout(dto.refreshToken, requestContext(req));
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async me(@CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.auth.getProfile(user.id);
|
||||
}
|
||||
}
|
||||
28
apps/api/src/auth/auth.module.ts
Normal file
28
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
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";
|
||||
import { RateLimitModule } from "../common/rate-limit/rate-limit.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [RateLimitModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
PasswordService,
|
||||
TokenService,
|
||||
MfaService,
|
||||
FieldEncryptionService,
|
||||
JwtAuthGuard,
|
||||
PermissionGuard,
|
||||
SuperAdminGuard,
|
||||
],
|
||||
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard, SuperAdminGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
323
apps/api/src/auth/auth.service.ts
Normal file
323
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { usersRepo, sessionsRepo, auditRepo, type User } from "@eden/database";
|
||||
import { sendMail } from "@eden/integrations";
|
||||
import type { EdenEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "../config/config.constants.js";
|
||||
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 {
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
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 interface PublicUser {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
roleKey: string;
|
||||
}
|
||||
|
||||
function toPublicUser(user: User): PublicUser {
|
||||
return { id: user.id, email: user.email, fullName: user.full_name, roleKey: user.role_key };
|
||||
}
|
||||
|
||||
const GENERIC_LOGIN_ERROR = "Credenciais inválidas";
|
||||
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h — deliberately short-lived (this token grants a password change)
|
||||
|
||||
function hashResetToken(plaintext: string): string {
|
||||
return createHash("sha256").update(plaintext).digest("hex");
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly passwords: PasswordService,
|
||||
private readonly tokens: TokenService,
|
||||
private readonly mfa: MfaService,
|
||||
@Inject(EDEN_ENV) private readonly env: EdenEnv,
|
||||
) {}
|
||||
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
applicationKey: "core" | "reseller" | "subscriber",
|
||||
ctx: RequestContext,
|
||||
): Promise<LoginResult> {
|
||||
const user = await usersRepo.findUserByEmail(email);
|
||||
|
||||
// Same generic error whether the account doesn't exist or the password
|
||||
// is wrong — anti-enumeration, mirrors eden.md §4.1.
|
||||
if (!user || !user.password_hash) {
|
||||
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
|
||||
}
|
||||
|
||||
const valid = await this.passwords.verify(user.password_hash, password);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
|
||||
}
|
||||
|
||||
if (this.passwords.needsRehash(user.password_algo_version)) {
|
||||
const { hash, algoVersion } = await this.passwords.hash(password);
|
||||
await usersRepo.updateUserPassword(user.id, hash, algoVersion);
|
||||
}
|
||||
|
||||
const grantedApps = await usersRepo.getUserApplications(user.id);
|
||||
if (!grantedApps.includes(applicationKey)) {
|
||||
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({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.login",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
metadata: { applicationKey },
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Second step of login when the account has MFA enabled — exchanges a password-verified challenge + TOTP code for real tokens. */
|
||||
async completeMfaLogin(challengeToken: string, code: string, ctx: RequestContext): Promise<AuthResult> {
|
||||
let payload;
|
||||
try {
|
||||
payload = this.tokens.verifyMfaChallenge(challengeToken);
|
||||
} catch {
|
||||
throw new UnauthorizedException("Desafio MFA inválido ou expirado");
|
||||
}
|
||||
|
||||
const user = await usersRepo.findUserById(payload.sub);
|
||||
if (!user || !user.is_active || !user.mfa_enabled || !user.mfa_secret_encrypted) {
|
||||
throw new UnauthorizedException("Desafio MFA inválido ou expirado");
|
||||
}
|
||||
|
||||
if (!(await this.mfa.verifyCode(user.mfa_secret_encrypted, code))) {
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.mfa_challenge_failed",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
throw new UnauthorizedException("Código inválido");
|
||||
}
|
||||
|
||||
const result = await this.issueTokens(user, payload.app, ctx);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.login",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
metadata: { applicationKey: payload.app, viaMfa: true },
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async refresh(refreshTokenPlaintext: string, ctx: RequestContext): Promise<AuthResult> {
|
||||
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
|
||||
const active = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
|
||||
|
||||
if (!active) {
|
||||
// Replay detection: a token that exists but is revoked/expired being
|
||||
// reused is a signal the refresh token chain may be compromised —
|
||||
// revoke every session of that user defensively (ADR-0003).
|
||||
const anyState = await sessionsRepo.findSessionByRefreshTokenHashAnyState(hash);
|
||||
if (anyState?.revoked_at) {
|
||||
await sessionsRepo.revokeAllUserSessions(anyState.user_id);
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "system",
|
||||
actorId: anyState.user_id,
|
||||
action: "auth.refresh_replay_detected",
|
||||
resourceType: "session",
|
||||
resourceId: anyState.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
}
|
||||
throw new UnauthorizedException("Sessão inválida ou expirada");
|
||||
}
|
||||
|
||||
const user = await usersRepo.findUserById(active.user_id);
|
||||
if (!user || !user.is_active) {
|
||||
throw new UnauthorizedException("Sessão inválida ou expirada");
|
||||
}
|
||||
|
||||
await sessionsRepo.touchSession(active.id);
|
||||
const result = await this.issueTokens(user, active.application_key, ctx, active.id);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.refresh",
|
||||
resourceType: "session",
|
||||
resourceId: active.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async logout(refreshTokenPlaintext: string, ctx: RequestContext): Promise<void> {
|
||||
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
|
||||
const session = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
|
||||
if (!session) return; // idempotent — already logged out
|
||||
await sessionsRepo.revokeSession(session.id);
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: session.user_id,
|
||||
action: "auth.logout",
|
||||
resourceType: "session",
|
||||
resourceId: session.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
const user = await usersRepo.findUserById(userId);
|
||||
if (!user || !user.is_active) throw new UnauthorizedException();
|
||||
const permissions = await resolvePermissions(user.role_key);
|
||||
const applications = await usersRepo.getUserApplications(user.id);
|
||||
return {
|
||||
user: toPublicUser(user),
|
||||
permissions: permissions.superAdmin
|
||||
? ("ALL" as const)
|
||||
: permissions.grants.map((g) => ({ resource: g.resource, action: g.action, scope: g.scope })),
|
||||
applications,
|
||||
};
|
||||
}
|
||||
|
||||
private async issueTokens(
|
||||
user: User,
|
||||
applicationKey: "core" | "reseller" | "subscriber",
|
||||
ctx: RequestContext,
|
||||
replacesSessionId?: string,
|
||||
): Promise<AuthResult> {
|
||||
const access = this.tokens.signAccessToken({ sub: user.id, app: applicationKey });
|
||||
const refresh = this.tokens.generateRefreshToken();
|
||||
|
||||
await sessionsRepo.createSession({
|
||||
userId: user.id,
|
||||
applicationKey,
|
||||
refreshTokenHash: refresh.hash,
|
||||
expiresAt: refresh.expiresAt,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
replacesSessionId,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: access.token,
|
||||
expiresIn: access.expiresIn,
|
||||
refreshToken: refresh.plaintext,
|
||||
user: toPublicUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Always resolves the same way whether or not the email is registered —
|
||||
* same anti-enumeration discipline as login (eden.md §4.1). If the
|
||||
* account exists (and is active), stages a reset token and emails a
|
||||
* link; the mailer is fail-soft (packages/integrations), so a delivery
|
||||
* failure never surfaces as an error here either.
|
||||
*/
|
||||
async forgotPassword(email: string, ctx: RequestContext): Promise<void> {
|
||||
const user = await usersRepo.findUserByEmail(email);
|
||||
if (!user) return;
|
||||
|
||||
const plaintextToken = randomBytes(32).toString("hex");
|
||||
const tokenHash = hashResetToken(plaintextToken);
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS);
|
||||
await usersRepo.setResetToken(user.id, tokenHash, expiresAt);
|
||||
|
||||
const resetUrl = `${this.env.PUBLIC_BASE_URL ?? "http://localhost:3001"}/reset-password?token=${plaintextToken}`;
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: "EDEN — Redefinição de senha",
|
||||
html: `
|
||||
<p>Olá${user.full_name ? `, ${user.full_name}` : ""},</p>
|
||||
<p>Recebemos um pedido para redefinir sua senha no EDEN. Se foi você, clique no link abaixo (válido por 1 hora):</p>
|
||||
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
||||
<p>Se você não pediu essa redefinição, pode ignorar este e-mail — sua senha continua a mesma.</p>
|
||||
`,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.password_reset_requested",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a reset token issued by forgotPassword(). The token is
|
||||
* cleared whether or not this call succeeds past validation, so a
|
||||
* token can never be replayed — and every existing session is revoked
|
||||
* (a password change invalidates any session that might have been
|
||||
* established under the old, possibly-compromised credential).
|
||||
*/
|
||||
async resetPassword(plaintextToken: string, newPassword: string, ctx: RequestContext): Promise<void> {
|
||||
const tokenHash = hashResetToken(plaintextToken);
|
||||
const user = await usersRepo.findUserByResetTokenHash(tokenHash);
|
||||
if (!user) {
|
||||
throw new BadRequestException("Token inválido ou expirado");
|
||||
}
|
||||
|
||||
const { hash, algoVersion } = await this.passwords.hash(newPassword);
|
||||
await usersRepo.updateUserPassword(user.id, hash, algoVersion);
|
||||
await usersRepo.clearResetToken(user.id);
|
||||
await sessionsRepo.revokeAllUserSessions(user.id);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.password_reset_completed",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
7
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
|
||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<Request>();
|
||||
return request.edenUser;
|
||||
});
|
||||
19
apps/api/src/auth/decorators/require-permission.decorator.ts
Normal file
19
apps/api/src/auth/decorators/require-permission.decorator.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
import type { RolePermission } from "@eden/database";
|
||||
|
||||
export const REQUIRE_PERMISSION_KEY = "eden:requirePermission";
|
||||
|
||||
export interface RequiredPermission {
|
||||
resource: string;
|
||||
action: RolePermission["action"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares that an endpoint needs (resource, action) per ADR-0004.
|
||||
* super_admin always passes. Confirms the actor holds *some* scope for
|
||||
* this resource/action — row-level filtering by the granted scope (own
|
||||
* vs. all, etc.) is the responsibility of the service/query layer, which
|
||||
* reads it off `request.edenUser.permissions` (see permissions.ts).
|
||||
*/
|
||||
export const RequirePermission = (resource: string, action: RequiredPermission["action"]) =>
|
||||
SetMetadata(REQUIRE_PERMISSION_KEY, { resource, action } satisfies RequiredPermission);
|
||||
6
apps/api/src/auth/dto/forgot-password.dto.ts
Normal file
6
apps/api/src/auth/dto/forgot-password.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsEmail } from "class-validator";
|
||||
|
||||
export class ForgotPasswordDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
13
apps/api/src/auth/dto/login.dto.ts
Normal file
13
apps/api/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsIn, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password!: string;
|
||||
|
||||
@IsIn(["core", "reseller", "subscriber"])
|
||||
applicationKey!: "core" | "reseller" | "subscriber";
|
||||
}
|
||||
11
apps/api/src/auth/dto/mfa-challenge.dto.ts
Normal file
11
apps/api/src/auth/dto/mfa-challenge.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, Length, MinLength } from "class-validator";
|
||||
|
||||
export class MfaChallengeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
mfaChallenge!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
code!: string;
|
||||
}
|
||||
7
apps/api/src/auth/dto/mfa-code.dto.ts
Normal file
7
apps/api/src/auth/dto/mfa-code.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, Length } from "class-validator";
|
||||
|
||||
export class MfaCodeDto {
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
code!: string;
|
||||
}
|
||||
7
apps/api/src/auth/dto/refresh.dto.ts
Normal file
7
apps/api/src/auth/dto/refresh.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class RefreshDto {
|
||||
@IsString()
|
||||
@MinLength(32)
|
||||
refreshToken!: string;
|
||||
}
|
||||
10
apps/api/src/auth/dto/reset-password.dto.ts
Normal file
10
apps/api/src/auth/dto/reset-password.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@IsString()
|
||||
token!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8, { message: "A nova senha precisa ter ao menos 8 caracteres" })
|
||||
newPassword!: string;
|
||||
}
|
||||
71
apps/api/src/auth/jwt-auth.guard.ts
Normal file
71
apps/api/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { usersRepo } from "@eden/database";
|
||||
import { TokenService } from "./token.service.js";
|
||||
import { resolvePermissions, type ResolvedPermissions } from "./permissions.js";
|
||||
|
||||
export interface EdenAuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
roleKey: string;
|
||||
applicationKey: "core" | "reseller" | "subscriber";
|
||||
permissions: ResolvedPermissions;
|
||||
/** Nullable — scopes the actor to a reseller for scope='reseller' permission checks (eden.md §7, ADR-0004). */
|
||||
resellerId: string | null;
|
||||
/** eden.md §1.1/§3.3 — only meaningful combined with roleKey==='admin'; super_admin bypasses regardless. */
|
||||
canApproveDiscount: boolean;
|
||||
}
|
||||
|
||||
declare module "express" {
|
||||
interface Request {
|
||||
edenUser?: EdenAuthenticatedUser;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the access token's signature/expiry, then re-resolves the user
|
||||
* and their permissions from the database on every request — deliberately
|
||||
* not cached in the JWT payload, so a role/permission change (or account
|
||||
* deactivation) takes effect immediately rather than waiting out the
|
||||
* token's 15-minute lifetime. Mirrors the legacy's LEFT JOIN-on-every-
|
||||
* request philosophy (eden.md §1, §3.3).
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly tokens: TokenService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const header = request.headers.authorization;
|
||||
if (!header?.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException("Token de acesso ausente");
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = this.tokens.verifyAccessToken(header.slice("Bearer ".length));
|
||||
} catch {
|
||||
throw new UnauthorizedException("Token de acesso inválido ou expirado");
|
||||
}
|
||||
|
||||
const user = await usersRepo.findUserById(payload.sub);
|
||||
if (!user || !user.is_active) {
|
||||
throw new UnauthorizedException("Usuário inativo ou inexistente");
|
||||
}
|
||||
|
||||
const permissions = await resolvePermissions(user.role_key);
|
||||
|
||||
request.edenUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: user.full_name,
|
||||
roleKey: user.role_key,
|
||||
applicationKey: payload.app,
|
||||
permissions,
|
||||
resellerId: user.reseller_id,
|
||||
canApproveDiscount: user.can_approve_discount,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
49
apps/api/src/auth/mfa.service.ts
Normal file
49
apps/api/src/auth/mfa.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { generateSecret, generateURI, verify } from "otplib";
|
||||
import { usersRepo } from "@eden/database";
|
||||
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
|
||||
|
||||
const ISSUER = "EDEN";
|
||||
|
||||
@Injectable()
|
||||
export class MfaService {
|
||||
constructor(private readonly fieldEncryption: FieldEncryptionService) {}
|
||||
|
||||
/** Stages a new TOTP secret (not yet active — see confirmEnrollment). */
|
||||
async startEnrollment(userId: string, email: string): Promise<{ secret: string; otpauthUrl: string }> {
|
||||
const secret = generateSecret();
|
||||
await usersRepo.setMfaSecret(userId, this.fieldEncryption.encrypt(secret));
|
||||
const otpauthUrl = generateURI({ issuer: ISSUER, label: email, secret });
|
||||
return { secret, otpauthUrl };
|
||||
}
|
||||
|
||||
/** Verifies the first code against the staged secret and flips mfa_enabled on. */
|
||||
async confirmEnrollment(userId: string, code: string): Promise<void> {
|
||||
const user = await usersRepo.findUserById(userId);
|
||||
if (!user?.mfa_secret_encrypted) {
|
||||
throw new BadRequestException("Nenhum cadastro de MFA pendente para este usuário");
|
||||
}
|
||||
if (!(await this.verifyCode(user.mfa_secret_encrypted, code))) {
|
||||
throw new BadRequestException("Código inválido");
|
||||
}
|
||||
await usersRepo.confirmMfaEnrollment(userId);
|
||||
}
|
||||
|
||||
/** Requires a currently-valid code — proves the factor, not just an authenticated session. */
|
||||
async disable(userId: string, code: string): Promise<void> {
|
||||
const user = await usersRepo.findUserById(userId);
|
||||
if (!user?.mfa_enabled || !user.mfa_secret_encrypted) {
|
||||
throw new BadRequestException("MFA não está habilitado para este usuário");
|
||||
}
|
||||
if (!(await this.verifyCode(user.mfa_secret_encrypted, code))) {
|
||||
throw new UnauthorizedException("Código inválido");
|
||||
}
|
||||
await usersRepo.disableMfa(userId);
|
||||
}
|
||||
|
||||
async verifyCode(encryptedSecret: string, code: string): Promise<boolean> {
|
||||
const secret = this.fieldEncryption.decrypt(encryptedSecret);
|
||||
const result = await verify({ token: code, secret });
|
||||
return result.valid;
|
||||
}
|
||||
}
|
||||
44
apps/api/src/auth/password.service.ts
Normal file
44
apps/api/src/auth/password.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
/**
|
||||
* Argon2id password hashing with versioned parameters (ADR-0005). Bumping
|
||||
* CURRENT_ALGO_VERSION and its entry in PARAMS_BY_VERSION lets us raise
|
||||
* cost over time without invalidating existing hashes — verification
|
||||
* always uses the parameters embedded in argon2's own encoded hash
|
||||
* string, and a stored user is only re-hashed with the new parameters on
|
||||
* their next successful login (see needsRehash()).
|
||||
*/
|
||||
const PARAMS_BY_VERSION: Record<number, argon2.Options> = {
|
||||
1: {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 19456, // 19 MiB — OWASP minimum recommendation for argon2id
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const CURRENT_ALGO_VERSION = 1;
|
||||
|
||||
@Injectable()
|
||||
export class PasswordService {
|
||||
async hash(plain: string): Promise<{ hash: string; algoVersion: number }> {
|
||||
const params = PARAMS_BY_VERSION[CURRENT_ALGO_VERSION];
|
||||
const hash = await argon2.hash(plain, params);
|
||||
return { hash, algoVersion: CURRENT_ALGO_VERSION };
|
||||
}
|
||||
|
||||
async verify(hash: string, plain: string): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, plain);
|
||||
} catch {
|
||||
// Malformed/foreign hash — treat as verification failure, never throw
|
||||
// into the caller (which would otherwise leak a 500 instead of 401).
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
needsRehash(algoVersion: number): boolean {
|
||||
return algoVersion !== CURRENT_ALGO_VERSION;
|
||||
}
|
||||
}
|
||||
36
apps/api/src/auth/permission.guard.ts
Normal file
36
apps/api/src/auth/permission.guard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import type { Request } from "express";
|
||||
import { REQUIRE_PERMISSION_KEY, type RequiredPermission } from "./decorators/require-permission.decorator.js";
|
||||
import { hasPermission } from "./permissions.js";
|
||||
|
||||
/**
|
||||
* Runs after JwtAuthGuard (which populates request.edenUser). Endpoints
|
||||
* without @RequirePermission are left alone — authentication only.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.get<RequiredPermission | undefined>(
|
||||
REQUIRE_PERMISSION_KEY,
|
||||
context.getHandler(),
|
||||
);
|
||||
if (!required) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.edenUser;
|
||||
if (!user) {
|
||||
throw new ForbiddenException("Autenticação necessária");
|
||||
}
|
||||
|
||||
if (!hasPermission(user.permissions, required.resource, required.action)) {
|
||||
throw new ForbiddenException(
|
||||
`Sem permissão para '${required.action}' em '${required.resource}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
55
apps/api/src/auth/permissions.ts
Normal file
55
apps/api/src/auth/permissions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { rolesRepo, type RolePermission } from "@eden/database";
|
||||
|
||||
export const SUPER_ADMIN_ROLE = "super_admin";
|
||||
|
||||
export type ResolvedPermissions =
|
||||
| { superAdmin: true }
|
||||
| { superAdmin: false; grants: RolePermission[] };
|
||||
|
||||
/**
|
||||
* super_admin is never looked up in role_permissions — hardcoded bypass,
|
||||
* mirrored from the legacy system and enforced again at the DB level
|
||||
* (docs/adr/0004-permission-model.md, chk_role_permissions_no_super_admin).
|
||||
*/
|
||||
export async function resolvePermissions(roleKey: string): Promise<ResolvedPermissions> {
|
||||
if (roleKey === SUPER_ADMIN_ROLE) return { superAdmin: true };
|
||||
const grants = await rolesRepo.getRolePermissions(roleKey);
|
||||
return { superAdmin: false, grants };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum scope the actor holds for (resource, action), or
|
||||
* null if they hold none. Scope precedence — broader wins when a role
|
||||
* somehow has more than one row for the same resource+action (shouldn't
|
||||
* happen given the UNIQUE constraint per scope, but a role could have
|
||||
* both e.g. 'own' and 'all' rows intentionally):
|
||||
*/
|
||||
const SCOPE_RANK: Record<RolePermission["scope"], number> = {
|
||||
own: 1,
|
||||
team: 2,
|
||||
reseller: 3,
|
||||
legal_entity: 4,
|
||||
all: 5,
|
||||
};
|
||||
|
||||
export function maxGrantedScope(
|
||||
permissions: ResolvedPermissions,
|
||||
resource: string,
|
||||
action: RolePermission["action"],
|
||||
): RolePermission["scope"] | "all" | null {
|
||||
if (permissions.superAdmin) return "all";
|
||||
const matches = permissions.grants.filter((g) => g.resource === resource && g.action === action);
|
||||
if (matches.length === 0) return null;
|
||||
return matches.reduce<RolePermission["scope"]>(
|
||||
(best, g) => (SCOPE_RANK[g.scope] > SCOPE_RANK[best] ? g.scope : best),
|
||||
matches[0]!.scope,
|
||||
);
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
permissions: ResolvedPermissions,
|
||||
resource: string,
|
||||
action: RolePermission["action"],
|
||||
): boolean {
|
||||
return maxGrantedScope(permissions, resource, action) !== null;
|
||||
}
|
||||
42
apps/api/src/auth/role-weight.ts
Normal file
42
apps/api/src/auth/role-weight.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||
import { rolesRepo } from "@eden/database";
|
||||
import { SUPER_ADMIN_ROLE } from "./permissions.js";
|
||||
|
||||
/**
|
||||
* Role weight (eden.md §1.6, ADR-0004): an actor can never assign a role
|
||||
* heavier than their own, nor act on a user whose *current* role is
|
||||
* heavier than the actor's — orthogonal to the resource/action/scope
|
||||
* permission check. super_admin (weight 100, hardcoded ceiling) always
|
||||
* passes the *weight comparison* — but role existence is still validated
|
||||
* even for super_admin, otherwise an invalid role key would sail past
|
||||
* this check and only fail later as an unhandled FK-constraint 500 from
|
||||
* the database (an earlier bug caught by manual testing: this function
|
||||
* must be the single place that turns "role doesn't exist" into a clean
|
||||
* 400, not the DB constraint).
|
||||
*/
|
||||
async function getWeight(roleKey: string): Promise<number> {
|
||||
const role = await rolesRepo.getRoleByKey(roleKey);
|
||||
if (!role) throw new BadRequestException(`Papel '${roleKey}' não existe`);
|
||||
return role.weight;
|
||||
}
|
||||
|
||||
export async function assertCanAssignRole(actorRoleKey: string, targetRoleKey: string): Promise<void> {
|
||||
const targetWeight = await getWeight(targetRoleKey);
|
||||
if (actorRoleKey === SUPER_ADMIN_ROLE) return;
|
||||
const actorWeight = await getWeight(actorRoleKey);
|
||||
if (targetWeight > actorWeight) {
|
||||
throw new ForbiddenException("Você não pode atribuir um papel com peso maior que o seu.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertCanActOnUser(
|
||||
actorRoleKey: string,
|
||||
targetUserRoleKey: string,
|
||||
): Promise<void> {
|
||||
const targetWeight = await getWeight(targetUserRoleKey);
|
||||
if (actorRoleKey === SUPER_ADMIN_ROLE) return;
|
||||
const actorWeight = await getWeight(actorRoleKey);
|
||||
if (targetWeight > actorWeight) {
|
||||
throw new ForbiddenException("Você não pode alterar um usuário com papel de peso maior que o seu.");
|
||||
}
|
||||
}
|
||||
23
apps/api/src/auth/super-admin.guard.ts
Normal file
23
apps/api/src/auth/super-admin.guard.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { SUPER_ADMIN_ROLE } from "./permissions.js";
|
||||
|
||||
/**
|
||||
* Role definition and the permission matrix itself (role_permissions) are
|
||||
* the root of the whole authorization system — deliberately kept
|
||||
* super_admin-only and hardcoded, exactly like the legacy's
|
||||
* `requireSuperAdmin` (eden.md §8), rather than generalized to the
|
||||
* resource/action/scope model. Generalizing config-of-the-authorization-
|
||||
* system-itself would let a misconfigured permission accidentally grant
|
||||
* permission-configuration rights — not worth the flexibility.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SuperAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
if (request.edenUser?.roleKey !== SUPER_ADMIN_ROLE) {
|
||||
throw new ForbiddenException("Exclusivo do super_admin");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
96
apps/api/src/auth/token.service.ts
Normal file
96
apps/api/src/auth/token.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { EdenEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "../config/config.constants.js";
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
sub: string; // user id
|
||||
app: "core" | "reseller" | "subscriber";
|
||||
}
|
||||
|
||||
/**
|
||||
* 8h — revisado a partir dos 15min originais da ADR-0003 (ver o adendo no
|
||||
* próprio documento): o frontend não implementa refresh silencioso ainda
|
||||
* (`AuthContext.tsx`, `session.ts` — "No refresh-token rotation in this
|
||||
* leva"), então um access token de 15min derrubava o usuário no meio do
|
||||
* trabalho. 8h cobre um expediente inteiro sem exigir login de novo;
|
||||
* revogação continua granular via a tabela `sessions` (logout/"encerrar
|
||||
* todas as sessões" não dependem da expiração do JWT).
|
||||
*/
|
||||
const ACCESS_TOKEN_TTL_SECONDS = 8 * 60 * 60;
|
||||
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 {
|
||||
private readonly secret: string;
|
||||
|
||||
constructor(@Inject(EDEN_ENV) env: EdenEnv) {
|
||||
this.secret = env.JWT_SIGNING_SECRET;
|
||||
}
|
||||
|
||||
signAccessToken(payload: AccessTokenPayload): { token: string; expiresIn: number } {
|
||||
const token = jwt.sign(payload, this.secret, { expiresIn: ACCESS_TOKEN_TTL_SECONDS });
|
||||
return { token, expiresIn: ACCESS_TOKEN_TTL_SECONDS };
|
||||
}
|
||||
|
||||
verifyAccessToken(token: string): 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
|
||||
* plaintext (shown once to the client) and its SHA-256 hash (the only
|
||||
* thing persisted). */
|
||||
generateRefreshToken(): { plaintext: string; hash: string; expiresAt: Date } {
|
||||
const plaintext = randomBytes(32).toString("hex"); // 256 bits
|
||||
const hash = this.hashRefreshToken(plaintext);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||
return { plaintext, hash, expiresAt };
|
||||
}
|
||||
|
||||
hashRefreshToken(plaintext: string): string {
|
||||
return createHash("sha256").update(plaintext).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived, single-purpose token issued after password verification
|
||||
* when the account has MFA enabled — proves "password already checked"
|
||||
* without granting API access. The `purpose` claim is what keeps this
|
||||
* out of JwtAuthGuard (see verifyAccessToken's explicit rejection of any
|
||||
* `purpose` claim) — without it this token would be a fully valid
|
||||
* access token, defeating MFA entirely.
|
||||
*/
|
||||
signMfaChallenge(payload: Omit<MfaChallengePayload, "purpose">): { token: string; expiresIn: number } {
|
||||
const token = jwt.sign({ ...payload, purpose: MFA_CHALLENGE_PURPOSE }, this.secret, {
|
||||
expiresIn: MFA_CHALLENGE_TTL_SECONDS,
|
||||
});
|
||||
return { token, expiresIn: MFA_CHALLENGE_TTL_SECONDS };
|
||||
}
|
||||
|
||||
verifyMfaChallenge(token: string): MfaChallengePayload {
|
||||
const payload = jwt.verify(token, this.secret) as Partial<MfaChallengePayload>;
|
||||
if (payload.purpose !== MFA_CHALLENGE_PURPOSE || !payload.sub || !payload.app) {
|
||||
throw new Error("Token de desafio MFA inválido");
|
||||
}
|
||||
return payload as MfaChallengePayload;
|
||||
}
|
||||
}
|
||||
104
apps/api/src/billing/billing.controller.ts
Normal file
104
apps/api/src/billing/billing.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { BillingService } from "./billing.service.js";
|
||||
import { CreateBillingSubscriptionDto } from "./dto/create-billing-subscription.dto.js";
|
||||
import { CreateBillingRunDto } from "./dto/create-billing-run.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import type {
|
||||
BillingRunStatus,
|
||||
BillingSubscriptionStatus,
|
||||
InvoiceStatus,
|
||||
} from "@eden/database";
|
||||
|
||||
/**
|
||||
* Billing — Master Prompt §6.10, ADR-0008. Recurring subscription ->
|
||||
* billing_run -> invoice MVP. Explicitly NOT Finance/AR-AP (títulos,
|
||||
* boletos, dunning) or Fiscal (documento fiscal) — see ADR-0008. See
|
||||
* billing.service.ts for the authorization rules (why run
|
||||
* creation/consolidation requires 'all' scope).
|
||||
*/
|
||||
@ApiTags("billing")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/billing")
|
||||
export class BillingController {
|
||||
constructor(private readonly billing: BillingService) {}
|
||||
|
||||
@Get("subscriptions")
|
||||
@RequirePermission("billing", "view")
|
||||
listSubscriptions(
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
@Query("status") status?: BillingSubscriptionStatus,
|
||||
) {
|
||||
return this.billing.listSubscriptions(user, status);
|
||||
}
|
||||
|
||||
@Post("subscriptions")
|
||||
@RequirePermission("billing", "edit")
|
||||
createSubscription(
|
||||
@Body() dto: CreateBillingSubscriptionDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.billing.createSubscription(dto, user);
|
||||
}
|
||||
|
||||
@Patch("subscriptions/:id/cancel")
|
||||
@RequirePermission("billing", "edit")
|
||||
cancelSubscription(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.billing.cancelSubscription(id, user);
|
||||
}
|
||||
|
||||
@Post("runs")
|
||||
@RequirePermission("billing", "edit")
|
||||
createRun(@Body() dto: CreateBillingRunDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.billing.createOrRegenerateRun(dto, user);
|
||||
}
|
||||
|
||||
@Get("runs")
|
||||
@RequirePermission("billing", "view")
|
||||
listRuns(
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
@Query("referenceYear") referenceYear?: string,
|
||||
@Query("referenceMonth") referenceMonth?: string,
|
||||
@Query("status") status?: BillingRunStatus,
|
||||
) {
|
||||
return this.billing.listRuns(user, {
|
||||
referenceYear: referenceYear != null ? Number(referenceYear) : undefined,
|
||||
referenceMonth: referenceMonth != null ? Number(referenceMonth) : undefined,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("runs/:id")
|
||||
@RequirePermission("billing", "view")
|
||||
getRun(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.billing.getRun(id, user);
|
||||
}
|
||||
|
||||
@Post("runs/:id/consolidate")
|
||||
@RequirePermission("billing", "edit")
|
||||
consolidateRun(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.billing.consolidateRun(id, user);
|
||||
}
|
||||
|
||||
@Get("invoices")
|
||||
@RequirePermission("billing", "view")
|
||||
listInvoices(
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
@Query("status") status?: InvoiceStatus,
|
||||
@Query("referenceYear") referenceYear?: string,
|
||||
@Query("referenceMonth") referenceMonth?: string,
|
||||
@Query("resellerId") resellerId?: string,
|
||||
) {
|
||||
return this.billing.listInvoices(user, {
|
||||
status,
|
||||
referenceYear: referenceYear != null ? Number(referenceYear) : undefined,
|
||||
referenceMonth: referenceMonth != null ? Number(referenceMonth) : undefined,
|
||||
resellerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
12
apps/api/src/billing/billing.module.ts
Normal file
12
apps/api/src/billing/billing.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { BillingController } from "./billing.controller.js";
|
||||
import { BillingService } from "./billing.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
262
apps/api/src/billing/billing.service.ts
Normal file
262
apps/api/src/billing/billing.service.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
billingRepo,
|
||||
type BillingRun,
|
||||
type BillingRunWithInvoices,
|
||||
type BillingSubscription,
|
||||
type Invoice,
|
||||
} from "@eden/database";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import { maxGrantedScope } from "../auth/permissions.js";
|
||||
import type { CreateBillingSubscriptionDto } from "./dto/create-billing-subscription.dto.js";
|
||||
import type { CreateBillingRunDto } from "./dto/create-billing-run.dto.js";
|
||||
|
||||
const {
|
||||
BillingSubscriptionNotFoundError,
|
||||
BillingRunNotFoundError,
|
||||
BillingRunConsolidatedError,
|
||||
BillingPeriodAlreadyConsolidatedError,
|
||||
} = billingRepo;
|
||||
|
||||
const RESOURCE = "billing";
|
||||
|
||||
/**
|
||||
* Billing (Master Prompt §6.10, ADR-0008) — recurring subscription ->
|
||||
* billing_run -> invoice flow. Strictly distinct from Finance/AR-AP
|
||||
* (títulos/boletos/cobrança) and Fiscal (documento fiscal) — neither is
|
||||
* touched here.
|
||||
*
|
||||
* Authorization: creating/regenerating and consolidating a billing_run
|
||||
* always requires 'all' scope for 'billing'/'edit' — a run spans every
|
||||
* reseller with a matching billing_day, so only central back-office
|
||||
* operators (not a single reseller) can trigger a billing close. Reading
|
||||
* and per-subscription/invoice mutations respect the actor's scope
|
||||
* ('all' vs 'reseller'), same pattern as contracts.service.ts.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
// -------------------------------------------------------------------
|
||||
// billing_subscriptions
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
async listSubscriptions(
|
||||
user: EdenAuthenticatedUser,
|
||||
status?: BillingSubscription["status"],
|
||||
): Promise<BillingSubscription[]> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
|
||||
if (scope === "all") return billingRepo.listBillingSubscriptions({ scope: "all", status });
|
||||
return billingRepo.listBillingSubscriptions({
|
||||
scope: "reseller",
|
||||
resellerId: user.resellerId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
async createSubscription(
|
||||
dto: CreateBillingSubscriptionDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<BillingSubscription> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "edit");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'edit' em '${RESOURCE}'`);
|
||||
|
||||
// Never trust a client-supplied resellerId to place a subscription
|
||||
// under an arbitrary reseller (CLAUDE.md: no authz decision trusts a
|
||||
// client-supplied reseller_id). A 'reseller'-scoped actor always gets
|
||||
// their own session-resolved reseller; only 'all' scope may target an
|
||||
// explicit resellerId from the body.
|
||||
let resellerId: string | null;
|
||||
if (scope === "all") {
|
||||
resellerId = dto.resellerId ?? null;
|
||||
} else {
|
||||
resellerId = user.resellerId;
|
||||
if (!resellerId) {
|
||||
throw new ForbiddenException("Usuário não está vinculado a nenhuma revenda");
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = await billingRepo.createBillingSubscription({
|
||||
contractId: dto.contractId,
|
||||
resellerId,
|
||||
monthlyValue: dto.monthlyValue,
|
||||
billingDay: dto.billingDay,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "billing_subscription.created",
|
||||
resourceType: "billing_subscription",
|
||||
resourceId: subscription.id,
|
||||
metadata: { contractId: dto.contractId, billingDay: dto.billingDay },
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async cancelSubscription(id: string, user: EdenAuthenticatedUser): Promise<BillingSubscription> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "edit");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'edit' em '${RESOURCE}'`);
|
||||
|
||||
const existing = await billingRepo.getBillingSubscriptionById(id);
|
||||
if (!existing) throw new NotFoundException("Subscription não encontrada");
|
||||
if (scope !== "all" && existing.reseller_id !== user.resellerId) {
|
||||
throw new NotFoundException("Subscription não encontrada");
|
||||
}
|
||||
|
||||
const cancelled = await billingRepo.cancelBillingSubscription(id, user.id);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "billing_subscription.cancelled",
|
||||
resourceType: "billing_subscription",
|
||||
resourceId: id,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// billing_runs / invoices
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates a new draft run, or — if a draft already exists for the same
|
||||
* (referenceYear, referenceMonth, billingDay) — regenerates its
|
||||
* invoices in place (idempotent: never duplicates). 409s if a run for
|
||||
* that exact period is already consolidated (controlled re-billing is
|
||||
* deferred, see PENDING_billing.cjs header).
|
||||
*/
|
||||
async createOrRegenerateRun(
|
||||
dto: CreateBillingRunDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<BillingRunWithInvoices> {
|
||||
this.requireAllScope(user, "edit");
|
||||
|
||||
try {
|
||||
const run = await billingRepo.generateOrRegenerateBillingRun({
|
||||
referenceYear: dto.referenceYear,
|
||||
referenceMonth: dto.referenceMonth,
|
||||
billingDay: dto.billingDay,
|
||||
triggeredBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "billing_run.generated",
|
||||
resourceType: "billing_run",
|
||||
resourceId: run.id,
|
||||
metadata: {
|
||||
referenceYear: dto.referenceYear,
|
||||
referenceMonth: dto.referenceMonth,
|
||||
billingDay: dto.billingDay,
|
||||
invoiceCount: run.invoices.length,
|
||||
totalAmount: run.totalAmount,
|
||||
},
|
||||
});
|
||||
|
||||
return run;
|
||||
} catch (err) {
|
||||
if (err instanceof BillingPeriodAlreadyConsolidatedError) {
|
||||
throw new ConflictException(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async listRuns(
|
||||
user: EdenAuthenticatedUser,
|
||||
filter: { referenceYear?: number; referenceMonth?: number; status?: BillingRun["status"] },
|
||||
): Promise<BillingRun[]> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
|
||||
return billingRepo.listBillingRuns(filter);
|
||||
}
|
||||
|
||||
async getRun(id: string, user: EdenAuthenticatedUser): Promise<BillingRunWithInvoices> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
|
||||
|
||||
const run = await billingRepo.getBillingRunById(id);
|
||||
if (!run) throw new NotFoundException("Billing run não encontrado");
|
||||
|
||||
if (scope !== "all") {
|
||||
// Reseller-scoped actors only ever see their own reseller's slice
|
||||
// of a run's invoices — never another reseller's amounts.
|
||||
run.invoices = run.invoices.filter((inv: Invoice) => inv.reseller_id === user.resellerId);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async consolidateRun(id: string, user: EdenAuthenticatedUser): Promise<BillingRunWithInvoices> {
|
||||
this.requireAllScope(user, "edit");
|
||||
|
||||
try {
|
||||
const run = await billingRepo.consolidateBillingRun(id, user.id);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "billing_run.consolidated",
|
||||
resourceType: "billing_run",
|
||||
resourceId: id,
|
||||
metadata: { invoiceCount: run.invoices.length, totalAmount: run.totalAmount },
|
||||
});
|
||||
|
||||
return run;
|
||||
} catch (err) {
|
||||
if (err instanceof BillingRunNotFoundError) throw new NotFoundException("Billing run não encontrado");
|
||||
if (err instanceof BillingRunConsolidatedError) {
|
||||
throw new ConflictException("Billing run já está consolidado");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async listInvoices(
|
||||
user: EdenAuthenticatedUser,
|
||||
filter: {
|
||||
status?: Invoice["status"];
|
||||
referenceYear?: number;
|
||||
referenceMonth?: number;
|
||||
/** Only honored when the actor holds 'all' scope — see below. */
|
||||
resellerId?: string;
|
||||
},
|
||||
): Promise<Invoice[]> {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
|
||||
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
|
||||
const { resellerId: requestedResellerId, ...rest } = filter;
|
||||
|
||||
if (scope === "all") {
|
||||
// A client-supplied resellerId is only ever honored once the
|
||||
// actor's own server-resolved scope already grants full ('all')
|
||||
// visibility — it narrows an already-unrestricted view, it never
|
||||
// grants access beyond what the session allows.
|
||||
return billingRepo.listInvoices(
|
||||
requestedResellerId
|
||||
? { scope: "reseller", resellerId: requestedResellerId, ...rest }
|
||||
: { scope: "all", ...rest },
|
||||
);
|
||||
}
|
||||
return billingRepo.listInvoices({ scope: "reseller", resellerId: user.resellerId, ...rest });
|
||||
}
|
||||
|
||||
private requireAllScope(user: EdenAuthenticatedUser, action: "view" | "edit"): void {
|
||||
const scope = maxGrantedScope(user.permissions, RESOURCE, action);
|
||||
if (scope !== "all") {
|
||||
throw new ForbiddenException(
|
||||
`Fechamento de billing exige escopo 'all' para '${RESOURCE}:${action}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/api/src/billing/dto/create-billing-run.dto.ts
Normal file
18
apps/api/src/billing/dto/create-billing-run.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsInt, Max, Min } from "class-validator";
|
||||
|
||||
export class CreateBillingRunDto {
|
||||
@IsInt()
|
||||
@Min(2000)
|
||||
@Max(2100)
|
||||
referenceYear!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(12)
|
||||
referenceMonth!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(28)
|
||||
billingDay!: number;
|
||||
}
|
||||
30
apps/api/src/billing/dto/create-billing-subscription.dto.ts
Normal file
30
apps/api/src/billing/dto/create-billing-subscription.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsInt, IsNumber, IsOptional, IsUUID, Max, Min } from "class-validator";
|
||||
|
||||
/**
|
||||
* `monthlyValue` is accepted directly in the body (never recalculated
|
||||
* from a contract) — this worktree/leva has no guaranteed access to the
|
||||
* real `contracts` module to look the value up server-side, so the
|
||||
* caller (typically a Contracts-side workflow) supplies the snapshot
|
||||
* explicitly. `resellerId` is only actually trusted from the client when
|
||||
* the caller holds 'all' scope; for 'reseller' scope it is always
|
||||
* overridden server-side with the actor's own reseller (see
|
||||
* billing.service.ts#create) — no authorization decision ever trusts a
|
||||
* client-supplied resellerId (CLAUDE.md).
|
||||
*/
|
||||
export class CreateBillingSubscriptionDto {
|
||||
@IsUUID()
|
||||
contractId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
resellerId?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
monthlyValue!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(28)
|
||||
billingDay!: number;
|
||||
}
|
||||
9
apps/api/src/bootstrap/bootstrap.module.ts
Normal file
9
apps/api/src/bootstrap/bootstrap.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { SeedService } from "./seed.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [SeedService],
|
||||
})
|
||||
export class BootstrapModule {}
|
||||
50
apps/api/src/bootstrap/seed.service.ts
Normal file
50
apps/api/src/bootstrap/seed.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Inject, Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import { usersRepo } from "@eden/database";
|
||||
import type { EdenEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "../config/config.constants.js";
|
||||
import { PasswordService } from "../auth/password.service.js";
|
||||
import { SUPER_ADMIN_ROLE } from "../auth/permissions.js";
|
||||
|
||||
/**
|
||||
* Mirrors the legacy's seedAdmin() (eden.md §5.7): runs once at boot,
|
||||
* only acts if `users` is empty, creates the first super_admin from env
|
||||
* vars — never hardcoded, never auto-generated-and-logged. If the env
|
||||
* vars aren't set, boot proceeds without seeding (useful for CI/tests
|
||||
* that don't need a real login) rather than crashing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SeedService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SeedService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(EDEN_ENV) private readonly env: EdenEnv,
|
||||
private readonly passwords: PasswordService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const existing = await usersRepo.countUsers();
|
||||
if (existing > 0) return;
|
||||
|
||||
if (!this.env.EDEN_SUPERADMIN_EMAIL || !this.env.EDEN_SUPERADMIN_PASSWORD) {
|
||||
this.logger.warn(
|
||||
"users está vazia e EDEN_SUPERADMIN_EMAIL/EDEN_SUPERADMIN_PASSWORD não estão definidos — pulando seed do super_admin.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { hash, algoVersion } = await this.passwords.hash(this.env.EDEN_SUPERADMIN_PASSWORD);
|
||||
const user = await usersRepo.createUser({
|
||||
email: this.env.EDEN_SUPERADMIN_EMAIL,
|
||||
passwordHash: hash,
|
||||
passwordAlgoVersion: algoVersion,
|
||||
roleKey: SUPER_ADMIN_ROLE,
|
||||
fullName: "Super Administrador",
|
||||
});
|
||||
|
||||
await usersRepo.grantUserApplication(user.id, "core");
|
||||
await usersRepo.grantUserApplication(user.id, "reseller");
|
||||
await usersRepo.grantUserApplication(user.id, "subscriber");
|
||||
|
||||
this.logger.log(`super_admin inicial criado (${user.email}) a partir de EDEN_SUPERADMIN_EMAIL.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { ClientRegistrationsService } from "./client-registrations.service.js";
|
||||
import { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
|
||||
import { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
|
||||
import { CreatePartnerDto } from "./dto/create-partner.dto.js";
|
||||
import { UpdatePartnerDto } from "./dto/update-partner.dto.js";
|
||||
import { ApplyCnpjLookupDto } from "./dto/apply-cnpj-lookup.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";
|
||||
|
||||
/**
|
||||
* Customer 360 / Client Registration (eden.md §3, Master Prompt §6.5) —
|
||||
* authenticated-only MVP. Unlike Companies/Products/Resellers (open read),
|
||||
* every route here — including reads — requires both authentication and
|
||||
* the `client-registrations` resource permission, since this data carries
|
||||
* customer PII (CPF/CNPJ, contacts, address). Deliberately out of scope
|
||||
* for this round: the public unauthenticated token-based self-service
|
||||
* form, file attachments, CEP-only lookup — see the migration and
|
||||
* repository comments for the full list. CNPJ lookup (`POST
|
||||
* :id/cnpj-lookup`) IS implemented — see `client-registrations.service.ts`.
|
||||
*/
|
||||
@ApiTags("client-registrations")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/client-registrations")
|
||||
export class ClientRegistrationsController {
|
||||
constructor(private readonly clientRegistrations: ClientRegistrationsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission("client-registrations", "view")
|
||||
list(@CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.clientRegistrations.list(user);
|
||||
}
|
||||
|
||||
@Post("direct")
|
||||
@RequirePermission("client-registrations", "edit")
|
||||
createDirect(
|
||||
@Body() dto: CreateDirectClientRegistrationDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.clientRegistrations.createDirect(dto, user);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("client-registrations", "view")
|
||||
get(@Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.clientRegistrations.get(id, user);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequirePermission("client-registrations", "edit")
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateClientRegistrationDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.clientRegistrations.update(id, dto, user);
|
||||
}
|
||||
|
||||
@Get(":id/partners")
|
||||
@RequirePermission("client-registrations", "view")
|
||||
listPartners(@Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.clientRegistrations.listPartners(id, user);
|
||||
}
|
||||
|
||||
@Post(":id/partners")
|
||||
@RequirePermission("client-registrations", "edit")
|
||||
createPartner(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreatePartnerDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.clientRegistrations.createPartner(id, dto, user);
|
||||
}
|
||||
|
||||
@Patch(":id/partners/:partnerId")
|
||||
@RequirePermission("client-registrations", "edit")
|
||||
updatePartner(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("partnerId", ParseUUIDPipe) partnerId: string,
|
||||
@Body() dto: UpdatePartnerDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.clientRegistrations.updatePartner(id, partnerId, dto, user);
|
||||
}
|
||||
|
||||
@Post(":id/cnpj-lookup")
|
||||
@RequirePermission("client-registrations", "edit")
|
||||
applyCnpjLookup(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ApplyCnpjLookupDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.clientRegistrations.applyCnpjLookup(id, dto, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { ClientRegistrationsController } from "./client-registrations.controller.js";
|
||||
import { ClientRegistrationsService } from "./client-registrations.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ClientRegistrationsController],
|
||||
providers: [ClientRegistrationsService],
|
||||
exports: [ClientRegistrationsService],
|
||||
})
|
||||
export class ClientRegistrationsModule {}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
clientRegistrationsRepo,
|
||||
clientRegistrationPartnersRepo,
|
||||
type ClientRegistration,
|
||||
type ClientRegistrationPartner,
|
||||
} from "@eden/database";
|
||||
import { lookupCnpj } from "@eden/integrations";
|
||||
import { maxGrantedScope } from "../auth/permissions.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import type { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
|
||||
import type { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
|
||||
import type { CreatePartnerDto } from "./dto/create-partner.dto.js";
|
||||
import type { UpdatePartnerDto } from "./dto/update-partner.dto.js";
|
||||
import type { ApplyCnpjLookupDto } from "./dto/apply-cnpj-lookup.dto.js";
|
||||
|
||||
/**
|
||||
* Source tag for partners created by the CNPJ-lookup flow below —
|
||||
* deliberately not the legacy's hardcoded `'brasilapi'` (see
|
||||
* `client-registration-partners.ts`'s module comment): cnpj.ws is the
|
||||
* primary source in this port, so a fixed `'brasilapi'` tag would
|
||||
* misattribute provenance on the (common) success path.
|
||||
*/
|
||||
const CNPJ_LOOKUP_PARTNER_SOURCE = "cnpj_lookup";
|
||||
|
||||
/** `undefined`/blank → `null`, so a field the lookup didn't bring back never clobbers an existing value (COALESCE in the repo). */
|
||||
function orNull(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
type ResolvedScope =
|
||||
| { kind: "all" }
|
||||
| { kind: "own"; actorId: string }
|
||||
| { kind: "reseller"; resellerId: string };
|
||||
|
||||
@Injectable()
|
||||
export class ClientRegistrationsService {
|
||||
/**
|
||||
* eden.md §7.1 documents two hardcoded feature keys with two different
|
||||
* scoping rules: `clientes_todos` (full backoffice view, scoped by
|
||||
* *authorship* — `created_by_id = user.id` when the actor lacks the
|
||||
* "todos" grant) vs. `clientes_revenda` (minimal directory, scoped by
|
||||
* *reseller* — `reseller_id = user.reseller_id`). EDEN generalizes both
|
||||
* into the single resource+action+scope permission model (ADR-0004)
|
||||
* instead of two separate feature keys — a deliberate generalization,
|
||||
* not a literal port:
|
||||
* - granted scope `all` → see everything.
|
||||
* - granted scope `reseller` (actor has a reseller) → scoped to that
|
||||
* reseller_id, mirroring `clientes_revenda`.
|
||||
* - anything narrower (`own`/`team`/`legal_entity`), or `reseller`
|
||||
* without an actual reseller_id on the actor → falls back to
|
||||
* authorship (`own`), mirroring `clientes_todos`'s non-admin default.
|
||||
*/
|
||||
private resolveScope(user: EdenAuthenticatedUser, action: "view" | "edit"): ResolvedScope {
|
||||
const granted = maxGrantedScope(user.permissions, "client-registrations", action);
|
||||
if (granted === "all") return { kind: "all" };
|
||||
if (granted === "reseller" && user.resellerId) {
|
||||
return { kind: "reseller", resellerId: user.resellerId };
|
||||
}
|
||||
return { kind: "own", actorId: user.id };
|
||||
}
|
||||
|
||||
private isVisible(registration: ClientRegistration, scope: ResolvedScope): boolean {
|
||||
if (scope.kind === "all") return true;
|
||||
if (scope.kind === "own") return registration.created_by === scope.actorId;
|
||||
return registration.reseller_id === scope.resellerId;
|
||||
}
|
||||
|
||||
list(user: EdenAuthenticatedUser): Promise<ClientRegistration[]> {
|
||||
const scope = this.resolveScope(user, "view");
|
||||
if (scope.kind === "all") {
|
||||
return clientRegistrationsRepo.listClientRegistrations({ scope: "all" });
|
||||
}
|
||||
if (scope.kind === "reseller") {
|
||||
return clientRegistrationsRepo.listClientRegistrations({
|
||||
scope: "reseller",
|
||||
resellerId: scope.resellerId,
|
||||
});
|
||||
}
|
||||
return clientRegistrationsRepo.listClientRegistrations({ scope: "own", actorId: scope.actorId });
|
||||
}
|
||||
|
||||
/** 404, never 403, for a record outside the actor's scope — don't confirm a UUID exists (eden-commercial rules). */
|
||||
async get(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistration> {
|
||||
const scope = this.resolveScope(user, "view");
|
||||
const registration = await clientRegistrationsRepo.getClientRegistrationById(id);
|
||||
if (!registration || !this.isVisible(registration, scope)) {
|
||||
throw new NotFoundException("Cadastro de cliente não encontrado");
|
||||
}
|
||||
return registration;
|
||||
}
|
||||
|
||||
/** Same 404-not-403 treatment, but scoped by the actor's *edit* grant — write access shouldn't leak past what read visibility would allow either. */
|
||||
private async getForEdit(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistration> {
|
||||
const scope = this.resolveScope(user, "edit");
|
||||
const registration = await clientRegistrationsRepo.getClientRegistrationById(id);
|
||||
if (!registration || !this.isVisible(registration, scope)) {
|
||||
throw new NotFoundException("Cadastro de cliente não encontrado");
|
||||
}
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cadastro "direto" (eden.md §2.6). `quoteId` is never accepted here —
|
||||
* no Quotes integration in this worktree yet (built in parallel).
|
||||
*
|
||||
* Legacy rule (eden.md §2.6): "quem não é admin/backoffice só cria para
|
||||
* a própria revenda (req.user.reseller_id); admin/backoffice pode
|
||||
* opcionalmente indicar reseller_id." Replicated here, generalized
|
||||
* through the scope model instead of a role check: only an actor
|
||||
* holding `all` edit scope for client-registrations may set an
|
||||
* arbitrary `resellerId`; anyone narrower has their *own* reseller_id
|
||||
* forced server-side — the body's `resellerId` is ignored in that case,
|
||||
* never trusted for an authorization-relevant assignment.
|
||||
*/
|
||||
async createDirect(
|
||||
dto: CreateDirectClientRegistrationDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<ClientRegistration> {
|
||||
const editScope = maxGrantedScope(user.permissions, "client-registrations", "edit");
|
||||
const resellerId = editScope === "all" ? (dto.resellerId ?? null) : (user.resellerId ?? null);
|
||||
|
||||
const registration = await clientRegistrationsRepo.createClientRegistration({
|
||||
personType: dto.personType,
|
||||
isPortability: dto.isPortability,
|
||||
resellerId,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "client_registration.created",
|
||||
resourceType: "client_registration",
|
||||
resourceId: registration.id,
|
||||
metadata: { personType: registration.person_type, isPortability: registration.is_portability },
|
||||
});
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single generic PATCH, matching the legacy (eden.md §1.1.1) — no
|
||||
* dedicated status-transition endpoint/guard. `dto.registrationStatus`,
|
||||
* if present, is applied via `updateClientRegistrationStatus` (which
|
||||
* also stamps `activated_at` the first time it becomes `ativo`); every
|
||||
* other field goes through `updateClientRegistrationProfile`, which
|
||||
* structurally cannot touch pf_cpf/pj_cnpj/pj_rep_cpf (see that
|
||||
* function's Input type).
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateClientRegistrationDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<ClientRegistration> {
|
||||
await this.getForEdit(id, user); // 404 if missing or outside the actor's edit scope
|
||||
|
||||
let updated: ClientRegistration | null = null;
|
||||
|
||||
if (dto.registrationStatus) {
|
||||
updated = await clientRegistrationsRepo.updateClientRegistrationStatus(id, dto.registrationStatus);
|
||||
}
|
||||
|
||||
const { registrationStatus: _registrationStatus, ...profileFields } = dto;
|
||||
const hasProfileFields = Object.values(profileFields).some((value) => value !== undefined);
|
||||
if (hasProfileFields) {
|
||||
updated = await clientRegistrationsRepo.updateClientRegistrationProfile(id, profileFields);
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
updated = await clientRegistrationsRepo.getClientRegistrationById(id);
|
||||
}
|
||||
if (!updated) throw new NotFoundException("Cadastro de cliente não encontrado");
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "client_registration.updated",
|
||||
resourceType: "client_registration",
|
||||
resourceId: id,
|
||||
metadata: { fields: Object.keys(dto) },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async listPartners(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistrationPartner[]> {
|
||||
await this.get(id, user); // reuse the view-scope visibility check
|
||||
return clientRegistrationPartnersRepo.listPartnersForRegistration(id);
|
||||
}
|
||||
|
||||
async createPartner(
|
||||
id: string,
|
||||
dto: CreatePartnerDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<ClientRegistrationPartner> {
|
||||
await this.getForEdit(id, user);
|
||||
|
||||
const partner = await clientRegistrationPartnersRepo.createPartner({
|
||||
clientRegistrationId: id,
|
||||
name: dto.name,
|
||||
document: dto.document,
|
||||
qualification: dto.qualification,
|
||||
cpf: dto.cpf,
|
||||
willSign: dto.willSign,
|
||||
signatureEmail: dto.signatureEmail,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "client_registration.partner_created",
|
||||
resourceType: "client_registration_partner",
|
||||
resourceId: partner.id,
|
||||
metadata: { clientRegistrationId: id },
|
||||
});
|
||||
|
||||
return partner;
|
||||
}
|
||||
|
||||
async updatePartner(
|
||||
id: string,
|
||||
partnerId: string,
|
||||
dto: UpdatePartnerDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<ClientRegistrationPartner> {
|
||||
await this.getForEdit(id, user);
|
||||
|
||||
const existing = await clientRegistrationPartnersRepo.getPartnerById(partnerId);
|
||||
if (!existing || existing.client_registration_id !== id) {
|
||||
throw new NotFoundException("Sócio não encontrado");
|
||||
}
|
||||
|
||||
const partner = await clientRegistrationPartnersRepo.updatePartner(partnerId, {
|
||||
cpf: dto.cpf,
|
||||
willSign: dto.willSign,
|
||||
signatureEmail: dto.signatureEmail,
|
||||
});
|
||||
if (!partner) throw new NotFoundException("Sócio não encontrado");
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "client_registration.partner_updated",
|
||||
resourceType: "client_registration_partner",
|
||||
resourceId: partnerId,
|
||||
metadata: { clientRegistrationId: id, fields: Object.keys(dto) },
|
||||
});
|
||||
|
||||
return partner;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /:id/cnpj-lookup` — single dedicated action that resolves and
|
||||
* applies CNPJ data in one step (no separate "preview" endpoint; mirrors
|
||||
* the legacy's on-blur autofill). The server always re-runs the
|
||||
* authoritative lookup itself from `dto.cnpj` — never trusts a
|
||||
* client-submitted "already found" payload for persistence (Master
|
||||
* Prompt §23 / this file's class-level discipline).
|
||||
*/
|
||||
async applyCnpjLookup(
|
||||
id: string,
|
||||
dto: ApplyCnpjLookupDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<{ registration: ClientRegistration; partners: ClientRegistrationPartner[] }> {
|
||||
const registration = await this.getForEdit(id, user);
|
||||
if (registration.person_type !== "pj") {
|
||||
throw new BadRequestException("Lookup de CNPJ só se aplica a cadastros de pessoa jurídica (PJ)");
|
||||
}
|
||||
|
||||
const result = await lookupCnpj(dto.cnpj);
|
||||
if (!result) {
|
||||
throw new NotFoundException("CNPJ não encontrado — preencha os dados manualmente.");
|
||||
}
|
||||
|
||||
const normalizedCnpj = dto.cnpj.replace(/\D/g, "");
|
||||
|
||||
const updated = await clientRegistrationsRepo.applyCnpjLookup(id, {
|
||||
cnpj: normalizedCnpj,
|
||||
companyName: orNull(result.companyName),
|
||||
tradeName: orNull(result.tradeName),
|
||||
stateRegistration: orNull(result.stateRegistration),
|
||||
openingDate: orNull(result.openingDate),
|
||||
legalNature: orNull(result.legalNature),
|
||||
cnpjSituation: orNull(result.cnpjSituation),
|
||||
mainActivity: orNull(result.mainActivity),
|
||||
addressZip: orNull(result.address.zip),
|
||||
addressStreet: orNull(result.address.street),
|
||||
addressNumber: orNull(result.address.number),
|
||||
addressComplement: orNull(result.address.complement),
|
||||
addressNeighborhood: orNull(result.address.neighborhood),
|
||||
addressCity: orNull(result.address.city),
|
||||
addressState: orNull(result.address.state),
|
||||
});
|
||||
if (!updated) throw new NotFoundException("Cadastro de cliente não encontrado");
|
||||
|
||||
let partnersCreated = 0;
|
||||
for (const partner of result.partners) {
|
||||
const document = orNull(partner.document);
|
||||
if (document) {
|
||||
const existing = await clientRegistrationPartnersRepo.findPartnerByDocument(id, document);
|
||||
if (existing) continue;
|
||||
}
|
||||
await clientRegistrationPartnersRepo.createPartner({
|
||||
clientRegistrationId: id,
|
||||
name: partner.name,
|
||||
document,
|
||||
qualification: orNull(partner.qualification),
|
||||
source: CNPJ_LOOKUP_PARTNER_SOURCE,
|
||||
});
|
||||
partnersCreated += 1;
|
||||
}
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "client_registration.cnpj_lookup_applied",
|
||||
resourceType: "client_registration",
|
||||
resourceId: id,
|
||||
metadata: { cnpj: normalizedCnpj, partnersFound: result.partners.length, partnersCreated },
|
||||
});
|
||||
|
||||
const partners = await clientRegistrationPartnersRepo.listPartnersForRegistration(id);
|
||||
return { registration: updated, partners };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* Body for `POST /:id/cnpj-lookup`. The server re-resolves this CNPJ
|
||||
* itself against the authoritative lookup APIs (`@eden/integrations`'s
|
||||
* `lookupCnpj`) — this DTO carries only the raw input, never any
|
||||
* "already looked up" data from the client. Full 14-digit/malformed
|
||||
* validation happens inside `lookupCnpj` itself (returns `null`, mapped
|
||||
* to 404 by the service); this is just a basic shape guard.
|
||||
*/
|
||||
export class ApplyCnpjLookupDto {
|
||||
@IsString()
|
||||
@MinLength(11)
|
||||
cnpj!: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IsBoolean, IsIn, IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
export const PERSON_TYPES = ["pf", "pj"] as const;
|
||||
export type PersonType = (typeof PERSON_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Cadastro "direto" (eden.md §2.6) — the only creation path in this MVP,
|
||||
* `quote_id` stays null (no Quotes integration yet, being built in
|
||||
* parallel — see the migration comment). `quoteId` is deliberately NOT a
|
||||
* field here.
|
||||
*/
|
||||
export class CreateDirectClientRegistrationDto {
|
||||
@IsIn(PERSON_TYPES)
|
||||
personType!: PersonType;
|
||||
|
||||
@IsBoolean()
|
||||
isPortability!: boolean;
|
||||
|
||||
/**
|
||||
* Optional; only actually honored server-side when the actor holds
|
||||
* `all` edit scope for client-registrations — see the service's
|
||||
* `createDirect` comment. Never trusted at face value for a narrower
|
||||
* scope (cross-tenant isolation, ADR-0004).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
resellerId?: string;
|
||||
}
|
||||
22
apps/api/src/client-registrations/dto/create-partner.dto.ts
Normal file
22
apps/api/src/client-registrations/dto/create-partner.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* `source` is not client-settable — always defaults to `'manual'`
|
||||
* (DB default, packages/database/migrations/*_client-registrations.cjs)
|
||||
* for partners entered through this endpoint. Partners populated by the
|
||||
* CNPJ-lookup flow (`POST :id/cnpj-lookup`) go through a different,
|
||||
* server-only path (`clientRegistrationPartnersRepo.createPartner`'s
|
||||
* `source` param) and are tagged `'cnpj_lookup'` instead — see
|
||||
* `client-registrations.service.ts`.
|
||||
*/
|
||||
export class CreatePartnerDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional() @IsString() document?: string;
|
||||
@IsOptional() @IsString() qualification?: string;
|
||||
@IsOptional() @IsString() cpf?: string;
|
||||
@IsOptional() @IsBoolean() willSign?: boolean;
|
||||
@IsOptional() @IsEmail() signatureEmail?: string;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
export const REGISTRATION_STATUSES = [
|
||||
"rascunho",
|
||||
"pendente_validacao",
|
||||
"ativo",
|
||||
"bloqueado",
|
||||
"inativo",
|
||||
] as const;
|
||||
export type RegistrationStatus = (typeof REGISTRATION_STATUSES)[number];
|
||||
|
||||
export class PortabilityRangeDto {
|
||||
@IsString()
|
||||
first!: string;
|
||||
|
||||
@IsString()
|
||||
last!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Administratively editable fields per eden.md §1.1.2 — and *only* those.
|
||||
* `pfCpf`, `pjCnpj`, and `pjRepCpf` are deliberately not declared here:
|
||||
* with the global ValidationPipe's `forbidNonWhitelisted` (main.ts), any
|
||||
* request that tries to send them is rejected outright rather than
|
||||
* silently ignored. Changing the document itself would need a dedicated
|
||||
* re-verification flow — never a side effect of a generic admin PATCH.
|
||||
*
|
||||
* `registrationStatus` is validated as one of the 5 legal *values* here;
|
||||
* full state-machine transition legality (e.g. blocking `rascunho`→`ativo`
|
||||
* directly) is NOT enforced in this MVP — matches the legacy, which also
|
||||
* has no dedicated transition endpoint (eden.md §1.1.1: "não há endpoint
|
||||
* dedicado de transição", just this same generic PATCH).
|
||||
*/
|
||||
export class UpdateClientRegistrationDto {
|
||||
@IsOptional()
|
||||
@IsIn(REGISTRATION_STATUSES)
|
||||
registrationStatus?: RegistrationStatus;
|
||||
|
||||
@IsOptional() @IsString() internalNotes?: string;
|
||||
@IsOptional() @IsString() ixcClientId?: string;
|
||||
@IsOptional() @IsString() ixcContractNumber?: string;
|
||||
|
||||
@IsOptional() @IsString() addressZip?: string;
|
||||
@IsOptional() @IsString() addressStreet?: string;
|
||||
@IsOptional() @IsString() addressNumber?: string;
|
||||
@IsOptional() @IsString() addressComplement?: string;
|
||||
@IsOptional() @IsString() addressNeighborhood?: string;
|
||||
@IsOptional() @IsString() addressCity?: string;
|
||||
@IsOptional() @IsString() addressState?: string;
|
||||
@IsOptional() @IsString() addressCountry?: string;
|
||||
|
||||
@IsOptional() @IsString() contactPrincipalName?: string;
|
||||
@IsOptional() @IsEmail() contactPrincipalEmail?: string;
|
||||
@IsOptional() @IsString() contactPrincipalPhone?: string;
|
||||
@IsOptional() @IsString() contactFinancialName?: string;
|
||||
@IsOptional() @IsEmail() contactFinancialEmail?: string;
|
||||
@IsOptional() @IsString() contactFinancialPhone?: string;
|
||||
@IsOptional() @IsString() contactTechnicalName?: string;
|
||||
@IsOptional() @IsEmail() contactTechnicalEmail?: string;
|
||||
@IsOptional() @IsString() contactTechnicalPhone?: string;
|
||||
|
||||
// PF — pfCpf intentionally absent, see class comment.
|
||||
@IsOptional() @IsString() pfFullName?: string;
|
||||
@IsOptional() @IsDateString() pfBirthDate?: string;
|
||||
@IsOptional() @IsEmail() pfEmail?: string;
|
||||
@IsOptional() @IsString() pfMobilePhone?: string;
|
||||
@IsOptional() @IsString() pfAltPhone?: string;
|
||||
@IsOptional() @IsString() pfSocialName?: string;
|
||||
@IsOptional() @IsString() pfIdDocument?: string;
|
||||
@IsOptional() @IsString() pfIdIssuer?: string;
|
||||
|
||||
// PJ — pjCnpj and pjRepCpf intentionally absent, see class comment.
|
||||
@IsOptional() @IsString() pjCompanyName?: string;
|
||||
@IsOptional() @IsString() pjTradeName?: string;
|
||||
@IsOptional() @IsString() pjStateRegistration?: string;
|
||||
@IsOptional() @IsString() pjMunicipalRegistration?: string;
|
||||
@IsOptional() @IsDateString() pjOpeningDate?: string;
|
||||
@IsOptional() @IsString() pjLegalNature?: string;
|
||||
@IsOptional() @IsString() pjCnpjSituation?: string;
|
||||
@IsOptional() @IsString() pjMainActivity?: string;
|
||||
@IsOptional() @IsString() pjRepFullName?: string;
|
||||
@IsOptional() @IsString() pjRepRole?: string;
|
||||
@IsOptional() @IsEmail() pjRepEmail?: string;
|
||||
@IsOptional() @IsString() pjRepMobilePhone?: string;
|
||||
@IsOptional() @IsBoolean() pjRepAuthorizationAck?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
portabilityNumbers?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortabilityRangeDto)
|
||||
portabilityRanges?: PortabilityRangeDto[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsBoolean, IsEmail, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** eden.md §2.5: backoffice can correct only these 3 fields post-submission. */
|
||||
export class UpdatePartnerDto {
|
||||
@IsOptional() @IsString() cpf?: string;
|
||||
@IsOptional() @IsBoolean() willSign?: boolean;
|
||||
@IsOptional() @IsEmail() signatureEmail?: string;
|
||||
}
|
||||
120
apps/api/src/common/rate-limit/rate-limit-rules.ts
Normal file
120
apps/api/src/common/rate-limit/rate-limit-rules.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { RateLimitRule } from "./rate-limit.decorator.js";
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
/**
|
||||
* Concrete limits for every public (no-session) route in this API.
|
||||
* Numbers are deliberately round, chosen for two constraints at once:
|
||||
* - generous enough that this project's own real-app integration test
|
||||
* suite (apps/api/test/*.spec.ts — real login/refresh/signature flows,
|
||||
* all from the same loopback IP) never trips them by accident;
|
||||
* - tight enough to turn an unthrottled endpoint (previously: unlimited
|
||||
* requests/second) into a hard, auditable ceiling per IP or per link.
|
||||
* None of these claim to fully stop a determined, IP-rotating attacker —
|
||||
* that's a documented residual risk in docs/security/threat-model.md,
|
||||
* not a gap this change pretends to close.
|
||||
*/
|
||||
|
||||
// --- apps/api/src/auth/auth.controller.ts -----------------------------
|
||||
|
||||
/** Credential guessing target — argon2 already raises the per-guess cost; this caps guess *rate*. */
|
||||
export const AUTH_LOGIN_RULE: RateLimitRule = { name: "auth-login", keyBy: "ip", limit: 10, windowMs: MINUTE };
|
||||
|
||||
/** Same order of magnitude as login: this is the second half of an MFA login, equally sensitive. */
|
||||
export const AUTH_MFA_CHALLENGE_RULE: RateLimitRule = {
|
||||
name: "auth-mfa-challenge",
|
||||
keyBy: "ip",
|
||||
limit: 10,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/** Doesn't leak account existence (anti-enumeration response either way), but still caps inbox-spam/abuse potential. */
|
||||
export const AUTH_FORGOT_PASSWORD_RULE: RateLimitRule = {
|
||||
name: "auth-forgot-password",
|
||||
keyBy: "ip",
|
||||
limit: 10,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/** Token is 256-bit random (auth.service.ts) — not brute-forceable; this is generic abuse/DoS hygiene. */
|
||||
export const AUTH_RESET_PASSWORD_RULE: RateLimitRule = {
|
||||
name: "auth-reset-password",
|
||||
keyBy: "ip",
|
||||
limit: 10,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/** Refresh tokens are 256-bit random too; higher ceiling because legitimate multi-tab/background refresh is frequent by design. */
|
||||
export const AUTH_REFRESH_RULE: RateLimitRule = { name: "auth-refresh", keyBy: "ip", limit: 30, windowMs: MINUTE };
|
||||
|
||||
/**
|
||||
* Not in the task's original enumerated list, but POST /v1/auth/logout has
|
||||
* no JwtAuthGuard either (auth.controller.ts) — genuinely public. No
|
||||
* guessing target (needs an already-known refresh token), so this is
|
||||
* generic abuse/DoS hygiene only, same ceiling as refresh.
|
||||
*/
|
||||
export const AUTH_LOGOUT_RULE: RateLimitRule = { name: "auth-logout", keyBy: "ip", limit: 30, windowMs: MINUTE };
|
||||
|
||||
// --- apps/api/src/signatures/public-signature.controller.ts -----------
|
||||
|
||||
/**
|
||||
* Shared across every /v1/public-signature/:token/* handler, keyed by IP —
|
||||
* catches one source scanning/hammering many DIFFERENT tokens (link
|
||||
* enumeration or scraping), which a per-token limit alone can't see.
|
||||
*/
|
||||
export const PUBLIC_SIGNATURE_IP_RULE: RateLimitRule = {
|
||||
name: "pubsig-ip",
|
||||
keyBy: "ip",
|
||||
limit: 60,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-token ceiling for the ordinary read/ack steps of the flow — a real
|
||||
* signer hits each of these once or twice (reload, double-click); this
|
||||
* only bites automated hammering of one leaked link, from any number of
|
||||
* source IPs (that's the point of keying by token, not IP, here).
|
||||
*/
|
||||
export const PUBLIC_SIGNATURE_TOKEN_ACTION_RULE: RateLimitRule = {
|
||||
name: "pubsig-token-action",
|
||||
keyBy: "token",
|
||||
limit: 20,
|
||||
windowMs: 5 * MINUTE,
|
||||
};
|
||||
|
||||
/** Sends an email — tighter, since abuse here has a real cost (spamming the signer's inbox), not just a wasted read. */
|
||||
export const PUBLIC_SIGNATURE_OTP_REQUEST_RULE: RateLimitRule = {
|
||||
name: "pubsig-otp-request",
|
||||
keyBy: "token",
|
||||
limit: 5,
|
||||
windowMs: 15 * MINUTE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Complementary to the DB-persisted per-challenge lockout already in
|
||||
* public-signature.service.ts::verifyOtp (blocks after N wrong codes,
|
||||
* survives across source IPs) — this caps raw request *rate* against the
|
||||
* endpoint itself, independent of whether attempts are right or wrong.
|
||||
*/
|
||||
export const PUBLIC_SIGNATURE_OTP_VERIFY_RULE: RateLimitRule = {
|
||||
name: "pubsig-otp-verify",
|
||||
keyBy: "token",
|
||||
limit: 15,
|
||||
windowMs: 5 * MINUTE,
|
||||
};
|
||||
|
||||
// --- apps/api/src/signatures/public-verify.controller.ts --------------
|
||||
|
||||
export const PUBLIC_VERIFY_IP_RULE: RateLimitRule = {
|
||||
name: "pubverify-ip",
|
||||
keyBy: "ip",
|
||||
limit: 60,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
export const PUBLIC_VERIFY_ID_RULE: RateLimitRule = {
|
||||
name: "pubverify-id",
|
||||
keyBy: "verificationId",
|
||||
limit: 30,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
16
apps/api/src/common/rate-limit/rate-limit.decorator.ts
Normal file
16
apps/api/src/common/rate-limit/rate-limit.decorator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export type RateLimitKeyBy = "ip" | "token" | "verificationId";
|
||||
|
||||
export interface RateLimitRule {
|
||||
/** Bucket namespace — must be unique per logical limit, shared across handlers on purpose (e.g. one IP-wide budget for a whole controller). */
|
||||
name: string;
|
||||
keyBy: RateLimitKeyBy;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
export const RATE_LIMIT_KEY = "rateLimit";
|
||||
|
||||
/** One or more independent limits checked for a route — ALL must pass. */
|
||||
export const RateLimit = (...rules: RateLimitRule[]) => SetMetadata(RATE_LIMIT_KEY, rules);
|
||||
75
apps/api/src/common/rate-limit/rate-limit.guard.ts
Normal file
75
apps/api/src/common/rate-limit/rate-limit.guard.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import type { Request, Response } from "express";
|
||||
import { RATE_LIMIT_KEY, type RateLimitRule } from "./rate-limit.decorator.js";
|
||||
import { RateLimiterService } from "./rate-limiter.service.js";
|
||||
|
||||
/**
|
||||
* Enforces every `@RateLimit(...)` rule declared on a handler (public
|
||||
* routes only — see auth.controller.ts / public-signature.controller.ts /
|
||||
* public-verify.controller.ts). Handlers without the decorator are left
|
||||
* alone (returns true immediately), so this guard is safe to attach at
|
||||
* controller level without affecting authenticated routes.
|
||||
*
|
||||
* All rules on a handler must pass; the first one that doesn't wins the
|
||||
* `Retry-After` value and gets logged. A hit here is a signal worth
|
||||
* operator attention (repeated 429s from one source usually means brute
|
||||
* force or scraping, not an accident), hence `warn`, not `debug`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RateLimitGuard implements CanActivate {
|
||||
private readonly logger = new Logger(RateLimitGuard.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly limiter: RateLimiterService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const rules = this.reflector.get<RateLimitRule[] | undefined>(RATE_LIMIT_KEY, context.getHandler());
|
||||
if (!rules || rules.length === 0) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
for (const rule of rules) {
|
||||
const keyValue = this.resolveKeyValue(rule, request);
|
||||
const bucketKey = `${rule.name}:${keyValue}`;
|
||||
const verdict = this.limiter.consume(bucketKey, rule.limit, rule.windowMs);
|
||||
|
||||
if (!verdict.allowed) {
|
||||
this.logger.warn(
|
||||
`rate limit exceeded: rule=${rule.name} keyBy=${rule.keyBy} key=${keyValue} ip=${request.ip ?? "unknown"} route=${request.method} ${request.originalUrl}`,
|
||||
);
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
response.set("Retry-After", String(verdict.retryAfterSeconds));
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: "Muitas requisições. Tente novamente mais tarde.",
|
||||
retryAfterSeconds: verdict.retryAfterSeconds,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private resolveKeyValue(rule: RateLimitRule, request: Request): string {
|
||||
switch (rule.keyBy) {
|
||||
case "ip":
|
||||
return request.ip ?? "unknown";
|
||||
case "token":
|
||||
return this.stringParam(request.params.token);
|
||||
case "verificationId":
|
||||
return this.stringParam(request.params.verificationId);
|
||||
}
|
||||
}
|
||||
|
||||
/** @types/express v5's ParamsDictionary types values as `string | string[]` — every route here declares a single `:param`, so this is always a scalar in practice. */
|
||||
private stringParam(value: string | string[] | undefined): string {
|
||||
if (Array.isArray(value)) return value[0] ?? "unknown";
|
||||
return value ?? "unknown";
|
||||
}
|
||||
}
|
||||
16
apps/api/src/common/rate-limit/rate-limit.module.ts
Normal file
16
apps/api/src/common/rate-limit/rate-limit.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { RateLimiterService } from "./rate-limiter.service.js";
|
||||
import { RateLimitGuard } from "./rate-limit.guard.js";
|
||||
|
||||
/**
|
||||
* Imported by every module that has public (no-session) routes. Nest
|
||||
* dedupes module imports within one application graph, so AuthModule and
|
||||
* SignaturesModule importing this both resolve to the SAME
|
||||
* RateLimiterService instance — one shared in-memory store, bucket names
|
||||
* are what keep routes from colliding (see rate-limit.decorator.ts).
|
||||
*/
|
||||
@Module({
|
||||
providers: [RateLimiterService, RateLimitGuard],
|
||||
exports: [RateLimiterService, RateLimitGuard],
|
||||
})
|
||||
export class RateLimitModule {}
|
||||
55
apps/api/src/common/rate-limit/rate-limiter.service.ts
Normal file
55
apps/api/src/common/rate-limit/rate-limiter.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
export interface RateLimitVerdict {
|
||||
allowed: boolean;
|
||||
/** Whole seconds until the current window resets — for the `Retry-After` header. */
|
||||
retryAfterSeconds: number;
|
||||
}
|
||||
|
||||
interface Bucket {
|
||||
count: number;
|
||||
windowStartedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fixed-window counter — no Redis in this environment yet
|
||||
* (compose.yaml/docs: "BullMQ quando Redis existir"), and this API runs as
|
||||
* a single instance (ADR-0002), so a process-local Map is both sufficient
|
||||
* and correct for this stage. Fixed-window (not sliding-window/token-
|
||||
* bucket) is a deliberate simplicity choice: it lets a caller burst up to
|
||||
* 2x the nominal limit right at a window boundary, which is an acceptable
|
||||
* trade-off for closing "no rate limiting at all" — revisit only if real
|
||||
* abuse patterns exploit that boundary.
|
||||
*
|
||||
* One instance of this service is shared (via RateLimitModule, imported by
|
||||
* both AuthModule and SignaturesModule) across every route this API
|
||||
* protects — buckets are namespaced by the caller-supplied `key`, which
|
||||
* always embeds a rule name so different routes never collide.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RateLimiterService {
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
|
||||
consume(key: string, limit: number, windowMs: number): RateLimitVerdict {
|
||||
const now = Date.now();
|
||||
const existing = this.buckets.get(key);
|
||||
|
||||
if (!existing || now - existing.windowStartedAt >= windowMs) {
|
||||
this.buckets.set(key, { count: 1, windowStartedAt: now });
|
||||
return { allowed: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
if (existing.count >= limit) {
|
||||
const retryAfterSeconds = Math.ceil((existing.windowStartedAt + windowMs - now) / 1000);
|
||||
return { allowed: false, retryAfterSeconds: Math.max(retryAfterSeconds, 1) };
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return { allowed: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
/** Test-only escape hatch — never called from production code paths. */
|
||||
reset(): void {
|
||||
this.buckets.clear();
|
||||
}
|
||||
}
|
||||
50
apps/api/src/companies/companies.controller.ts
Normal file
50
apps/api/src/companies/companies.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { CompaniesService } from "./companies.service.js";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto.js";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.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";
|
||||
|
||||
/**
|
||||
* Read is open to any authenticated user (not gated by a resource
|
||||
* permission) — company data isn't sensitive and is needed to render
|
||||
* contracts/documents from screens any seller/backoffice user can reach,
|
||||
* mirroring the legacy (eden.md §1, §6). Write requires companies:manage.
|
||||
*/
|
||||
@ApiTags("companies")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/companies")
|
||||
export class CompaniesController {
|
||||
constructor(private readonly companies: CompaniesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query("includeInactive") includeInactive?: string) {
|
||||
return this.companies.list(includeInactive === "true");
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id") id: string) {
|
||||
return this.companies.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("companies", "manage")
|
||||
create(@Body() dto: CreateCompanyDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.companies.create(dto, user.id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequirePermission("companies", "manage")
|
||||
update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateCompanyDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.companies.update(id, dto, user.id);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/companies/companies.module.ts
Normal file
11
apps/api/src/companies/companies.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { CompaniesController } from "./companies.controller.js";
|
||||
import { CompaniesService } from "./companies.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService],
|
||||
})
|
||||
export class CompaniesModule {}
|
||||
102
apps/api/src/companies/companies.service.ts
Normal file
102
apps/api/src/companies/companies.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { companiesRepo, auditRepo, type Company } from "@eden/database";
|
||||
import { onlyDigits, isValidCNPJ } from "@eden/domain-shared";
|
||||
import type { CreateCompanyDto } from "./dto/create-company.dto.js";
|
||||
import type { UpdateCompanyDto } from "./dto/update-company.dto.js";
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
list(includeInactive: boolean): Promise<Company[]> {
|
||||
return companiesRepo.listCompanies(includeInactive);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Company> {
|
||||
const company = await companiesRepo.getCompanyById(id);
|
||||
if (!company) throw new NotFoundException("Empresa não encontrada");
|
||||
return company;
|
||||
}
|
||||
|
||||
async create(dto: CreateCompanyDto, actorId: string): Promise<Company> {
|
||||
this.assertValidCnpjIfPresent(dto.cnpj);
|
||||
const company = await companiesRepo.createCompany({
|
||||
companyName: dto.companyName,
|
||||
tradeName: dto.tradeName,
|
||||
cnpj: dto.cnpj ? onlyDigits(dto.cnpj) : undefined,
|
||||
stateRegistration: dto.stateRegistration,
|
||||
municipalRegistration: dto.municipalRegistration,
|
||||
addressZip: dto.addressZip,
|
||||
addressStreet: dto.addressStreet,
|
||||
addressNumber: dto.addressNumber,
|
||||
addressComplement: dto.addressComplement,
|
||||
addressNeighborhood: dto.addressNeighborhood,
|
||||
addressCity: dto.addressCity,
|
||||
addressState: dto.addressState,
|
||||
addressCountry: dto.addressCountry,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
website: dto.website,
|
||||
legalRepName: dto.legalRepName,
|
||||
legalRepCpf: dto.legalRepCpf ? onlyDigits(dto.legalRepCpf) : undefined,
|
||||
legalRepRole: dto.legalRepRole,
|
||||
legalRepEmail: dto.legalRepEmail,
|
||||
createdBy: actorId,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId,
|
||||
action: "company.created",
|
||||
resourceType: "company",
|
||||
resourceId: company.id,
|
||||
});
|
||||
|
||||
return company;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCompanyDto, actorId: string): Promise<Company> {
|
||||
await this.get(id); // 404 if missing
|
||||
this.assertValidCnpjIfPresent(dto.cnpj);
|
||||
|
||||
const company = await companiesRepo.updateCompany(id, {
|
||||
companyName: dto.companyName,
|
||||
tradeName: dto.tradeName,
|
||||
cnpj: dto.cnpj ? onlyDigits(dto.cnpj) : undefined,
|
||||
stateRegistration: dto.stateRegistration,
|
||||
municipalRegistration: dto.municipalRegistration,
|
||||
addressZip: dto.addressZip,
|
||||
addressStreet: dto.addressStreet,
|
||||
addressNumber: dto.addressNumber,
|
||||
addressComplement: dto.addressComplement,
|
||||
addressNeighborhood: dto.addressNeighborhood,
|
||||
addressCity: dto.addressCity,
|
||||
addressState: dto.addressState,
|
||||
addressCountry: dto.addressCountry,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
website: dto.website,
|
||||
legalRepName: dto.legalRepName,
|
||||
legalRepCpf: dto.legalRepCpf ? onlyDigits(dto.legalRepCpf) : undefined,
|
||||
legalRepRole: dto.legalRepRole,
|
||||
legalRepEmail: dto.legalRepEmail,
|
||||
isActive: dto.isActive,
|
||||
});
|
||||
if (!company) throw new NotFoundException("Empresa não encontrada");
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId,
|
||||
action: "company.updated",
|
||||
resourceType: "company",
|
||||
resourceId: id,
|
||||
metadata: { fields: Object.keys(dto) },
|
||||
});
|
||||
|
||||
return company;
|
||||
}
|
||||
|
||||
private assertValidCnpjIfPresent(cnpj: string | undefined): void {
|
||||
if (cnpj && !isValidCNPJ(cnpj)) {
|
||||
throw new BadRequestException("CNPJ inválido");
|
||||
}
|
||||
}
|
||||
}
|
||||
27
apps/api/src/companies/dto/create-company.dto.ts
Normal file
27
apps/api/src/companies/dto/create-company.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
companyName!: string;
|
||||
|
||||
@IsOptional() @IsString() tradeName?: string;
|
||||
@IsOptional() @IsString() cnpj?: string;
|
||||
@IsOptional() @IsString() stateRegistration?: string;
|
||||
@IsOptional() @IsString() municipalRegistration?: string;
|
||||
@IsOptional() @IsString() addressZip?: string;
|
||||
@IsOptional() @IsString() addressStreet?: string;
|
||||
@IsOptional() @IsString() addressNumber?: string;
|
||||
@IsOptional() @IsString() addressComplement?: string;
|
||||
@IsOptional() @IsString() addressNeighborhood?: string;
|
||||
@IsOptional() @IsString() addressCity?: string;
|
||||
@IsOptional() @IsString() addressState?: string;
|
||||
@IsOptional() @IsString() addressCountry?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsEmail() email?: string;
|
||||
@IsOptional() @IsString() website?: string;
|
||||
@IsOptional() @IsString() legalRepName?: string;
|
||||
@IsOptional() @IsString() legalRepCpf?: string;
|
||||
@IsOptional() @IsString() legalRepRole?: string;
|
||||
@IsOptional() @IsEmail() legalRepEmail?: string;
|
||||
}
|
||||
9
apps/api/src/companies/dto/update-company.dto.ts
Normal file
9
apps/api/src/companies/dto/update-company.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { IsOptional, IsBoolean } from "class-validator";
|
||||
import { CreateCompanyDto } from "./create-company.dto.js";
|
||||
|
||||
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
1
apps/api/src/config/config.constants.ts
Normal file
1
apps/api/src/config/config.constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const EDEN_ENV = Symbol("EDEN_ENV");
|
||||
19
apps/api/src/config/config.module.ts
Normal file
19
apps/api/src/config/config.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { loadEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "./config.constants.js";
|
||||
|
||||
/**
|
||||
* Fails fast at boot (via loadEnv) if a required secret/config var is
|
||||
* missing — see packages/config/src/env-schema.ts and eden.md §5.6.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: EDEN_ENV,
|
||||
useFactory: () => loadEnv(),
|
||||
},
|
||||
],
|
||||
exports: [EDEN_ENV],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
74
apps/api/src/contracts/contracts.controller.ts
Normal file
74
apps/api/src/contracts/contracts.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { ContractsService } from "./contracts.service.js";
|
||||
import { CreateContractDto } from "./dto/create-contract.dto.js";
|
||||
import { UpdateContractStatusDto } from "./dto/update-contract-status.dto.js";
|
||||
import { ContractItemInputDto } from "./dto/contract-item-input.dto.js";
|
||||
import { ContractPartyInputDto } from "./dto/contract-party-input.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";
|
||||
|
||||
/**
|
||||
* Contracts as a first-class aggregate — docs/adr/0006-contract-first-class.md.
|
||||
* Every write recalculates monthly_value/contract_total server-side from
|
||||
* the items array (Master Prompt §6.6/§24) — a client-supplied total is
|
||||
* never trusted.
|
||||
*/
|
||||
@ApiTags("contracts")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/contracts")
|
||||
export class ContractsController {
|
||||
constructor(private readonly contracts: ContractsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission("contracts", "view")
|
||||
list(@CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.contracts.list(user);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("contracts", "view")
|
||||
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.contracts.get(id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("contracts", "edit")
|
||||
create(@Body() dto: CreateContractDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.contracts.create(dto, user);
|
||||
}
|
||||
|
||||
@Patch(":id/status")
|
||||
@RequirePermission("contracts", "edit")
|
||||
updateStatus(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateContractStatusDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.contracts.updateStatus(id, dto, user);
|
||||
}
|
||||
|
||||
@Post(":id/items")
|
||||
@RequirePermission("contracts", "edit")
|
||||
addItem(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ContractItemInputDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.contracts.addItem(id, dto, user);
|
||||
}
|
||||
|
||||
@Post(":id/parties")
|
||||
@RequirePermission("contracts", "edit")
|
||||
addParty(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ContractPartyInputDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.contracts.addParty(id, dto, user);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/contracts/contracts.module.ts
Normal file
12
apps/api/src/contracts/contracts.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { ContractsController } from "./contracts.controller.js";
|
||||
import { ContractsService } from "./contracts.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ContractsController],
|
||||
providers: [ContractsService],
|
||||
exports: [ContractsService],
|
||||
})
|
||||
export class ContractsModule {}
|
||||
216
apps/api/src/contracts/contracts.service.ts
Normal file
216
apps/api/src/contracts/contracts.service.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
contractsRepo,
|
||||
productsRepo,
|
||||
type Contract,
|
||||
type ContractWithDetails,
|
||||
type CreateContractItemInput,
|
||||
} from "@eden/database";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import { maxGrantedScope } from "../auth/permissions.js";
|
||||
import type { CreateContractDto } from "./dto/create-contract.dto.js";
|
||||
import type { UpdateContractStatusDto } from "./dto/update-contract-status.dto.js";
|
||||
import type { ContractItemInputDto } from "./dto/contract-item-input.dto.js";
|
||||
import type { ContractPartyInputDto } from "./dto/contract-party-input.dto.js";
|
||||
|
||||
const { ContractNotFoundError, InvalidContractTransitionError } = contractsRepo;
|
||||
|
||||
const SUPER_ADMIN = "super_admin";
|
||||
|
||||
@Injectable()
|
||||
export class ContractsService {
|
||||
async list(user: EdenAuthenticatedUser): Promise<Contract[]> {
|
||||
const scope = maxGrantedScope(user.permissions, "contracts", "view");
|
||||
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'contracts'");
|
||||
if (scope === "all") return contractsRepo.listContracts({ scope: "all" });
|
||||
return contractsRepo.listContracts({ scope: "reseller", resellerId: user.resellerId });
|
||||
}
|
||||
|
||||
async get(id: string, user: EdenAuthenticatedUser): Promise<ContractWithDetails> {
|
||||
const scope = maxGrantedScope(user.permissions, "contracts", "view");
|
||||
const contract = await contractsRepo.getContractById(id);
|
||||
if (!contract || !scope) throw new NotFoundException("Contrato não encontrado");
|
||||
if (scope !== "all" && contract.reseller_id !== user.resellerId) {
|
||||
throw new NotFoundException("Contrato não encontrado");
|
||||
}
|
||||
return contract;
|
||||
}
|
||||
|
||||
async create(dto: CreateContractDto, user: EdenAuthenticatedUser): Promise<ContractWithDetails> {
|
||||
if (dto.fidelityPeriod != null && dto.fidelityPeriod > dto.contractPeriod) {
|
||||
throw new BadRequestException("fidelityPeriod não pode ser maior que contractPeriod");
|
||||
}
|
||||
|
||||
const items = await this.buildItems(dto.items, dto.contractPeriod);
|
||||
const monthlyValue = round2(items.reduce((sum, item) => sum + item.total, 0));
|
||||
const implementationFee = dto.implementationFee ?? 0;
|
||||
const contractTotal = round2(monthlyValue * (dto.contractPeriod || 1) + implementationFee);
|
||||
|
||||
const contract = await contractsRepo.createContract({
|
||||
quoteId: dto.quoteId ?? null,
|
||||
clientRegistrationId: dto.clientRegistrationId ?? null,
|
||||
resellerId: dto.resellerId ?? null,
|
||||
companyId: dto.companyId ?? null,
|
||||
contractPeriod: dto.contractPeriod,
|
||||
fidelityPeriod: dto.fidelityPeriod ?? null,
|
||||
// fidelity_approved always starts false — approval is a distinct,
|
||||
// explicit act (mirrors quotes.approval_status never starting
|
||||
// 'approved' on creation either); no endpoint to set it yet in this
|
||||
// MVP since the approval workflow lives on Quotes for now — a
|
||||
// contract created from an already-approved quote will carry that
|
||||
// forward once the Quotes→Contract integration lands.
|
||||
fidelityApproved: false,
|
||||
monthlyValue,
|
||||
contractTotal,
|
||||
implementationFee,
|
||||
notes: dto.notes ?? null,
|
||||
createdBy: user.id,
|
||||
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
||||
parties: dto.parties.map(this.mapParty),
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "contract.created",
|
||||
resourceType: "contract",
|
||||
resourceId: contract.id,
|
||||
metadata: { contractTotal, itemCount: items.length },
|
||||
});
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
async updateStatus(id: string, dto: UpdateContractStatusDto, user: EdenAuthenticatedUser): Promise<Contract> {
|
||||
await this.get(id, user); // scope check — 404 before attempting the transition
|
||||
|
||||
try {
|
||||
const updated = await contractsRepo.updateContractStatus(id, dto.toStatus, dto.reason ?? null, user.id);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "contract.status_changed",
|
||||
resourceType: "contract",
|
||||
resourceId: id,
|
||||
metadata: { toStatus: dto.toStatus, reason: dto.reason ?? null },
|
||||
});
|
||||
|
||||
return updated;
|
||||
} catch (err) {
|
||||
if (err instanceof ContractNotFoundError) throw new NotFoundException("Contrato não encontrado");
|
||||
if (err instanceof InvalidContractTransitionError) {
|
||||
throw new ConflictException(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async addItem(id: string, dto: ContractItemInputDto, user: EdenAuthenticatedUser) {
|
||||
const contract = await this.get(id, user);
|
||||
const overrode = this.assertEditable(contract, user);
|
||||
const [item] = await this.buildItems([dto], contract.contract_period);
|
||||
const created = await contractsRepo.addContractItem(id, { ...item!, sortOrder: contract.items.length });
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: overrode ? "contract.item_added_override" : "contract.item_added",
|
||||
resourceType: "contract",
|
||||
resourceId: id,
|
||||
metadata: { productId: dto.productId, contractStatus: contract.status, overrode },
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async addParty(id: string, dto: ContractPartyInputDto, user: EdenAuthenticatedUser) {
|
||||
const contract = await this.get(id, user);
|
||||
const overrode = this.assertEditable(contract, user);
|
||||
const created = await contractsRepo.addContractParty(id, this.mapParty(dto));
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: overrode ? "contract.party_added_override" : "contract.party_added",
|
||||
resourceType: "contract",
|
||||
resourceId: id,
|
||||
metadata: { partyRole: dto.partyRole, contractStatus: contract.status, overrode },
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-0006's own stated invariant: "fluxo de fechamento de oferta →
|
||||
* geração de contrato preserva a trava equivalente [...] exceto por
|
||||
* papel com permissão de correção auditada equivalente ao super_admin
|
||||
* do legado" — ported here from Quotes' assertNotLocked (same shape:
|
||||
* hard lock once the aggregate has moved past its editable draft state,
|
||||
* super_admin-only override). `draft` is the only status items/parties
|
||||
* can be freely added to; anything past it means signing has started or
|
||||
* the contract is already live — an edit past that point is either a
|
||||
* correction (audited, super_admin-only) or should go through a future
|
||||
* amendment instead (contract_amendments, not yet built — ADR-0006).
|
||||
* Returns whether this call IS an override, so the caller can tag the
|
||||
* audit event distinctly.
|
||||
*/
|
||||
private assertEditable(contract: Contract, user: EdenAuthenticatedUser): boolean {
|
||||
if (contract.status === "draft") return false;
|
||||
if (user.roleKey !== SUPER_ADMIN) {
|
||||
throw new ConflictException(
|
||||
`Este contrato não está mais em rascunho (status atual: ${contract.status}) — itens/partes só podem ser adicionados enquanto o contrato está em rascunho.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private mapParty(dto: ContractPartyInputDto) {
|
||||
return {
|
||||
partyRole: dto.partyRole,
|
||||
personType: dto.personType ?? null,
|
||||
fullName: dto.fullName,
|
||||
document: dto.document ?? null,
|
||||
email: dto.email ?? null,
|
||||
phone: dto.phone ?? null,
|
||||
roleTitle: dto.roleTitle ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildItems(
|
||||
inputs: ContractItemInputDto[],
|
||||
contractPeriod: number,
|
||||
): Promise<CreateContractItemInput[]> {
|
||||
const priceKey = `price_${contractPeriod}` as "price_0" | "price_12" | "price_24" | "price_36" | "price_48";
|
||||
|
||||
const items: CreateContractItemInput[] = [];
|
||||
for (const input of inputs) {
|
||||
const product = await productsRepo.getProductById(input.productId);
|
||||
if (!product) throw new BadRequestException(`Produto ${input.productId} não encontrado`);
|
||||
|
||||
const unitPriceRaw = product[priceKey];
|
||||
const unitPrice = unitPriceRaw != null ? Number(unitPriceRaw) : 0;
|
||||
|
||||
items.push({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
quantity: input.quantity,
|
||||
unitPrice,
|
||||
total: round2(unitPrice * input.quantity),
|
||||
sortOrder: 0,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
11
apps/api/src/contracts/dto/contract-item-input.dto.ts
Normal file
11
apps/api/src/contracts/dto/contract-item-input.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNumber, IsUUID, Min } from "class-validator";
|
||||
|
||||
/** Same principle as Quotes' item DTO: only productId/quantity are client-supplied; unitPrice/productName are always derived server-side from the live product record. */
|
||||
export class ContractItemInputDto {
|
||||
@IsUUID()
|
||||
productId!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
quantity!: number;
|
||||
}
|
||||
17
apps/api/src/contracts/dto/contract-party-input.dto.ts
Normal file
17
apps/api/src/contracts/dto/contract-party-input.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IsEmail, IsIn, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class ContractPartyInputDto {
|
||||
@IsIn(["contratada", "contratante"])
|
||||
partyRole!: "contratada" | "contratante";
|
||||
|
||||
@IsOptional() @IsIn(["pf", "pj"]) personType?: "pf" | "pj";
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
fullName!: string;
|
||||
|
||||
@IsOptional() @IsString() document?: string;
|
||||
@IsOptional() @IsEmail() email?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() roleTitle?: string;
|
||||
}
|
||||
45
apps/api/src/contracts/dto/create-contract.dto.ts
Normal file
45
apps/api/src/contracts/dto/create-contract.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ContractItemInputDto } from "./contract-item-input.dto.js";
|
||||
import { ContractPartyInputDto } from "./contract-party-input.dto.js";
|
||||
|
||||
export class CreateContractDto {
|
||||
@IsOptional() @IsUUID() resellerId?: string;
|
||||
@IsOptional() @IsUUID() companyId?: string;
|
||||
@IsOptional() @IsUUID() quoteId?: string;
|
||||
@IsOptional() @IsUUID() clientRegistrationId?: string;
|
||||
|
||||
@IsIn([0, 12, 24, 36, 48])
|
||||
contractPeriod!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
fidelityPeriod?: number;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ContractItemInputDto)
|
||||
items!: ContractItemInputDto[];
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ContractPartyInputDto)
|
||||
parties!: ContractPartyInputDto[];
|
||||
|
||||
@IsOptional() @IsNumber() @Min(0) implementationFee?: number;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
16
apps/api/src/contracts/dto/update-contract-status.dto.ts
Normal file
16
apps/api/src/contracts/dto/update-contract-status.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateContractStatusDto {
|
||||
@IsIn(["draft", "pending_signature", "active", "suspended", "cancelled", "terminated", "expired", "renewed"])
|
||||
toStatus!:
|
||||
| "draft"
|
||||
| "pending_signature"
|
||||
| "active"
|
||||
| "suspended"
|
||||
| "cancelled"
|
||||
| "terminated"
|
||||
| "expired"
|
||||
| "renewed";
|
||||
|
||||
@IsOptional() @IsString() reason?: string;
|
||||
}
|
||||
61
apps/api/src/crypto/field-encryption.service.ts
Normal file
61
apps/api/src/crypto/field-encryption.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import type { EdenEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "../config/config.constants.js";
|
||||
|
||||
const KEY_VERSION = "v1";
|
||||
const IV_LENGTH_BYTES = 12; // 96 bits — AES-GCM recommended nonce size
|
||||
|
||||
/**
|
||||
* Generic reversible field encryption (ADR-0005) — AES-256-GCM, random IV
|
||||
* per operation, authenticated (tampering makes decrypt() throw). Stored
|
||||
* format mirrors the legacy's `iv:tag:ciphertext` scheme with an added key
|
||||
* version prefix so a future root-key rotation can be applied incrementally
|
||||
* instead of re-encrypting everything at once: `v1:iv:tag:ciphertext` (all
|
||||
* three payload segments base64).
|
||||
*
|
||||
* Root key comes from EDEN_FIELD_ENCRYPTION_KEY (env-only, never in the
|
||||
* database) and is hashed to exactly 32 bytes via SHA-256 so any
|
||||
* sufficiently-long passphrase works as AES-256 key material.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FieldEncryptionService {
|
||||
private readonly key: Buffer | null;
|
||||
|
||||
constructor(@Inject(EDEN_ENV) env: EdenEnv) {
|
||||
this.key = env.EDEN_FIELD_ENCRYPTION_KEY
|
||||
? createHash("sha256").update(env.EDEN_FIELD_ENCRYPTION_KEY).digest()
|
||||
: null;
|
||||
}
|
||||
|
||||
encrypt(plaintext: string): string {
|
||||
const key = this.requireKey();
|
||||
const iv = randomBytes(IV_LENGTH_BYTES);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [KEY_VERSION, iv.toString("base64"), tag.toString("base64"), ciphertext.toString("base64")].join(":");
|
||||
}
|
||||
|
||||
decrypt(stored: string): string {
|
||||
const key = this.requireKey();
|
||||
const [version, ivB64, tagB64, ciphertextB64] = stored.split(":");
|
||||
if (version !== KEY_VERSION || !ivB64 || !tagB64 || !ciphertextB64) {
|
||||
throw new Error("Formato de campo cifrado inválido ou versão de chave desconhecida");
|
||||
}
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64"));
|
||||
decipher.setAuthTag(Buffer.from(tagB64, "base64"));
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextB64, "base64")),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plaintext.toString("utf8");
|
||||
}
|
||||
|
||||
private requireKey(): Buffer {
|
||||
if (!this.key) {
|
||||
throw new Error("EDEN_FIELD_ENCRYPTION_KEY não configurada — obrigatória para criptografia de campo reversível");
|
||||
}
|
||||
return this.key;
|
||||
}
|
||||
}
|
||||
107
apps/api/src/documents/documents.controller.ts
Normal file
107
apps/api/src/documents/documents.controller.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Body, Controller, Get, Header, Param, Patch, Post, Query, Res, StreamableFile, UseGuards } from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { DocumentsService } from "./documents.service";
|
||||
import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||
import { UpdateDraftDto } from "./dto/update-draft.dto";
|
||||
import { PreviewDraftDto } from "./dto/preview-draft.dto";
|
||||
import { CreateGenerationDto } from "./dto/create-generation.dto";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../auth/permission.guard";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
|
||||
|
||||
/**
|
||||
* Templates de documento + geração (eden.md §4, seções 1.1-1.3). Só a
|
||||
* metade "templates + geração" do módulo 4 — Assinatura Eletrônica
|
||||
* (seções 1.4-1.9) fica para uma leva dedicada futura (ver o comentário
|
||||
* no topo da migration PENDING_documents.cjs).
|
||||
*/
|
||||
@ApiTags("documents")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/documents")
|
||||
export class DocumentsController {
|
||||
constructor(private readonly documents: DocumentsService) {}
|
||||
|
||||
@Get("variable-catalog")
|
||||
@RequirePermission("documents", "view")
|
||||
getVariableCatalog() {
|
||||
return this.documents.getVariableCatalog();
|
||||
}
|
||||
|
||||
@Get("templates")
|
||||
@RequirePermission("documents", "view")
|
||||
listTemplates(@Query("docGroup") docGroup?: "cliente" | "revenda") {
|
||||
return this.documents.listTemplates(docGroup);
|
||||
}
|
||||
|
||||
@Post("templates")
|
||||
@RequirePermission("documents", "edit")
|
||||
createTemplate(@Body() dto: CreateDocumentTemplateDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.documents.createTemplate(dto, user);
|
||||
}
|
||||
|
||||
@Get("templates/:id")
|
||||
@RequirePermission("documents", "view")
|
||||
getTemplate(@Param("id") id: string) {
|
||||
return this.documents.getTemplate(id);
|
||||
}
|
||||
|
||||
@Post("templates/:id/draft")
|
||||
@RequirePermission("documents", "edit")
|
||||
createDraft(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.documents.createDraft(id, user);
|
||||
}
|
||||
|
||||
@Patch("templates/:id/draft")
|
||||
@RequirePermission("documents", "edit")
|
||||
updateDraft(@Param("id") id: string, @Body() dto: UpdateDraftDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.documents.updateDraft(id, dto, user);
|
||||
}
|
||||
|
||||
@Post("templates/:id/draft/preview")
|
||||
@RequirePermission("documents", "edit")
|
||||
@Header("Content-Type", "application/pdf")
|
||||
async previewDraft(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: PreviewDraftDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const buffer = await this.documents.previewDraft(id, dto);
|
||||
res.set({ "Content-Disposition": 'inline; filename="preview.pdf"' });
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post("templates/:id/publish")
|
||||
@RequirePermission("documents", "edit")
|
||||
publish(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.documents.publish(id, user);
|
||||
}
|
||||
|
||||
@Post("generations")
|
||||
@RequirePermission("documents", "edit")
|
||||
createGeneration(@Body() dto: CreateGenerationDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.documents.createGeneration(dto, user);
|
||||
}
|
||||
|
||||
@Get("generations/:id/pdf")
|
||||
@RequirePermission("documents", "view")
|
||||
@Header("Content-Type", "application/pdf")
|
||||
async downloadGenerationPdf(@Param("id") id: string, @Res({ passthrough: true }) res: Response) {
|
||||
const { generation, stream } = await this.documents.getGenerationPdf(id);
|
||||
res.set({ "Content-Disposition": `inline; filename="${generation.id}.pdf"` });
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
@Get("generations")
|
||||
@RequirePermission("documents", "view")
|
||||
listGenerations(
|
||||
@Query("templateId") templateId?: string,
|
||||
@Query("clientRegistrationId") clientRegistrationId?: string,
|
||||
@Query("resellerId") resellerId?: string,
|
||||
) {
|
||||
return this.documents.listGenerations({ templateId, clientRegistrationId, resellerId });
|
||||
}
|
||||
}
|
||||
12
apps/api/src/documents/documents.module.ts
Normal file
12
apps/api/src/documents/documents.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { DocumentsController } from "./documents.controller";
|
||||
import { DocumentsService } from "./documents.service";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [DocumentsController],
|
||||
providers: [DocumentsService],
|
||||
exports: [DocumentsService],
|
||||
})
|
||||
export class DocumentsModule {}
|
||||
243
apps/api/src/documents/documents.service.ts
Normal file
243
apps/api/src/documents/documents.service.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { Readable } from "node:stream";
|
||||
import { auditRepo, documentsRepo } from "@eden/database";
|
||||
import type { DocumentGeneration } from "@eden/database";
|
||||
import { getObjectStream, renderPdf, uploadObject } from "@eden/integrations";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
|
||||
import { resolveDocument } from "./merge-engine";
|
||||
import { collectMergeFieldKeys, renderEditorContentToHtml } from "./editor-content-renderer";
|
||||
import { VARIABLE_CATALOG } from "./variable-catalog";
|
||||
import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||
import type { UpdateDraftDto } from "./dto/update-draft.dto";
|
||||
import type { PreviewDraftDto } from "./dto/preview-draft.dto";
|
||||
import type { CreateGenerationDto } from "./dto/create-generation.dto";
|
||||
|
||||
const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError, DraftNotFoundError } =
|
||||
documentsRepo;
|
||||
|
||||
/**
|
||||
* Templates de documento + geração — eden.md §4, seções 1.1-1.3. Só a
|
||||
* metade "templates + geração" do módulo 4; Assinatura Eletrônica
|
||||
* (seções 1.4-1.9) fica para uma leva dedicada futura (ver o comentário
|
||||
* no topo da migration PENDING_documents.cjs). Endpoint-level
|
||||
* authorization (@RequirePermission) lives on DocumentsController — this
|
||||
* service assumes the caller already holds the resource+action it needs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
getVariableCatalog() {
|
||||
return VARIABLE_CATALOG;
|
||||
}
|
||||
|
||||
async listTemplates(docGroup?: "cliente" | "revenda") {
|
||||
return documentsRepo.listTemplates(docGroup ? { docGroup } : {});
|
||||
}
|
||||
|
||||
async getTemplate(id: string) {
|
||||
const template = await documentsRepo.getTemplateWithVersions(id);
|
||||
if (!template) throw new NotFoundException("Template de documento não encontrado");
|
||||
return template;
|
||||
}
|
||||
|
||||
async createTemplate(dto: CreateDocumentTemplateDto, user: EdenAuthenticatedUser) {
|
||||
try {
|
||||
const template = await documentsRepo.createTemplate({
|
||||
key: dto.key,
|
||||
name: dto.name,
|
||||
docGroup: dto.docGroup,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "document_template.created",
|
||||
resourceType: "document_template",
|
||||
resourceId: template.id,
|
||||
metadata: { key: template.key, docGroup: template.doc_group },
|
||||
});
|
||||
|
||||
return template;
|
||||
} catch (err) {
|
||||
if (err instanceof TemplateKeyConflictError) {
|
||||
throw new ConflictException(`A chave '${dto.key}' já está em uso por outro template`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async createDraft(templateId: string, user: EdenAuthenticatedUser) {
|
||||
try {
|
||||
const draft = await documentsRepo.createDraft(templateId, user.id, renderEditorContentToHtml);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "document_template.draft_created",
|
||||
resourceType: "document_template_version",
|
||||
resourceId: draft.id,
|
||||
metadata: { templateId, version: draft.version },
|
||||
});
|
||||
|
||||
return draft;
|
||||
} catch (err) {
|
||||
if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado");
|
||||
if (err instanceof DraftAlreadyExistsError) {
|
||||
throw new ConflictException("Este template já possui um rascunho em andamento");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async updateDraft(templateId: string, dto: UpdateDraftDto, user: EdenAuthenticatedUser) {
|
||||
try {
|
||||
const updated = await documentsRepo.updateDraft(
|
||||
templateId,
|
||||
{ editorContent: dto.editorContent, cssContent: dto.cssContent, pageConfig: dto.pageConfig },
|
||||
renderEditorContentToHtml,
|
||||
);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "document_template.draft_updated",
|
||||
resourceType: "document_template_version",
|
||||
resourceId: updated.id,
|
||||
metadata: { templateId },
|
||||
});
|
||||
|
||||
return updated;
|
||||
} catch (err) {
|
||||
if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado");
|
||||
if (err instanceof DraftNotFoundError) {
|
||||
throw new NotFoundException("Este template não possui um rascunho para editar");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pré-visualização em PDF de um rascunho — nunca persiste nada (sem
|
||||
* `document_generations`, sem upload S3), é só uma renderização
|
||||
* descartável pra validar o layout/CSS antes da publicação irreversível.
|
||||
* Roda sobre o `editorContent`/`cssContent` recebidos diretamente no
|
||||
* corpo da requisição (o estado atual do editor, possivelmente ainda não
|
||||
* salvo) — não busca o rascunho persistido, pra permitir iterar
|
||||
* (editar → pré-visualizar → editar) sem precisar salvar a cada volta.
|
||||
*
|
||||
* Como não há um contexto real (nenhum cliente/revenda selecionado nesta
|
||||
* tela), toda `mergeField` encontrada vira um placeholder visível
|
||||
* (`[variavel]`) em vez de string vazia.
|
||||
*/
|
||||
async previewDraft(templateId: string, dto: PreviewDraftDto): Promise<Buffer> {
|
||||
const template = await documentsRepo.getTemplateById(templateId);
|
||||
if (!template) throw new NotFoundException("Template de documento não encontrado");
|
||||
|
||||
const keys = collectMergeFieldKeys(dto.editorContent);
|
||||
const placeholderContext = Object.fromEntries(keys.map((key) => [key, `[${key}]`]));
|
||||
const { html } = resolveDocument(dto.editorContent, placeholderContext);
|
||||
|
||||
return renderPdf({ html, css: dto.cssContent });
|
||||
}
|
||||
|
||||
async publish(templateId: string, user: EdenAuthenticatedUser) {
|
||||
try {
|
||||
const published = await documentsRepo.publishDraft(templateId, user.id);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "document_template.published",
|
||||
resourceType: "document_template_version",
|
||||
resourceId: published.id,
|
||||
metadata: { templateId, version: published.version },
|
||||
});
|
||||
|
||||
return published;
|
||||
} catch (err) {
|
||||
if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado");
|
||||
if (err instanceof DraftNotFoundError) {
|
||||
throw new ConflictException("Este template não possui um rascunho para publicar");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async createGeneration(dto: CreateGenerationDto, user: EdenAuthenticatedUser) {
|
||||
const hasClient = dto.clientRegistrationId != null;
|
||||
const hasReseller = dto.resellerId != null;
|
||||
if (hasClient === hasReseller) {
|
||||
throw new BadRequestException("Informe exatamente uma origem: clientRegistrationId OU resellerId");
|
||||
}
|
||||
|
||||
let published: Awaited<ReturnType<typeof documentsRepo.getPublishedVersionForTemplate>>;
|
||||
try {
|
||||
published = await documentsRepo.getPublishedVersionForTemplate(dto.templateId);
|
||||
} catch (err) {
|
||||
if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado");
|
||||
throw err;
|
||||
}
|
||||
if (!published) {
|
||||
throw new NotFoundException("Este template ainda não tem uma versão publicada");
|
||||
}
|
||||
|
||||
// eden.md §4 descreve o pipeline completo com sanitizeDocumentHtml —
|
||||
// não reimplementado nesta leva; mas a renderização de PDF em si
|
||||
// (Chromium) e o upload para S3 já são reais a partir daqui.
|
||||
const { html: resolved, missingVariables } = resolveDocument(published.version.editor_content, dto.context ?? {});
|
||||
|
||||
const origin = { clientRegistrationId: dto.clientRegistrationId ?? null, resellerId: dto.resellerId ?? null };
|
||||
|
||||
// Evita renderizar/subir um PDF à toa quando o resultado vai ser
|
||||
// descartado de qualquer forma pela idempotência (mesmo par
|
||||
// template_version + origem já finalizado).
|
||||
const existing = await documentsRepo.findFinalGeneration(published.version.id, origin);
|
||||
if (existing) {
|
||||
return { ...existing, missingVariables, deduped: true };
|
||||
}
|
||||
|
||||
const pdfBuffer = await renderPdf({ html: resolved, css: published.version.css_content ?? undefined });
|
||||
const pdfSha256 = createHash("sha256").update(pdfBuffer).digest("hex");
|
||||
const pdfS3Key = `documents/generations/${randomUUID()}.pdf`;
|
||||
await uploadObject(pdfS3Key, pdfBuffer, "application/pdf");
|
||||
|
||||
const { generation, deduped } = await documentsRepo.createGenerationIdempotent({
|
||||
templateId: dto.templateId,
|
||||
templateVersionId: published.version.id,
|
||||
clientRegistrationId: origin.clientRegistrationId,
|
||||
resellerId: origin.resellerId,
|
||||
renderContext: dto.context ?? {},
|
||||
renderedHtml: resolved,
|
||||
generatedBy: user.id,
|
||||
pdfS3Key,
|
||||
pdfSha256,
|
||||
pdfSizeBytes: pdfBuffer.length,
|
||||
});
|
||||
|
||||
if (!deduped) {
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "document_generation.created",
|
||||
resourceType: "document_generation",
|
||||
resourceId: generation.id,
|
||||
metadata: { templateId: dto.templateId, templateVersionId: published.version.id, missingVariables },
|
||||
});
|
||||
}
|
||||
|
||||
return { ...generation, missingVariables, deduped };
|
||||
}
|
||||
|
||||
async getGenerationPdf(id: string): Promise<{ generation: DocumentGeneration; stream: Readable }> {
|
||||
const generation = await documentsRepo.getGenerationById(id);
|
||||
if (!generation) throw new NotFoundException("Geração de documento não encontrada");
|
||||
if (!generation.pdf_s3_key) throw new NotFoundException("Esta geração ainda não tem um PDF disponível");
|
||||
const stream = await getObjectStream(generation.pdf_s3_key);
|
||||
return { generation, stream };
|
||||
}
|
||||
|
||||
async listGenerations(filter: { templateId?: string; clientRegistrationId?: string; resellerId?: string }) {
|
||||
return documentsRepo.listGenerations(filter);
|
||||
}
|
||||
}
|
||||
18
apps/api/src/documents/dto/create-document-template.dto.ts
Normal file
18
apps/api/src/documents/dto/create-document-template.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsIn, IsString, Matches, MaxLength } from "class-validator";
|
||||
|
||||
export class CreateDocumentTemplateDto {
|
||||
/** Identificador estável fixado pelo sistema (eden.md §1.1) — só ASCII minúsculo/underscore, nunca editável depois de criado nesta leva. */
|
||||
@IsString()
|
||||
@Matches(/^[a-z][a-z0-9_]*$/, {
|
||||
message: "key deve começar com letra minúscula e conter apenas letras minúsculas, números e underscore",
|
||||
})
|
||||
@MaxLength(100)
|
||||
key!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@IsIn(["cliente", "revenda"])
|
||||
docGroup!: "cliente" | "revenda";
|
||||
}
|
||||
19
apps/api/src/documents/dto/create-generation.dto.ts
Normal file
19
apps/api/src/documents/dto/create-generation.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IsObject, IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
/**
|
||||
* POST /v1/documents/generations body (eden.md §1.3, simplified for this
|
||||
* leva). Exactly one of clientRegistrationId/resellerId is required —
|
||||
* enforced in DocumentsService (mirrors the DB CHECK
|
||||
* chk_document_generations_one_source), not here, since class-validator
|
||||
* doesn't cleanly express an XOR across two optional fields.
|
||||
*/
|
||||
export class CreateGenerationDto {
|
||||
@IsUUID() templateId!: string;
|
||||
|
||||
@IsOptional() @IsUUID() clientRegistrationId?: string;
|
||||
|
||||
@IsOptional() @IsUUID() resellerId?: string;
|
||||
|
||||
/** {chave: valor} usado pelo merge engine (merge-engine.ts) para resolver `{{variavel}}`. */
|
||||
@IsObject() context!: Record<string, string>;
|
||||
}
|
||||
12
apps/api/src/documents/dto/preview-draft.dto.ts
Normal file
12
apps/api/src/documents/dto/preview-draft.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/**
|
||||
* `POST .../draft/preview` body — deliberately takes the CURRENT, possibly
|
||||
* unsaved, editor state (same shape as `UpdateDraftDto`) instead of reading
|
||||
* the persisted draft: the point is fast iteration (edit → preview → edit)
|
||||
* without forcing a save first. Nothing here is persisted.
|
||||
*/
|
||||
export class PreviewDraftDto {
|
||||
@IsObject() editorContent!: Record<string, unknown>;
|
||||
@IsOptional() @IsString() cssContent?: string;
|
||||
}
|
||||
14
apps/api/src/documents/dto/update-draft.dto.ts
Normal file
14
apps/api/src/documents/dto/update-draft.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/**
|
||||
* PATCH .../draft body (eden.md §1.2 step 2) — every field optional and
|
||||
* independently omit-able; an omitted field keeps the draft's current
|
||||
* value (see documents.ts's updateDraft `patch.field !== undefined`
|
||||
* semantics). `editorContent` is accepted as generic JSONB — this leva
|
||||
* does not validate the Tiptap tree shape.
|
||||
*/
|
||||
export class UpdateDraftDto {
|
||||
@IsOptional() @IsObject() editorContent?: Record<string, unknown>;
|
||||
@IsOptional() @IsString() cssContent?: string;
|
||||
@IsOptional() @IsObject() pageConfig?: Record<string, unknown>;
|
||||
}
|
||||
178
apps/api/src/documents/editor-content-renderer.ts
Normal file
178
apps/api/src/documents/editor-content-renderer.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Serializador do documento Tiptap (`editor_content`) para HTML — porta fiel
|
||||
* de `renderEditorContent.js` do legado OrçaFácil
|
||||
* (`.tmp/orcafacil-reference/server/src/lib/documentTemplates/renderEditorContent.js`),
|
||||
* escrito à mão sem depender de `@tiptap/html` (este pacote não pode
|
||||
* depender de bibliotecas do editor/frontend — eden.md §2.2).
|
||||
*
|
||||
* Cobre os nós que o editor visual (apps/core-web/src/routes/documents/editor)
|
||||
* realmente produz: paragraph/heading/bulletList/orderedList/listItem/image/
|
||||
* pageBreak/table+tableRow+tableHeader+tableCell/mergeField/conditional,
|
||||
* marks bold/italic/underline/strike/textStyle(fontSize). `conditional`
|
||||
* (envelope em volta de uma `tableRow`, usado pelo Termo de Contratação
|
||||
* STFC importado do legado) sempre RENDERIZA sua linha — avaliação real da
|
||||
* condição contra o contexto é fora de escopo desta leva (ver
|
||||
* docs/progress.md). NÃO implementado: `tableRow.attrs.repeat` (expansão de
|
||||
* linha de tabela) — nenhum dos 7 templates importados do legado usa.
|
||||
*/
|
||||
|
||||
export interface TiptapMark {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TiptapNode {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: TiptapNode[];
|
||||
marks?: TiptapMark[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** Resolve o valor final de uma `mergeField` pela sua `key` — quando omitida, o nó vira o chip visual (preview "com variáveis"). */
|
||||
export type ResolveMergeField = (key: string) => string;
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// textAlign é atributo do NODE (parágrafo/título) no Tiptap — não é mark.
|
||||
function textAlignStyle(node: TiptapNode): string {
|
||||
const align = node.attrs?.textAlign;
|
||||
return typeof align === "string" && align.length > 0 ? ` style="text-align:${escapeHtml(align)}"` : "";
|
||||
}
|
||||
|
||||
// fontSize é mark (aplicado por trecho de texto) da extensão oficial
|
||||
// @tiptap/extension-text-style — vira um <span> em volta do texto marcado.
|
||||
function renderMarks(text: string, marks: TiptapMark[] | undefined): string {
|
||||
let html = escapeHtml(text);
|
||||
for (const mark of marks ?? []) {
|
||||
if (mark.type === "bold") html = `<strong>${html}</strong>`;
|
||||
else if (mark.type === "italic") html = `<em>${html}</em>`;
|
||||
else if (mark.type === "underline") html = `<u>${html}</u>`;
|
||||
else if (mark.type === "strike") html = `<s>${html}</s>`;
|
||||
else if (mark.type === "textStyle" && mark.attrs?.fontSize) {
|
||||
html = `<span style="font-size:${escapeHtml(mark.attrs.fontSize)}">${html}</span>`;
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderInline(nodes: TiptapNode[] | undefined, resolveMergeField?: ResolveMergeField): string {
|
||||
return (nodes ?? [])
|
||||
.map((node) => {
|
||||
if (node.type === "text") return renderMarks(node.text ?? "", node.marks);
|
||||
if (node.type === "mergeField") {
|
||||
const key = String(node.attrs?.key ?? "");
|
||||
if (resolveMergeField) return escapeHtml(resolveMergeField(key));
|
||||
const label = typeof node.attrs?.label === "string" ? node.attrs.label : key;
|
||||
return `<span class="merge-field-chip" data-merge-field="${escapeHtml(key)}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderBlock(node: TiptapNode, resolveMergeField?: ResolveMergeField): string {
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return `<p${textAlignStyle(node)}>${renderInline(node.content, resolveMergeField) || " "}</p>`;
|
||||
case "heading": {
|
||||
const level = typeof node.attrs?.level === "number" ? node.attrs.level : 1;
|
||||
return `<h${level}${textAlignStyle(node)}>${renderInline(node.content, resolveMergeField)}</h${level}>`;
|
||||
}
|
||||
case "bulletList":
|
||||
return `<ul>${(node.content ?? []).map((li) => renderBlock(li, resolveMergeField)).join("")}</ul>`;
|
||||
case "orderedList":
|
||||
return `<ol>${(node.content ?? []).map((li) => renderBlock(li, resolveMergeField)).join("")}</ol>`;
|
||||
case "listItem":
|
||||
return `<li>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</li>`;
|
||||
case "image":
|
||||
return `<img src="${escapeHtml(node.attrs?.src)}" alt="${escapeHtml(node.attrs?.alt ?? "")}" />`;
|
||||
case "pageBreak":
|
||||
return `<div class="page-break" style="break-after:page;page-break-after:always;"></div>`;
|
||||
case "table": {
|
||||
// <thead> de verdade (não só a 1ª linha) — é o que faz o Chromium
|
||||
// repetir o cabeçalho da tabela em toda página nova quando ela
|
||||
// atravessa uma quebra.
|
||||
const rows = node.content ?? [];
|
||||
const headerRows = rows.filter((r) => r.content?.[0]?.type === "tableHeader");
|
||||
const bodyRows = rows.filter((r) => r.content?.[0]?.type !== "tableHeader");
|
||||
const theadHtml = headerRows.length
|
||||
? `<thead>${headerRows.map((r) => renderBlock(r, resolveMergeField)).join("")}</thead>`
|
||||
: "";
|
||||
const tbodyHtml = `<tbody>${bodyRows.map((r) => renderBlock(r, resolveMergeField)).join("")}</tbody>`;
|
||||
return `<table>${theadHtml}${tbodyHtml}</table>`;
|
||||
}
|
||||
case "tableRow":
|
||||
return `<tr>${(node.content ?? []).map((cell) => renderBlock(cell, resolveMergeField)).join("")}</tr>`;
|
||||
case "tableHeader": {
|
||||
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
|
||||
return `<th${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</th>`;
|
||||
}
|
||||
case "tableCell": {
|
||||
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
|
||||
return `<td${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</td>`;
|
||||
}
|
||||
case "conditional":
|
||||
// Envelope em volta de uma `tableRow` (attrs: field/operator/value) —
|
||||
// avaliação real da condição contra o contexto (eden.md §3.2,
|
||||
// `applyConditionals`) é fora de escopo desta leva (ver
|
||||
// docs/progress.md): a linha sempre passa adiante, igual ao
|
||||
// comportamento do próprio legado quando não avaliada.
|
||||
return (node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isTiptapDoc(value: unknown): value is { content?: TiptapNode[] } {
|
||||
return value !== null && typeof value === "object" && (value as { type?: unknown }).type === "doc";
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializa `editor_content` para HTML. Sem `resolveMergeField`, toda
|
||||
* `mergeField` vira o chip visual (é o que fica cacheado em
|
||||
* `document_template_versions.html_content`, eden.md §1.2); com
|
||||
* `resolveMergeField`, cada uma vira o valor final resolvido (usado na
|
||||
* geração real e na pré-visualização de rascunho).
|
||||
*/
|
||||
export function renderToHtml(editorContent: unknown, resolveMergeField?: ResolveMergeField): string {
|
||||
if (!isTiptapDoc(editorContent)) return "";
|
||||
return (editorContent.content ?? []).map((node) => renderBlock(node, resolveMergeField)).join("\n");
|
||||
}
|
||||
|
||||
/** `renderEditorContentToHtml` do stand-in anterior — mantido como nome de import estável para os chamadores existentes. */
|
||||
export function renderEditorContentToHtml(editorContent: unknown): string {
|
||||
return renderToHtml(editorContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Varre a árvore e devolve todas as chaves de `mergeField` usadas no
|
||||
* documento — usado tanto para "Variáveis usadas neste modelo" (frontend)
|
||||
* quanto para achar `missingVariables` na resolução (merge-engine.ts).
|
||||
* Sem suporte a `tableRow.attrs.repeat`/escopo `collection` nesta leva
|
||||
* (fora de escopo — ver docblock do arquivo).
|
||||
*/
|
||||
export function collectMergeFieldKeys(editorContent: unknown): string[] {
|
||||
const keys = new Set<string>();
|
||||
|
||||
function walk(node: TiptapNode | undefined): void {
|
||||
if (!node) return;
|
||||
if (node.type === "mergeField" && typeof node.attrs?.key === "string") {
|
||||
keys.add(node.attrs.key);
|
||||
return;
|
||||
}
|
||||
(node.content ?? []).forEach(walk);
|
||||
}
|
||||
|
||||
if (isTiptapDoc(editorContent)) {
|
||||
(editorContent.content ?? []).forEach(walk);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
29
apps/api/src/documents/merge-engine.ts
Normal file
29
apps/api/src/documents/merge-engine.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Motor de resolução de variáveis — opera direto sobre a árvore Tiptap
|
||||
* (`editor_content`), não mais sobre `{{variavel}}` em texto puro (essa era
|
||||
* a versão stand-in de uma leva anterior, antes do editor visual real
|
||||
* existir). Inspirado em `resolveDocument()` do legado (eden.md §3.2), mas
|
||||
* simplificado: sem nó `conditional`, sem expansão de `tableRow.attrs.repeat`
|
||||
* (fora de escopo — o editor/catálogo desta leva nunca produz nenhum dos
|
||||
* dois) e sem bloquear com `MISSING_REQUIRED_FIELDS`/`UNRESOLVED_VARIABLES`
|
||||
* (HTTP 422) — uma `mergeField` sem valor em `context` vira string vazia e é
|
||||
* só reportada em `missingVariables` (deduplicada, ordem da primeira
|
||||
* ocorrência); o chamador decide o que fazer com isso.
|
||||
*/
|
||||
import { collectMergeFieldKeys, renderToHtml } from "./editor-content-renderer";
|
||||
|
||||
export interface ResolveDocumentResult {
|
||||
/** HTML final, com toda `mergeField` já substituída pelo valor do contexto (ou string vazia, quando ausente). */
|
||||
html: string;
|
||||
/** Chaves referenciadas no documento mas ausentes de `context`, deduplicadas, na ordem de `collectMergeFieldKeys`. */
|
||||
missingVariables: string[];
|
||||
}
|
||||
|
||||
export function resolveDocument(editorContent: unknown, context: Record<string, string>): ResolveDocumentResult {
|
||||
const keys = collectMergeFieldKeys(editorContent);
|
||||
const missingVariables = keys.filter((key) => !Object.prototype.hasOwnProperty.call(context, key));
|
||||
|
||||
const html = renderToHtml(editorContent, (key) => context[key] ?? "");
|
||||
|
||||
return { html, missingVariables };
|
||||
}
|
||||
172
apps/api/src/documents/variable-catalog.ts
Normal file
172
apps/api/src/documents/variable-catalog.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Catálogo fechado de variáveis (whitelist) — porta de `VARIABLE_CATALOG`
|
||||
* em `.tmp/orcafacil-reference/server/src/lib/documentTemplates/variableRegistry.js`,
|
||||
* só as entradas `scope: 'document'` (sem `item.*`/`pdf.*` — resolução por
|
||||
* collection/renderer está fora de escopo desta leva, ver docs/progress.md).
|
||||
*
|
||||
* O editor nunca deixa o usuário digitar uma chave livre, só escolher daqui
|
||||
* — cada `mergeField` inserido carrega `key`/`label` vindos de uma entrada
|
||||
* deste catálogo. Servido também ao frontend via
|
||||
* `GET /v1/documents/variable-catalog` para montar o menu "Inserir
|
||||
* variável" e o painel "Variáveis usadas neste modelo" sem duplicar a lista
|
||||
* à mão nos dois lados.
|
||||
*/
|
||||
|
||||
export interface VariableCatalogEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
function cedenteCessionarioEntries(): VariableCatalogEntry[] {
|
||||
return (["cedente", "cessionario"] as const).flatMap((prefix) => {
|
||||
const label = prefix === "cedente" ? "Cedente" : "Cessionária";
|
||||
return [
|
||||
{ key: `${prefix}.razao_social`, label: `Razão Social (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.cnpj`, label: `CNPJ/CPF (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.email`, label: `E-mail (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.endereco`, label: `Endereço (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.bairro`, label: `Bairro (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.cidade`, label: `Cidade (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.uf`, label: `UF (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.cep`, label: `CEP (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.responsavel_nome`, label: `Nome do Responsável (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.responsavel_cpf`, label: `CPF do Responsável (${label})`, category: label, required: false },
|
||||
{
|
||||
key: `${prefix}.responsavel_telefone`,
|
||||
label: `Telefone do Responsável (${label})`,
|
||||
category: label,
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
|
||||
// CLIENTE — client_registrations (PF/PJ) OU resellers, dependendo da origem do documento.
|
||||
{ key: "cliente.razao_social", label: "Razão Social", category: "Cliente", required: true },
|
||||
{ key: "cliente.nome_fantasia", label: "Nome Fantasia", category: "Cliente", required: false },
|
||||
{ key: "cliente.cnpj", label: "CNPJ/CPF", category: "Cliente", required: true },
|
||||
{ key: "cliente.email", label: "E-mail", category: "Cliente", required: false },
|
||||
{ key: "cliente.telefone", label: "Telefone", category: "Cliente", required: false },
|
||||
{ key: "cliente.cep", label: "CEP", category: "Cliente", required: false },
|
||||
{ key: "cliente.endereco", label: "Endereço", category: "Cliente", required: true },
|
||||
{ key: "cliente.complemento", label: "Complemento", category: "Cliente", required: false },
|
||||
{ key: "cliente.bairro", label: "Bairro", category: "Cliente", required: false },
|
||||
{ key: "cliente.cidade", label: "Cidade", category: "Cliente", required: false },
|
||||
{ key: "cliente.uf", label: "UF", category: "Cliente", required: false },
|
||||
{ key: "cliente.ie_rg", label: "IE/RG", category: "Cliente", required: false },
|
||||
{ key: "cliente.responsavel_nome", label: "Nome do Responsável", category: "Cliente", required: false },
|
||||
{ key: "cliente.responsavel_cpf", label: "CPF do Responsável", category: "Cliente", required: false },
|
||||
{ key: "cliente.responsavel_telefone", label: "Telefone do Responsável", category: "Cliente", required: false },
|
||||
{ key: "cliente.celular_pf", label: "Celular (pessoa física)", category: "Cliente", required: false },
|
||||
|
||||
...cedenteCessionarioEntries(),
|
||||
|
||||
// EMPRESA EMISSORA
|
||||
{ key: "empresa.razao_social", label: "Razão Social da Empresa", category: "Empresa", required: true },
|
||||
{ key: "empresa.nome_fantasia", label: "Nome Fantasia da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.cnpj", label: "CNPJ da Empresa", category: "Empresa", required: true },
|
||||
{ key: "empresa.ie", label: "Inscrição Estadual da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.im", label: "Inscrição Municipal da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.endereco", label: "Endereço da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.bairro", label: "Bairro da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.cidade", label: "Cidade da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.uf", label: "UF da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.cep", label: "CEP da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.cidade_uf", label: "Cidade/UF da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.telefone", label: "Telefone da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.email", label: "E-mail da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.website", label: "Site da Empresa", category: "Empresa", required: false },
|
||||
|
||||
// OFERTA — tabela quotes.
|
||||
{ key: "oferta.numero", label: "Número da Oferta", category: "Oferta", required: false },
|
||||
{ key: "oferta.data", label: "Data da Oferta", category: "Oferta", required: false },
|
||||
{ key: "oferta.validade", label: "Validade da Oferta", category: "Oferta", required: false },
|
||||
{ key: "oferta.fidelidade", label: "Fidelidade (meses)", category: "Oferta", required: false },
|
||||
{ key: "oferta.valor_mensal", label: "Valor Mensal", category: "Oferta", required: false },
|
||||
{ key: "oferta.valor_implantacao", label: "Valor de Implantação", category: "Oferta", required: false },
|
||||
{ key: "oferta.valor_total", label: "Valor Total do Contrato", category: "Oferta", required: false },
|
||||
{ key: "oferta.nome_comercial", label: "Nome Comercial da Oferta", category: "Oferta", required: false },
|
||||
{ key: "oferta.vigencia_texto", label: "Prazo de Vigência (texto)", category: "Oferta", required: false },
|
||||
{ key: "oferta.destinacao", label: "Destinação (Corporativo/Residencial)", category: "Oferta", required: false },
|
||||
{ key: "oferta.combo", label: "Contratação em Combo (Sim/Não)", category: "Oferta", required: false },
|
||||
{
|
||||
key: "oferta.permanencia_aplicavel",
|
||||
label: "Prazo de Permanência Aplicável (Sim/Não)",
|
||||
category: "Oferta",
|
||||
required: false,
|
||||
},
|
||||
{ key: "oferta.franquia_minutos", label: "Franquia de Minutos", category: "Oferta", required: false },
|
||||
{ key: "oferta.desconto_fidelidade", label: "Desconto por Fidelidade", category: "Oferta", required: false },
|
||||
{ key: "oferta.total_beneficios", label: "Total de Benefícios (VTB)", category: "Oferta", required: false },
|
||||
{ key: "oferta.comercializacao_inicio", label: "Início do Prazo de Comercialização", category: "Oferta", required: false },
|
||||
{ key: "oferta.comercializacao_fim", label: "Fim do Prazo de Comercialização", category: "Oferta", required: false },
|
||||
|
||||
// 0800 — sem cadastro próprio, preenchidos manualmente na geração.
|
||||
{ key: "oferta.inclui_0800", label: "Inclui Número 0800? (Sim/Não)", category: "Oferta", required: false },
|
||||
{ key: "oferta.numero_0800", label: "Número 0800", category: "Oferta", required: false },
|
||||
{ key: "oferta.franquia_0800_min", label: "Franquia Mensal de 0800 (min)", category: "Oferta", required: false },
|
||||
{ key: "oferta.valor_0800_padrao", label: "Valor 0800 — Padrão (por minuto)", category: "Oferta", required: false },
|
||||
{ key: "oferta.valor_0800_reduzido", label: "Valor 0800 — Reduzido (por minuto)", category: "Oferta", required: false },
|
||||
|
||||
// Tabela de tarifação STFC — automática a partir de products.tariff_rates do produto medido da oferta.
|
||||
...(["lc", "ldn", "vc1", "vc2", "vc3", "ldi"] as const).flatMap((t) => [
|
||||
{ key: `oferta.tarifa_${t}_normal`, label: `Tarifa ${t.toUpperCase()} — Normal (por minuto)`, category: "Oferta", required: false },
|
||||
{ key: `oferta.tarifa_${t}_reduzida`, label: `Tarifa ${t.toUpperCase()} — Reduzida (por minuto)`, category: "Oferta", required: false },
|
||||
]),
|
||||
|
||||
// CONTRATO — client_registrations.ixc_contract_number + quotes.contract_period/fidelity_period.
|
||||
{ key: "contrato.numero", label: "Número do Contrato IXC", category: "Contrato", required: false },
|
||||
{ key: "contrato.codigo_cliente_ixc", label: "Código do Cliente IXC", category: "Contrato", required: false },
|
||||
{ key: "contrato.vigencia_meses", label: "Vigência (meses)", category: "Contrato", required: false },
|
||||
{ key: "contrato.data_inicio", label: "Início da Vigência", category: "Contrato", required: false },
|
||||
{ key: "contrato.data_termino", label: "Fim da Vigência", category: "Contrato", required: false },
|
||||
{ key: "contrato.software_nome", label: "Nome do Software", category: "Contrato", required: false },
|
||||
{ key: "contrato.numeros_portar", label: "Números a Portar", category: "Contrato", required: false },
|
||||
{ key: "contrato.quantidade_portar", label: "Quantidade de Números a Portar", category: "Contrato", required: false },
|
||||
{ key: "contrato.prazo_instalacao", label: "Prazo de Instalação", category: "Contrato", required: false },
|
||||
|
||||
// PORTABILIDADE / CESSÃO — sem cadastro próprio, preenchidos manualmente na geração.
|
||||
{ key: "portabilidade.prestadora_doadora", label: "Prestadora Doadora", category: "Portabilidade", required: false },
|
||||
{ key: "portabilidade.cnpj_doadora", label: "CNPJ da Doadora", category: "Portabilidade", required: false },
|
||||
{
|
||||
key: "portabilidade.titularidade_doadora",
|
||||
label: "Titularidade na Doadora",
|
||||
category: "Portabilidade",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "portabilidade.titular_cpf_cnpj_doadora",
|
||||
label: "CNPJ/CPF do Titular na Doadora",
|
||||
category: "Portabilidade",
|
||||
required: false,
|
||||
},
|
||||
{ key: "cessao.codigo_acesso_cedido", label: "Código(s) de Acesso Cedido(s)", category: "Cessão", required: false },
|
||||
{ key: "cessao.numeros_contratados", label: "Números Contratados", category: "Cessão", required: false },
|
||||
|
||||
// ASSINATURA — leitura do módulo de assinatura eletrônica já existente.
|
||||
{ key: "assinatura.signatario_nome", label: "Nome do Signatário", category: "Assinatura", required: false },
|
||||
{ key: "assinatura.signatario_cpf", label: "CPF do Signatário", category: "Assinatura", required: false },
|
||||
{ key: "assinatura.signatario_email", label: "E-mail do Signatário", category: "Assinatura", required: false },
|
||||
{ key: "assinatura.data", label: "Data da Assinatura", category: "Assinatura", required: false },
|
||||
{ key: "assinatura.envelope_numero", label: "Número do Envelope", category: "Assinatura", required: false },
|
||||
{
|
||||
key: "assinatura.codigo_autenticidade",
|
||||
label: "Código de Autenticidade",
|
||||
category: "Assinatura",
|
||||
required: false,
|
||||
},
|
||||
|
||||
// DOCUMENTO — metadados do próprio documento sendo gerado.
|
||||
{ key: "documento.numero", label: "Número do Documento", category: "Documento", required: false },
|
||||
{ key: "documento.ano_atual", label: "Ano Atual", category: "Documento", required: false },
|
||||
{ key: "documento.data_geracao", label: "Data de Geração", category: "Documento", required: false },
|
||||
{
|
||||
key: "documento.data_geracao_extenso",
|
||||
label: "Data de Geração (por extenso)",
|
||||
category: "Documento",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
21
apps/api/src/fiscal/dto/create-cfop.dto.ts
Normal file
21
apps/api/src/fiscal/dto/create-cfop.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/**
|
||||
* direction/scope are free-text, no CHECK/enum — exactly as the legacy
|
||||
* (eden.md §4): the CFOP code's 1st digit encodes direction+scope in
|
||||
* practice, but neither this DTO nor the schema derives it automatically.
|
||||
*/
|
||||
export class CreateCfopDto {
|
||||
@IsString() code!: string;
|
||||
@IsString() description!: string;
|
||||
|
||||
@IsOptional() @IsString() direction?: string;
|
||||
@IsOptional() @IsString() scope?: string;
|
||||
@IsOptional() @IsDateString() validFrom?: string;
|
||||
@IsOptional() @IsDateString() validUntil?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
12
apps/api/src/fiscal/dto/create-municipality.dto.ts
Normal file
12
apps/api/src/fiscal/dto/create-municipality.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
/** fiscal_municipality has NO valid_from/valid_until/metadata — eden.md §2.3 ("não faz sentido um município expirar"). */
|
||||
export class CreateMunicipalityDto {
|
||||
@IsString() code!: string;
|
||||
@IsString() name!: string;
|
||||
@IsString() @Length(2, 2) state!: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
}
|
||||
14
apps/api/src/fiscal/dto/create-ncm.dto.ts
Normal file
14
apps/api/src/fiscal/dto/create-ncm.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CreateNcmDto {
|
||||
@IsString() code!: string;
|
||||
@IsString() description!: string;
|
||||
|
||||
@IsOptional() @IsDateString() validFrom?: string;
|
||||
@IsOptional() @IsDateString() validUntil?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
23
apps/api/src/fiscal/dto/create-product-fiscal-profile.dto.ts
Normal file
23
apps/api/src/fiscal/dto/create-product-fiscal-profile.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsDateString, IsIn, IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
/**
|
||||
* Creates a new ACTIVE profile for a (productId, billingComponent) pair.
|
||||
* If one is already active, the service inactivates it and creates this
|
||||
* one in the same transaction (eden.md §2.5's central invariant) — never a
|
||||
* destructive update of the old profile.
|
||||
*/
|
||||
export class CreateProductFiscalProfileDto {
|
||||
@IsIn(["RECURRING", "IMPLEMENTATION"])
|
||||
billingComponent!: "RECURRING" | "IMPLEMENTATION";
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["NFCOM", "NFSE", "NFE", "NONE"])
|
||||
documentType?: "NFCOM" | "NFSE" | "NFE" | "NONE";
|
||||
|
||||
@IsOptional() @IsUUID() ncmId?: string;
|
||||
@IsOptional() @IsUUID() cfopId?: string;
|
||||
@IsOptional() @IsUUID() municipalityId?: string;
|
||||
@IsOptional() @IsUUID() operationNatureId?: string;
|
||||
@IsOptional() @IsDateString() validFrom?: string;
|
||||
@IsOptional() @IsDateString() validUntil?: string;
|
||||
}
|
||||
15
apps/api/src/fiscal/dto/update-cfop.dto.ts
Normal file
15
apps/api/src/fiscal/dto/update-cfop.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateCfopDto {
|
||||
@IsOptional() @IsString() code?: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsString() direction?: string;
|
||||
@IsOptional() @IsString() scope?: string;
|
||||
@IsOptional() @IsDateString() validFrom?: string;
|
||||
@IsOptional() @IsDateString() validUntil?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
10
apps/api/src/fiscal/dto/update-municipality.dto.ts
Normal file
10
apps/api/src/fiscal/dto/update-municipality.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
export class UpdateMunicipalityDto {
|
||||
@IsOptional() @IsString() code?: string;
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() @Length(2, 2) state?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
}
|
||||
14
apps/api/src/fiscal/dto/update-ncm.dto.ts
Normal file
14
apps/api/src/fiscal/dto/update-ncm.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** All fields optional — PATCH is a partial update, including flipping `active` alone. */
|
||||
export class UpdateNcmDto {
|
||||
@IsOptional() @IsString() code?: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsDateString() validFrom?: string;
|
||||
@IsOptional() @IsDateString() validUntil?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceVersion?: string;
|
||||
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
142
apps/api/src/fiscal/fiscal.controller.ts
Normal file
142
apps/api/src/fiscal/fiscal.controller.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { FiscalService } from "./fiscal.service.js";
|
||||
import { CreateNcmDto } from "./dto/create-ncm.dto.js";
|
||||
import { UpdateNcmDto } from "./dto/update-ncm.dto.js";
|
||||
import { CreateCfopDto } from "./dto/create-cfop.dto.js";
|
||||
import { UpdateCfopDto } from "./dto/update-cfop.dto.js";
|
||||
import { CreateMunicipalityDto } from "./dto/create-municipality.dto.js";
|
||||
import { UpdateMunicipalityDto } from "./dto/update-municipality.dto.js";
|
||||
import { CreateProductFiscalProfileDto } from "./dto/create-product-fiscal-profile.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* Fiscal catalogs + product fiscal profiles (eden.md módulo 5). Uses the
|
||||
* same `fiscal` permission feature as the legacy, with `view`/`edit`
|
||||
* levels (eden.md §8). No bulk/mass operations in this MVP — writes always
|
||||
* touch a single catalog row, never a whole table.
|
||||
*/
|
||||
@ApiTags("fiscal")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/fiscal")
|
||||
export class FiscalController {
|
||||
constructor(private readonly fiscal: FiscalService) {}
|
||||
|
||||
// -- fiscal_ncm ---------------------------------------------------------
|
||||
|
||||
@Get("ncm")
|
||||
@RequirePermission("fiscal", "view")
|
||||
listNcm(@Query("q") q?: string, @Query("active") active?: string) {
|
||||
return this.fiscal.listNcm(q, parseActive(active));
|
||||
}
|
||||
|
||||
@Get("ncm/:id")
|
||||
@RequirePermission("fiscal", "view")
|
||||
getNcm(@Param("id") id: string) {
|
||||
return this.fiscal.getNcm(id);
|
||||
}
|
||||
|
||||
@Post("ncm")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
createNcm(@Body() dto: CreateNcmDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.fiscal.createNcm(dto, user);
|
||||
}
|
||||
|
||||
@Patch("ncm/:id")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
updateNcm(@Param("id") id: string, @Body() dto: UpdateNcmDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.fiscal.updateNcm(id, dto, user);
|
||||
}
|
||||
|
||||
// -- fiscal_cfop ----------------------------------------------------------
|
||||
|
||||
@Get("cfop")
|
||||
@RequirePermission("fiscal", "view")
|
||||
listCfop(@Query("q") q?: string, @Query("active") active?: string) {
|
||||
return this.fiscal.listCfop(q, parseActive(active));
|
||||
}
|
||||
|
||||
@Get("cfop/:id")
|
||||
@RequirePermission("fiscal", "view")
|
||||
getCfop(@Param("id") id: string) {
|
||||
return this.fiscal.getCfop(id);
|
||||
}
|
||||
|
||||
@Post("cfop")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
createCfop(@Body() dto: CreateCfopDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.fiscal.createCfop(dto, user);
|
||||
}
|
||||
|
||||
@Patch("cfop/:id")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
updateCfop(@Param("id") id: string, @Body() dto: UpdateCfopDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.fiscal.updateCfop(id, dto, user);
|
||||
}
|
||||
|
||||
// -- fiscal_municipality --------------------------------------------------
|
||||
|
||||
@Get("municipalities")
|
||||
@RequirePermission("fiscal", "view")
|
||||
listMunicipalities(@Query("q") q?: string, @Query("active") active?: string) {
|
||||
return this.fiscal.listMunicipalities(q, parseActive(active));
|
||||
}
|
||||
|
||||
@Get("municipalities/:id")
|
||||
@RequirePermission("fiscal", "view")
|
||||
getMunicipality(@Param("id") id: string) {
|
||||
return this.fiscal.getMunicipality(id);
|
||||
}
|
||||
|
||||
@Post("municipalities")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
createMunicipality(@Body() dto: CreateMunicipalityDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.fiscal.createMunicipality(dto, user);
|
||||
}
|
||||
|
||||
@Patch("municipalities/:id")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
updateMunicipality(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateMunicipalityDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.fiscal.updateMunicipality(id, dto, user);
|
||||
}
|
||||
|
||||
// -- fiscal_operation_nature — read-only in this MVP -----------------------
|
||||
|
||||
@Get("operation-natures")
|
||||
@RequirePermission("fiscal", "view")
|
||||
listOperationNatures() {
|
||||
return this.fiscal.listOperationNatures();
|
||||
}
|
||||
|
||||
// -- product_fiscal_profiles ----------------------------------------------
|
||||
|
||||
@Get("products/:productId/profiles")
|
||||
@RequirePermission("fiscal", "view")
|
||||
listProfiles(@Param("productId") productId: string) {
|
||||
return this.fiscal.listProfiles(productId);
|
||||
}
|
||||
|
||||
@Post("products/:productId/profiles")
|
||||
@RequirePermission("fiscal", "edit")
|
||||
createProfile(
|
||||
@Param("productId") productId: string,
|
||||
@Body() dto: CreateProductFiscalProfileDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.fiscal.createProfile(productId, dto, user);
|
||||
}
|
||||
}
|
||||
|
||||
function parseActive(value?: string): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return value === "true";
|
||||
}
|
||||
12
apps/api/src/fiscal/fiscal.module.ts
Normal file
12
apps/api/src/fiscal/fiscal.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { FiscalController } from "./fiscal.controller.js";
|
||||
import { FiscalService } from "./fiscal.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [FiscalController],
|
||||
providers: [FiscalService],
|
||||
exports: [FiscalService],
|
||||
})
|
||||
export class FiscalModule {}
|
||||
219
apps/api/src/fiscal/fiscal.service.ts
Normal file
219
apps/api/src/fiscal/fiscal.service.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
fiscalRepo,
|
||||
productsRepo,
|
||||
type FiscalCfop,
|
||||
type FiscalMunicipality,
|
||||
type FiscalNcm,
|
||||
type FiscalOperationNature,
|
||||
type ProductFiscalProfile,
|
||||
} from "@eden/database";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import type { CreateNcmDto } from "./dto/create-ncm.dto.js";
|
||||
import type { UpdateNcmDto } from "./dto/update-ncm.dto.js";
|
||||
import type { CreateCfopDto } from "./dto/create-cfop.dto.js";
|
||||
import type { UpdateCfopDto } from "./dto/update-cfop.dto.js";
|
||||
import type { CreateMunicipalityDto } from "./dto/create-municipality.dto.js";
|
||||
import type { UpdateMunicipalityDto } from "./dto/update-municipality.dto.js";
|
||||
import type { CreateProductFiscalProfileDto } from "./dto/create-product-fiscal-profile.dto.js";
|
||||
|
||||
/**
|
||||
* Fiscal catalogs (eden.md módulo 5) — see PENDING_fiscal.cjs migration
|
||||
* header for the MVP scope. Read (`fiscal:view`) is open to any
|
||||
* authenticated user holding that grant; write (`fiscal:edit`) creates or
|
||||
* PATCHes a single catalog row — no bulk/mass operations in this MVP.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FiscalService {
|
||||
// ---------------------------------------------------------------------
|
||||
// fiscal_ncm
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listNcm(q?: string, active?: boolean): Promise<FiscalNcm[]> {
|
||||
return fiscalRepo.listNcm({ q, active });
|
||||
}
|
||||
|
||||
async getNcm(id: string): Promise<FiscalNcm> {
|
||||
const row = await fiscalRepo.getNcmById(id);
|
||||
if (!row) throw new NotFoundException("NCM não encontrado");
|
||||
return row;
|
||||
}
|
||||
|
||||
async createNcm(dto: CreateNcmDto, user: EdenAuthenticatedUser): Promise<FiscalNcm> {
|
||||
const row = await fiscalRepo.createNcm(dto);
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.ncm.created",
|
||||
resourceType: "fiscal_ncm",
|
||||
resourceId: row.id,
|
||||
metadata: { code: row.code },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateNcm(id: string, dto: UpdateNcmDto, user: EdenAuthenticatedUser): Promise<FiscalNcm> {
|
||||
const row = await fiscalRepo.updateNcm(id, dto);
|
||||
if (!row) throw new NotFoundException("NCM não encontrado");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.ncm.updated",
|
||||
resourceType: "fiscal_ncm",
|
||||
resourceId: row.id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// fiscal_cfop
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listCfop(q?: string, active?: boolean): Promise<FiscalCfop[]> {
|
||||
return fiscalRepo.listCfop({ q, active });
|
||||
}
|
||||
|
||||
async getCfop(id: string): Promise<FiscalCfop> {
|
||||
const row = await fiscalRepo.getCfopById(id);
|
||||
if (!row) throw new NotFoundException("CFOP não encontrado");
|
||||
return row;
|
||||
}
|
||||
|
||||
async createCfop(dto: CreateCfopDto, user: EdenAuthenticatedUser): Promise<FiscalCfop> {
|
||||
const row = await fiscalRepo.createCfop(dto);
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.cfop.created",
|
||||
resourceType: "fiscal_cfop",
|
||||
resourceId: row.id,
|
||||
metadata: { code: row.code },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateCfop(id: string, dto: UpdateCfopDto, user: EdenAuthenticatedUser): Promise<FiscalCfop> {
|
||||
const row = await fiscalRepo.updateCfop(id, dto);
|
||||
if (!row) throw new NotFoundException("CFOP não encontrado");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.cfop.updated",
|
||||
resourceType: "fiscal_cfop",
|
||||
resourceId: row.id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// fiscal_municipality
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listMunicipalities(q?: string, active?: boolean): Promise<FiscalMunicipality[]> {
|
||||
return fiscalRepo.listMunicipalities({ q, active });
|
||||
}
|
||||
|
||||
async getMunicipality(id: string): Promise<FiscalMunicipality> {
|
||||
const row = await fiscalRepo.getMunicipalityById(id);
|
||||
if (!row) throw new NotFoundException("Município não encontrado");
|
||||
return row;
|
||||
}
|
||||
|
||||
async createMunicipality(
|
||||
dto: CreateMunicipalityDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<FiscalMunicipality> {
|
||||
const row = await fiscalRepo.createMunicipality(dto);
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.municipality.created",
|
||||
resourceType: "fiscal_municipality",
|
||||
resourceId: row.id,
|
||||
metadata: { code: row.code },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateMunicipality(
|
||||
id: string,
|
||||
dto: UpdateMunicipalityDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<FiscalMunicipality> {
|
||||
const row = await fiscalRepo.updateMunicipality(id, dto);
|
||||
if (!row) throw new NotFoundException("Município não encontrado");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.municipality.updated",
|
||||
resourceType: "fiscal_municipality",
|
||||
resourceId: row.id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// fiscal_operation_nature — read-only in this MVP (fixed business enum,
|
||||
// seeded by the migration)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listOperationNatures(): Promise<FiscalOperationNature[]> {
|
||||
return fiscalRepo.listOperationNatures();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// product_fiscal_profiles
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listProfiles(productId: string): Promise<ProductFiscalProfile[]> {
|
||||
await this.assertProductExists(productId);
|
||||
return fiscalRepo.listProductFiscalProfiles(productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ACTIVE profile for (productId, billingComponent). If one
|
||||
* is already active, the repository layer inactivates it and creates the
|
||||
* new one in a single transaction — never a destructive update of the
|
||||
* old profile (eden.md §2.5).
|
||||
*/
|
||||
async createProfile(
|
||||
productId: string,
|
||||
dto: CreateProductFiscalProfileDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<ProductFiscalProfile> {
|
||||
await this.assertProductExists(productId);
|
||||
|
||||
const created = await fiscalRepo.createActiveProductFiscalProfile({
|
||||
productId,
|
||||
billingComponent: dto.billingComponent,
|
||||
documentType: dto.documentType,
|
||||
ncmId: dto.ncmId ?? null,
|
||||
cfopId: dto.cfopId ?? null,
|
||||
municipalityId: dto.municipalityId ?? null,
|
||||
operationNatureId: dto.operationNatureId ?? null,
|
||||
validFrom: dto.validFrom ?? null,
|
||||
validUntil: dto.validUntil ?? null,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "fiscal.product_profile.created",
|
||||
resourceType: "product_fiscal_profile",
|
||||
resourceId: created.id,
|
||||
metadata: { productId, billingComponent: dto.billingComponent, documentType: created.document_type },
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async assertProductExists(productId: string): Promise<void> {
|
||||
const product = await productsRepo.getProductById(productId);
|
||||
if (!product) throw new NotFoundException("Produto não encontrado");
|
||||
}
|
||||
}
|
||||
35
apps/api/src/hr-timeclock/afd-imports.controller.ts
Normal file
35
apps/api/src/hr-timeclock/afd-imports.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { AfdImportsService } from "./afd-imports.service.js";
|
||||
import { CreateAfdImportDto } from "./dto/create-afd-import.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* eden.md módulo 6 §4.4/§4.5 — AFD import. See afd-imports.service.ts and
|
||||
* CreateAfdImportDto for this leva's transport simplification (JSON
|
||||
* string body, not multipart) and its documented encoding caveat.
|
||||
*/
|
||||
@ApiTags("hr-timeclock-afd-imports")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
|
||||
@Controller("v1/hr-timeclock/afd-imports")
|
||||
export class AfdImportsController {
|
||||
constructor(private readonly afdImports: AfdImportsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("afd-import", "edit")
|
||||
import(@Body() dto: CreateAfdImportDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.afdImports.import(dto, user);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("afd-import", "view")
|
||||
get(@Param("id") id: string) {
|
||||
return this.afdImports.getById(id);
|
||||
}
|
||||
}
|
||||
202
apps/api/src/hr-timeclock/afd-imports.service.ts
Normal file
202
apps/api/src/hr-timeclock/afd-imports.service.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
hrTimeclockRepo,
|
||||
type AfdImportBatch,
|
||||
type AfdImportBatchStatus,
|
||||
type TimeclockEmployee,
|
||||
} from "@eden/database";
|
||||
import { parseAfdFile, normalizeAfdCpf, type AfdParseSummary } from "./afd-parser.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import type { CreateAfdImportDto } from "./dto/create-afd-import.dto.js";
|
||||
|
||||
export interface ImportAfdFileResult {
|
||||
alreadyImported: boolean;
|
||||
batch: AfdImportBatch;
|
||||
summary?: AfdParseSummary;
|
||||
}
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && (err as { code?: unknown }).code === "23505";
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AfdImportsService {
|
||||
/**
|
||||
* The whole AFD import orchestration: whole-file idempotency (SHA-256),
|
||||
* pure parsing (afd-parser.ts — never touches the DB), best-effort
|
||||
* employee resolution by CPF (never guesses — ambiguous/unmatched stays
|
||||
* null), then the transactional write (hrTimeclockRepo.importAfdBatch,
|
||||
* which does the per-device locking + per-record idempotency).
|
||||
*
|
||||
* ENCODING CAVEAT (see afd-parser.ts's header): `dto.fileContent` is
|
||||
* expected to already be decoded as ISO-8859-1 by the caller — this
|
||||
* leva's transport is a JSON string, not a raw multipart upload.
|
||||
*/
|
||||
async import(dto: CreateAfdImportDto, user: EdenAuthenticatedUser): Promise<ImportAfdFileResult> {
|
||||
const device = await hrTimeclockRepo.getTimeclockDeviceById(dto.deviceId);
|
||||
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
|
||||
|
||||
// Whole-file idempotency layer (eden.md módulo 6 §12) — hash is over
|
||||
// the content AS RECEIVED by this endpoint (see migration comment on
|
||||
// afd_import_batches.file_hash for why this isn't necessarily
|
||||
// byte-identical to a raw .afd file's own hash in this leva's
|
||||
// string-transport simplification).
|
||||
const fileHash = createHash("sha256").update(dto.fileContent, "utf8").digest("hex");
|
||||
const fileSize = Buffer.byteLength(dto.fileContent, "utf8");
|
||||
|
||||
const existing = await hrTimeclockRepo.findAfdImportBatchByFileHash(fileHash);
|
||||
if (existing) {
|
||||
throw new ConflictException({
|
||||
message: "Este arquivo já foi importado anteriormente (idempotência por SHA-256)",
|
||||
alreadyImported: true,
|
||||
importId: existing.id,
|
||||
status: existing.status,
|
||||
});
|
||||
}
|
||||
|
||||
const parseResult = parseAfdFile(dto.fileContent);
|
||||
|
||||
const employeeIdByCpf = await this.resolveEmployeeIds(device.company_id, parseResult.records.map((r) => r.employeeCpfRaw));
|
||||
|
||||
const byStatus = parseResult.summary.byValidationStatus;
|
||||
const validCount = byStatus.VALID ?? 0;
|
||||
const warningCount = byStatus.WARNING ?? 0;
|
||||
const invalidCount = byStatus.INVALID ?? 0;
|
||||
const unsupportedCount = byStatus.UNSUPPORTED_LAYOUT ?? 0;
|
||||
const parseFailureCount = parseResult.failures.length;
|
||||
|
||||
// Batch-level status: INVALID when nothing usable came out of the
|
||||
// file at all; WARNING when the file is usable but has some
|
||||
// invalid/unsupported/failed lines; VALID only when everything parsed
|
||||
// clean. Never silently treated as fully VALID when problems exist.
|
||||
const status: AfdImportBatchStatus =
|
||||
validCount === 0 ? "INVALID" : invalidCount + unsupportedCount + parseFailureCount > 0 ? "WARNING" : "VALID";
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await hrTimeclockRepo.importAfdBatch({
|
||||
batch: {
|
||||
deviceId: dto.deviceId,
|
||||
companyId: device.company_id,
|
||||
fileHash,
|
||||
fileSize,
|
||||
recordCount: parseResult.summary.parsedCount,
|
||||
validCount,
|
||||
warningCount,
|
||||
invalidCount,
|
||||
unsupportedCount,
|
||||
parseFailureCount,
|
||||
status,
|
||||
firstNsr: parseResult.summary.firstNsr,
|
||||
lastNsr: parseResult.summary.lastNsr,
|
||||
summary: parseResult.summary as unknown as Record<string, unknown>,
|
||||
importedBy: user.id,
|
||||
},
|
||||
records: parseResult.records.map((r) => ({
|
||||
nsr: r.nsr,
|
||||
recordType: r.recordType,
|
||||
eventDatetime: r.eventDatetime,
|
||||
eventDatetimeRaw: r.eventDatetimeRaw,
|
||||
employeeCpfRaw: r.employeeCpfRaw,
|
||||
employeeId: employeeIdByCpf.get(r.employeeCpfRaw ?? "") ?? null,
|
||||
rawLine: r.rawLine,
|
||||
crcReceived: r.crcReceived,
|
||||
crcCalculated: r.crcCalculated,
|
||||
crcValid: r.crcValid,
|
||||
parsedData: r.parsedData,
|
||||
validationStatus: r.validationStatus,
|
||||
validationNotes: r.validationNotes,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof hrTimeclockRepo.TimeclockDeviceNotFoundError) {
|
||||
throw new NotFoundException("Equipamento de ponto não encontrado");
|
||||
}
|
||||
if (isUniqueViolation(err)) {
|
||||
// Race: another request imported the same file_hash between our
|
||||
// check above and this INSERT. Never duplicate — report the
|
||||
// existing batch exactly like the up-front check would have.
|
||||
const raced = await hrTimeclockRepo.findAfdImportBatchByFileHash(fileHash);
|
||||
if (raced) {
|
||||
throw new ConflictException({
|
||||
message: "Este arquivo já foi importado anteriormente (idempotência por SHA-256)",
|
||||
alreadyImported: true,
|
||||
importId: raced.id,
|
||||
status: raced.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Import itself is a mutation — audited like any other, per the task
|
||||
// brief's explicit instruction ("obrigatório em toda mutação incluindo
|
||||
// o próprio import").
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "afd_import.imported",
|
||||
resourceType: "afd_import_batch",
|
||||
resourceId: result.batch.id,
|
||||
metadata: {
|
||||
deviceId: dto.deviceId,
|
||||
fileHash,
|
||||
recordCount: parseResult.summary.parsedCount,
|
||||
insertedCount: result.insertedCount,
|
||||
status,
|
||||
},
|
||||
});
|
||||
|
||||
return { alreadyImported: false, batch: result.batch, summary: parseResult.summary };
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AfdImportBatch> {
|
||||
const batch = await hrTimeclockRepo.getAfdImportBatchById(id);
|
||||
if (!batch) throw new NotFoundException("Importação de AFD não encontrada");
|
||||
return batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort CPF -> timeclock_employees.id resolution. NEVER guesses:
|
||||
* a CPF matched in more than one company when the device has no
|
||||
* company (or vice-versa, ambiguous within the same company — can't
|
||||
* actually happen given the (company_id, cpf) UNIQUE constraint, but
|
||||
* the code doesn't rely on that) is left unresolved (null) rather than
|
||||
* picking arbitrarily. Returns a map keyed by the RAW (unnormalized)
|
||||
* cpf field, since that's what afd_records.employee_cpf_raw stores and
|
||||
* what callers look records up by.
|
||||
*/
|
||||
private async resolveEmployeeIds(
|
||||
deviceCompanyId: string | null,
|
||||
rawCpfs: (string | null)[],
|
||||
): Promise<Map<string, string | null>> {
|
||||
const normalizedByRaw = new Map<string, string>();
|
||||
for (const raw of rawCpfs) {
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeAfdCpf(raw);
|
||||
if (normalized && normalized.length === 11) normalizedByRaw.set(raw, normalized);
|
||||
}
|
||||
|
||||
const distinctNormalized = [...new Set(normalizedByRaw.values())];
|
||||
const candidates = distinctNormalized.length > 0
|
||||
? await hrTimeclockRepo.findTimeclockEmployeesByCpfs(distinctNormalized)
|
||||
: [];
|
||||
|
||||
const candidatesByCpf = new Map<string, TimeclockEmployee[]>();
|
||||
for (const employee of candidates) {
|
||||
const list = candidatesByCpf.get(employee.cpf) ?? [];
|
||||
list.push(employee);
|
||||
candidatesByCpf.set(employee.cpf, list);
|
||||
}
|
||||
|
||||
const result = new Map<string, string | null>();
|
||||
for (const [raw, normalized] of normalizedByRaw) {
|
||||
const matches = candidatesByCpf.get(normalized) ?? [];
|
||||
const scoped = deviceCompanyId ? matches.filter((m) => m.company_id === deviceCompanyId) : matches;
|
||||
result.set(raw, scoped.length === 1 ? scoped[0]!.id : null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
495
apps/api/src/hr-timeclock/afd-parser.ts
Normal file
495
apps/api/src/hr-timeclock/afd-parser.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* AFD (Arquivo Fonte de Dados — Portaria MTP 671/2021, leiaute versão 004)
|
||||
* parser/validator. Ported faithfully from the legacy OrçaFácil
|
||||
* `parser.js`/`crc16.js` — see `eden.md` módulo 6 §4 (linhas ~3660-3771).
|
||||
*
|
||||
* Pure functions only — no I/O, no DB access. This module intentionally
|
||||
* knows nothing about how the caller obtained the file content or what it
|
||||
* will do with the parsed records (persist, preview, whatever).
|
||||
*
|
||||
* SCOPE OF THIS LEVA: parsing + CRC/structural validation only. Nothing
|
||||
* here computes apuração (eden.md §5), fechamento de período (§6), ajustes
|
||||
* (§7), banco de horas (§9) or relatórios (§10) — those are a future leva.
|
||||
*
|
||||
* ── Encoding note (real limitation of this leva's transport, documented
|
||||
* per instruction) ──────────────────────────────────────────────────────
|
||||
* The real AFD file is ISO-8859-1 (latin1), never UTF-8. This leva's HTTP
|
||||
* endpoint accepts the file content as a plain JSON string (multipart
|
||||
* upload is deferred — see afd-imports.controller.ts), which means the
|
||||
* CALLER is responsible for decoding the original file bytes as latin1
|
||||
* into a JS string *before* putting it in the JSON body. If the caller
|
||||
* instead decodes as UTF-8 (or lets some HTTP client guess), any byte
|
||||
* ≥ 0x80 (accented characters in type-2 `razao_social`/`local` fields —
|
||||
* type-3 punch records, the ones that matter for attendance, are plain
|
||||
* ASCII/digits and unaffected) will be corrupted and CRC validation for
|
||||
* that line will legitimately fail (which is the CORRECT behavior for
|
||||
* corrupted input — the parser never silently "fixes" a CRC mismatch).
|
||||
* This module reconstructs bytes for CRC purposes via
|
||||
* `Buffer.from(str, "latin1")`, which round-trips correctly only if the
|
||||
* string was decoded as latin1 to begin with.
|
||||
*
|
||||
* ── Field positions ─────────────────────────────────────────────────────
|
||||
* eden.md documents positions as 1-indexed, inclusive ranges (e.g.
|
||||
* "011-034" = characters 11 through 34, 24 chars wide). Converted here to
|
||||
* JS's 0-indexed, end-exclusive `slice(a-1, b)`. Every record starts with
|
||||
* a 9-digit NSR (positions 001-009) + 1-digit record type (position 010),
|
||||
* i.e. `slice(0, 9)` / `slice(9, 10)` — so every type-specific field list
|
||||
* below picks up at `slice(10, ...)`, matching "011" in the source doc.
|
||||
*
|
||||
* Principle carried over verbatim from the legacy: only fields with an
|
||||
* exact, documented position are decoded. Everything else — undocumented
|
||||
* trailer bytes, unsupported record types, truncated lines — is captured
|
||||
* as `raw_tail` inside `parsedData`, never guessed/invented.
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CRC-16/KERMIT
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* CRC-16/KERMIT: polynomial 0x1021 reflected (used in the bit-reversed
|
||||
* form 0x8408), init 0x0000, no final XOR. Applies to AFD record types
|
||||
* 2/3/4 only — type 7 uses its own hash chain (never recomputed here) and
|
||||
* types 1/5/6/9 have no CRC field at all.
|
||||
*
|
||||
* Known test vector (verified in afd-parser.spec.ts and via an ad-hoc
|
||||
* script before this was wired into the import path): CRC-16/KERMIT of
|
||||
* the ASCII string "123456789" is 0x2189.
|
||||
*/
|
||||
export function crc16Kermit(data: string | Buffer): number {
|
||||
const buffer = typeof data === "string" ? Buffer.from(data, "latin1") : data;
|
||||
let crc = 0x0000;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
crc ^= buffer[i]!;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
if (crc & 0x0001) {
|
||||
crc = (crc >>> 1) ^ 0x8408;
|
||||
} else {
|
||||
crc = crc >>> 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc & 0xffff;
|
||||
}
|
||||
|
||||
/** Zero-padded 4-hex-digit representation, uppercase, matching AFD's own CRC field format. */
|
||||
export function crc16Hex(data: string | Buffer): string {
|
||||
return crc16Kermit(data).toString(16).padStart(4, "0").toUpperCase();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CPF normalization
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The CPF field in AFD types 3/7 is 12 characters wide in the documented
|
||||
* position, but a real CPF is 11 digits — eden.md §4.3: take the LAST 11
|
||||
* numeric digits of the raw field, never assume exact formatting. If
|
||||
* fewer than 11 digits are present (corrupt/short data), returns exactly
|
||||
* what was found — never left-pads with invented zeros, since that would
|
||||
* fabricate digits that were never actually in the file.
|
||||
*/
|
||||
export function normalizeAfdCpf(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
if (digits.length === 0) return null;
|
||||
return digits.length >= 11 ? digits.slice(-11) : digits;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AfdCrcValidity = "VALID" | "INVALID" | "NOT_APPLICABLE" | "NOT_VALIDATED";
|
||||
export type AfdValidationStatus = "VALID" | "WARNING" | "INVALID" | "UNSUPPORTED_LAYOUT";
|
||||
|
||||
export interface ParsedAfdRecord {
|
||||
lineNumber: number;
|
||||
nsr: number | null;
|
||||
nsrRaw: string;
|
||||
recordType: string;
|
||||
eventDatetime: Date | null;
|
||||
eventDatetimeRaw: string | null;
|
||||
employeeCpfRaw: string | null;
|
||||
rawLine: string;
|
||||
crcReceived: string | null;
|
||||
crcCalculated: string | null;
|
||||
crcValid: AfdCrcValidity;
|
||||
/** Type-specific decoded fields (only those with a documented position) + `raw_tail` for the rest. Never guessed. */
|
||||
parsedData: Record<string, unknown>;
|
||||
validationStatus: AfdValidationStatus;
|
||||
validationNotes: string | null;
|
||||
}
|
||||
|
||||
export interface AfdParseFailure {
|
||||
lineNumber: number;
|
||||
rawLine: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface AfdParseSummary {
|
||||
totalLines: number;
|
||||
parsedCount: number;
|
||||
failureCount: number;
|
||||
byRecordType: Record<string, number>;
|
||||
byValidationStatus: Record<string, number>;
|
||||
/** Includes the sentinel trailer NSR (999999999); records whose NSR could not be parsed as a number are excluded. */
|
||||
firstNsr: number | null;
|
||||
lastNsr: number | null;
|
||||
}
|
||||
|
||||
export interface AfdParseResult {
|
||||
records: ParsedAfdRecord[];
|
||||
failures: AfdParseFailure[];
|
||||
summary: AfdParseSummary;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Datetime parsing — AAAA-MM-ddThh:mm:00ZZZZZ (24 chars), e.g. "2026-08-06T08:30:00-0300"
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const AFD_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-]\d{4})$/;
|
||||
|
||||
/**
|
||||
* Parses the fixed-width AFD datetime field. The UTC offset is always
|
||||
* taken verbatim from the text (never assumed/injected from the server's
|
||||
* own timezone) — in practice this is always -0300 (America/Sao_Paulo,
|
||||
* no DST in Brazil since 2019), but this function doesn't hardcode that;
|
||||
* it trusts whatever offset the equipment actually wrote, exactly per the
|
||||
* "never invent" principle. Returns null (never throws) if the 24-char
|
||||
* field doesn't match the documented format — the caller still keeps the
|
||||
* raw text in `eventDatetimeRaw` regardless.
|
||||
*/
|
||||
function parseAfdDatetime(raw: string): Date | null {
|
||||
const match = AFD_DATETIME_RE.exec(raw.trim());
|
||||
if (!match) return null;
|
||||
const [, y, mo, d, h, mi, s, offset] = match;
|
||||
const offsetWithColon = `${offset!.slice(0, 3)}:${offset!.slice(3)}`;
|
||||
const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}${offsetWithColon}`;
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Per-line parsing
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HEADER_LENGTH = 10; // 9-digit NSR + 1-digit record type
|
||||
|
||||
function parseNsr(nsrRaw: string): number | null {
|
||||
// Never default to 0/autoincrement — only a well-formed 9-digit numeric
|
||||
// field becomes a number; anything else stays null (eden.md módulo 6 §12).
|
||||
return /^\d{9}$/.test(nsrRaw) ? Number(nsrRaw) : null;
|
||||
}
|
||||
|
||||
interface CrcCheck {
|
||||
crcReceived: string | null;
|
||||
crcCalculated: string | null;
|
||||
crcValid: AfdCrcValidity;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** CRC is computed over the line bytes up to (not including) the CRC field itself, per eden.md §4.2. */
|
||||
function checkCrc(line: string, dataEnd: number, crcFieldStart: number, crcFieldEnd: number): CrcCheck {
|
||||
if (line.length < crcFieldEnd) {
|
||||
return { crcReceived: null, crcCalculated: null, crcValid: "INVALID", note: "linha mais curta que o esperado para conter o campo de CRC" };
|
||||
}
|
||||
const crcReceived = line.slice(crcFieldStart, crcFieldEnd).trim();
|
||||
const crcCalculated = crc16Hex(line.slice(0, dataEnd));
|
||||
const valid = crcReceived.toUpperCase() === crcCalculated;
|
||||
return {
|
||||
crcReceived,
|
||||
crcCalculated,
|
||||
crcValid: valid ? "VALID" : "INVALID",
|
||||
note: valid ? null : `CRC divergente: recebido=${crcReceived || "(vazio)"} calculado=${crcCalculated}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single AFD line. Never throws under normal malformed input —
|
||||
* a line that doesn't fit any documented shape still produces a record
|
||||
* (validationStatus INVALID/UNSUPPORTED_LAYOUT, nsr possibly null,
|
||||
* rawLine always preserved) rather than being silently dropped, per
|
||||
* eden.md's "preservar mesmo se inválido" invariant. `parseAfdFile` wraps
|
||||
* this in a try/catch as a second line of defense for truly unexpected
|
||||
* exceptions (defensive programming, not the normal path).
|
||||
*/
|
||||
export function parseAfdLine(rawLine: string, lineNumber: number): ParsedAfdRecord {
|
||||
const notes: string[] = [];
|
||||
|
||||
if (rawLine.length < HEADER_LENGTH) {
|
||||
return {
|
||||
lineNumber,
|
||||
nsr: null,
|
||||
nsrRaw: rawLine,
|
||||
recordType: "",
|
||||
eventDatetime: null,
|
||||
eventDatetimeRaw: null,
|
||||
employeeCpfRaw: null,
|
||||
rawLine,
|
||||
crcReceived: null,
|
||||
crcCalculated: null,
|
||||
crcValid: "NOT_VALIDATED",
|
||||
parsedData: { raw_tail: rawLine },
|
||||
validationStatus: "INVALID",
|
||||
validationNotes: "linha menor que o cabeçalho mínimo (NSR de 9 dígitos + 1 dígito de tipo, 10 caracteres)",
|
||||
};
|
||||
}
|
||||
|
||||
const nsrRaw = rawLine.slice(0, 9);
|
||||
const recordType = rawLine.slice(9, 10);
|
||||
const nsr = parseNsr(nsrRaw);
|
||||
if (nsr === null) {
|
||||
notes.push("NSR ausente ou não numérico — preservado como null, nunca substituído por 0/autoincrement");
|
||||
}
|
||||
|
||||
let eventDatetimeRaw: string | null = null;
|
||||
let employeeCpfRaw: string | null = null;
|
||||
let parsedData: Record<string, unknown> = {};
|
||||
let crcValid: AfdCrcValidity = "NOT_VALIDATED";
|
||||
let crcReceived: string | null = null;
|
||||
let crcCalculated: string | null = null;
|
||||
let validationStatus: AfdValidationStatus = "VALID";
|
||||
|
||||
switch (recordType) {
|
||||
case "1": {
|
||||
// Cabeçalho do arquivo — só o NSR fixo (000000000) + tipo são documentados.
|
||||
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH) };
|
||||
if (nsrRaw !== "000000000") notes.push("NSR do cabeçalho difere do valor fixo esperado (000000000)");
|
||||
break;
|
||||
}
|
||||
case "2": {
|
||||
// Inclusão/alteração do empregador no REP
|
||||
const dh = rawLine.slice(10, 34);
|
||||
const cpfResp = rawLine.slice(34, 48);
|
||||
const tipoIdent = rawLine.slice(48, 49);
|
||||
const cnpjEmpregador = rawLine.slice(49, 63);
|
||||
const cnoCaepf = rawLine.slice(63, 77);
|
||||
const razaoSocial = rawLine.slice(77, 227);
|
||||
const local = rawLine.slice(227, 327);
|
||||
eventDatetimeRaw = dh;
|
||||
parsedData = { dh, cpf_resp: cpfResp, tipo_ident: tipoIdent, cnpj_empregador: cnpjEmpregador, cno_caepf: cnoCaepf, razao_social: razaoSocial, local };
|
||||
if (rawLine.length < 331) {
|
||||
validationStatus = "INVALID";
|
||||
notes.push("linha mais curta que o esperado para o tipo 2 (esperado >= 331 caracteres)");
|
||||
crcValid = "INVALID";
|
||||
} else {
|
||||
const crc = checkCrc(rawLine, 327, 327, 331);
|
||||
crcReceived = crc.crcReceived;
|
||||
crcCalculated = crc.crcCalculated;
|
||||
crcValid = crc.crcValid;
|
||||
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "3": {
|
||||
// Marcação REP-C/REP-A — a principal (batida de ponto convencional)
|
||||
const dh = rawLine.slice(10, 34);
|
||||
const cpf = rawLine.slice(34, 46);
|
||||
eventDatetimeRaw = dh;
|
||||
employeeCpfRaw = cpf;
|
||||
parsedData = { dh, cpf };
|
||||
if (rawLine.length < 50) {
|
||||
validationStatus = "INVALID";
|
||||
notes.push("linha mais curta que o esperado para o tipo 3 (esperado 50 caracteres)");
|
||||
crcValid = "INVALID";
|
||||
} else {
|
||||
const crc = checkCrc(rawLine, 46, 46, 50);
|
||||
crcReceived = crc.crcReceived;
|
||||
crcCalculated = crc.crcCalculated;
|
||||
crcValid = crc.crcValid;
|
||||
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "4": {
|
||||
// Ajuste do relógio do REP
|
||||
const dhAntes = rawLine.slice(10, 34);
|
||||
const dhAjustada = rawLine.slice(34, 58);
|
||||
const cpfResp = rawLine.slice(58, 69);
|
||||
eventDatetimeRaw = dhAjustada;
|
||||
// cpf_resp here is the CPF of whoever adjusted the clock, not
|
||||
// necessarily a timeclock_employees row — stored in the same
|
||||
// generic employee_cpf_raw column; resolution against
|
||||
// timeclock_employees simply won't match if they aren't one
|
||||
// (harmless — never fabricates a match).
|
||||
employeeCpfRaw = cpfResp;
|
||||
parsedData = { dh_antes: dhAntes, dh_ajustada: dhAjustada, cpf_resp: cpfResp };
|
||||
if (rawLine.length < 73) {
|
||||
validationStatus = "INVALID";
|
||||
notes.push("linha mais curta que o esperado para o tipo 4 (esperado 73 caracteres)");
|
||||
crcValid = "INVALID";
|
||||
} else {
|
||||
const crc = checkCrc(rawLine, 69, 69, 73);
|
||||
crcReceived = crc.crcReceived;
|
||||
crcCalculated = crc.crcCalculated;
|
||||
crcValid = crc.crcValid;
|
||||
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "5": {
|
||||
// Inclusão/alteração/exclusão de empregado no REP — só dh + operação documentados
|
||||
const dh = rawLine.slice(10, 34);
|
||||
const operacao = rawLine.slice(34, 35);
|
||||
eventDatetimeRaw = dh;
|
||||
parsedData = { dh, operacao, raw_tail: rawLine.slice(35) };
|
||||
if (!["I", "A", "E"].includes(operacao)) {
|
||||
notes.push(`código de operação não documentado: '${operacao}'`);
|
||||
validationStatus = "WARNING";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "6": {
|
||||
// Evento sensível do REP — só dh documentado
|
||||
const dh = rawLine.slice(10, 34);
|
||||
eventDatetimeRaw = dh;
|
||||
parsedData = { dh, raw_tail: rawLine.slice(34) };
|
||||
break;
|
||||
}
|
||||
case "7": {
|
||||
// Marcação REP-P — usa hash próprio de encadeamento, NUNCA recalculado aqui
|
||||
const dhMarcacao = rawLine.slice(10, 34);
|
||||
const cpf = rawLine.slice(34, 46);
|
||||
const dhGravacao = rawLine.slice(46, 70);
|
||||
const coletor = rawLine.slice(70, 72);
|
||||
const onlineOffline = rawLine.slice(72, 73);
|
||||
const hash = rawLine.slice(73, 137);
|
||||
eventDatetimeRaw = dhMarcacao;
|
||||
employeeCpfRaw = cpf;
|
||||
parsedData = { dh_marcacao: dhMarcacao, cpf, dh_gravacao: dhGravacao, coletor, online_offline: onlineOffline, hash };
|
||||
crcValid = "NOT_APPLICABLE";
|
||||
if (rawLine.length < 137) {
|
||||
validationStatus = "INVALID";
|
||||
notes.push("linha mais curta que o esperado para o tipo 7 (esperado 137 caracteres, hash de 64 posições incompleto)");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "9": {
|
||||
// Trailer — só o NSR fixo (999999999) é documentado; contagens internas não têm posição documentada.
|
||||
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH), trailer_cross_check: "not_implemented_no_documented_positions" };
|
||||
if (nsrRaw !== "999999999") notes.push("NSR do trailer difere do valor fixo esperado (999999999)");
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH) };
|
||||
validationStatus = "UNSUPPORTED_LAYOUT";
|
||||
notes.push(`tipo de registro não reconhecido: '${recordType}'`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const eventDatetime = eventDatetimeRaw ? parseAfdDatetime(eventDatetimeRaw) : null;
|
||||
if (eventDatetimeRaw && eventDatetime === null) {
|
||||
notes.push(`campo de data/hora fora do formato esperado (AAAA-MM-ddThh:mm:00ZZZZZ): '${eventDatetimeRaw}'`);
|
||||
if (validationStatus === "VALID") validationStatus = "WARNING";
|
||||
}
|
||||
|
||||
if (nsr === null && validationStatus === "VALID") {
|
||||
validationStatus = "INVALID";
|
||||
}
|
||||
|
||||
return {
|
||||
lineNumber,
|
||||
nsr,
|
||||
nsrRaw,
|
||||
recordType,
|
||||
eventDatetime,
|
||||
eventDatetimeRaw,
|
||||
employeeCpfRaw,
|
||||
rawLine,
|
||||
crcReceived,
|
||||
crcCalculated,
|
||||
crcValid,
|
||||
parsedData,
|
||||
validationStatus,
|
||||
validationNotes: notes.length > 0 ? notes.join("; ") : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// File-level parsing
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Splits AFD file content into lines (CRLF per spec; also tolerates bare
|
||||
* LF defensively) and parses each one. Never throws for a bad line and
|
||||
* never stops processing because of one — see module doc for the
|
||||
* "records vs. failures" partial-error policy:
|
||||
*
|
||||
* - A line that is merely malformed AFD data (wrong type, bad CRC, too
|
||||
* short, unsupported record type, etc.) still produces a `records`
|
||||
* entry with an INVALID/WARNING/UNSUPPORTED_LAYOUT status — it is
|
||||
* NEVER dropped, because raw_line is legally-relevant raw data that
|
||||
* must always be preserved (eden.md módulo 6 §12).
|
||||
* - A line that causes an actual unexpected exception in `parseAfdLine`
|
||||
* (a defensive safety net, not the normal path) still produces a
|
||||
* fallback INVALID record so nothing is silently lost, AND is also
|
||||
* recorded in `failures` for import-summary visibility.
|
||||
*
|
||||
* Fully blank lines (an artifact of a trailing CRLF at EOF, or stray
|
||||
* blank lines in a hand-edited file) are skipped without generating a
|
||||
* record — they carry no data to preserve.
|
||||
*/
|
||||
export function parseAfdFile(content: string): AfdParseResult {
|
||||
const rawLines = content.split(/\r\n|\n\r|\n/);
|
||||
const records: ParsedAfdRecord[] = [];
|
||||
const failures: AfdParseFailure[] = [];
|
||||
|
||||
let lineNumber = 0;
|
||||
for (const line of rawLines) {
|
||||
lineNumber += 1;
|
||||
if (line.length === 0) continue; // trailing EOF newline / stray blank line — no data to preserve
|
||||
|
||||
try {
|
||||
records.push(parseAfdLine(line, lineNumber));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
failures.push({ lineNumber, rawLine: line, error: message });
|
||||
records.push({
|
||||
lineNumber,
|
||||
nsr: null,
|
||||
nsrRaw: line.slice(0, 9),
|
||||
recordType: line.slice(9, 10) || "",
|
||||
eventDatetime: null,
|
||||
eventDatetimeRaw: null,
|
||||
employeeCpfRaw: null,
|
||||
rawLine: line,
|
||||
crcReceived: null,
|
||||
crcCalculated: null,
|
||||
crcValid: "NOT_VALIDATED",
|
||||
parsedData: { raw_tail: line },
|
||||
validationStatus: "INVALID",
|
||||
validationNotes: `Erro inesperado ao parsear linha: ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const byRecordType: Record<string, number> = {};
|
||||
const byValidationStatus: Record<string, number> = {};
|
||||
let firstNsr: number | null = null;
|
||||
let lastNsr: number | null = null;
|
||||
|
||||
for (const record of records) {
|
||||
byRecordType[record.recordType] = (byRecordType[record.recordType] ?? 0) + 1;
|
||||
byValidationStatus[record.validationStatus] = (byValidationStatus[record.validationStatus] ?? 0) + 1;
|
||||
if (record.nsr !== null) {
|
||||
if (firstNsr === null) firstNsr = record.nsr;
|
||||
lastNsr = record.nsr;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
records,
|
||||
failures,
|
||||
summary: {
|
||||
totalLines: lineNumber,
|
||||
parsedCount: records.length,
|
||||
failureCount: failures.length,
|
||||
byRecordType,
|
||||
byValidationStatus,
|
||||
firstNsr,
|
||||
lastNsr,
|
||||
},
|
||||
};
|
||||
}
|
||||
37
apps/api/src/hr-timeclock/afd-records.controller.ts
Normal file
37
apps/api/src/hr-timeclock/afd-records.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, Param, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { AfdRecordsService } from "./afd-records.service.js";
|
||||
import { ListAfdRecordsQueryDto } from "./dto/list-afd-records-query.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
|
||||
/**
|
||||
* READ-ONLY — eden.md módulo 6 §12: afd_records (dado bruto de ponto) é
|
||||
* imutável. This controller intentionally has no POST/PATCH/PUT/DELETE
|
||||
* handler, at any permission level — the only way a row is ever created
|
||||
* is AfdImportsController's import endpoint (via
|
||||
* hrTimeclockRepo.importAfdBatch's INSERT), and the database itself
|
||||
* additionally rejects UPDATE/DELETE via a trigger with no bypass (see
|
||||
* the migration) — so this is defense in depth, not the only guard.
|
||||
*/
|
||||
@ApiTags("hr-timeclock-afd-records")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
|
||||
@Controller("v1/hr-timeclock/afd-records")
|
||||
export class AfdRecordsController {
|
||||
constructor(private readonly afdRecords: AfdRecordsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission("afd-import", "view")
|
||||
list(@Query() query: ListAfdRecordsQueryDto) {
|
||||
return this.afdRecords.list(query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("afd-import", "view")
|
||||
get(@Param("id") id: string) {
|
||||
return this.afdRecords.get(id);
|
||||
}
|
||||
}
|
||||
45
apps/api/src/hr-timeclock/afd-records.service.ts
Normal file
45
apps/api/src/hr-timeclock/afd-records.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { hrTimeclockRepo, type AfdRecord } from "@eden/database";
|
||||
import type { ListAfdRecordsQueryDto } from "./dto/list-afd-records-query.dto.js";
|
||||
|
||||
/**
|
||||
* Read-only, matching eden.md §4.5 ("nunca há rota de escrita para esta
|
||||
* tabela"). There is deliberately no create/update/delete method here —
|
||||
* see hr-timeclock/afd-records.controller.ts, which exposes GET only.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AfdRecordsService {
|
||||
async list(query: ListAfdRecordsQueryDto): Promise<{ records: AfdRecord[]; total: number; limit: number; offset: number }> {
|
||||
const limit = query.limit ?? 50;
|
||||
const offset = query.offset ?? 0;
|
||||
|
||||
// dateTo is inclusive at the day level per eden.md §4.5 ("date_to usa
|
||||
// limite exclusivo do dia seguinte para incluir o dia inteiro") — the
|
||||
// query DTO takes a plain date/datetime string; if it looks like a
|
||||
// bare date (no time component), advance to the start of the next
|
||||
// day so the whole end day is included.
|
||||
let dateTo: Date | undefined;
|
||||
if (query.dateTo) {
|
||||
const parsed = new Date(query.dateTo);
|
||||
dateTo = /^\d{4}-\d{2}-\d{2}$/.test(query.dateTo) ? new Date(parsed.getTime() + 24 * 60 * 60 * 1000) : parsed;
|
||||
}
|
||||
|
||||
const { records, total } = await hrTimeclockRepo.listAfdRecords({
|
||||
deviceId: query.deviceId,
|
||||
employeeId: query.employeeId,
|
||||
recordType: query.recordType,
|
||||
dateFrom: query.dateFrom ? new Date(query.dateFrom) : undefined,
|
||||
dateTo,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return { records, total, limit, offset };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<AfdRecord> {
|
||||
const record = await hrTimeclockRepo.getAfdRecordById(id);
|
||||
if (!record) throw new NotFoundException("Registro de AFD não encontrado");
|
||||
return record;
|
||||
}
|
||||
}
|
||||
20
apps/api/src/hr-timeclock/dto/create-afd-import.dto.ts
Normal file
20
apps/api/src/hr-timeclock/dto/create-afd-import.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsString, IsUUID, MinLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* SIMPLIFICATION OF THIS LEVA: the AFD file is accepted as a plain string
|
||||
* in the JSON body, not a real multipart upload — see
|
||||
* afd-imports.controller.ts / afd-parser.ts header comments for why (and
|
||||
* the encoding caveat this implies). Real multipart upload (matching
|
||||
* eden.md §4.5's `POST /afd-imports/import`) is future work.
|
||||
*/
|
||||
export class CreateAfdImportDto {
|
||||
@IsUUID() deviceId!: string;
|
||||
|
||||
/**
|
||||
* The AFD file's full text content, decoded by the CALLER as
|
||||
* ISO-8859-1 (latin1) — see afd-parser.ts's encoding note. Sending
|
||||
* UTF-8-decoded content here will cause CRC validation to legitimately
|
||||
* fail for any line with a byte ≥ 0x80.
|
||||
*/
|
||||
@IsString() @MinLength(1) fileContent!: string;
|
||||
}
|
||||
20
apps/api/src/hr-timeclock/dto/create-timeclock-device.dto.ts
Normal file
20
apps/api/src/hr-timeclock/dto/create-timeclock-device.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min, MinLength } from "class-validator";
|
||||
|
||||
export class CreateTimeclockDeviceDto {
|
||||
@IsOptional() @IsUUID() companyId?: string;
|
||||
|
||||
@IsString() @MinLength(1) @MaxLength(200) name!: string;
|
||||
@IsOptional() @IsString() @MaxLength(2000) description?: string;
|
||||
@IsOptional() @IsString() @MaxLength(200) branch?: string;
|
||||
|
||||
@IsString() @MinLength(1) @MaxLength(255) host!: string;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(65535) port?: number;
|
||||
@IsOptional() @IsBoolean() useHttps?: boolean;
|
||||
|
||||
@IsString() @MinLength(1) @MaxLength(200) username!: string;
|
||||
|
||||
/** Plaintext in — encrypted server-side via FieldEncryptionService before storage, never persisted or logged as-is. */
|
||||
@IsString() @MinLength(1) @MaxLength(500) password!: string;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(100) timezone?: string;
|
||||
}
|
||||
18
apps/api/src/hr-timeclock/dto/list-afd-records-query.dto.ts
Normal file
18
apps/api/src/hr-timeclock/dto/list-afd-records-query.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { IsDateString, IsIn, IsInt, IsOptional, IsUUID, Max, Min } from "class-validator";
|
||||
|
||||
const AFD_RECORD_TYPES = ["1", "2", "3", "4", "5", "6", "7", "9"] as const;
|
||||
|
||||
export class ListAfdRecordsQueryDto {
|
||||
@IsOptional() @IsUUID() deviceId?: string;
|
||||
@IsOptional() @IsUUID() employeeId?: string;
|
||||
@IsOptional() @IsIn(AFD_RECORD_TYPES) recordType?: string;
|
||||
|
||||
/** Inclusive lower bound (ISO date/datetime string). */
|
||||
@IsOptional() @IsDateString() dateFrom?: string;
|
||||
/** Inclusive upper bound at the DAY level — the service applies the next-day-exclusive convention (eden.md §4.5). */
|
||||
@IsOptional() @IsDateString() dateTo?: string;
|
||||
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) limit?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) offset?: number;
|
||||
}
|
||||
18
apps/api/src/hr-timeclock/dto/update-timeclock-device.dto.ts
Normal file
18
apps/api/src/hr-timeclock/dto/update-timeclock-device.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateTimeclockDeviceDto {
|
||||
@IsOptional() @IsString() @MinLength(1) @MaxLength(200) name?: string;
|
||||
@IsOptional() @IsString() @MaxLength(2000) description?: string;
|
||||
@IsOptional() @IsString() @MaxLength(200) branch?: string;
|
||||
|
||||
@IsOptional() @IsString() @MinLength(1) @MaxLength(255) host?: string;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(65535) port?: number;
|
||||
@IsOptional() @IsBoolean() useHttps?: boolean;
|
||||
|
||||
@IsOptional() @IsString() @MinLength(1) @MaxLength(200) username?: string;
|
||||
|
||||
/** Only re-encrypted/stored when present — omit to leave the current credential untouched. */
|
||||
@IsOptional() @IsString() @MinLength(1) @MaxLength(500) password?: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
45
apps/api/src/hr-timeclock/hr-timeclock.module.ts
Normal file
45
apps/api/src/hr-timeclock/hr-timeclock.module.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "../config/config.module.js";
|
||||
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { TokenService } from "../auth/token.service.js";
|
||||
import { TimeclockDevicesController } from "./timeclock-devices.controller.js";
|
||||
import { TimeclockDevicesService } from "./timeclock-devices.service.js";
|
||||
import { AfdImportsController } from "./afd-imports.controller.js";
|
||||
import { AfdImportsService } from "./afd-imports.service.js";
|
||||
import { AfdRecordsController } from "./afd-records.controller.js";
|
||||
import { AfdRecordsService } from "./afd-records.service.js";
|
||||
|
||||
/**
|
||||
* HR/Timeclock (eden.md módulo 6) — foundation leva. See
|
||||
* afd-parser.ts and packages/database/migrations/PENDING_hr-timeclock.cjs
|
||||
* for the full scope/deferral notes.
|
||||
*
|
||||
* INTEGRATION NOTE for whoever merges this: `ConfigModule`,
|
||||
* `FieldEncryptionService`, `JwtAuthGuard`, `PermissionGuard` and
|
||||
* `TokenService` are provided directly here (rather than imported from a
|
||||
* shared `AuthModule`) because this worktree was isolated at a bootstrap
|
||||
* commit with no Identity/Auth module yet — see apps/api/src/auth/*'s
|
||||
* header comments. Once the real Identity/Auth module lands with its own
|
||||
* `AuthModule` (exporting these same classes) and a global `ConfigModule`
|
||||
* wired into `AppModule`, this module's `providers` list should shrink to
|
||||
* just the HR-specific services and `imports: [AuthModule]` should
|
||||
* replace the local guard/token/config providers below — no controller or
|
||||
* service code needs to change, since they all import from the same
|
||||
* `../auth/...`/`../crypto/...` paths either way.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [TimeclockDevicesController, AfdImportsController, AfdRecordsController],
|
||||
providers: [
|
||||
FieldEncryptionService,
|
||||
TokenService,
|
||||
JwtAuthGuard,
|
||||
PermissionGuard,
|
||||
TimeclockDevicesService,
|
||||
AfdImportsService,
|
||||
AfdRecordsService,
|
||||
],
|
||||
})
|
||||
export class HrTimeclockModule {}
|
||||
57
apps/api/src/hr-timeclock/timeclock-devices.controller.ts
Normal file
57
apps/api/src/hr-timeclock/timeclock-devices.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { TimeclockDevicesService } from "./timeclock-devices.service.js";
|
||||
import { CreateTimeclockDeviceDto } from "./dto/create-timeclock-device.dto.js";
|
||||
import { UpdateTimeclockDeviceDto } from "./dto/update-timeclock-device.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* eden.md módulo 6 §1.1/§2 — Control iD REP iDClass device registry.
|
||||
* password_encrypted is never returned in plaintext by any route here —
|
||||
* see TimeclockDevicesService's toView (hasCredential flag only).
|
||||
*
|
||||
* NOTE: `@UsePipes(ValidationPipe)` is applied locally (rather than
|
||||
* relying on a global pipe in main.ts) because this bootstrap-stage
|
||||
* worktree's main.ts doesn't wire one up yet — see this task's final
|
||||
* summary. Harmless/redundant once the real global pipe lands.
|
||||
*/
|
||||
@ApiTags("hr-timeclock-devices")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
|
||||
@Controller("v1/hr-timeclock/devices")
|
||||
export class TimeclockDevicesController {
|
||||
constructor(private readonly devices: TimeclockDevicesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission("timeclock-devices", "view")
|
||||
list(@Query("includeInactive") includeInactive?: string) {
|
||||
return this.devices.list(includeInactive === "true");
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("timeclock-devices", "view")
|
||||
get(@Param("id") id: string) {
|
||||
return this.devices.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("timeclock-devices", "edit")
|
||||
create(@Body() dto: CreateTimeclockDeviceDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.devices.create(dto, user);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequirePermission("timeclock-devices", "edit")
|
||||
update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateTimeclockDeviceDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.devices.update(id, dto, user);
|
||||
}
|
||||
}
|
||||
95
apps/api/src/hr-timeclock/timeclock-devices.service.ts
Normal file
95
apps/api/src/hr-timeclock/timeclock-devices.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { auditRepo, hrTimeclockRepo, type TimeclockDevice } from "@eden/database";
|
||||
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
import type { CreateTimeclockDeviceDto } from "./dto/create-timeclock-device.dto.js";
|
||||
import type { UpdateTimeclockDeviceDto } from "./dto/update-timeclock-device.dto.js";
|
||||
|
||||
/** Never expose password_encrypted — GET responses only ever carry hasCredential. */
|
||||
export type TimeclockDeviceView = Omit<TimeclockDevice, "password_encrypted"> & { hasCredential: boolean };
|
||||
|
||||
function toView(device: TimeclockDevice): TimeclockDeviceView {
|
||||
const { password_encrypted, ...rest } = device;
|
||||
return { ...rest, hasCredential: password_encrypted.length > 0 };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TimeclockDevicesService {
|
||||
constructor(private readonly fieldEncryption: FieldEncryptionService) {}
|
||||
|
||||
async list(includeInactive: boolean): Promise<TimeclockDeviceView[]> {
|
||||
const devices = await hrTimeclockRepo.listTimeclockDevices(includeInactive);
|
||||
return devices.map(toView);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<TimeclockDeviceView> {
|
||||
const device = await hrTimeclockRepo.getTimeclockDeviceById(id);
|
||||
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
|
||||
return toView(device);
|
||||
}
|
||||
|
||||
async create(dto: CreateTimeclockDeviceDto, user: EdenAuthenticatedUser): Promise<TimeclockDeviceView> {
|
||||
const passwordEncrypted = this.fieldEncryption.encrypt(dto.password);
|
||||
|
||||
const device = await hrTimeclockRepo.createTimeclockDevice({
|
||||
companyId: dto.companyId ?? null,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
branch: dto.branch ?? null,
|
||||
host: dto.host,
|
||||
port: dto.port,
|
||||
useHttps: dto.useHttps,
|
||||
username: dto.username,
|
||||
passwordEncrypted,
|
||||
timezone: dto.timezone,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "timeclock_device.created",
|
||||
resourceType: "timeclock_device",
|
||||
resourceId: device.id,
|
||||
metadata: { name: device.name, host: device.host },
|
||||
});
|
||||
|
||||
return toView(device);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateTimeclockDeviceDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<TimeclockDeviceView> {
|
||||
await this.get(id); // 404 before attempting the update
|
||||
|
||||
const passwordEncrypted = dto.password ? this.fieldEncryption.encrypt(dto.password) : undefined;
|
||||
|
||||
const device = await hrTimeclockRepo.updateTimeclockDevice(id, {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
branch: dto.branch,
|
||||
host: dto.host,
|
||||
port: dto.port,
|
||||
useHttps: dto.useHttps,
|
||||
username: dto.username,
|
||||
passwordEncrypted,
|
||||
active: dto.active,
|
||||
updatedBy: user.id,
|
||||
});
|
||||
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "timeclock_device.updated",
|
||||
resourceType: "timeclock_device",
|
||||
resourceId: id,
|
||||
// Never log the password itself, even encrypted — just whether it changed.
|
||||
metadata: { fields: Object.keys(dto).filter((f) => f !== "password"), credentialChanged: passwordEncrypted !== undefined },
|
||||
});
|
||||
|
||||
return toView(device);
|
||||
}
|
||||
}
|
||||
21
apps/api/src/inventory/dto/create-asset.dto.ts
Normal file
21
apps/api/src/inventory/dto/create-asset.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IsDateString, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* At least one of serial/patrimonyTag is required — enforced in the
|
||||
* service (not here, class-validator doesn't do cross-field rules
|
||||
* cleanly) so the 400 message can name both fields together. Mirrors the
|
||||
* DB-level chk_serialized_assets_has_identifier constraint.
|
||||
*/
|
||||
export class CreateAssetDto {
|
||||
@IsUUID()
|
||||
productId!: string;
|
||||
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@IsOptional() @IsString() @MinLength(1) serial?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) patrimonyTag?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) macAddress?: string;
|
||||
@IsOptional() @IsDateString() warrantyExpiresAt?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
22
apps/api/src/inventory/dto/create-reservation.dto.ts
Normal file
22
apps/api/src/inventory/dto/create-reservation.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, Min } from "class-validator";
|
||||
|
||||
/**
|
||||
* Generic reservation — product+quantity, no serial selected yet. The
|
||||
* first step of the Master Prompt §6.8 flow (reserve → later pick the
|
||||
* specific serialized unit → install).
|
||||
*/
|
||||
export class CreateReservationDto {
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@IsUUID()
|
||||
productId!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
quantity!: number;
|
||||
|
||||
@IsOptional() @IsString() referenceType?: string;
|
||||
@IsOptional() @IsUUID() referenceId?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
16
apps/api/src/inventory/dto/create-warehouse.dto.ts
Normal file
16
apps/api/src/inventory/dto/create-warehouse.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { IsBoolean, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
|
||||
|
||||
export class CreateWarehouseDto {
|
||||
@IsUUID()
|
||||
companyId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
code!: string;
|
||||
|
||||
@IsOptional() @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
11
apps/api/src/inventory/dto/install-asset.dto.ts
Normal file
11
apps/api/src/inventory/dto/install-asset.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsIn, IsOptional, IsString, IsUUID } from "class-validator";
|
||||
|
||||
export class InstallAssetDto {
|
||||
@IsUUID()
|
||||
contractId!: string;
|
||||
|
||||
@IsIn(["installed", "comodato"])
|
||||
assignmentType!: "installed" | "comodato";
|
||||
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
7
apps/api/src/inventory/dto/reserve-asset.dto.ts
Normal file
7
apps/api/src/inventory/dto/reserve-asset.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString, IsUUID } from "class-validator";
|
||||
|
||||
export class ReserveAssetDto {
|
||||
@IsOptional() @IsString() referenceType?: string;
|
||||
@IsOptional() @IsUUID() referenceId?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
9
apps/api/src/inventory/dto/return-asset.dto.ts
Normal file
9
apps/api/src/inventory/dto/return-asset.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsOptional, IsString, IsUUID } from "class-validator";
|
||||
|
||||
export class ReturnAssetDto {
|
||||
/** Warehouse receiving the physical unit back — see serialized_assets.warehouse_id comment in the migration for why this can't default to "the warehouse it left from" (that location was cleared on install). */
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
7
apps/api/src/inventory/dto/update-warehouse.dto.ts
Normal file
7
apps/api/src/inventory/dto/update-warehouse.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateWarehouseDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) code?: string;
|
||||
@IsOptional() @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
120
apps/api/src/inventory/inventory.controller.ts
Normal file
120
apps/api/src/inventory/inventory.controller.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { InventoryService } from "./inventory.service.js";
|
||||
import { CreateAssetDto } from "./dto/create-asset.dto.js";
|
||||
import { ReserveAssetDto } from "./dto/reserve-asset.dto.js";
|
||||
import { InstallAssetDto } from "./dto/install-asset.dto.js";
|
||||
import { ReturnAssetDto } from "./dto/return-asset.dto.js";
|
||||
import { CreateReservationDto } from "./dto/create-reservation.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* Serialized assets, the stock ledger, and reservations — Master Prompt
|
||||
* §6.8, ADR-0007. Full cycle modeled here: reserve (generic or a specific
|
||||
* serial) → install/comodato → return. Every mutation appends a
|
||||
* stock_movement (ledger, never an editable balance column — see
|
||||
* PENDING_inventory.cjs's header comment) and an audit_log event.
|
||||
*/
|
||||
@ApiTags("inventory")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/inventory")
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventory: InventoryService) {}
|
||||
|
||||
@Get("assets")
|
||||
@RequirePermission("inventory", "view")
|
||||
listAssets(
|
||||
@Query("status") status?: string,
|
||||
@Query("warehouseId") warehouseId?: string,
|
||||
@Query("productId") productId?: string,
|
||||
) {
|
||||
return this.inventory.listAssets(status, warehouseId, productId);
|
||||
}
|
||||
|
||||
@Get("assets/:id")
|
||||
@RequirePermission("inventory", "view")
|
||||
getAsset(@Param("id") id: string) {
|
||||
return this.inventory.getAsset(id);
|
||||
}
|
||||
|
||||
@Post("assets")
|
||||
@RequirePermission("inventory", "edit")
|
||||
createAsset(@Body() dto: CreateAssetDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.inventory.createAsset(dto, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves this SPECIFIC serialized asset (quantity is always 1 —
|
||||
* the serial has already been picked). For the generic product+quantity
|
||||
* reservation described in the Master Prompt as the step BEFORE a
|
||||
* serial is picked, see POST /v1/inventory/reservations instead.
|
||||
*/
|
||||
@Post("assets/:id/reserve")
|
||||
@RequirePermission("inventory", "edit")
|
||||
reserveAsset(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ReserveAssetDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.inventory.reserveAsset(id, dto, user);
|
||||
}
|
||||
|
||||
@Post("assets/:id/install")
|
||||
@RequirePermission("inventory", "edit")
|
||||
installAsset(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: InstallAssetDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.inventory.installAsset(id, dto, user);
|
||||
}
|
||||
|
||||
@Post("assets/:id/return")
|
||||
@RequirePermission("inventory", "edit")
|
||||
returnAsset(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ReturnAssetDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.inventory.returnAsset(id, dto, user);
|
||||
}
|
||||
|
||||
@Get("movements")
|
||||
@RequirePermission("inventory", "view")
|
||||
listMovements(
|
||||
@Query("warehouseId") warehouseId?: string,
|
||||
@Query("productId") productId?: string,
|
||||
@Query("referenceType") referenceType?: string,
|
||||
@Query("referenceId") referenceId?: string,
|
||||
) {
|
||||
return this.inventory.listMovements(warehouseId, productId, referenceType, referenceId);
|
||||
}
|
||||
|
||||
/** Generic quantity reservation (product+warehouse, no serial picked yet) — the first step of the Master Prompt §6.8 flow. */
|
||||
@Get("reservations")
|
||||
@RequirePermission("inventory", "view")
|
||||
listReservations(
|
||||
@Query("status") status?: string,
|
||||
@Query("warehouseId") warehouseId?: string,
|
||||
@Query("productId") productId?: string,
|
||||
) {
|
||||
return this.inventory.listReservations(status, warehouseId, productId);
|
||||
}
|
||||
|
||||
@Post("reservations")
|
||||
@RequirePermission("inventory", "edit")
|
||||
createReservation(@Body() dto: CreateReservationDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.inventory.createReservation(dto, user);
|
||||
}
|
||||
|
||||
@Post("reservations/:id/release")
|
||||
@RequirePermission("inventory", "edit")
|
||||
releaseReservation(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.inventory.releaseReservation(id, user);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user