SavvySolve Docs

Customer Session View

Public-facing session interface for customers to join support sessions without authentication

Customer Session View

The customer session view is the public interface that customers use to participate in their support session. Unlike the solver-side session interface, this page requires no authentication. Customers access it via a secure link sent to them via SMS, containing a unique access token.

Why Token-Based Access?

The PRD emphasizes a friction-free experience for customers—many of whom are seniors who may not want to create an account just to get tech help. By using secure, single-use tokens embedded in SMS links, customers can join their session with a single tap.

The URL format is:

https://savvysolve.io/join/{sessionId}?token={accessToken}

When a solver starts a session, the system generates a secure access token and sends the customer an SMS with this link. No login, no password, no friction.

Session Access Tokens

Access tokens are generated using cryptographically secure random bytes:

lib/auth/session-token.ts
import { randomBytes } from "crypto";

const TOKEN_LENGTH_BYTES = 32; // 256 bits of entropy

export function generateSessionToken(): string {
  return randomBytes(TOKEN_LENGTH_BYTES).toString("hex");
}

export function generateShortSessionToken(): string {
  return randomBytes(TOKEN_LENGTH_BYTES)
    .toString("base64url")
    .replace(/[=]/g, ""); // Remove padding for shorter URLs
}

export function isValidTokenFormat(token: string): boolean {
  // Accept both legacy hex tokens (64 chars) and new base64url tokens (40-50 chars)
  if (/^[a-f0-9]{64}$/i.test(token)) return true;
  if (/^[A-Za-z0-9_-]{40,50}$/.test(token)) return true;
  return false;
}

export function buildSessionUrl(
  sessionId: string,
  accessToken: string,
  baseUrl?: string
): string {
  const base = baseUrl || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
  return `${base}/join/${sessionId}?token=${accessToken}`;
}

The buildSessionUrl helper is used in two places:

  • SMS notifications - When sending the session link to customers via text message
  • Session header - The solver can copy the URL to share manually with the customer

Tokens are stored in the sessions table with a unique index for fast lookups:

lib/db/schema/sessions.ts
export const sessions = pgTable("sessions", {
  // ... other fields
  accessToken: text("access_token").notNull().unique(),
}, (table) => [
  index("sessions_access_token_idx").on(table.accessToken),
]);

Public tRPC Procedure

The getByToken procedure allows unauthenticated access to session data:

server/routers/sessions.ts
getByToken: publicProcedure
  .input(z.object({
    sessionId: z.string().uuid(),
    token: z.string(),
  }))
  .query(async ({ input }) => {
    const { sessionId, token } = input;

    // Validate token format before database query
    if (!isValidTokenFormat(token)) {
      throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid token format" });
    }

    // Find session with matching ID and token
    const session = await db.query.sessions.findFirst({
      where: and(
        eq(sessions.id, sessionId),
        eq(sessions.accessToken, token)
      ),
      with: {
        ticket: true,
        solver: {
          with: {
            user: {
              columns: { name: true }, // Only expose safe fields
            },
          },
        },
      },
    });

    if (!session) {
      throw new TRPCError({ code: "NOT_FOUND", message: "Session not found or invalid token" });
    }

    // Return customer-safe session data
    return {
      id: session.id,
      status: session.status,
      tier: session.tier,
      elapsedTime: calculateElapsedTime(session),
      startedAt: session.startedAt,
      endedAt: session.endedAt,
      ticket: {
        id: session.ticket.id,
        description: session.ticket.description,
        customerName: session.ticket.customerInfo.name,
      },
      solver: { name: session.solver.user.name },
      pricing: session.pricing,
    };
  }),

Notice how the procedure carefully limits what data is exposed—no email addresses, phone numbers, or internal IDs reach the customer.

Customer Session Page

The page at /join/[id] handles token validation and renders the appropriate view:

app/join/[id]/page.tsx
"use client";

import { useParams, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { CustomerSessionView } from "@/components/session/CustomerSessionView";
import { trpc } from "@/lib/trpc/client";

function CustomerSessionContent() {
  const params = useParams();
  const searchParams = useSearchParams();
  const sessionId = params.id as string;
  const token = searchParams.get("token");

  if (!sessionId || !token) {
    return <SessionError message="Missing session ID or access token." />;
  }

  const { data: session, isLoading, error, refetch } = trpc.sessions.getByToken.useQuery(
    { sessionId, token },
    { retry: false, refetchInterval: 30000 }
  );

  if (isLoading) return <SessionLoader />;
  if (error || !session) return <SessionError message={error?.message} />;

  return <CustomerSessionView session={session} token={token} onRefetch={refetch} />;
}

The page auto-refreshes every 30 seconds to keep the session status current.

CustomerSessionView Component

The main component is designed with seniors in mind—large touch targets, clear typography, and a reassuring UX:

components/session/CustomerSessionView.tsx
export function CustomerSessionView({ session, token, onRefetch }: Props) {
  const isCompleted = session.status === "completed";
  const customerId = `customer_${session.ticket.id.slice(0, 8)}`;

  if (isCompleted) {
    return <CompletedView session={session} onRate={handleRate} />;
  }

  return (
    <AblyProvider authUrl={`/api/ably/auth?customerId=${customerId}`}>
      <div className="flex min-h-screen flex-col">
        <StatusBeacon status={session.status} solverName={session.solver.name} />
        <SessionMetrics elapsedTime={session.elapsedTime} tier={session.tier} status={session.status} />
        <ChatWindow sessionId={session.id} userId={customerId} userRole="customer" />
        <EscalationButton onEscalate={handleEscalate} />
      </div>
    </AblyProvider>
  );
}

StatusBeacon

Shows the solver's name and connection status with a gentle pulse animation:

StatusColorMessage
not_startedAmber (pulsing)"Connecting you with..."
activeGreen (pulsing)"You're connected with..."
pausedAmber"Session paused..."
completedGray"Session completed..."

SessionMetrics

Displays time elapsed and the tier's flat-rate cost:

const TIER_PRICING = {
  quick: { rate: 6900, limit: 20 * 60, name: "Quick Assist" },    // $69 / 20min
  standard: { rate: 12900, limit: 45 * 60, name: "Standard Solve" }, // $129 / 45min
  extended: { rate: 21900, limit: 90 * 60, name: "Deep Dive" },   // $219 / 90min
};

A progress bar shows how much of the included time has been used, turning amber when exceeded.

EscalationButton

If a customer feels unresponded to, they can tap "Need Help? Request Assistance" to escalate the session to a supervisor.

CustomerProfileEditor

Customers can update their own contact information through a collapsible profile section. This is especially important for ad-hoc sessions where the solver may not have complete customer details:

components/session/CustomerProfileEditor.tsx
export function CustomerProfileEditor({
  sessionId,
  token,
  initialName,
  initialPhone,
  initialEmail,
  onUpdate,
}: CustomerProfileEditorProps) {
  const updateInfo = trpc.sessions.updateCustomerInfo.useMutation({
    onSuccess: () => {
      setSaved(true);
      setTimeout(() => setSaved(false), 2000);
      onUpdate?.();
    },
  });

  return (
    <Collapsible>
      <CollapsibleTrigger asChild>
        <button className="flex w-full items-center justify-between rounded-xl bg-white/80 p-4">
          <div className="flex items-center gap-3">
            <User className="h-5 w-5" />
            <div>
              <p className="font-medium">{name}</p>
              <p className="text-sm text-muted-foreground">
                {phone || "Add phone number"}
              </p>
            </div>
          </div>
          <ChevronDown className={cn("h-5 w-5", isOpen && "rotate-180")} />
        </button>
      </CollapsibleTrigger>
      <CollapsibleContent>
        {/* Name, phone, email inputs */}
        <Button onClick={handleSave}>Save Changes</Button>
      </CollapsibleContent>
    </Collapsible>
  );
}

This is backed by a public tRPC procedure that validates the session token:

server/routers/sessions.ts
updateCustomerInfo: publicProcedure
  .input(z.object({
    sessionId: z.string().uuid(),
    token: z.string(),
    customerName: z.string().min(1).max(100).optional(),
    customerPhone: z.string().optional(),
    customerEmail: z.string().email().optional(),
  }))
  .mutation(async ({ input }) => {
    // Validate token matches session
    const session = await db.query.sessions.findFirst({
      where: and(
        eq(sessions.id, input.sessionId),
        eq(sessions.accessToken, input.token)
      ),
      with: { ticket: true },
    });

    if (!session) {
      throw new TRPCError({ code: "NOT_FOUND", message: "Session not found" });
    }

    // Update ticket's customer info
    const currentInfo = session.ticket.customerInfo ?? {};
    await db.update(tickets).set({
      customerInfo: {
        ...currentInfo,
        name: input.customerName ?? currentInfo.name,
        phone: input.customerPhone ?? currentInfo.phone,
        email: input.customerEmail ?? currentInfo.email,
      },
    }).where(eq(tickets.id, session.ticketId));
  }),

CompletedView

When the session ends, customers see a summary with duration and total cost, plus a 5-star rating prompt.

Real-Time Timer Synchronization

The customer view maintains a local timer that stays synchronized with the solver's timer through Ably events. Rather than receiving updates every second, the customer receives status change events (start/pause) and calculates elapsed time locally.

Event-Based Sync

When a solver starts or pauses the timer, the server broadcasts a SESSION_STATUS event:

await publishToChannel(
  CHANNEL_NAMES.sessionChat(sessionId),
  MESSAGE_EVENTS.SESSION_STATUS,
  {
    sessionId,
    status: "active", // or "paused"
    elapsedSeconds: currentDuration,
    timestamp: new Date().toISOString(),
  },
);

Network Latency Compensation

The customer view accounts for network delay to keep timers in sync:

components/session/CustomerSessionView.tsx
const handleStatusUpdate = useCallback((message: {
  data: { status: string; elapsedSeconds: number; timestamp: string }
}) => {
  const { status, elapsedSeconds, timestamp } = message.data;

  // Calculate time since server sent this message
  const serverTime = new Date(timestamp).getTime();
  const now = Date.now();
  const networkDelay = Math.max(0, Math.floor((now - serverTime) / 1000));

  setCurrentStatus(status);

  // Add network delay when resuming to sync up
  if (status === "active") {
    setCurrentElapsed(elapsedSeconds + networkDelay);
  } else {
    setCurrentElapsed(elapsedSeconds);
  }
}, []);

This keeps the solver and customer timers synchronized within 1-2 seconds while minimizing WebSocket traffic.

Ably Authentication for Customers

Customers authenticate with Ably differently than authenticated users. The auth route accepts a customerId query parameter:

app/api/ably/auth/route.ts
export async function GET(request: Request) {
  const url = new URL(request.url);
  const customerId = url.searchParams.get("customerId");

  if (customerId) {
    // Customer access - validate format, grant limited permissions
    if (!customerId.startsWith("customer_")) {
      return NextResponse.json({ error: "Invalid customer ID format" }, { status: 400 });
    }

    return generateToken({
      clientId: customerId,
      capability: { "session:*": ["publish", "subscribe", "presence"] },
    });
  }

  // Standard authenticated user flow...
}

Customers can only access session:* channels—they cannot subscribe to the queue or other administrative channels.

Mobile-First Design

The customer view targets a mobile audience (60%+ expected traffic):

  1. Large touch targets - All buttons are at least 44x44 pixels
  2. Clear typography - Poppins font at readable sizes
  3. High contrast - Primary colors meet WCAG AA standards
  4. Sticky header - Logo and status always visible
  5. Accessible rating - Star rating buttons are 56x56 pixels

Security Considerations

The customer session flow incorporates several security measures:

  1. Token validation - Format is validated before database queries
  2. Rate limiting - Failed token attempts should be rate-limited (future work)
  3. Data minimization - Only necessary fields exposed to customers
  4. Channel isolation - Customers can only access their specific session channel
  5. No sensitive data in URL - Token is the only identifier, session ID is a UUID

Testing

Key test scenarios for customer session access:

  1. Valid token access - Customer can load session with correct token
  2. Invalid token rejection - Wrong or malformed tokens return errors
  3. Ably customer auth - Customer ID format validated, correct permissions granted
  4. Status updates - UI reflects status changes from server
  5. Mobile viewport - All elements remain usable on small screens

On this page