diff --git a/server/lib/validations/admin.schema.ts b/server/lib/validations/admin.schema.ts index 55b342c6..bec05105 100644 --- a/server/lib/validations/admin.schema.ts +++ b/server/lib/validations/admin.schema.ts @@ -205,14 +205,18 @@ export const SignerInputSchema = z.object({ }).openapi('AgreementSignerInput'); export const SendAgreementSchema = z.object({ - agreementId: z.string().uuid().openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }).describe('TODO describe agreementId field for the OpenInspection MCP integration'), + // Plain non-empty string, NOT .uuid(): agreements.id / inspections.id are TEXT + // columns and may hold non-UUID values (e.g. Spectora-imported or seeded rows). + // The handler resolves them by tenant-scoped lookup, so .uuid() would only reject + // legitimate non-UUID rows. (Matches the hub send fix in inspection.schema.ts.) + agreementId: z.string().min(1).openapi({ example: 'agr-0c1f2e3d' }).describe('Agreement template id (TEXT; not necessarily a UUID)'), // Track I-a Task 9 — `clientEmail` is only consumed on the legacy // single-recipient path; the multi-signer path keys recipients off the // `signers` array. Optional here, gated by the refine below so exactly one // of the two paths is always satisfiable. clientEmail: z.string().email().optional().openapi({ example: 'client@example.com' }).describe('Recipient email for the legacy single-signer send; omit when `signers` is provided'), clientName: z.string().max(100).optional().openapi({ example: 'John Smith' }).describe('TODO describe clientName field for the OpenInspection MCP integration'), - inspectionId: z.string().uuid().optional().openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }).describe('TODO describe inspectionId field for the OpenInspection MCP integration'), + inspectionId: z.string().min(1).optional().openapi({ example: 'insp-0c1f2e3d' }).describe('Inspection id (TEXT; not necessarily a UUID)'), // Track I-a Task 9 — multi-signer envelope. When `signers` is provided the // send routes through AgreementService.findOrCreate (signer rows + snapshot // pinning + per-signer links). Omitted → legacy single-recipient behavior. diff --git a/tests/unit/agreement-send-endpoints.spec.ts b/tests/unit/agreement-send-endpoints.spec.ts index d1d65db7..bb10541d 100644 --- a/tests/unit/agreement-send-endpoints.spec.ts +++ b/tests/unit/agreement-send-endpoints.spec.ts @@ -148,6 +148,33 @@ describe('POST /api/admin/agreements/send — multi-signer', () => { expect(emailSend).toHaveBeenCalledTimes(2); }); + it('accepts NON-UUID agreementId / inspectionId (TEXT ids, e.g. Spectora-imported or seeded rows)', async () => { + // Regression: the body schema once gated agreementId/inspectionId with + // .uuid(), which 400'd legitimate non-UUID rows. agreements.id and + // inspections.id are TEXT columns; the handler resolves them by tenant- + // scoped lookup. Mirrors the hub send fix in inspection.schema.ts. + const TEXT_AGR = 'agr-seeded-not-a-uuid'; + const TEXT_INSP = 'insp-seeded-not-a-uuid'; + await db.insert(schema.inspections).values({ id: TEXT_INSP, tenantId: TENANT, propertyAddress: '2 Oak St', clientName: 'Pat', clientEmail: 'pat@test.com', date: '2026-06-02', status: 'draft', paymentStatus: 'unpaid', price: 40000, agreementRequired: true, paymentRequired: false, createdAt: new Date() }); + await db.insert(schema.agreements).values({ id: TEXT_AGR, tenantId: TENANT, name: 'Imported Agreement', content: 'Imported text...', version: 1, createdAt: new Date() }); + + const res = await buildApp().request('/api/admin/agreements/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agreementId: TEXT_AGR, + inspectionId: TEXT_INSP, + completionPolicy: 'all', + signers: [{ name: 'Pat', email: 'pat@test.com', role: 'client' }], + }), + }, ENV, EXEC); + // Must NOT be rejected by UUID validation — reaches the handler and sends. + expect(res.status).toBe(200); + const body = await res.json() as { success: boolean; data: { requestId: string } }; + expect(body.success).toBe(true); + expect(body.data.requestId).toBeTruthy(); + }); + it('rejects a request with neither clientEmail nor signers', async () => { const res = await buildApp().request('/api/admin/agreements/send', { method: 'POST',