Skip to content

Commit a60bab9

Browse files
psandersclaude
andcommitted
feat(common): add validated-function utilities and conventions guide
Implements the function-conventions OpenSpec change and records the project's coding conventions. - ValidationError: structured, field-level wrapper over ZodError with toJSON() - withErrorHandlingAndValidation: validates input via a Zod schema, throws ValidationError on failure, passes typed data through on success - unit tests (node:test) for both, exported from @qcobro/common - CLAUDE.md: conventions home (validated-function pattern, i18n, commits, layout) - /create-validated-function command to scaffold the pattern Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1a17d89 commit a60bab9

17 files changed

Lines changed: 545 additions & 2 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
---
2+
name: "Create Validated Function"
3+
description: Scaffold a function using the validated-function pattern (DI + Zod validation + error handling)
4+
---
5+
6+
# Create Validated Function
7+
8+
Create a new function using the validation and error-handling pattern with Zod schemas.
9+
10+
## Pattern Overview
11+
12+
A builder-style approach:
13+
14+
1. **Outer function** (`createXxx`) accepts dependencies as parameters (dependency injection).
15+
2. **Inner function** (`fn`) contains the actual business logic with typed parameters.
16+
3. **Wrapper** (`withErrorHandlingAndValidation`) handles validation and errors.
17+
18+
This enables **dependency injection**, making functions easy to test by swapping real
19+
dependencies with mocks.
20+
21+
## Instructions
22+
23+
1. **Identify or create the Zod schema** in `@qcobro/common`:
24+
- Check if a schema already exists in `mods/common/src/schemas/`.
25+
- If not, create a new schema file following the naming convention: `<domain>.ts`.
26+
- Export the schema and its inferred type from `mods/common/src/schemas/index.ts`.
27+
- Export from `mods/common/src/index.ts`.
28+
29+
2. **Use existing client interfaces** from `@qcobro/common`:
30+
- Client interfaces live in `mods/common/src/types/`.
31+
- Import them: `import type { CustomerClient } from "@qcobro/common"`.
32+
- If a new interface is needed, add it to the types folder.
33+
34+
3. **Create the function file** following the naming pattern `create<FunctionName>.ts`:
35+
36+
```typescript
37+
import {
38+
withErrorHandlingAndValidation,
39+
<schemaName>,
40+
type <InputType>,
41+
type <ClientType>
42+
} from "@qcobro/common";
43+
44+
export function create<FunctionName>(client: <ClientType>) {
45+
const fn = async (params: <InputType>) => {
46+
// Business logic here using the injected client
47+
return client.doSomething(params);
48+
};
49+
50+
return withErrorHandlingAndValidation(fn, <schemaName>);
51+
}
52+
```
53+
54+
4. **Export the function** from the appropriate barrel file or index.
55+
56+
## Example: Customer Operations
57+
58+
### Using Shared Types
59+
60+
```typescript
61+
import {
62+
withErrorHandlingAndValidation,
63+
createCustomerSchema,
64+
type CreateCustomerInput,
65+
type CustomerClient
66+
} from "@qcobro/common";
67+
68+
export function createCreateCustomer(client: CustomerClient) {
69+
const fn = async (params: CreateCustomerInput) => {
70+
return client.customer.create({ data: params });
71+
};
72+
73+
return withErrorHandlingAndValidation(fn, createCustomerSchema);
74+
}
75+
```
76+
77+
### Production Usage
78+
79+
```typescript
80+
import { prisma } from "./db.js";
81+
import { createCreateCustomer } from "./customers/createCreateCustomer.js";
82+
83+
// Inject the real database client
84+
const createCustomer = createCreateCustomer(prisma);
85+
86+
// Validates input and throws ValidationError if invalid
87+
const customer = await createCustomer({
88+
name: "John Doe",
89+
phone: "+1234567890"
90+
});
91+
```
92+
93+
## Example: Custom Service Function
94+
95+
The pattern works for any function, not just database operations.
96+
97+
### Schema
98+
99+
```typescript
100+
// mods/common/src/schemas/notification.ts
101+
import { z } from "zod";
102+
103+
export const sendNotificationSchema = z.object({
104+
recipient: z.string().email(),
105+
subject: z.string().min(1),
106+
body: z.string().min(1),
107+
priority: z.enum(["low", "normal", "high"]).optional()
108+
});
109+
110+
export type SendNotificationInput = z.infer<typeof sendNotificationSchema>;
111+
```
112+
113+
### Client Interface
114+
115+
```typescript
116+
// mods/common/src/types/notification.ts
117+
import type { SendNotificationInput } from "../schemas/notification.js";
118+
119+
export interface NotificationClient {
120+
send(params: SendNotificationInput): Promise<{ messageId: string }>;
121+
}
122+
```
123+
124+
## Testing
125+
126+
Inject a mock client and assert on validation + delegation:
127+
128+
```typescript
129+
it("throws ValidationError and never calls the client on invalid input", async () => {
130+
const mockClient = { customer: { create: sinon.stub() } };
131+
const createCustomer = createCreateCustomer(mockClient);
132+
133+
try {
134+
await createCustomer({ name: "" });
135+
expect.fail("Expected ValidationError to be thrown");
136+
} catch (error) {
137+
expect(error).to.be.instanceOf(ValidationError);
138+
expect(mockClient.customer.create.called).to.be.false;
139+
}
140+
});
141+
```
142+
143+
Benefits: testability (mocks, no live services), isolation, fast tests, predictable behavior,
144+
and guaranteed validation coverage (invalid input never reaches the client).
145+
146+
## Error Handling
147+
148+
The `withErrorHandlingAndValidation` wrapper:
149+
150+
- Validates input against the Zod schema using `safeParse`.
151+
- Throws `ValidationError` with field-level errors if validation fails.
152+
- Passes validated, typed data to the inner function.
153+
154+
`ValidationError` includes:
155+
156+
- `message`: human-readable message.
157+
- `fieldErrors`: array of `{ field, message, code }`.
158+
- `zodError`: original Zod error for debugging.
159+
- `toJSON()`: serializable form for API responses.
160+
161+
## Files Reference
162+
163+
- Utility: `mods/common/src/utils/withErrorHandlingAndValidation.ts`
164+
- Error: `mods/common/src/errors/ValidationError.ts`
165+
- Schemas: `mods/common/src/schemas/`
166+
- Client interfaces: `mods/common/src/types/`

CLAUDE.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# QCobro — Agent Guide
2+
3+
QCobro (by Fonoster) is a **multilingual** AI-voice debt-collections platform. It is being
4+
rebuilt spec-first with OpenSpec. The previous implementation is archived on the `demo` branch.
5+
6+
## How work is organized
7+
8+
- **Product behavior (the WHAT)** lives in OpenSpec specs under `openspec/specs/`, and is changed
9+
through proposals in `openspec/changes/`. Use `/opsx:propose`, `/opsx:apply`, `/opsx:archive`.
10+
Specs describe observable, testable behavior — not coding style.
11+
- **Coding conventions (the HOW)** live in this file. They apply to every change.
12+
13+
## Repository layout
14+
15+
- `mods/common` — shared types and Zod schemas; the single source of truth for contracts.
16+
Depends on no other workspace package.
17+
- `mods/apiserver` — tRPC API over Prisma/PostgreSQL. Procedures reach shared services
18+
(DB today; telephony and other integrations later) through the tRPC **context**.
19+
- `mods/webapp` — React + Vite + Tailwind operator console; i18n-ready.
20+
- `site` — marketing site (hand-authored; ESLint/Prettier-ignored).
21+
22+
## Coding conventions
23+
24+
### Validated functions (preferred pattern for service/data functions)
25+
26+
When defining a function that takes external input and performs an operation (DB writes,
27+
service calls, business logic), use the **validated-function** pattern: a builder that injects
28+
dependencies and wraps the logic with Zod validation.
29+
30+
```typescript
31+
import {
32+
withErrorHandlingAndValidation,
33+
createCustomerSchema,
34+
type CreateCustomerInput,
35+
type CustomerClient
36+
} from "@qcobro/common";
37+
38+
export function createCreateCustomer(client: CustomerClient) {
39+
const fn = async (params: CreateCustomerInput) => {
40+
return client.customer.create({ data: params });
41+
};
42+
43+
return withErrorHandlingAndValidation(fn, createCustomerSchema);
44+
}
45+
```
46+
47+
- Schemas and client interfaces live in `@qcobro/common` (`src/schemas/`, `src/types/`).
48+
- Dependencies are injected (the outer `create…` takes the client), so tests swap real clients
49+
for mocks — no live services needed.
50+
- Invalid input throws `ValidationError` (field-level errors, `toJSON()` for API responses)
51+
before the inner function runs.
52+
- Full guide and scaffolding: run `/create-validated-function`.
53+
54+
> Apply this pattern when it fits (input-validating operations). Trivial pure helpers or
55+
> framework glue don't need it.
56+
57+
### General
58+
59+
- TypeScript strict; no `any` (ESLint enforces `@typescript-eslint/no-explicit-any`).
60+
- Share contracts via `@qcobro/common`; don't duplicate types between apiserver and webapp.
61+
- All user-facing console text goes through the i18n layer (`mods/webapp/src/lib/i18n.tsx`),
62+
never hardcoded literals. The product is multilingual; assume no single default language.
63+
- Reach services through the tRPC context, not via ad-hoc imports inside procedures.
64+
65+
## Commits
66+
67+
Use **Conventional Commits** (`type(scope): subject`, e.g. `feat(api): add objectives router`).
68+
A Husky `commit-msg` hook runs commitlint and rejects non-conforming messages.

mods/common/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
"scripts": {
1010
"build": "tsc -b --force",
1111
"clean": "rm -rf dist *.tsbuildinfo",
12-
"typecheck": "tsc --noEmit"
12+
"typecheck": "tsc --noEmit",
13+
"test": "node --import tsx --test \"src/**/*.test.ts\""
1314
},
1415
"dependencies": {
1516
"zod": "^4.0.0"
1617
},
1718
"devDependencies": {
19+
"tsx": "^4.0.0",
1820
"typescript": "^5.9.0"
1921
}
2022
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { z } from "zod";
4+
import { ValidationError } from "./ValidationError.js";
5+
6+
const schema = z.object({ name: z.string().min(1), age: z.number() });
7+
8+
function zodErrorFor(input: unknown): z.ZodError {
9+
const result = schema.safeParse(input);
10+
assert.equal(result.success, false);
11+
return (result as { success: false; error: z.ZodError }).error;
12+
}
13+
14+
describe("ValidationError", () => {
15+
it("exposes a stable code and field-level errors", () => {
16+
const error = new ValidationError(zodErrorFor({ name: "", age: "nope" }));
17+
18+
assert.equal(error.code, "VALIDATION_ERROR");
19+
assert.equal(error.name, "ValidationError");
20+
assert.ok(error instanceof Error);
21+
22+
const fields = error.fieldErrors.map((f) => f.field);
23+
assert.ok(fields.includes("name"));
24+
assert.ok(fields.includes("age"));
25+
for (const fieldError of error.fieldErrors) {
26+
assert.equal(typeof fieldError.message, "string");
27+
assert.equal(typeof fieldError.code, "string");
28+
}
29+
});
30+
31+
it("serializes to a JSON shape for API responses", () => {
32+
const error = new ValidationError(zodErrorFor({ name: "", age: 1 }));
33+
const json = error.toJSON();
34+
35+
assert.deepEqual(Object.keys(json).sort(), ["code", "fieldErrors", "message"]);
36+
assert.equal(json.code, "VALIDATION_ERROR");
37+
});
38+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { z } from "zod";
2+
3+
export interface FieldError {
4+
field: string;
5+
message: string;
6+
code: string;
7+
}
8+
9+
/**
10+
* Wraps a Zod validation error with structured, field-level details suitable
11+
* for API responses.
12+
*/
13+
export class ValidationError extends Error {
14+
public readonly code = "VALIDATION_ERROR";
15+
public readonly fieldErrors: FieldError[];
16+
public readonly zodError: z.ZodError;
17+
18+
constructor(zodError: z.ZodError) {
19+
const fieldErrors = ValidationError.extractFieldErrors(zodError);
20+
const message = ValidationError.formatMessage(fieldErrors);
21+
22+
super(message);
23+
this.name = "ValidationError";
24+
this.zodError = zodError;
25+
this.fieldErrors = fieldErrors;
26+
27+
// Maintains proper stack trace for where the error was thrown (V8 engines).
28+
if (Error.captureStackTrace) {
29+
Error.captureStackTrace(this, ValidationError);
30+
}
31+
}
32+
33+
private static extractFieldErrors(zodError: z.ZodError): FieldError[] {
34+
return zodError.issues.map((issue) => ({
35+
field: issue.path.join(".") || "root",
36+
message: issue.message,
37+
code: issue.code
38+
}));
39+
}
40+
41+
private static formatMessage(fieldErrors: FieldError[]): string {
42+
if (fieldErrors.length === 0) {
43+
return "Validation failed";
44+
}
45+
46+
if (fieldErrors.length === 1) {
47+
const { field, message } = fieldErrors[0];
48+
return field === "root" ? message : `${field}: ${message}`;
49+
}
50+
51+
const details = fieldErrors
52+
.map(({ field, message }) => (field === "root" ? message : `${field}: ${message}`))
53+
.join("; ");
54+
55+
return `Validation failed: ${details}`;
56+
}
57+
58+
/**
59+
* Returns a JSON-serializable representation for API responses.
60+
*/
61+
toJSON() {
62+
return {
63+
code: this.code,
64+
message: this.message,
65+
fieldErrors: this.fieldErrors
66+
};
67+
}
68+
}

mods/common/src/errors/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ValidationError, type FieldError } from "./ValidationError.js";

mods/common/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { z } from "zod";
22

3+
export * from "./errors/index.js";
4+
export * from "./utils/index.js";
5+
36
/**
47
* Placeholder contract proving the shared-schema pattern.
58
*

mods/common/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { withErrorHandlingAndValidation } from "./withErrorHandlingAndValidation.js";

0 commit comments

Comments
 (0)