Implementação MVP
This commit is contained in:
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Logo obrigatório para identidade da proposta. Cores são opcionais. */
|
||||
export const BRANDING_MODULE = "domain.branding" as const;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Propostas, rascunho e versões formais entram nos prompts 09–13. */
|
||||
export const PROPOSALS_MODULE = "domain.proposals" as const;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { GenerationOutcome, GenerationRequest } from "../domain/generation/types";
|
||||
|
||||
export interface GenerationCoordinator {
|
||||
generate(request: GenerationRequest): Promise<GenerationOutcome>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user