Implementação MVP

This commit is contained in:
camilafagundes committed 2026-10-03 00:00:45 -03:00
commit 807b3d72cd
54 files changed
+5241

No files matched your search

@@ -0,0 +1,23 @@
/**
* Autorização de workspace neste repositório. A implementação chega no prompt 03.
* Não há adapter que aprove por omissão: sem implementação, a ação não é autorizada.
* `workspaceId` é resolvido no servidor. O cliente não é fonte de autoridade.
*/
export type WorkspaceRole = "owner" | "admin" | "gestor" | "agent";
export type WorkspaceAction =
| "models.read"
| "models.write"
| "proposals.read"
| "proposals.write"
| "proposals.generate";
export type AccessDecision = "allowed" | "denied";
export interface WorkspaceAuthorizer {
authorize(input: {
readonly actorId: string;
readonly workspaceId: string;
readonly action: WorkspaceAction;
}): Promise<AccessDecision>;
}
@@ -0,0 +1,300 @@
import { describe, expect, it } from "vitest";
import { createCorrelation } from "../../domain/shared/correlation";
import type { GenerationRequest } from "../../domain/generation/types";
import type { GenerationCoordinator } from "../../ports/generation-coordinator";
import type { ProposalPdfRenderer } from "../../ports/proposal-pdf-renderer";
import { createGenerationCoordinator } from "./run-generation";
import {
createInMemoryArtifacts,
createInMemoryFormalGenerationStore,
createInMemoryGenerationLedger,
type CommitFailurePoint,
} from "../../infrastructure/memory/in-memory-generation";
import { createTestNumberAllocator } from "../../infrastructure/memory/test-commercial-number-allocator";
function request(overrides: Partial<GenerationRequest> = {}): GenerationRequest {
return {
workspaceId: "workspace-a",
proposalId: "proposal-1",
draftId: "draft-1",
actorId: "user-1",
idempotencyKey: "click-1",
contentHash: "hash-1",
modelVersionId: "model-version-1",
existingCommercialNumber: null,
correlation: createCorrelation({
workspaceId: "workspace-a",
actorId: "user-1",
jobId: "job-1",
requestId: "request-1",
}),
...overrides,
};
}
function harness() {
const numbers = createTestNumberAllocator();
const artifacts = createInMemoryArtifacts();
const ledger = createInMemoryGenerationLedger({ now: () => Date.now() });
let failure: CommitFailurePoint | null = null;
const store = createInMemoryFormalGenerationStore({
takeFailure: () => {
const point = failure;
failure = null;
return point;
},
demote: artifacts.demote,
});
const coordinator = createGenerationCoordinator({
store,
ledger,
renderer: artifacts.renderer,
staging: artifacts.staging,
numbers,
attemptTtlMs: 60_000,
});
return {
numbers,
artifacts,
ledger,
store,
coordinator,
failNextCommit(point: CommitFailurePoint) {
failure = point;
},
};
}
describe("geração formal", () => {
it("grava número e versão só depois do artefato promovido", async () => {
const { coordinator, numbers, artifacts } = harness();
const outcome = await coordinator.generate(request());
expect(outcome.status).toBe("committed");
if (outcome.status !== "committed") {
return;
}
expect(outcome.replayed).toBe(false);
expect(outcome.versionNo).toBe(1);
expect(outcome.commercialNumber.startsWith("TEST-")).toBe(true);
expect(outcome.pdfRef.startsWith("formal:")).toBe(true);
expect(numbers.committedCount()).toBe(1);
expect(artifacts.formal()).toEqual([outcome.pdfRef]);
expect(artifacts.pending()).toEqual([]);
expect(Object.isFrozen(outcome)).toBe(true);
});
it("repete a mesma versão no retry com a mesma chave", async () => {
const { coordinator, numbers, artifacts } = harness();
const first = await coordinator.generate(request());
const second = await coordinator.generate(request());
expect(first.status).toBe("committed");
expect(second.status).toBe("committed");
if (first.status !== "committed" || second.status !== "committed") {
return;
}
expect(second.replayed).toBe(true);
expect(second.proposalVersionId).toBe(first.proposalVersionId);
expect(second.commercialNumber).toBe(first.commercialNumber);
expect(numbers.reserveCalls()).toBe(1);
expect(artifacts.renderCount()).toBe(1);
expect(artifacts.formal()).toHaveLength(1);
});
it("não cria segunda versão quando a mesma chave chega com outro conteúdo", async () => {
const { coordinator, artifacts } = harness();
await coordinator.generate(request());
const conflict = await coordinator.generate(request({ contentHash: "hash-2" }));
expect(conflict).toMatchObject({
status: "conflict",
commercialNumberAssigned: false,
formalVersionCreated: false,
});
expect(artifacts.formal()).toHaveLength(1);
expect(artifacts.renderCount()).toBe(1);
});
it("não consome número nem versão quando a renderização falha", async () => {
const { coordinator, numbers, artifacts } = harness();
artifacts.failNextRender();
const outcome = await coordinator.generate(request());
expect(outcome).toEqual({
status: "failed",
stage: "render",
commercialNumberAssigned: false,
formalVersionCreated: false,
});
expect(numbers.reserveCalls()).toBe(0);
expect(numbers.committedCount()).toBe(0);
expect(artifacts.formal()).toEqual([]);
expect(artifacts.pending()).toEqual([]);
});
it("desfaz número e artefato quando o commit falha depois da reserva", async () => {
const { coordinator, numbers, artifacts, failNextCommit } = harness();
failNextCommit("after-reserve");
const failed = await coordinator.generate(request());
const retried = await coordinator.generate(request());
expect(failed).toMatchObject({ status: "failed", stage: "commit" });
expect(numbers.committedCount()).toBe(1);
expect(retried.status).toBe("committed");
if (retried.status !== "committed") {
return;
}
expect(retried.commercialNumber).toBe("TEST-000001");
expect(artifacts.formal()).toEqual([retried.pdfRef]);
});
it("não deixa PDF formal visível quando a promoção não chega a commitar", async () => {
const { coordinator, numbers, artifacts, failNextCommit } = harness();
failNextCommit("after-promote");
const outcome = await coordinator.generate(request());
expect(outcome).toMatchObject({
status: "failed",
stage: "commit",
formalVersionCreated: false,
commercialNumberAssigned: false,
});
expect(numbers.committedCount()).toBe(0);
expect(artifacts.formal()).toEqual([]);
expect(artifacts.pending()).toEqual([]);
});
it("reutiliza o número já existente na versão seguinte", async () => {
const { coordinator, numbers, artifacts } = harness();
const first = await coordinator.generate(request());
if (first.status !== "committed") {
throw new Error("primeira geração deveria commitar");
}
const second = await coordinator.generate(
request({
idempotencyKey: "click-2",
contentHash: "hash-2",
existingCommercialNumber: first.commercialNumber,
}),
);
expect(second).toMatchObject({
status: "committed",
replayed: false,
versionNo: 2,
commercialNumber: first.commercialNumber,
});
expect(numbers.reserveCalls()).toBe(1);
expect(artifacts.formal()).toHaveLength(2);
});
it("não abre outra versão se a proposta ainda não tem número comercial", async () => {
const { coordinator, numbers, artifacts } = harness();
await coordinator.generate(request());
const outcome = await coordinator.generate(
request({
idempotencyKey: "click-2",
contentHash: "hash-2",
existingCommercialNumber: null,
}),
);
expect(outcome).toMatchObject({ status: "failed", stage: "commit" });
expect(numbers.reserveCalls()).toBe(1);
expect(artifacts.formal()).toHaveLength(1);
});
it("não reaproveita geração formal de outro workspace", async () => {
const { coordinator, artifacts } = harness();
const first = await coordinator.generate(request());
const otherWorkspace = await coordinator.generate(
request({
workspaceId: "workspace-b",
correlation: createCorrelation({
workspaceId: "workspace-b",
actorId: "user-1",
requestId: "request-2",
}),
}),
);
expect(first.status).toBe("committed");
expect(otherWorkspace.status).toBe("committed");
if (first.status !== "committed" || otherWorkspace.status !== "committed") {
return;
}
expect(otherWorkspace.proposalVersionId).not.toBe(first.proposalVersionId);
expect(otherWorkspace.pdfRef).not.toBe(first.pdfRef);
expect(artifacts.formal()).toHaveLength(2);
});
it("um segundo clique enquanto a geração está em andamento não cria outra versão", async () => {
const numbers = createTestNumberAllocator();
const artifacts = createInMemoryArtifacts();
const ledger = createInMemoryGenerationLedger({ now: () => Date.now() });
const store = createInMemoryFormalGenerationStore({
takeFailure: () => null,
demote: artifacts.demote,
});
const gate: { current: GenerationCoordinator | null } = { current: null };
let nestedStatus: string | null = null;
const renderer: ProposalPdfRenderer = {
async render(input) {
if (nestedStatus === null && gate.current) {
const nested = await gate.current.generate(request());
nestedStatus = nested.status;
}
return artifacts.renderer.render(input);
},
};
const coordinator = createGenerationCoordinator({
store,
ledger,
renderer,
staging: artifacts.staging,
numbers,
attemptTtlMs: 60_000,
});
gate.current = coordinator;
const outcome = await coordinator.generate(request());
expect(nestedStatus).toBe("in-progress");
expect(outcome.status).toBe("committed");
expect(numbers.committedCount()).toBe(1);
expect(artifacts.formal()).toHaveLength(1);
expect(artifacts.renderCount()).toBe(1);
});
});
describe("claim efêmero da geração", () => {
it("expira e aceita uma nova tentativa", async () => {
let now = 0;
const ledger = createInMemoryGenerationLedger({ now: () => now });
const identity = {
workspaceId: "workspace-a",
proposalId: "proposal-1",
idempotencyKey: "click-1",
};
await expect(
ledger.claim({ ...identity, fingerprint: "a", ttlMs: 10 }),
).resolves.toBe("claimed");
await expect(
ledger.claim({ ...identity, fingerprint: "b", ttlMs: 10 }),
).resolves.toBe("conflict");
now = 10;
await expect(
ledger.claim({ ...identity, fingerprint: "b", ttlMs: 10 }),
).resolves.toBe("claimed");
});
});
@@ -0,0 +1,108 @@
import {
failedGeneration,
generationFingerprint,
} from "../../domain/generation/types";
import type { GenerationOutcome, GenerationRequest } from "../../domain/generation/types";
import type { CommercialNumberAllocator } from "../../ports/commercial-number-allocator";
import type { FormalGenerationStore } from "../../ports/formal-generation-store";
import type { GenerationAttemptLedger } from "../../ports/generation-attempt-ledger";
import type { GenerationCoordinator } from "../../ports/generation-coordinator";
import type { ProposalPdfRenderer } from "../../ports/proposal-pdf-renderer";
import type { StagingArtifactStore } from "../../ports/staging-artifact";
export type GenerationCoordinatorDependencies = {
readonly store: FormalGenerationStore;
readonly ledger: GenerationAttemptLedger;
readonly renderer: ProposalPdfRenderer;
readonly staging: StagingArtifactStore;
readonly numbers: CommercialNumberAllocator;
/**
* TTL do claim efêmero no Redis. Não é meta de desempenho.
* P-012 continua aberto; quem liga o coordinator informa o valor operacional.
*/
readonly attemptTtlMs: number;
};
const blocked = {
commercialNumberAssigned: false,
formalVersionCreated: false,
} as const;
/**
* Orquestra a geração sem persistir número comercial nem versão formal
* antes de existir artefato válido dentro do commit final.
* Falha de render ou de commit não grava resultado idempotente, para o retry poder repetir.
*/
export function createGenerationCoordinator(
deps: GenerationCoordinatorDependencies,
): GenerationCoordinator {
return {
async generate(request: GenerationRequest): Promise<GenerationOutcome> {
const fingerprint = generationFingerprint(request);
const identity = {
workspaceId: request.workspaceId,
proposalId: request.proposalId,
idempotencyKey: request.idempotencyKey,
};
const existing = await deps.store.findByIdempotency(identity);
if (existing) {
if (existing.fingerprint !== fingerprint) {
return { status: "conflict", ...blocked };
}
return Object.freeze({ ...existing, replayed: true });
}
const claim = await deps.ledger.claim({
...identity,
fingerprint,
ttlMs: deps.attemptTtlMs,
});
if (claim === "conflict") {
return { status: "conflict", ...blocked };
}
if (claim === "same-in-progress") {
return { status: "in-progress", ...blocked };
}
let stagingRef: string | null = null;
let committed = false;
try {
let rendered: Awaited<ReturnType<ProposalPdfRenderer["render"]>>;
try {
rendered = await deps.renderer.render({
workspaceId: request.workspaceId,
proposalId: request.proposalId,
modelVersionId: request.modelVersionId,
contentHash: request.contentHash,
correlation: request.correlation,
});
} catch {
return failedGeneration("render");
}
if (rendered.status === "failed") {
return failedGeneration("render");
}
stagingRef = rendered.stagingRef;
if (rendered.contentHash !== request.contentHash) {
return failedGeneration("render");
}
const result = await deps.store.commit({
request,
stagingRef,
numbers: deps.numbers,
staging: deps.staging,
});
committed = true;
return Object.freeze({ ...result, replayed: false });
} catch {
return failedGeneration("commit");
} finally {
if (stagingRef !== null && !committed) {
await deps.staging.discard(stagingRef);
}
await deps.ledger.release(identity);
}
},
};
}
+8
View File
@@ -0,0 +1,8 @@
/** Eventos mínimos. Retenção é P-007 e não é decidida aqui. */
export const AUDIT_MODULE = "domain.audit" as const;
export type AuditEventName =
| "proposal.checkpoint"
| "proposal.generated"
| "proposal.email"
| "model.activated";
+2
View File
@@ -0,0 +1,2 @@
/** Logo obrigatório para identidade da proposta. Cores são opcionais. */
export const BRANDING_MODULE = "domain.branding" as const;
+5
View File
@@ -0,0 +1,5 @@
/**
* Pré-preenchimento opcional. O fluxo standalone não depende deste módulo.
* A fonte de Cliente no uso independente é P-002.
*/
export const CRO_MODULE = "domain.cro" as const;
+9
View File
@@ -0,0 +1,9 @@
/**
* P-001 permanece aberto: sem e-mail comercial não há Reply-To decidido.
* P-011 permanece aberto: não há provider escolhido.
*/
export type ReplyToPolicy =
| { readonly status: "commercial"; readonly address: string }
| { readonly status: "unresolved"; readonly pending: "P-001" };
export const DELIVERY_MODULE = "domain.delivery" as const;
+13
View File
@@ -0,0 +1,13 @@
export {
COMMERCIAL_NUMBER_PATTERN,
failedGeneration,
generationFingerprint,
} from "./types";
export type {
CommittedGeneration,
GenerationFingerprint,
GenerationOutcome,
GenerationRequest,
} from "./types";
export const GENERATION_MODULE = "domain.generation" as const;
+62
View File
@@ -0,0 +1,62 @@
import type { CorrelationContext } from "../shared/correlation";
/** Formato visual já decidido na source of truth. O escopo do contador é P-009. */
export const COMMERCIAL_NUMBER_PATTERN = /^PROP-\d{6}$/;
export type GenerationRequest = {
readonly workspaceId: string;
readonly proposalId: string;
readonly draftId: string;
readonly actorId: string;
readonly idempotencyKey: string;
readonly contentHash: string;
readonly modelVersionId: string;
readonly existingCommercialNumber: string | null;
readonly correlation: CorrelationContext;
};
export type GenerationFingerprint = string;
export function generationFingerprint(request: GenerationRequest): GenerationFingerprint {
return `${request.draftId}\u0000${request.modelVersionId}\u0000${request.contentHash}`;
}
export type CommittedGeneration = {
readonly status: "committed";
readonly proposalVersionId: string;
readonly versionNo: number;
readonly commercialNumber: string;
readonly pdfRef: string;
readonly contentHash: string;
readonly draftId: string;
readonly modelVersionId: string;
readonly fingerprint: GenerationFingerprint;
};
export type GenerationOutcome =
| (CommittedGeneration & { readonly replayed: boolean })
| {
readonly status: "failed";
readonly stage: "render" | "commit";
readonly commercialNumberAssigned: false;
readonly formalVersionCreated: false;
}
| {
readonly status: "conflict";
readonly commercialNumberAssigned: false;
readonly formalVersionCreated: false;
}
| {
readonly status: "in-progress";
readonly commercialNumberAssigned: false;
readonly formalVersionCreated: false;
};
export function failedGeneration(stage: "render" | "commit"): GenerationOutcome {
return {
status: "failed",
stage,
commercialNumberAssigned: false,
formalVersionCreated: false,
};
}
+2
View File
@@ -0,0 +1,2 @@
/** Ciclo de modelo, limite de 10 e versões entram nos prompts 05–08. */
export const MODELS_MODULE = "domain.models" as const;
+2
View File
@@ -0,0 +1,2 @@
/** Propostas, rascunho e versões formais entram nos prompts 09–13. */
export const PROPOSALS_MODULE = "domain.proposals" as const;
+24
View File
@@ -0,0 +1,24 @@
/**
* Identificadores de correlação permitidos em Sentry e logs.
* Não carregam documento, template, snapshot nem PII.
*/
export type CorrelationContext = {
readonly requestId: string;
readonly jobId: string | null;
readonly workspaceId: string;
readonly actorId: string;
};
export function createCorrelation(input: {
readonly workspaceId: string;
readonly actorId: string;
readonly jobId?: string | null;
readonly requestId?: string;
}): CorrelationContext {
return {
requestId: input.requestId ?? crypto.randomUUID(),
jobId: input.jobId ?? null,
workspaceId: input.workspaceId,
actorId: input.actorId,
};
}
+4
View File
@@ -0,0 +1,4 @@
/** RN-016. TTL funcional do lock de edição. O MVP não tem tomada forçada. */
export const EDIT_LOCK_TTL_MS = 10 * 60 * 1000;
export const EDIT_LOCK_ALLOWS_FORCE_TAKEOVER = false as const;
+12
View File
@@ -0,0 +1,12 @@
/**
* P-006 / ADR-004 permanecem Proposed.
* Não há limite de tamanho, antivírus, sandbox nem regra de extração parcial neste tipo.
*/
export type FileSecurityPolicy =
| { readonly status: "unapproved"; readonly pending: "P-006" }
| { readonly status: "approved"; readonly decisionRef: string };
export const UNAPPROVED_FILE_SECURITY_POLICY: FileSecurityPolicy = {
status: "unapproved",
pending: "P-006",
};
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { createCorrelation } from "../../domain/shared/correlation";
import { UNAPPROVED_FILE_SECURITY_POLICY } from "../../domain/shared/file-security-policy";
import { createBlockedTemplateIngestion } from "./blocked-template-ingestion";
import { createUnconfiguredEmailProvider } from "./unconfigured-email-provider";
const correlation = createCorrelation({
workspaceId: "workspace-a",
actorId: "user-1",
requestId: "request-1",
});
describe("adaptações ainda sem decisão", () => {
it("não analisa arquivo enquanto P-006 e P-010 seguem abertos", async () => {
const ingestion = createBlockedTemplateIngestion();
await expect(
ingestion.ingest({
workspaceId: "workspace-a",
sourceFileRef: "file-1",
mediaType: "application/pdf",
policy: UNAPPROVED_FILE_SECURITY_POLICY,
correlation,
}),
).resolves.toEqual({ status: "blocked", pending: "P-006" });
await expect(
ingestion.ingest({
workspaceId: "workspace-a",
sourceFileRef: "file-1",
mediaType: "application/pdf",
policy: { status: "approved", decisionRef: "ADR-004" },
correlation,
}),
).resolves.toEqual({ status: "blocked", pending: "P-010" });
});
it("não envia e-mail enquanto P-001 ou P-011 seguem abertos", async () => {
const email = createUnconfiguredEmailProvider();
const command = {
workspaceId: "workspace-a",
proposalVersionId: "version-1",
pdfRef: "formal:version-1",
toAddress: "cliente@example.com",
subject: "Proposta",
body: "Segue a proposta.",
correlation,
};
await expect(
email.send({
...command,
replyTo: { status: "unresolved", pending: "P-001" },
}),
).resolves.toEqual({ status: "blocked", pending: "P-001" });
await expect(
email.send({
...command,
replyTo: { status: "commercial", address: "comercial@example.com" },
}),
).resolves.toEqual({ status: "blocked", pending: "P-011" });
});
});
@@ -0,0 +1,13 @@
import type { TemplateIngestionService } from "../../ports/template-ingestion";
/** Não extrai arquivo. Política e motor seguem P-006 e P-010. */
export function createBlockedTemplateIngestion(): TemplateIngestionService {
return {
async ingest(input) {
if (input.policy.status === "unapproved") {
return { status: "blocked", pending: "P-006" };
}
return { status: "blocked", pending: "P-010" };
},
};
}
@@ -0,0 +1,181 @@
import { generationFingerprint } from "../../domain/generation/types";
import type { CommittedGeneration } from "../../domain/generation/types";
import type { FormalGenerationIdentity, FormalGenerationStore } from "../../ports/formal-generation-store";
import type { GenerationAttemptLedger, GenerationClaim } from "../../ports/generation-attempt-ledger";
import type { NumberReservation } from "../../ports/commercial-number-allocator";
import type { ProposalPdfRenderer } from "../../ports/proposal-pdf-renderer";
import type { StagingArtifactStore } from "../../ports/staging-artifact";
export type CommitFailurePoint = "after-reserve" | "after-promote";
type ClaimRow = {
readonly fingerprint: string;
readonly expiresAt: number;
};
function identityKey(identity: FormalGenerationIdentity): string {
return `${identity.workspaceId}\u0000${identity.proposalId}\u0000${identity.idempotencyKey}`;
}
export function createInMemoryGenerationLedger(clock: {
readonly now: () => number;
}): GenerationAttemptLedger {
const rows = new Map<string, ClaimRow>();
return {
async claim(input): Promise<GenerationClaim> {
const key = identityKey(input);
const now = clock.now();
const current = rows.get(key);
if (current && current.expiresAt > now) {
return current.fingerprint === input.fingerprint ? "same-in-progress" : "conflict";
}
rows.set(key, {
fingerprint: input.fingerprint,
expiresAt: now + input.ttlMs,
});
return "claimed";
},
async release(identity): Promise<void> {
rows.delete(identityKey(identity));
},
};
}
export function createInMemoryArtifacts(): {
readonly renderer: ProposalPdfRenderer;
readonly staging: StagingArtifactStore;
readonly demote: (pdfRef: string) => void;
readonly formal: () => readonly string[];
readonly pending: () => readonly string[];
readonly renderCount: () => number;
readonly failNextRender: () => void;
} {
const pending = new Set<string>();
const formal = new Set<string>();
let renders = 0;
let failRender = false;
const renderer: ProposalPdfRenderer = {
async render(input) {
renders += 1;
if (failRender) {
failRender = false;
return { status: "failed", code: "render-failed" };
}
const stagingRef = `staging:${input.proposalId}:${input.contentHash}:${renders}`;
pending.add(stagingRef);
return {
status: "rendered",
stagingRef,
contentHash: input.contentHash,
};
},
};
const staging: StagingArtifactStore = {
async promote(stagingRef) {
if (!pending.has(stagingRef)) {
throw new Error("missing-staging");
}
pending.delete(stagingRef);
const pdfRef = `formal:${stagingRef}`;
formal.add(pdfRef);
return { pdfRef };
},
async discard(stagingRef) {
pending.delete(stagingRef);
},
};
return {
renderer,
staging,
demote(pdfRef) {
formal.delete(pdfRef);
},
formal: () => [...formal],
pending: () => [...pending],
renderCount: () => renders,
failNextRender() {
failRender = true;
},
};
}
export function createInMemoryFormalGenerationStore(hooks: {
readonly takeFailure: () => CommitFailurePoint | null;
readonly demote: (pdfRef: string) => void;
}): FormalGenerationStore {
const byKey = new Map<string, CommittedGeneration>();
const byProposal = new Map<string, CommittedGeneration[]>();
return {
async findByIdempotency(identity) {
return byKey.get(identityKey(identity)) ?? null;
},
async commit(input) {
const fingerprint = generationFingerprint(input.request);
const key = identityKey(input.request);
const existing = byKey.get(key);
if (existing) {
if (existing.fingerprint !== fingerprint) {
throw new Error("idempotency-conflict");
}
return existing;
}
const proposalKey = `${input.request.workspaceId}\u0000${input.request.proposalId}`;
const current = byProposal.get(proposalKey) ?? [];
const versionNo = current.length + 1;
const failurePoint = hooks.takeFailure();
let reservation: NumberReservation | null = null;
let pdfRef: string | null = null;
try {
let commercialNumber = input.request.existingCommercialNumber;
if (commercialNumber === null) {
if (versionNo !== 1) {
throw new Error("missing-commercial-number");
}
reservation = await input.numbers.reserve({
workspaceId: input.request.workspaceId,
proposalId: input.request.proposalId,
});
commercialNumber = reservation.commercialNumber;
}
if (failurePoint === "after-reserve") {
throw new Error("commit-failed");
}
const promoted = await input.staging.promote(input.stagingRef);
pdfRef = promoted.pdfRef;
if (failurePoint === "after-promote") {
throw new Error("commit-failed");
}
const committed: CommittedGeneration = Object.freeze({
status: "committed",
proposalVersionId: crypto.randomUUID(),
versionNo,
commercialNumber,
pdfRef,
contentHash: input.request.contentHash,
draftId: input.request.draftId,
modelVersionId: input.request.modelVersionId,
fingerprint,
});
byProposal.set(proposalKey, [...current, committed]);
byKey.set(key, committed);
reservation?.commit();
return committed;
} catch (error) {
reservation?.rollback();
if (pdfRef !== null) {
hooks.demote(pdfRef);
}
throw error;
}
},
};
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { EDIT_LOCK_TTL_MS } from "../../domain/shared/edit-lock";
import type { DistributedLockService } from "../../ports/distributed-lock";
import { createInMemoryDistributedLock } from "./in-memory-lock";
type TakeoverIsAbsent = DistributedLockService extends { forceTakeover: unknown }
? never
: true;
describe("lock distribuído", () => {
it("não oferece tomada forçada", () => {
const takeoverIsAbsent: TakeoverIsAbsent = true;
expect(takeoverIsAbsent).toBe(true);
});
it("mantém um editor até o TTL e só o dono libera", async () => {
let now = 1_000;
const lock = createInMemoryDistributedLock({ now: () => now });
const first = await lock.acquire({
key: "model:1",
ttlMs: EDIT_LOCK_TTL_MS,
ownerId: "user-1",
});
const blocked = await lock.acquire({
key: "model:1",
ttlMs: EDIT_LOCK_TTL_MS,
ownerId: "user-2",
});
expect(first).not.toBeNull();
expect(blocked).toBeNull();
if (!first) {
return;
}
await expect(lock.release({ ...first, ownerId: "user-2" })).resolves.toBe(false);
await expect(lock.release(first)).resolves.toBe(true);
const afterRelease = await lock.acquire({
key: "model:1",
ttlMs: EDIT_LOCK_TTL_MS,
ownerId: "user-2",
});
expect(afterRelease).not.toBeNull();
if (!afterRelease) {
return;
}
now += EDIT_LOCK_TTL_MS;
const afterExpiry = await lock.acquire({
key: "model:1",
ttlMs: EDIT_LOCK_TTL_MS,
ownerId: "user-3",
});
expect(afterExpiry).not.toBeNull();
});
});
@@ -0,0 +1,42 @@
import type { DistributedLockService, LockHandle } from "../../ports/distributed-lock";
type LockRow = {
readonly ownerId: string;
readonly token: string;
readonly expiresAt: number;
};
export function createInMemoryDistributedLock(clock: {
readonly now: () => number;
}): DistributedLockService {
const rows = new Map<string, LockRow>();
return {
async acquire(input): Promise<LockHandle | null> {
const now = clock.now();
const current = rows.get(input.key);
if (current && current.expiresAt > now) {
return null;
}
const handle: LockHandle = {
key: input.key,
ownerId: input.ownerId,
token: crypto.randomUUID(),
};
rows.set(input.key, {
ownerId: handle.ownerId,
token: handle.token,
expiresAt: now + input.ttlMs,
});
return handle;
},
async release(handle): Promise<boolean> {
const current = rows.get(handle.key);
if (!current || current.token !== handle.token || current.ownerId !== handle.ownerId) {
return false;
}
rows.delete(handle.key);
return true;
},
};
}
@@ -0,0 +1,56 @@
import type {
CommercialNumberAllocator,
NumberReservation,
} from "../../ports/commercial-number-allocator";
/**
* Duplo de teste. O contador único deste arquivo não é a decisão de P-009.
* O token `TEST-######` também não é o número comercial de produção (`PROP-######`).
*/
export function createTestNumberAllocator(): CommercialNumberAllocator & {
readonly reserveCalls: () => number;
readonly committedCount: () => number;
} {
const free: number[] = [];
const committed = new Set<number>();
let watermark = 1;
let reserves = 0;
function take(): number {
free.sort((left, right) => left - right);
const reused = free.shift();
if (reused !== undefined) {
return reused;
}
const issued = watermark;
watermark += 1;
return issued;
}
return {
reserveCalls: () => reserves,
committedCount: () => committed.size,
async reserve(): Promise<NumberReservation> {
reserves += 1;
const issued = take();
let settled = false;
return {
commercialNumber: `TEST-${String(issued).padStart(6, "0")}`,
commit() {
if (settled) {
return;
}
settled = true;
committed.add(issued);
},
rollback() {
if (settled) {
return;
}
settled = true;
free.push(issued);
},
};
},
};
}
@@ -0,0 +1,13 @@
import type { ProposalEmailProvider } from "../../ports/proposal-email-provider";
/** Não envia e-mail e não escolhe provider. */
export function createUnconfiguredEmailProvider(): ProposalEmailProvider {
return {
async send(command) {
if (command.replyTo.status === "unresolved") {
return { status: "blocked", pending: "P-001" };
}
return { status: "blocked", pending: "P-011" };
},
};
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { standaloneCroPrefill } from "./cro-prefill";
describe("pré-preenchimento CRO", () => {
it("segue sem cliente quando não há contexto e não inventa a fonte", async () => {
const cro = standaloneCroPrefill();
await expect(
cro.lookup({ workspaceId: "workspace-a", clientContextId: null }),
).resolves.toEqual({ status: "no-context" });
await expect(
cro.lookup({ workspaceId: "workspace-a", clientContextId: "client-1" }),
).resolves.toEqual({ status: "pending-source", pending: "P-002" });
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Contrato futuro com o CRO. Não compartilha banco, sessão nem componentes.
* P-002: a fonte de Cliente no uso independente não está decidida.
* Sem contexto explícito, o resultado é `no-context` e o fluxo segue.
*/
export interface CroClientPrefillPort {
lookup(input: {
readonly workspaceId: string;
readonly clientContextId: string | null;
}): Promise<
| { readonly status: "no-context" }
| { readonly status: "pending-source"; readonly pending: "P-002" }
>;
}
export function standaloneCroPrefill(): CroClientPrefillPort {
return {
async lookup(input) {
if (input.clientContextId === null) {
return { status: "no-context" };
}
return { status: "pending-source", pending: "P-002" };
},
};
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { scrubForTelemetry } from "./scrub-for-telemetry";
describe("scrub de telemetria", () => {
it("mantém ids de correlação e remove documento, template e PII", () => {
const cyclic: Record<string, unknown> = { requestId: "request-1" };
cyclic.self = cyclic;
const scrubbed = scrubForTelemetry({
requestId: "request-1",
jobId: "job-1",
workspaceId: "workspace-a",
actorId: "user-1",
template: "contrato integral",
nested: {
email: "cliente@example.com",
contentSnapshot: { cliente: "Ana" },
proposalId: "proposal-1",
},
self: cyclic,
});
expect(scrubbed).toMatchObject({
requestId: "request-1",
jobId: "job-1",
workspaceId: "workspace-a",
actorId: "user-1",
template: "[redacted]",
nested: {
email: "[redacted]",
contentSnapshot: "[redacted]",
proposalId: "proposal-1",
},
});
expect(JSON.stringify(scrubbed)).not.toContain("contrato integral");
expect(JSON.stringify(scrubbed)).not.toContain("cliente@example.com");
expect(JSON.stringify(scrubbed)).not.toContain("Ana");
});
});
+48
View File
@@ -0,0 +1,48 @@
const FORBIDDEN_KEYS = new Set([
"email",
"toaddress",
"toemail",
"subject",
"body",
"message",
"document",
"template",
"datajson",
"contentsnapshot",
"normalizedtemplate",
"analysisjson",
"cpf",
"cnpj",
"phone",
"telefone",
"logoref",
"filename",
"rawtext",
"pdfbytes",
]);
/**
* Tags permitidas no Sentry: requestId, jobId, workspaceId, actorId, proposalId, modelId.
* Documento, template, snapshot e PII são removidos antes de qualquer envio.
*/
export function scrubForTelemetry(value: unknown): unknown {
return scrub(value, new WeakSet<object>());
}
function scrub(value: unknown, seen: WeakSet<object>): unknown {
if (value === null || typeof value !== "object") {
return value;
}
if (seen.has(value)) {
return "[circular]";
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => scrub(item, seen));
}
const output: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value)) {
output[key] = FORBIDDEN_KEYS.has(key.toLowerCase()) ? "[redacted]" : scrub(nested, seen);
}
return output;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Reserva de número comercial dentro da transação formal.
* O escopo da sequência (workspace, global ou outro) é P-009 / ADR-001 e não está decidido.
* Não há implementação de produção nesta fase.
*/
export interface NumberReservation {
readonly commercialNumber: string;
commit(): void;
rollback(): void;
}
export interface CommercialNumberAllocator {
reserve(input: {
readonly workspaceId: string;
readonly proposalId: string;
}): Promise<NumberReservation>;
}
+18
View File
@@ -0,0 +1,18 @@
export type LockHandle = {
readonly key: string;
readonly ownerId: string;
readonly token: string;
};
/**
* Lock distribuído. A implementação de produção será Redis.
* Não existe operação de tomada forçada.
*/
export interface DistributedLockService {
acquire(input: {
readonly key: string;
readonly ttlMs: number;
readonly ownerId: string;
}): Promise<LockHandle | null>;
release(handle: LockHandle): Promise<boolean>;
}
+24
View File
@@ -0,0 +1,24 @@
import type { CommittedGeneration, GenerationRequest } from "../domain/generation/types";
import type { CommercialNumberAllocator } from "./commercial-number-allocator";
import type { StagingArtifactStore } from "./staging-artifact";
export type FormalGenerationIdentity = {
readonly workspaceId: string;
readonly proposalId: string;
readonly idempotencyKey: string;
};
/**
* Fonte formal da geração. Em produção, uma transação PostgreSQL.
* Número e versão só persistem se o artefato promovido entrar no mesmo commit.
* Não há update nem delete de versão formal.
*/
export interface FormalGenerationStore {
findByIdempotency(identity: FormalGenerationIdentity): Promise<CommittedGeneration | null>;
commit(input: {
readonly request: GenerationRequest;
readonly stagingRef: string;
readonly numbers: CommercialNumberAllocator;
readonly staging: StagingArtifactStore;
}): Promise<CommittedGeneration>;
}
+15
View File
@@ -0,0 +1,15 @@
import type { FormalGenerationIdentity } from "./formal-generation-store";
export type GenerationClaim = "claimed" | "same-in-progress" | "conflict";
/**
* Estado efêmero do job. Em produção, Redis.
* PostgreSQL continua sendo a fonte do resultado formal.
*/
export interface GenerationAttemptLedger {
claim(input: FormalGenerationIdentity & {
readonly fingerprint: string;
readonly ttlMs: number;
}): Promise<GenerationClaim>;
release(identity: FormalGenerationIdentity): Promise<void>;
}
+5
View File
@@ -0,0 +1,5 @@
import type { GenerationOutcome, GenerationRequest } from "../domain/generation/types";
export interface GenerationCoordinator {
generate(request: GenerationRequest): Promise<GenerationOutcome>;
}
+26
View File
@@ -0,0 +1,26 @@
import type { CorrelationContext } from "../domain/shared/correlation";
import type { ReplyToPolicy } from "../domain/delivery";
export type ProposalEmailCommand = {
readonly workspaceId: string;
readonly proposalVersionId: string;
readonly pdfRef: string;
readonly toAddress: string;
readonly subject: string;
readonly body: string;
readonly replyTo: ReplyToPolicy;
readonly correlation: CorrelationContext;
};
export type ProposalEmailResult =
| { readonly status: "blocked"; readonly pending: "P-001" | "P-011" }
| { readonly status: "accepted"; readonly providerMessageId: string }
| { readonly status: "failed"; readonly providerMessageId: null };
/**
* P-011 / ADR-003: não há provider escolhido.
* O PDF enviado tem de ser o `pdfRef` da versão exibida. Este port não escolhe outra versão.
*/
export interface ProposalEmailProvider {
send(command: ProposalEmailCommand): Promise<ProposalEmailResult>;
}
+22
View File
@@ -0,0 +1,22 @@
import type { CorrelationContext } from "../domain/shared/correlation";
import type { RenderedStagingArtifact } from "./staging-artifact";
export type ProposalPdfRenderInput = {
readonly workspaceId: string;
readonly proposalId: string;
readonly modelVersionId: string;
readonly contentHash: string;
readonly correlation: CorrelationContext;
};
export type ProposalPdfRenderResult =
| ({ readonly status: "rendered" } & RenderedStagingArtifact)
| { readonly status: "failed"; readonly code: "render-failed" };
/**
* Composição do PDF a partir de representação normalizada.
* A stack prevê `@react-pdf/renderer` no adapter futuro. O adapter real não existe nesta fase.
*/
export interface ProposalPdfRenderer {
render(input: ProposalPdfRenderInput): Promise<ProposalPdfRenderResult>;
}
+13
View File
@@ -0,0 +1,13 @@
export type RenderedStagingArtifact = {
readonly stagingRef: string;
readonly contentHash: string;
};
export interface StagingArtifactStore {
/**
* Move o artefato de staging para uma referência formal.
* Só pode ficar visível se o commit da geração concluir.
*/
promote(stagingRef: string): Promise<{ readonly pdfRef: string }>;
discard(stagingRef: string): Promise<void>;
}
+28
View File
@@ -0,0 +1,28 @@
import type { CorrelationContext } from "../domain/shared/correlation";
import type { FileSecurityPolicy } from "../domain/shared/file-security-policy";
export type TemplateMediaType =
| "application/pdf"
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
export type TemplateIngestionResult =
| { readonly status: "blocked"; readonly pending: "P-006" | "P-010" }
| {
readonly status: "extracted";
readonly markers: readonly { readonly fieldKey: string; readonly occurrenceIndex: number }[];
};
/**
* P-010 / ADR-002: não há motor escolhido.
* Marcadores continuam sendo somente `[campo]` explícito. Este port não infere campos.
*/
export interface TemplateIngestionService {
ingest(input: {
readonly workspaceId: string;
readonly sourceFileRef: string;
readonly mediaType: TemplateMediaType;
readonly policy: FileSecurityPolicy;
readonly correlation: CorrelationContext;
}): Promise<TemplateIngestionResult>;
}
+6
View File
@@ -0,0 +1,6 @@
/**
* UI local. O design system nasce no prompt 04, neste repositório,
* preparado para convergir depois com o EloPro por tokens e contratos,
* sem importar componentes de outro repositório.
*/
export const UI_LAYER = "ui" as const;