feat(api,ui): MFA/TOTP, Inventory, Support, HR-Timeclock, Fiscal, Billing e Design System
Leva grande, pedida como "pelo menos mais 20 etapas, use subagentes":
fecha a pendência de MFA/TOTP da Fase 1 (feito diretamente, autenticação é
sensível demais para delegar) e lança 5 subagentes em paralelo em 2 ondas
(eden-inventory, eden-support, eden-hr-timeclock; depois eden-finance,
eden-fiscal, eden-frontend), cada um em worktree isolado, com integração
seletiva manual (barrel, app.module.ts, migrations renomeadas/aplicadas,
build/typecheck/teste ao vivo antes de cada merge).
## Backend
- MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `crypto/field-encryption.service.ts`):
login em duas etapas quando `mfa_enabled=true`, `FieldEncryptionService`
genérico (AES-256-GCM versionado, ADR-0005) reaproveitado por outros
segredos reversíveis (ex. credencial de equipamento Control iD).
- Inventory: warehouses, stock_movements (ledger append-only), serialized_assets,
asset_assignments, stock_reservations — invariante "serial/patrimônio/MAC
nunca duplicado entre ativos ativos" reforçada por índices únicos parciais
no banco (Master Prompt §15.3).
- Support/Service Desk: tickets com máquina de estados, SLA com pausa/retomada
em `waiting_customer`/`waiting_third_party` (funções puras testáveis).
- HR-Timeclock (fundação): dispositivos Control iD (senha cifrada), import de
AFD com parser + CRC-16/KERMIT, `afd_records` imutável sem exceção nem
para super_admin (sem válvula de escape, diferente de audit_log).
- Fiscal: NCM/CFOP/municípios/naturezas de operação (eden.md módulo 5, MVP de
5 das ~19 tabelas), product_fiscal_profiles com invariante de único
profile ativo por billing_component.
- Billing: subscription→billing_run→invoice idempotente (regenerar em draft
substitui, nunca duplica) e imutável após consolidar (ADR-0008: nunca
confunde faturamento/documento fiscal/recebimento).
- FKs cruzadas fechadas: quotes/client_registrations/contracts.
## Frontend — primeira leva
- `packages/ui`: Design System real extraído do tema DreamsERP comprado —
achado que mudou o escopo: o tema já vem em React+Tailwind (não só
Angular), tokens visuais reais portados (cores, Geist como fonte real,
divergindo da doc antiga que citava Nunito/Poppins).
- `apps/core-web`: login real (com suporte a MFA), sessão, shell autenticado
via `AppShell` — substitui a tela estática "Bootstrap da Fase 1".
## Bugs reais encontrados e corrigidos (todos via teste ao vivo, não leitura)
1. **Segurança**: o token de desafio MFA era aceito como access token normal
pelo `JwtAuthGuard` (mesma assinatura, nenhuma checagem da claim
`purpose`) — bypass total do segundo fator. Explorado e fechado ao vivo
contra o container real; teste de regressão em `test/mfa.spec.ts`.
2. Crase solta dentro de comentário SQL na migration de hr-timeclock quebrava
o template literal JS (`SyntaxError` ao rodar a migration).
3. CORS ausente por completo na API.
4. `turbo.json` não declarava `VITE_API_BASE_URL` como input de build — o
Turborepo cacheava o bundle do frontend ignorando mudança na env var.
5. `compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`, que colidia com a
variável de mesmo nome do `.env` local — o default nunca era aplicado.
6. Não existia `.dockerignore` — todo build copiava node_modules/.git/
worktrees de subagentes (665MB) para dentro do contexto Docker.
Decisão do operador incorporada: a API nunca tem domínio público próprio
(só IP interno) — os 3 frontends chamam por caminho relativo `/api/v1`,
proxy externo roteia same-origin; CORS só é relevante para dev local.
Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter
@eden/api test` (30/30, cresceu de 15), todo o lote testado ao vivo via
curl contra os containers reais reconstruídos, incluindo os 6 bugs acima
já fechados e confirmados, e a trilha de auditoria conferida no Postgres.
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user