feat(api,ui): MFA/TOTP, Inventory, Support, HR-Timeclock, Fiscal, Billing e Design System

Leva grande, pedida como "pelo menos mais 20 etapas, use subagentes":
fecha a pendência de MFA/TOTP da Fase 1 (feito diretamente, autenticação é
sensível demais para delegar) e lança 5 subagentes em paralelo em 2 ondas
(eden-inventory, eden-support, eden-hr-timeclock; depois eden-finance,
eden-fiscal, eden-frontend), cada um em worktree isolado, com integração
seletiva manual (barrel, app.module.ts, migrations renomeadas/aplicadas,
build/typecheck/teste ao vivo antes de cada merge).

## Backend

- MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `crypto/field-encryption.service.ts`):
  login em duas etapas quando `mfa_enabled=true`, `FieldEncryptionService`
  genérico (AES-256-GCM versionado, ADR-0005) reaproveitado por outros
  segredos reversíveis (ex. credencial de equipamento Control iD).
- Inventory: warehouses, stock_movements (ledger append-only), serialized_assets,
  asset_assignments, stock_reservations — invariante "serial/patrimônio/MAC
  nunca duplicado entre ativos ativos" reforçada por índices únicos parciais
  no banco (Master Prompt §15.3).
- Support/Service Desk: tickets com máquina de estados, SLA com pausa/retomada
  em `waiting_customer`/`waiting_third_party` (funções puras testáveis).
- HR-Timeclock (fundação): dispositivos Control iD (senha cifrada), import de
  AFD com parser + CRC-16/KERMIT, `afd_records` imutável sem exceção nem
  para super_admin (sem válvula de escape, diferente de audit_log).
- Fiscal: NCM/CFOP/municípios/naturezas de operação (eden.md módulo 5, MVP de
  5 das ~19 tabelas), product_fiscal_profiles com invariante de único
  profile ativo por billing_component.
- Billing: subscription→billing_run→invoice idempotente (regenerar em draft
  substitui, nunca duplica) e imutável após consolidar (ADR-0008: nunca
  confunde faturamento/documento fiscal/recebimento).
- FKs cruzadas fechadas: quotes/client_registrations/contracts.

## Frontend — primeira leva

- `packages/ui`: Design System real extraído do tema DreamsERP comprado —
  achado que mudou o escopo: o tema já vem em React+Tailwind (não só
  Angular), tokens visuais reais portados (cores, Geist como fonte real,
  divergindo da doc antiga que citava Nunito/Poppins).
- `apps/core-web`: login real (com suporte a MFA), sessão, shell autenticado
  via `AppShell` — substitui a tela estática "Bootstrap da Fase 1".

## Bugs reais encontrados e corrigidos (todos via teste ao vivo, não leitura)

1. **Segurança**: o token de desafio MFA era aceito como access token normal
   pelo `JwtAuthGuard` (mesma assinatura, nenhuma checagem da claim
   `purpose`) — bypass total do segundo fator. Explorado e fechado ao vivo
   contra o container real; teste de regressão em `test/mfa.spec.ts`.
2. Crase solta dentro de comentário SQL na migration de hr-timeclock quebrava
   o template literal JS (`SyntaxError` ao rodar a migration).
3. CORS ausente por completo na API.
4. `turbo.json` não declarava `VITE_API_BASE_URL` como input de build — o
   Turborepo cacheava o bundle do frontend ignorando mudança na env var.
5. `compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`, que colidia com a
   variável de mesmo nome do `.env` local — o default nunca era aplicado.
6. Não existia `.dockerignore` — todo build copiava node_modules/.git/
   worktrees de subagentes (665MB) para dentro do contexto Docker.

Decisão do operador incorporada: a API nunca tem domínio público próprio
(só IP interno) — os 3 frontends chamam por caminho relativo `/api/v1`,
proxy externo roteia same-origin; CORS só é relevante para dev local.

Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter
@eden/api test` (30/30, cresceu de 15), todo o lote testado ao vivo via
curl contra os containers reais reconstruídos, incluindo os 6 bugs acima
já fechados e confirmados, e a trilha de auditoria conferida no Postgres.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:25:31 -03:00
parent 18838a9031
commit eff515b70d
105 changed files with 8875 additions and 21 deletions

View File

@@ -0,0 +1,10 @@
import { IsBoolean, IsOptional, IsString, MinLength } from "class-validator";
export class CreateTicketCommentDto {
@IsString() @MinLength(1) body!: string;
// Defaults to false (public comment) — is_internal=true is never
// visible to the subscriber/reseller portals (enforced in
// support.service.ts::get, not just by UI convention).
@IsOptional() @IsBoolean() isInternal?: boolean;
}

View File

@@ -0,0 +1,24 @@
import { IsIn, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
export class CreateTicketDto {
@IsString() @MinLength(3) title!: string;
@IsString() @MinLength(3) description!: string;
@IsString() category!: string;
@IsOptional() @IsString() subcategory?: string;
@IsOptional()
@IsIn(["low", "medium", "high", "critical"])
priority?: "low" | "medium" | "high" | "critical";
// resellerId is only honored for an actor with 'all' scope on
// support-tickets:edit (core staff) — a reseller-scoped actor always
// gets their own resellerId forced server-side in support.service.ts,
// never trusting this field for authorization (CLAUDE.md: never trust
// a client-supplied reseller_id).
@IsOptional() @IsUUID() resellerId?: string;
@IsOptional() @IsUUID() clientRegistrationId?: string;
@IsOptional() @IsUUID() contractId?: string;
@IsOptional() @IsUUID() ownerUserId?: string;
}

View File

@@ -0,0 +1,31 @@
import { IsIn, IsOptional, IsString } from "class-validator";
export class UpdateTicketStatusDto {
@IsIn([
"new",
"triage",
"in_progress",
"waiting_customer",
"waiting_third_party",
"resolved",
"closed",
"cancelled",
])
toStatus!:
| "new"
| "triage"
| "in_progress"
| "waiting_customer"
| "waiting_third_party"
| "resolved"
| "closed"
| "cancelled";
@IsOptional() @IsString() reason?: string;
// Only persisted by the service layer on a transition into 'closed' —
// see support.service.ts::updateStatus and the root_cause column
// comment in PENDING_support.cjs.
@IsOptional() @IsString() rootCause?: string;
@IsOptional() @IsString() resolutionNotes?: string;
}

View File

@@ -0,0 +1,76 @@
/**
* Support/Service Desk SLA engine — Master Prompt §6.13. Pure, DB-free
* functions (mirrors apps/api/src/quotes/pricing.ts: business logic lives
* here, not scattered across the repository/service layers, and is
* trivially unit-testable without a database).
*
* SLA policy for this MVP is deliberately a single flat default
* (first response in 4h, resolution in 24h) applied to every ticket
* regardless of category/priority. A configurable, per-category SLA
* policy (and a calendar-aware "business hours only" due-date engine,
* instead of naive wall-clock offsets) is explicitly DEFERRED — see the
* migration header comment in PENDING_support.cjs.
*/
/** The subset of a sla_timers row this module needs — duck-typed so callers can pass the full DB row. */
export interface SlaTimerPauseState {
paused_since: Date | null;
accumulated_pause_seconds: number;
}
export interface DefaultSlaDueDates {
firstResponseDueAt: Date;
resolutionDueAt: Date;
}
const DEFAULT_FIRST_RESPONSE_HOURS = 4;
const DEFAULT_RESOLUTION_HOURS = 24;
/**
* The example SLA policy this MVP ships with: first_response_due_at =
* now + 4h, resolution_due_at = now + 24h, for every ticket regardless of
* priority/category. Called once at ticket creation
* (support.service.ts::create) — never recomputed afterwards.
*/
export function computeDefaultSlaDueDates(now: Date): DefaultSlaDueDates {
return {
firstResponseDueAt: new Date(now.getTime() + DEFAULT_FIRST_RESPONSE_HOURS * 3_600_000),
resolutionDueAt: new Date(now.getTime() + DEFAULT_RESOLUTION_HOURS * 3_600_000),
};
}
/**
* Total time (in seconds) the timer has spent paused, up to `now` —
* accumulated_pause_seconds from past pause/resume cycles, PLUS the
* duration of the current pause if the timer is still paused right now
* (paused_since is non-null). This is exactly the amount that must be
* subtracted from wall-clock elapsed time to get the SLA-relevant
* "elapsed" time — a ticket sitting in waiting_customer does not burn
* SLA time by default (eden-support persona spec).
*
* Never negative even if `now` is (pathologically) earlier than
* paused_since — clamped at 0 for that slice.
*/
export function computeElapsedSeconds(timer: SlaTimerPauseState, now: Date): number {
let pausedSeconds = timer.accumulated_pause_seconds;
if (timer.paused_since) {
const currentPauseSeconds = Math.floor((now.getTime() - timer.paused_since.getTime()) / 1000);
pausedSeconds += Math.max(0, currentPauseSeconds);
}
return pausedSeconds;
}
/**
* Whether the given SLA due date has been breached as of `now`, once the
* paused time (computeElapsedSeconds) is excluded from the elapsed
* wall-clock time. Works for either due date (first_response_due_at or
* resolution_due_at) — pass whichever is being checked.
*
* adjustedNow = now - totalPausedSeconds
* breached = adjustedNow > dueAt
*/
export function isSlaBreached(timer: SlaTimerPauseState, dueAt: Date, now: Date): boolean {
const pausedSeconds = computeElapsedSeconds(timer, now);
const adjustedNowMs = now.getTime() - pausedSeconds * 1000;
return adjustedNowMs > dueAt.getTime();
}

View File

@@ -0,0 +1,64 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { SupportService } from "./support.service.js";
import { CreateTicketDto } from "./dto/create-ticket.dto.js";
import { UpdateTicketStatusDto } from "./dto/update-ticket-status.dto.js";
import { CreateTicketCommentDto } from "./dto/create-ticket-comment.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Support/Service Desk — Master Prompt §6.13. Greenfield MVP: tickets,
* status machine + SLA timers (see apps/api/src/support/sla.ts),
* public/internal comments. DEFERRED (see PENDING_support.cjs header):
* real S3 attachments, CSAT, dedicated work-order entity, routing
* queues, configurable attendance calendar.
*/
@ApiTags("support")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/support/tickets")
export class SupportController {
constructor(private readonly support: SupportService) {}
@Get()
@RequirePermission("support-tickets", "view")
list(@CurrentUser() user: EdenAuthenticatedUser) {
return this.support.list(user);
}
@Get(":id")
@RequirePermission("support-tickets", "view")
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.support.get(id, user);
}
@Post()
@RequirePermission("support-tickets", "edit")
create(@Body() dto: CreateTicketDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.support.create(dto, user);
}
@Patch(":id/status")
@RequirePermission("support-tickets", "edit")
updateStatus(
@Param("id") id: string,
@Body() dto: UpdateTicketStatusDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.support.updateStatus(id, dto, user);
}
@Post(":id/comments")
@RequirePermission("support-tickets", "edit")
addComment(
@Param("id") id: string,
@Body() dto: CreateTicketCommentDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.support.addComment(id, dto, user);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { SupportController } from "./support.controller.js";
import { SupportService } from "./support.service.js";
@Module({
imports: [AuthModule],
controllers: [SupportController],
providers: [SupportService],
exports: [SupportService],
})
export class SupportModule {}

View File

@@ -0,0 +1,176 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import {
auditRepo,
supportTicketsRepo,
type SupportTicket,
type TicketComment,
type TicketWithDetails,
} from "@eden/database";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import { computeDefaultSlaDueDates, isSlaBreached } from "./sla.js";
import type { CreateTicketDto } from "./dto/create-ticket.dto.js";
import type { UpdateTicketStatusDto } from "./dto/update-ticket-status.dto.js";
import type { CreateTicketCommentDto } from "./dto/create-ticket-comment.dto.js";
const { TicketNotFoundError, InvalidTicketTransitionError } = supportTicketsRepo;
const RESOURCE = "support-tickets";
@Injectable()
export class SupportService {
async list(user: EdenAuthenticatedUser): Promise<SupportTicket[]> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'support-tickets'");
if (scope === "all") return supportTicketsRepo.listTickets({ scope: "all" });
return supportTicketsRepo.listTickets({ scope: "reseller", resellerId: user.resellerId });
}
async get(id: string, user: EdenAuthenticatedUser): Promise<TicketWithDetails> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
const ticket = await supportTicketsRepo.getTicketById(id);
if (!ticket || !scope) throw new NotFoundException("Ticket não encontrado");
if (scope !== "all" && ticket.reseller_id !== user.resellerId) {
throw new NotFoundException("Ticket não encontrado");
}
// is_internal comments are never visible outside 'all' scope (core
// staff) — enforced here, in the query layer, not left to the UI.
// Reinforces the eden-support persona invariant: "comentário interno
// nunca visível ao cliente/revenda". A dedicated 'own' scope for the
// subscriber portal isn't wired up in this MVP (mirrors contracts.ts,
// which also only distinguishes all/reseller) — when it lands, it
// must fall into this same filtered branch.
if (scope !== "all") {
return { ...ticket, comments: ticket.comments.filter((c) => !c.is_internal) };
}
return ticket;
}
async create(dto: CreateTicketDto, user: EdenAuthenticatedUser): Promise<TicketWithDetails> {
const editScope = maxGrantedScope(user.permissions, RESOURCE, "edit");
if (!editScope) throw new ForbiddenException("Sem permissão para 'edit' em 'support-tickets'");
// Never trust a client-supplied reseller_id for a reseller-scoped
// actor (CLAUDE.md: authorization never trusts a client field) — it's
// always forced to the caller's own reseller. Only an 'all'-scope
// actor (core staff) may open a ticket on behalf of an arbitrary
// reseller.
const resellerId = editScope === "all" ? (dto.resellerId ?? null) : user.resellerId;
const now = new Date();
const { firstResponseDueAt, resolutionDueAt } = computeDefaultSlaDueDates(now);
const ticket = await supportTicketsRepo.createTicket({
title: dto.title,
description: dto.description,
category: dto.category,
subcategory: dto.subcategory ?? null,
priority: dto.priority ?? "medium",
resellerId,
clientRegistrationId: dto.clientRegistrationId ?? null,
contractId: dto.contractId ?? null,
ownerUserId: dto.ownerUserId ?? null,
openedByUserId: user.id,
createdBy: user.id,
firstResponseDueAt,
resolutionDueAt,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "support_ticket.created",
resourceType: "support_ticket",
resourceId: ticket.id,
metadata: { protocolNumber: ticket.protocol_number, priority: ticket.priority, category: ticket.category },
});
return ticket;
}
async updateStatus(
id: string,
dto: UpdateTicketStatusDto,
user: EdenAuthenticatedUser,
): Promise<SupportTicket> {
await this.get(id, user); // scope check — 404 before attempting the transition
try {
const { ticket, slaTimer } = await supportTicketsRepo.updateTicketStatus(
id,
dto.toStatus,
dto.reason ?? null,
user.id,
{ rootCause: dto.rootCause ?? null, resolutionNotes: dto.resolutionNotes ?? null },
);
// Only reachable when entering 'resolved' from 'in_progress' (see
// TICKET_STATUS_TRANSITIONS), so the timer returned here is
// guaranteed unpaused — safe to evaluate the breach directly
// against resolution_due_at.
if (dto.toStatus === "resolved") {
const now = new Date();
const met = !isSlaBreached(slaTimer, slaTimer.resolution_due_at, now);
await supportTicketsRepo.recordResolutionMet(id, met);
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "support_ticket.status_changed",
resourceType: "support_ticket",
resourceId: id,
metadata: { toStatus: dto.toStatus, reason: dto.reason ?? null },
});
return ticket;
} catch (err) {
if (err instanceof TicketNotFoundError) throw new NotFoundException("Ticket não encontrado");
if (err instanceof InvalidTicketTransitionError) {
throw new ConflictException(err.message);
}
throw err;
}
}
async addComment(
id: string,
dto: CreateTicketCommentDto,
user: EdenAuthenticatedUser,
): Promise<TicketComment> {
const ticket = await this.get(id, user);
const isInternal = dto.isInternal ?? false;
const comment = await supportTicketsRepo.addTicketComment(id, {
authorUserId: user.id,
body: dto.body,
isInternal,
});
// First public response starts/settles first_response_at + the SLA
// first_response_met flag — only once, never recomputed afterwards
// (see recordFirstResponse's idempotent WHERE ... IS NULL guards).
if (!isInternal && ticket.first_response_at === null && ticket.slaTimer) {
const now = new Date();
const met = !isSlaBreached(ticket.slaTimer, ticket.slaTimer.first_response_due_at, now);
await supportTicketsRepo.recordFirstResponse(id, met, now);
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "support_ticket.comment_added",
resourceType: "support_ticket",
resourceId: id,
metadata: { isInternal },
});
return comment;
}
}