Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"!**/node_modules",
"!**/coverage",
"!**/*.generated.ts",
"!tests/helpers/print-portal-v1*.json"
"!tests/helpers/print-portal-v1*.json",
"!tests/helpers/print-portal-v2*.json"
]
}
}
41 changes: 21 additions & 20 deletions examples/print-portal.hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
* Boots a fake Incluir service API (real HTTP server backed by the
* in-memory fake), wires the portal exactly like src/http/server.ts but
* with the HTTP adapter pointed at that fake, and walks the supplier
* journey through the portal's JSON API: login → list → download →
* collected → quote → (staff approves) → printed → logout.
* batch journey through the portal's JSON API: login → open batch →
* download → collected → quote → (staff approves) → printed → logout.
*
* Usage: pnpm tsx examples/print-portal.hono.ts
*/
Expand All @@ -20,13 +20,13 @@ import { createPortalApp } from '../src/http/portal-app.js';
import { ConsoleLogger } from '../src/observability/console-logger.js';
import { noopTracer } from '../src/observability/tracer.js';
import { startFakeUpstream } from '../tests/helpers/fake-print-upstream.js';
import { PDF_BYTES, seedTwoFileOrder } from '../tests/helpers/print-fixtures.js';
import { PDF_BYTES, seedTwoFileRequest } from '../tests/helpers/print-fixtures.js';

console.log('🖨️ Frame Example: print-shop portal (Hono BFF → service API)');
console.log('============================================================');

const upstream = await startFakeUpstream();
const order = seedTwoFileOrder(upstream.api);
const request = seedTwoFileRequest(upstream.api);
const password = randomBytes(18).toString('base64url');
const observability = { logger: new ConsoleLogger(), tracer: noopTracer() };

Expand Down Expand Up @@ -87,25 +87,27 @@ try {
expectStatus(login, 200, 'POST /api/session (login)');
({ csrfToken } = (await login.json()) as { csrfToken: string });

const list = await call('/api/print/v1/orders');
expectStatus(list, 200, 'GET /api/print/v1/orders');
const list = await call('/api/print/v2/batches');
expectStatus(list, 200, 'GET /api/print/v2/batches');

const detail = await call(`/api/print/v1/orders/${order.id}`);
expectStatus(detail, 200, `GET order ${order.reference}`);
const file = await call(`/api/print/v1/orders/${order.id}/files/${order.jobs[0]?.file.id}`);
const detail = await call('/api/print/v2/batches/open');
const batch = ((await detail.json()) as { batch: { id: string; reference: string } }).batch;
expectStatus(detail, 200, `GET open batch ${batch.reference}`);
const api = `/api/print/v2/batches/${batch.id}`;
const file = await call(`${api}/orders/${request.orderId}/files/${request.jobs[0]?.file.id}`);
const digest = createHash('sha256')
.update(Buffer.from(await file.arrayBuffer()))
.digest('hex');
expectStatus(
file,
200,
`download file (sha256 ${digest === order.jobs[0]?.file.sha256 ? 'matches' : 'MISMATCH'})`,
`download file (sha256 ${digest === request.jobs[0]?.file.sha256 ? 'matches' : 'MISMATCH'})`,
);

const collected = await call(`/api/print/v1/orders/${order.id}/collected`, {
const collected = await call(`${api}/collected`, {
method: 'POST',
csrf: csrfToken,
...json({ revision: 1 }),
...json({}),
headers: {
'Content-Type': 'application/json',
'If-Match': detail.headers.get('etag') ?? '',
Expand All @@ -117,24 +119,23 @@ try {
const form = new FormData();
form.append('file', new File([PDF_BYTES], 'orcamento.pdf'));
form.append('amountCents', '45900');
form.append('orderRevision', '1');
const quoted = await call(`/api/print/v1/orders/${order.id}/quotes`, {
const quoted = await call(`${api}/quotes`, {
method: 'POST',
csrf: csrfToken,
body: form,
headers: { 'If-Match': collected.headers.get('etag') ?? '', 'Idempotency-Key': randomUUID() },
});
expectStatus(quoted, 201, 'POST quotes (R$ 459,00)');
const quoteId = ((await quoted.json()) as { order: { currentQuote: { id: string } } }).order
const quoteId = ((await quoted.json()) as { batch: { currentQuote: { id: string } } }).batch
.currentQuote.id;

upstream.api.approveQuote(order.id);
upstream.api.approveQuote(batch.id);
console.log('✅ Financeiro approves the quote (staff side, outside the portal)');
const approved = await call(`/api/print/v1/orders/${order.id}`);
const printed = await call(`/api/print/v1/orders/${order.id}/printed`, {
const approved = await call(api);
const printed = await call(`${api}/printed`, {
method: 'POST',
csrf: csrfToken,
body: JSON.stringify({ revision: 1, quoteId }),
body: JSON.stringify({ quoteId }),
headers: {
'Content-Type': 'application/json',
'If-Match': approved.headers.get('etag') ?? '',
Expand All @@ -148,7 +149,7 @@ try {
204,
'DELETE /api/session (logout)',
);
expectStatus(await call('/api/print/v1/orders'), 401, 'GET orders after logout');
expectStatus(await call('/api/print/v2/batches'), 401, 'GET batches after logout');
console.log('\n🎉 Example completed successfully!');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
Expand Down
2 changes: 1 addition & 1 deletion scripts/portal-loc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { readdirSync, readFileSync } from 'node:fs';

const PORTAL = {
src: [
/^src\/domain\/(money|monthly-close|portal-session|print-order)\.ts$/,
/^src\/domain\/(money|monthly-close|portal-session|print-batch|document-type)\.ts$/,
/^src\/adapters\/(print-api|session-store|login-throttle)[.a-z-]*\.ts$/,
/^src\/errors\/(csrf-failed|invalid-credentials|invalid-request|login-rate-limited|unauthenticated|upstream-[a-z]+)\.error\.ts$/,
/^src\/use-cases\/(?!create-cat)[a-z-]+\.ts$/,
Expand Down
92 changes: 65 additions & 27 deletions src/adapters/print-api.contract.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
/**
* Zod mirror of the FROZEN service contract
* `apps/hono-app/docs/contracts/print-portal-v1.schema.json`
* (monorepo-incluir PR B #1038 + PR C monthly closes; verified unchanged at 19d7ea66).
* `apps/hono-app/docs/contracts/print-portal-v2.schema.json`
* (monorepo-incluir #1053, re-freeze 27022467: batches + monthly closes).
*
* Every object is `.strict()` (= `additionalProperties: false`), so an
* upstream field the contract does not name — a bucket, a key, an email —
* fails parsing and never reaches the browser. The copy of the JSON Schema
* and the captured fixtures in tests/helpers are checked against these
* and the frozen fixture in tests/helpers are checked against these
* schemas by tests/unit/print-api-contract.test.ts.
*/
import { z } from 'zod';
import { CLOSE_STATES } from '../domain/monthly-close.js';
import { ORDER_STATUSES, QUOTE_DECISIONS } from '../domain/print-order.js';
import { BATCH_STATUSES, QUOTE_DECISIONS } from '../domain/print-batch.js';

const MAX_SAFE = Number.MAX_SAFE_INTEGER;
const MAX_CENTS = 2_147_483_647;
Expand All @@ -24,7 +24,8 @@ const uuid = z
const instant = z.iso.datetime();
const positive = z.number().int().min(1).max(MAX_SAFE);
const cents = z.number().int().min(1).max(MAX_CENTS);
const reference = z.string().regex(/^IMP-\d{4,}$/);
const orderReference = z.string().regex(/^IMP-\d{4,}$/);
const batchReference = z.string().regex(/^LOT-\d{4,}$/);

export const FileSchema = z
.object({
Expand All @@ -46,11 +47,36 @@ export const PrintJobSchema = z
})
.strict();

export const BatchItemSchema = z
.object({
orderId: uuid,
previouslyCancelledIn: batchReference.optional(),
reference: orderReference,
title: z.string().min(1),
revision: positive,
jobs: z.array(PrintJobSchema),
generalInstructions: z
.object({ text: z.string(), files: z.array(FileSchema) })
.strict()
.optional(),
})
.strict()
.refine(
(item) => {
// Each file appears exactly once within the item; at least one file.
const ids = [
...item.jobs.map((j) => j.file.id),
...(item.generalInstructions?.files ?? []).map((f) => f.id),
];
return ids.length > 0 && new Set(ids).size === ids.length;
},
{ message: 'Each item needs at least one file, each exactly once', path: ['jobs'] },
);

export const QuoteSchema = z
.object({
id: uuid,
revision: positive,
orderRevision: positive,
amountCents: cents,
currency: z.literal('BRL'),
document: FileSchema,
Expand All @@ -61,48 +87,56 @@ export const QuoteSchema = z
})
.strict();

const orderSummaryShape = {
const batchSummaryShape = {
id: uuid,
reference,
title: z.string().min(1),
revision: positive,
reference: batchReference,
status: z.enum(BATCH_STATUSES),
version: positive,
status: z.enum(ORDER_STATUSES),
itemCount: z.number().int().min(0).max(MAX_SAFE),
createdAt: instant,
collectedAt: instant.nullable(),
printedAt: instant.nullable(),
receivedAt: instant.nullable(),
approvedAmountCents: cents.nullable(),
};

export const OrderSummarySchema = z.object(orderSummaryShape).strict();
export const BatchSummarySchema = z.object(batchSummaryShape).strict();

export const OrderSchema = z
export const BatchSchema = z
.object({
...orderSummaryShape,
jobs: z.array(PrintJobSchema),
generalInstructions: z
.object({ text: z.string(), files: z.array(FileSchema).min(1) })
.strict()
.optional(),
...batchSummaryShape,
items: z.array(BatchItemSchema),
currentQuote: QuoteSchema.nullable(),
cancellationReason: z.string().nullable(),
})
.strict()
.refine(
(order) => (order.generalInstructions ? order.jobs.length === 0 : order.jobs.length > 0),
{ message: 'Exactly one instruction mode is required', path: ['jobs'] },
(batch) =>
batch.itemCount === batch.items.length &&
new Set(batch.items.map((i) => i.orderId)).size === batch.items.length,
{ message: 'itemCount equals items length; unique orderId', path: ['items'] },
);

export const OrderListResponseSchema = z
.object({ items: z.array(OrderSummarySchema), nextCursor: z.string().nullable() })
.strict();
export const BatchResponseSchema = z.object({ batch: BatchSchema }).strict();

export const OrderResponseSchema = z.object({ order: OrderSchema }).strict();
export const OpenBatchResponseSchema = z.object({ batch: BatchSchema.nullable() }).strict();

export const CloseItemSchema = z
.object({ orderId: uuid, reference, quoteId: uuid, amountCents: cents, printedAt: instant })
export const BatchListResponseSchema = z
.object({ items: z.array(BatchSummarySchema), nextCursor: z.string().nullable() })
.strict();

const closeCharge = {
reference: z.string(),
quoteId: uuid,
amountCents: cents,
printedAt: instant,
};

export const CloseItemSchema = z.discriminatedUnion('kind', [
z.object({ ...closeCharge, kind: z.literal('batch'), batchId: uuid }).strict(),
z.object({ ...closeCharge, kind: z.literal('legacy_order'), orderId: uuid }).strict(),
]);

export const CloseSchema = z
.object({
id: uuid.nullable(),
Expand Down Expand Up @@ -142,6 +176,10 @@ export const ERROR_CODES = [
'INTERNAL',
'NOT_CONFIGURED',
'UPSTREAM_UNAVAILABLE',
'BATCH_NOT_ACTIVE',
'BATCH_WORKFLOW_REQUIRED',
'EMPTY_BATCH',
'TOTAL_MISMATCH',
] as const;

export const ErrorSchema = z
Expand Down
Loading
Loading