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:
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:
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:
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:
"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:
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:
| Status | Color | Message |
|---|---|---|
not_started | Amber (pulsing) | "Connecting you with..." |
active | Green (pulsing) | "You're connected with..." |
paused | Amber | "Session paused..." |
completed | Gray | "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:
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:
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:
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:
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):
- Large touch targets - All buttons are at least 44x44 pixels
- Clear typography - Poppins font at readable sizes
- High contrast - Primary colors meet WCAG AA standards
- Sticky header - Logo and status always visible
- Accessible rating - Star rating buttons are 56x56 pixels
Security Considerations
The customer session flow incorporates several security measures:
- Token validation - Format is validated before database queries
- Rate limiting - Failed token attempts should be rate-limited (future work)
- Data minimization - Only necessary fields exposed to customers
- Channel isolation - Customers can only access their specific session channel
- No sensitive data in URL - Token is the only identifier, session ID is a UUID
Testing
Key test scenarios for customer session access:
- Valid token access - Customer can load session with correct token
- Invalid token rejection - Wrong or malformed tokens return errors
- Ably customer auth - Customer ID format validated, correct permissions granted
- Status updates - UI reflects status changes from server
- Mobile viewport - All elements remain usable on small screens
Related Documentation
- Session Interface - Solver-side session view
- Chat Interface - Real-time messaging system
- Ably Integration - WebSocket infrastructure