SavvySolve Docs

Chat Interface

Real-time messaging with message persistence, markdown support, and file uploads

Chat Interface

The chat interface enables real-time communication between solvers and customers during support sessions. Messages are persisted to the database, broadcast via Ably for instant delivery, and support markdown formatting and file attachments.

Architecture Overview

The chat system combines three layers:

  1. Database persistence - Messages stored in PostgreSQL via Drizzle ORM
  2. Real-time broadcast - Ably pub/sub for instant message delivery
  3. Optimistic UI - Messages appear immediately while persisting in the background

This architecture ensures messages are never lost (database is source of truth) while providing a responsive user experience (optimistic updates with real-time sync).

Database Schema

Chat messages are stored in the session_messages table:

lib/db/schema/session-messages.ts
export const sessionMessages = pgTable("session_messages", {
  id: uuid("id").primaryKey().defaultRandom(),
  sessionId: uuid("session_id").notNull().references(() => sessions.id),
  senderId: text("sender_id"),      // Clerk user ID (null for system)
  senderName: text("sender_name"),  // Display name
  senderType: senderTypeEnum("sender_type").notNull(), // customer | solver | system
  content: text("content").notNull(),
  attachments: jsonb("attachments").$type<MessageAttachment[]>().default([]),
  systemEventType: text("system_event_type"), // For system messages
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

The attachments field stores file metadata as JSON, referencing files uploaded to Cloudflare R2:

lib/db/schema/session-messages.ts
export interface MessageAttachment {
  url: string;         // Public CDN URL
  filename: string;    // Original filename
  contentType: string; // MIME type
  size: number;        // Bytes
}

tRPC Procedures

The sessions router provides three procedures for chat functionality:

getMessages

Retrieves paginated message history for a session:

server/routers/sessions.ts
getMessages: publicProcedure
  .input(z.object({
    sessionId: z.string().uuid(),
    limit: z.number().min(1).max(100).default(50),
    cursor: z.string().uuid().optional(),
  }))
  .query(async ({ input }) => {
    // Fetch messages ordered by createdAt desc
    // Return { items, nextCursor } for infinite scroll
  })

The procedure uses cursor-based pagination, returning the oldest messages first after reversing the query results. This allows the chat to load history from newest to oldest while displaying in chronological order.

sendMessage

Persists a message and broadcasts it via Ably:

server/routers/sessions.ts
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 into database
    const [message] = await db.insert(sessionMessages).values(input).returning();
    
    // 2. Broadcast via Ably for real-time delivery
    await publishToChannel(
      CHANNEL_NAMES.sessionChat(sessionId),
      MESSAGE_EVENTS.CHAT_MESSAGE,
      chatMessage
    );
    
    return message;
  })

Messages are rejected if the session status is completed, preventing post-session chat.

createSystemMessage

Creates system announcements for session events:

server/routers/sessions.ts
createSystemMessage: protectedProcedure
  .input(z.object({
    sessionId: z.string().uuid(),
    content: z.string(),
    eventType: z.string(), // e.g., "session_started", "solver_joined"
  }))
  .mutation(async ({ input }) => {
    // Insert with senderType: "system"
    // Broadcast with special system message format
  })

useChat Hook

The useChat hook provides a complete chat interface for React components:

hooks/use-chat.tsx
const {
  messages,         // All messages (history + real-time)
  sendMessage,      // Send a new message
  startTyping,      // Signal typing started
  stopTyping,       // Signal typing stopped
  typingUsers,      // List of users currently typing
  isConnected,      // Ably connection status
  isLoadingHistory, // Initial history load
  loadMore,         // Fetch older messages
  hasMore,          // More history available
} = useChat({
  sessionId: "session_123",
  userId: user.id,
  userName: user.name,
  userRole: "customer",
});

Message Flow

When a user sends a message:

  1. Optimistic update - Message appears immediately in the UI with a temporary ID
  2. tRPC mutation - sendMessage persists to database and broadcasts via Ably
  3. ID replacement - Temporary ID replaced with real database ID on success
  4. Rollback on error - Optimistic message removed if mutation fails

When receiving a message from another user:

  1. Ably subscription - handleMessage callback receives the broadcast
  2. Deduplication - Skip if message ID already exists (from history or own send)
  3. State update - Add to realtimeMessages array
  4. Merged display - messages combines history and real-time, sorted by timestamp

History Loading

The hook uses tRPC's infinite query for pagination:

const { data, fetchNextPage, hasNextPage } = trpc.sessions.getMessages
  .useInfiniteQuery(
    { sessionId, limit: 50 },
    { getNextPageParam: (lastPage) => lastPage.nextCursor }
  );

History messages are converted to ChatMessage format and merged with real-time messages using useMemo to avoid unnecessary re-renders.

ChatWindow Component

The ChatWindow component provides the complete chat UI:

components/chat/ChatWindow.tsx
<ChatWindow
  sessionId={session.id}
  userId={user.id}
  userName={user.name}
  userRole="solver"
/>

Features

  • Markdown rendering - Bold, italic, links, and inline code via react-markdown
  • System messages - Distinct styling for session events
  • Typing indicators - Animated dots when other user is typing
  • Presence awareness - Shows who's online in the session
  • Auto-scroll - Scrolls to bottom on new messages
  • Load more - Button to fetch older message history
  • Relative timestamps - "Just now", "5m ago", "2:30 PM"

Message Types

The component renders three message types differently:

  1. Own messages - Right-aligned, primary color background
  2. Other messages - Left-aligned, muted background, shows sender name
  3. System messages - Centered, subtle styling with info icon

File Uploads with R2

Chat supports image and file attachments via Cloudflare R2 storage.

Upload Flow

  1. Client requests presigned URL from /api/upload
  2. Client uploads directly to R2 using the presigned URL
  3. Client includes attachment metadata when sending the message

R2 Client

lib/r2/client.ts
export function getR2Client(): S3Client {
  return new S3Client({
    region: "auto",
    endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
    credentials: { accessKeyId, secretAccessKey },
  });
}

Presigned URLs

The upload API generates time-limited URLs for direct browser uploads:

lib/r2/upload.ts
export async function getUploadUrl(
  sessionId: string,
  filename: string,
  contentType: AllowedMimeType,
  contentLength: number
): Promise<{ uploadUrl: string; key: string; publicUrl: string }> {
  const key = generateFileKey(sessionId, filename);
  const command = new PutObjectCommand({ Bucket, Key: key, ContentType, ContentLength });
  const uploadUrl = await getSignedUrl(client, command, { expiresIn: 600 });
  return { uploadUrl, key, publicUrl: `${R2_PUBLIC_URL}/${key}` };
}

useFileUpload Hook

hooks/use-file-upload.tsx
const { upload, isUploading, progress, error } = useFileUpload({
  sessionId: session.id,
  onUploadComplete: (result) => {
    // Include attachment in next message
  },
});

// Upload a file
const result = await upload(file);
// result.publicUrl is the CDN URL for the uploaded file

Allowed File Types

  • Images: JPEG, PNG, GIF, WebP
  • Documents: PDF
  • Maximum size: 10MB

Environment Variables

Required configuration for chat functionality:

.env.local
# Ably Real-Time (required for chat)
ABLY_API_KEY=your-ably-api-key

# Cloudflare R2 (required for file uploads)
CLOUDFLARE_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_CHAT_ATTACHMENTS_BUCKET=savvysolve-chat-attachments
R2_PUBLIC_URL=https://cdn.savvysolve.io

Testing Strategy

Key test scenarios for the chat system:

  1. Message persistence - Verify messages are saved to database
  2. Real-time delivery - Mock Ably to test broadcast on send
  3. Pagination - Test cursor-based history loading
  4. Optimistic updates - Verify rollback on mutation failure
  5. File uploads - Mock R2 presigned URL generation

On this page