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:
10
apps/api/src/support/dto/create-ticket-comment.dto.ts
Normal file
10
apps/api/src/support/dto/create-ticket-comment.dto.ts
Normal 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;
|
||||
}
|
||||
24
apps/api/src/support/dto/create-ticket.dto.ts
Normal file
24
apps/api/src/support/dto/create-ticket.dto.ts
Normal 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;
|
||||
}
|
||||
31
apps/api/src/support/dto/update-ticket-status.dto.ts
Normal file
31
apps/api/src/support/dto/update-ticket-status.dto.ts
Normal 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;
|
||||
}
|
||||
76
apps/api/src/support/sla.ts
Normal file
76
apps/api/src/support/sla.ts
Normal 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();
|
||||
}
|
||||
64
apps/api/src/support/support.controller.ts
Normal file
64
apps/api/src/support/support.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/support/support.module.ts
Normal file
12
apps/api/src/support/support.module.ts
Normal 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 {}
|
||||
176
apps/api/src/support/support.service.ts
Normal file
176
apps/api/src/support/support.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user