SavvySolve Docs

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:

server/trpc.ts
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:

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:

server/routers/health.ts
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.

server/routers/solvers.ts
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

ProcedureTypeAuthDescription
getProfileQueryRequiredReturns solver profile with user data, stats, and type
getStatsQueryRequiredReturns dashboard metrics for display
updateStatusMutationRequiredSets 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.

server/routers/tickets.ts
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

ProcedureTypeAuthDescription
createMutationNoneCreates a ticket from the public intake form
listQueryRequiredLists pending tickets with urgency-based ordering
claimMutationRequiredAtomically claims a ticket for the current solver
getQueryRequiredRetrieves 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

ProcedureTypeAuthDescription
startMutationRequiredCreates a session from a claimed ticket
getQueryRequiredRetrieves session details with elapsed time
getByTokenQueryNoneCustomer access via session token (limited data)
getActiveQueryRequiredGets the solver's currently active session
updateStatusMutationRequiredState machine transitions (active, paused, completed)
updateNotesMutationRequiredUpdates solver notes for the session
completeMutationRequiredCompletes session with tier selection and pricing
syncTimerMutationRequiredPersists elapsed time periodically
getMessagesQueryNonePaginated chat messages for a session
sendMessageMutationNoneSends a chat message (customer or solver)
createSystemMessageMutationRequiredCreates system event messages

Session State Machine

Sessions follow a strict state machine with valid transitions:

not_started → active → paused ⟷ active → completed
                  └─────────────────────→ completed

Invalid transitions (e.g., completedactive) throw a BAD_REQUEST error.

Pricing Tiers

When completing a session, the solver selects a pricing tier:

TierNameBase PriceIncluded TimeOverage Rate
quickQuick Assist$6920 minutes$3/min
standardStandard Solve$12945 minutes$2.50/min
extendedDeep Dive$21990 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.

server/routers/ratings.ts
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

ProcedureTypeAuthDescription
submitMutationTokenCreates a rating for a completed session
getBySessionQueryTokenChecks if session already has a rating
getBySolverQueryRequiredPaginated list of solver's ratings
getSolverSummaryQueryNoneAggregate 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

ProcedureTypeAuthDescription
getWebRTCCredentialsQueryRequiredReturns SIP credentials for browser-based WebRTC calling
getCallPreferenceQueryRequiredGets the solver's current call preference (in_app or bridge)
updateCallPreferenceMutationRequiredUpdates the solver's call preference
initiateMutationRequiredInitiates call based on solver preference, creates call log
endMutationRequiredEnds call and updates duration in call log
getHistoryQueryRequiredRetrieves 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

  1. Initiate: Solver clicks "Call" → initiate checks solver's callPreference
  2. Routing: Based on preference, either starts WebRTC session or bridge call
  3. Telnyx Call Control: Telnyx manages the call via Call Control API
  4. Webhook: Telnyx sends events → webhook handler updates call log
  5. End: Call ends → end updates 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.

lib/schemas/health.ts
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:

app/api/trpc/[trpc]/route.ts
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:

lib/trpc/client.ts
"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:

lib/trpc/provider.tsx
"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:

  1. Create a Zod schema in lib/schemas/feature.ts
  2. Create the router in server/routers/feature.ts
  3. Add the router to server/routers/_app.ts

The types automatically propagate to the client through the AppRouter type export.

On this page