Layering rules
The rule
HTTP app/api/campaigns/[id]/broadcast/route.ts
│ authenticate → validate → delegate → serialise
▼
Service lib/services/phone-number-calling-service.ts
│ decide: eligibility, selection, caps, state transitions
▼
Repository lib/repositories/call-repository.ts
│ the only layer that imports Prisma
▼
PostgreSQL
Never put business logic in a route handler, and never import Prisma outside
lib/repositories. Two exceptions exist in the current codebase and are deliberate:
-
Webhook receivers upsert the raw
WebhookEventrow directly, because that write is part of accepting the request rather than part of the domain. -
A small number of routes read a single lookup row (for example resolving a campaign’s
subscriptionIdbefore delegating). New code should prefer a repository method.
What belongs in each layer
| Layer | Belongs here |
|---|---|
Route handler |
|
Service |
|
Repository |
|
Canonical route handler
import { NextResponse } from "next/server"
import { resolveSubscription } from "@/lib/auth/subscription"
export async function GET(request: Request) {
const result = await resolveSubscription()
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: result.status })
}
const { subscriptionId } = result.context
try {
// delegate to a service or repository, then map camelCase → snake_case
return NextResponse.json(payload)
} catch (error) {
console.error("Operation failed", error)
return NextResponse.json({ error: "Unable to complete operation" }, { status: 500 })
}
}
Status codes: 200 success, 201 created, 400 validation, 401 unauthenticated,
403 not a member of the workspace, 404 not found, 500 unexpected.
Serialisation boundary
Prisma models are camelCase; the HTTP API is snake_case. The conversion happens in the
route handler or in a dedicated serialiser under lib/serializers/
(email-domain.ts, email-variation.ts). Client-side TypeScript is camelCase again,
derived from the Zod schema that parses the response — so the snake_case shape exists only
on the wire.
Error taxonomy
Calling errors are typed in lib/utils/calling-errors.ts so that a failure can be attributed
to a layer without string matching:
| Error | Raised when |
|---|---|
|
Base class; carries |
|
No eligible source number, or validation of one failed |
|
The Retell API rejected or failed the call creation |
|
A persistence step failed after the external side effect |
Skip reasons — the non-error outcomes of a dial attempt — are enumerated by CallSkipReason
in lib/enums.ts: prior_disposition, warm_lead, lifecycle_stage, dnc,
campaign_not_running, invalid_e164, target_not_found, no_phone_number_available,
outside_call_window, retell_error. Workflows return the reason rather than throwing, so an
ineligible target does not consume a retry.
Validation and types
// lib/schema/reports.ts — the schema is the source of truth
export const DailyStatSchema = z.object({ /* … */ })
// lib/types.ts — the type is derived, never hand-written
export type DailyStat = z.infer<typeof DailyStatSchema>
Every React Query hook parses its response through the schema. A backend change that drops or
renames a field therefore fails loudly in the client instead of silently rendering undefined.