API Layer
End-to-end type-safe API architecture using tRPC with Zod validation
API Layer
SavvySolve uses tRPC for its API layer, providing end-to-end type safety from the database to the client without code generation. This architecture choice aligns with the project's philosophy of type safety and developer experience.
Why tRPC?
Traditional REST APIs require maintaining separate type definitions for the client and server, often leading to drift between what the API actually returns and what the client expects. GraphQL solves this with schemas but introduces complexity and code generation steps.
tRPC takes a different approach: the server defines procedures with TypeScript types, and those types flow directly to the client through the exported AppRouter type. When a procedure's return type changes, the client immediately sees the change through TypeScript's type checker. No schema files, no generated code, no drift.
This matters for SavvySolve because the platform involves multiple real-time interactions between solvers and customers. Type mismatches could cause subtle bugs in session management, payment flows, or chat functionality. tRPC catches these issues at compile time.
Architecture Overview
The tRPC setup follows a layered architecture:
Server Configuration
The tRPC server initializes in server/trpc.ts with a context that will be extended as features are added:
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
export interface Context {
// Extended with db, auth, etc. as features are added
}
export function createContext(): Context {
return {};
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;The superjson transformer handles serialization of rich JavaScript types like Date, Map, Set, and BigInt that JSON cannot represent natively. This is particularly important for SavvySolve where session timestamps, durations, and payment amounts need precise type handling.
Root Router
All feature routers combine into a single app router in server/routers/_app.ts:
import { router } from "../trpc";
import { healthRouter } from "./health";
import { solversRouter } from "./solvers";
import { ticketsRouter } from "./tickets";
import { sessionsRouter } from "./sessions";
import { callsRouter } from "./calls";
import { ratingsRouter } from "./ratings";
export const appRouter = router({
health: healthRouter,
solvers: solversRouter,
tickets: ticketsRouter,
sessions: sessionsRouter,
calls: callsRouter,
ratings: ratingsRouter,
});
export type AppRouter = typeof appRouter;The exported AppRouter type is the key to tRPC's type inference. Client code imports this type to get full autocomplete and type checking on all procedures.
Feature Routers
Each feature has its own router file. Routers are self-contained and can be tested independently.
Health Router
The health router provides a simple endpoint for monitoring:
import { publicProcedure, router } from "../trpc";
import type { HealthCheckResponse } from "@/lib/schemas/health";
const startTime = Date.now();
export const healthRouter = router({
check: publicProcedure.query((): HealthCheckResponse => {
return {
status: "healthy",
timestamp: new Date(),
version: process.env.npm_package_version ?? "0.1.0",
uptime: Date.now() - startTime,
};
}),
});Solvers Router
The solvers router handles solver profile management and availability. All procedures require authentication via Clerk.
export const solversRouter = router({
// Get the current solver's profile with user data
getProfile: protectedProcedure.query(async ({ ctx }) => {
// Returns solver profile, stats, and linked user info
}),
// Get dashboard statistics
getStats: protectedProcedure.query(async ({ ctx }) => {
// Returns: totalSessions, totalEarnings, averageRating,
// totalRatings, completionRate, currentStatus
}),
// Toggle availability status
updateStatus: protectedProcedure
.input(z.object({ status: z.enum(["available", "busy", "offline"]) }))
.mutation(async ({ ctx, input }) => {
// Updates solver status, blocked for onboarding solvers
}),
});Procedures
| Procedure | Type | Auth | Description |
|---|---|---|---|
getProfile | Query | Required | Returns solver profile with user data, stats, and type |
getStats | Query | Required | Returns dashboard metrics for display |
updateStatus | Mutation | Required | Sets availability to available, busy, or offline |
Usage Example
"use client";
import { trpc } from "@/lib/trpc/client";
export function SolverDashboard() {
const { data: stats } = trpc.solvers.getStats.useQuery();
const updateStatus = trpc.solvers.updateStatus.useMutation();
const goOnline = () => updateStatus.mutate({ status: "available" });
return (
<div>
<p>Total Sessions: {stats?.totalSessions}</p>
<p>Earnings: ${stats?.totalEarnings}</p>
<button onClick={goOnline}>Go Online</button>
</div>
);
}Tickets Router
The tickets router handles the complete ticket lifecycle—from customer submission through solver claim and beyond. It includes both public procedures for friction-free intake and protected procedures for solver workflows.
export const ticketsRouter = router({
// Create a new ticket (public - no auth required)
create: publicProcedure
.input(intakeFormSchema)
.mutation(async ({ input }) => {
// Creates ticket from intake form, returns ticket ID
}),
// List pending tickets with optional filters
list: protectedProcedure
.input(z.object({
urgency: z.enum(["low", "medium", "high", "critical"]).optional(),
deviceType: z.enum(["iphone", "android", ...]).optional(),
limit: z.number().min(1).max(100).default(50),
}).optional())
.query(async ({ input }) => {
// Returns tickets ordered by urgency, then oldest first
}),
// Atomically claim a ticket
claim: protectedProcedure
.input(z.object({ ticketId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
// Claims ticket if still pending, handles race conditions
}),
// Get single ticket details
get: protectedProcedure
.input(z.object({ ticketId: z.string().uuid() }))
.query(async ({ input }) => {
// Returns full ticket data
}),
});Procedures
| Procedure | Type | Auth | Description |
|---|---|---|---|
create | Mutation | None | Creates a ticket from the public intake form |
list | Query | Required | Lists pending tickets with urgency-based ordering |
claim | Mutation | Required | Atomically claims a ticket for the current solver |
get | Query | Required | Retrieves a single ticket by ID |
Public Ticket Creation
The create procedure is intentionally public—no authentication required. This supports the "friction-free entry" goal where customers can request help immediately without signing up:
create: publicProcedure
.input(intakeFormSchema)
.mutation(async ({ input }) => {
const [ticket] = await db
.insert(tickets)
.values({
customerInfo: {
name: input.name,
phone: input.phone,
email: input.email,
},
description: input.description,
deviceType: input.deviceType,
urgency: input.urgency,
status: "pending",
})
.returning();
return {
id: ticket.id,
status: ticket.status,
createdAt: ticket.createdAt,
};
}),The intake form schema validates:
- name: 2-100 characters
- phone: Valid US phone number format
- email: Optional, valid email format
- description: 10-500 characters
- deviceType: One of: iphone, android, ipad, tablet, mac, windows, chromebook, other
- urgency: One of: low, medium, high, critical
Claim Behavior
The claim procedure uses an atomic update pattern to prevent race conditions when multiple solvers try to claim the same ticket:
const result = await db
.update(tickets)
.set({
status: "claimed",
solverId: solver.id,
claimedAt: new Date(),
})
.where(
and(
eq(tickets.id, input.ticketId),
eq(tickets.status, "pending") // Only if still pending
)
)
.returning();If the ticket was already claimed by another solver, the update returns no rows, and the procedure throws a CONFLICT error.
Usage Example
"use client";
import { trpc } from "@/lib/trpc/client";
export function TicketQueue() {
const { data: tickets, refetch } = trpc.tickets.list.useQuery({
urgency: "high", // Optional filter
});
const claimMutation = trpc.tickets.claim.useMutation({
onSuccess: () => refetch(),
});
return (
<div>
{tickets?.map((ticket) => (
<div key={ticket.id}>
<p>{ticket.customerInfo.name}: {ticket.description}</p>
<button onClick={() => claimMutation.mutate({ ticketId: ticket.id })}>
Claim
</button>
</div>
))}
</div>
);
}Sessions Router
The sessions router manages the complete session lifecycle—from starting a session after claiming a ticket through completion with pricing calculation. It handles timer management, real-time chat messaging, and customer access via secure tokens.
Procedures
| Procedure | Type | Auth | Description |
|---|---|---|---|
start | Mutation | Required | Creates a session from a claimed ticket |
get | Query | Required | Retrieves session details with elapsed time |
getByToken | Query | None | Customer access via session token (limited data) |
getActive | Query | Required | Gets the solver's currently active session |
updateStatus | Mutation | Required | State machine transitions (active, paused, completed) |
updateNotes | Mutation | Required | Updates solver notes for the session |
complete | Mutation | Required | Completes session with tier selection and pricing |
syncTimer | Mutation | Required | Persists elapsed time periodically |
getMessages | Query | None | Paginated chat messages for a session |
sendMessage | Mutation | None | Sends a chat message (customer or solver) |
createSystemMessage | Mutation | Required | Creates system event messages |
Session State Machine
Sessions follow a strict state machine with valid transitions:
not_started → active → paused ⟷ active → completed
└─────────────────────→ completedInvalid transitions (e.g., completed → active) throw a BAD_REQUEST error.
Pricing Tiers
When completing a session, the solver selects a pricing tier:
| Tier | Name | Base Price | Included Time | Overage Rate |
|---|---|---|---|---|
quick | Quick Assist | $69 | 20 minutes | $3/min |
standard | Standard Solve | $129 | 45 minutes | $2.50/min |
extended | Deep Dive | $219 | 90 minutes | $2/min |
The complete procedure calculates the final pricing breakdown including overage charges and the solver's 65% revenue share.
Customer Access
Customers access their session via a secure token-based URL. The getByToken procedure returns limited data:
getByToken: publicProcedure
.input(z.object({
sessionId: z.string().uuid(),
token: z.string(),
}))
.query(async ({ input }) => {
// Returns: session status, tier, elapsed time, solver name
// Excludes: solver email, phone, internal IDs
}),Real-Time Chat
The sendMessage procedure stores messages in the database and broadcasts via Ably:
sendMessage: publicProcedure
.input(z.object({
sessionId: z.string().uuid(),
senderId: z.string(),
senderName: z.string(),
senderType: z.enum(["customer", "solver"]),
content: z.string().min(1).max(10000),
}))
.mutation(async ({ input }) => {
// 1. Insert message to database
// 2. Broadcast via Ably to session channel
// 3. Return created message
}),Usage Example
"use client";
import { trpc } from "@/lib/trpc/client";
export function SessionInterface({ sessionId }: { sessionId: string }) {
const { data: session } = trpc.sessions.get.useQuery({ id: sessionId });
const updateStatus = trpc.sessions.updateStatus.useMutation();
const complete = trpc.sessions.complete.useMutation();
const handleStart = () => updateStatus.mutate({ id: sessionId, status: "active" });
const handlePause = () => updateStatus.mutate({ id: sessionId, status: "paused" });
const handleComplete = () => complete.mutate({ id: sessionId, tier: "standard" });
return (
<div>
<p>Status: {session?.status}</p>
<p>Elapsed: {session?.elapsedTime}s</p>
<button onClick={handleStart}>Start</button>
<button onClick={handlePause}>Pause</button>
<button onClick={handleComplete}>Complete</button>
</div>
);
}Ratings Router
The ratings router handles session feedback from customers. It provides procedures for submitting ratings, checking if a session has been rated, and retrieving solver rating summaries.
export const ratingsRouter = router({
// Submit a rating for a completed session
submit: publicProcedure
.input(z.object({
sessionId: z.string().uuid(),
token: z.string().min(1),
rating: z.number().int().min(1).max(5),
comment: z.string().max(500).optional(),
}))
.mutation(async ({ input }) => {
// Validates token, creates rating, updates solver stats
}),
// Check if session already has a rating
getBySession: publicProcedure
.input(z.object({
sessionId: z.string().uuid(),
token: z.string().min(1),
}))
.query(async ({ input }) => {
// Returns existing rating or null
}),
// Get paginated ratings for a solver
getBySolver: protectedProcedure
.input(z.object({
solverId: z.string().uuid(),
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0),
}))
.query(async ({ input }) => {
// Returns ratings with customer names
}),
// Get solver's rating summary
getSolverSummary: publicProcedure
.input(z.object({ solverId: z.string().uuid() }))
.query(async ({ input }) => {
// Returns: averageRating, totalRatings, distribution
}),
});Procedures
| Procedure | Type | Auth | Description |
|---|---|---|---|
submit | Mutation | Token | Creates a rating for a completed session |
getBySession | Query | Token | Checks if session already has a rating |
getBySolver | Query | Required | Paginated list of solver's ratings |
getSolverSummary | Query | None | Aggregate rating statistics for a solver |
Token-Based Authentication
The submit and getBySession procedures use token-based authentication rather than Clerk auth. Customers access their session via a secure URL containing a session token. This token is validated against the session record:
// Verify token matches the session
const session = await db.query.sessions.findFirst({
where: eq(sessions.id, input.sessionId),
});
if (!session || session.accessToken !== input.token) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}Solver Stats Update
When a rating is submitted, the solver's aggregate stats are automatically recalculated:
async function updateSolverRating(solverId: string): Promise<void> {
const result = await db
.select({
averageRating: sql<number>`AVG(${ratings.rating})::numeric(3,2)`,
totalRatings: sql<number>`COUNT(*)`,
})
.from(ratings)
.where(eq(ratings.solverId, solverId));
await db.update(solvers)
.set({
stats: sql`
jsonb_set(
COALESCE(stats, '{}'),
'{averageRating}',
to_jsonb(${result[0].averageRating})
)
`,
})
.where(eq(solvers.id, solverId));
}Usage Example
"use client";
import { trpc } from "@/lib/trpc/client";
export function SessionRating({ sessionId, token }: Props) {
const { data: existing } = trpc.ratings.getBySession.useQuery(
{ sessionId, token },
{ enabled: !!token }
);
const submitRating = trpc.ratings.submit.useMutation();
const handleRate = (rating: number) => {
submitRating.mutate({ sessionId, token, rating });
};
if (existing) {
return <p>You rated this session {existing.rating} stars</p>;
}
return (
<div>
{[1, 2, 3, 4, 5].map((star) => (
<button key={star} onClick={() => handleRate(star)}>
{star} ★
</button>
))}
</div>
);
}Calls Router
The calls router handles voice call operations via Telnyx. It supports two calling modes based on solver preference: WebRTC (in-app browser calling) or bridge mode (phone-to-phone conference).
Procedures
| Procedure | Type | Auth | Description |
|---|---|---|---|
getWebRTCCredentials | Query | Required | Returns SIP credentials for browser-based WebRTC calling |
getCallPreference | Query | Required | Gets the solver's current call preference (in_app or bridge) |
updateCallPreference | Mutation | Required | Updates the solver's call preference |
initiate | Mutation | Required | Initiates call based on solver preference, creates call log |
end | Mutation | Required | Ends call and updates duration in call log |
getHistory | Query | Required | Retrieves call history for a session |
Call Modes
In-App (WebRTC): Solver calls from browser, customer receives phone call Bridge: Both solver and customer receive phone calls, connected in conference
Call Flow
- Initiate: Solver clicks "Call" →
initiatechecks solver'scallPreference - Routing: Based on preference, either starts WebRTC session or bridge call
- Telnyx Call Control: Telnyx manages the call via Call Control API
- Webhook: Telnyx sends events → webhook handler updates call log
- End: Call ends →
endupdates duration and final state
initiate: protectedProcedure
.input(z.object({
sessionId: z.string().uuid(),
to: z.string(),
}))
.mutation(async ({ ctx, input }) => {
// Verify solver owns this session
const session = await verifySessionOwnership(ctx.auth.userId, input.sessionId);
// Check solver's call preference
const solver = await getSolver(ctx.auth.userId);
if (solver.callPreference === "bridge") {
// Bridge: call both phones and connect
return initiateBridgeCall(solverPhone, customerPhone, sessionId);
} else {
// In-app: WebRTC for solver, phone call to customer
return initiateCall(customerPhone, sessionId);
}
}),The webhook handler (/api/webhooks/telnyx/voice) processes Telnyx Call Control events and updates call logs accordingly.
Usage Example
"use client";
import { trpc } from "@/lib/trpc/client";
import { useVoiceCall } from "@/hooks";
export function CallButton({ sessionId, customerPhone }: Props) {
const { call, hangup, callState, duration } = useVoiceCall(sessionId);
const handleCall = () => call(customerPhone);
return (
<div>
<p>Status: {callState}</p>
<p>Duration: {duration}s</p>
{callState === "idle" ? (
<button onClick={handleCall}>Call Customer</button>
) : (
<button onClick={hangup}>Hang Up</button>
)}
</div>
);
}Zod Schemas
Runtime validation uses Zod schemas defined in lib/schemas/. These schemas serve dual purposes: validating incoming data and providing TypeScript types.
import { z } from "zod";
export const healthCheckResponseSchema = z.object({
status: z.enum(["healthy", "degraded", "unhealthy"]),
timestamp: z.date(),
version: z.string(),
uptime: z.number().describe("Uptime in milliseconds"),
});
export type HealthCheckResponse = z.infer<typeof healthCheckResponseSchema>;The z.infer utility extracts the TypeScript type from the Zod schema, ensuring the type definition and validation logic stay synchronized.
Next.js Integration
The tRPC handler integrates with Next.js App Router through a catch-all route:
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext,
});
export { handler as GET, handler as POST };The fetchRequestHandler adapter converts Next.js request/response objects to tRPC's internal format. Both GET (for queries) and POST (for mutations) are handled by the same handler.
Client Setup
The client-side setup involves two files. First, creating the tRPC React hooks:
"use client";
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();Then, a provider component that initializes the tRPC client with React Query:
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { useState } from "react";
import superjson from "superjson";
import { trpc } from "./client";
function getBaseUrl() {
if (typeof window !== "undefined") {
return "";
}
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: process.env.NODE_ENV === "production",
staleTime: 60 * 1000,
},
},
})
);
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}The httpBatchLink automatically batches multiple tRPC calls made in the same render cycle into a single HTTP request, reducing network overhead.
Using tRPC in Components
With the provider wrapped around the app in app/layout.tsx, any client component can use tRPC hooks:
"use client";
import { trpc } from "@/lib/trpc/client";
export function HealthStatus() {
const health = trpc.health.check.useQuery();
if (health.isLoading) return <div>Checking...</div>;
if (health.error) return <div>Error: {health.error.message}</div>;
return (
<div>
Status: {health.data.status}
Uptime: {Math.round(health.data.uptime / 1000)}s
</div>
);
}The health.data object is fully typed based on the HealthCheckResponse type defined on the server. TypeScript will error if you try to access properties that don't exist.
Adding New Routers
To add a new feature router:
- Create a Zod schema in
lib/schemas/feature.ts - Create the router in
server/routers/feature.ts - Add the router to
server/routers/_app.ts
The types automatically propagate to the client through the AppRouter type export.
Related Documentation
- Architecture Overview - System design and project structure
- Project Setup - Initial configuration and dependencies