Back to blog
Building TickMate: An AI-Powered Support Ticket Management System with Semantic Search and Async Workflows
A technical deep-dive into building a production-grade ticket management platform with Express 5, Next.js 16, OpenAI embeddings, Qdrant vector search, and Inngest background jobs—from architecture to deployment.
34 min read·Talha Bilal
Share:

Introduction
Support teams face mounting pressure to resolve tickets quickly while maintaining quality. Traditional ticketing systems track issues but lack intelligence—they can't surface similar resolved tickets, automatically categorize incoming requests, or route tickets to specialists based on skills. Teams waste hours searching for related solutions and manually triaging incoming work.
TickMate addresses this gap by combining AI-powered ticket analysis with semantic search driven by vector embeddings. The system automatically categorizes incoming tickets, generates structured analysis including priority recommendations and suggested skills, and uses Qdrant vector search to find semantically similar resolved tickets. Background processing with Inngest handles email notifications and AI analysis asynchronously, ensuring responsive API performance.
Built as a full-stack monorepo with Express 5 and Next.js 16, the platform implements role-based access control (user, moderator, admin), comprehensive audit logging, and production-grade authentication with JWT tokens in HttpOnly cookies. The architecture separates concerns cleanly while maintaining type safety across the stack.
This case study explores the engineering decisions, architectural patterns, and implementation details behind building a scalable AI-powered support platform from the ground up.
Screenshot Placeholder
TODO: Add screenshot of the TickMate landing page showing the dashboard with ticket statistics, recent tickets, and quick action navigation.
The Problem Space
Requirements Analysis
The platform needed to solve several interconnected challenges:
- AI-powered ticket analysis — Automatically categorize tickets, determine priority, and recommend required skills using LLMs
- Semantic search — Find similar resolved tickets using vector embeddings and cosine similarity
- Role-based access control — Support three distinct roles (user, moderator, admin) with different permissions
- Async job processing — Handle email notifications and AI analysis without blocking API responses
- Audit trail — Track all actions for compliance and debugging
- Authentication — Secure JWT-based auth with magic link support and email verification
Technical Constraints
- Cost management — OpenAI API calls are expensive; only analyze completed public tickets for embeddings
- Vector search performance — Qdrant must return results within 100ms for responsive UX
- Database consistency — PostgreSQL with Drizzle ORM requires careful migration management
- Security — Password hashing with Argon2, HttpOnly cookies for tokens, RBAC middleware
- Scalability — Monorepo architecture must support independent deployment of frontend and backend
Solution Architecture
Technology Selection
The stack was chosen to balance developer productivity, production readiness, and ecosystem maturity:
| Layer | Technology | Rationale |
|---|---|---|
| Backend Framework | Express 5 (ESM) | Mature ecosystem with TypeScript ESM support and middleware composability |
| Frontend Framework | Next.js 16 (App Router) | Full-stack React with server components, file-based routing, and API integration |
| Language | TypeScript 5.9.3 | Type safety across frontend and backend reduces runtime errors |
| Database | PostgreSQL + Drizzle ORM | ACID compliance for transactional data with type-safe query builder |
| Vector Database | Qdrant 1.17.0 | High-performance vector search with filtering and cosine similarity |
| AI Framework | LangChain 1.2.29 + OpenAI | Structured output enforcement and prompt management |
| AI Model | GPT-4.1-mini | Cost-effective model for ticket analysis with adequate quality |
| Embeddings | text-embedding-3-small | 1536-dimension embeddings optimized for semantic search |
| Job Queue | Inngest 3.52.6 | Serverless background jobs with type-safe event definitions |
| Resend 6.9.3 | Modern email API with React template support | |
| Authentication | JWT (jsonwebtoken) | Industry-standard token-based auth with HttpOnly cookies |
| Password Hashing | Argon2 | Winner of Password Hashing Competition, resistant to GPU attacks |
| Validation | Zod 4.3.6 | Runtime type validation for API inputs and schemas |
| Styling | Tailwind CSS v4 + shadcn/ui | Utility-first CSS with composable UI components |
High-Level Architecture
The application follows a layered monorepo architecture with clear separation between frontend and backend:
text
1┌─────────────────────────────────────────────────────────────────┐2│ Monorepo Root │3│ ┌──────────────────────────┐ ┌──────────────────────────┐ │4│ │ tickmate-frontend/ │ │ tickmate-backend/ │ │5│ │ (Next.js 16) │◄──►│ (Express 5) │ │6│ │ - App Router Pages │ │ - REST API Routes │ │7│ │ - React 19 + RSC │ │ - Controllers │ │8│ │ - Tailwind v4 │ │ - Middleware │ │9│ │ - shadcn/ui Components │ │ - Services │ │10│ └──────────────────────────┘ └─────────┬────────────────┘ │11└────────────────────────────────────────────┼───────────────────┘12 │13 ┌────────────────────────┼────────────────────┐14 │ │ │15 ┌───────▼──────┐ ┌───────▼──────┐ ┌───────▼──────┐16 │ PostgreSQL │ │ Qdrant │ │ Inngest │17 │ (Drizzle) │ │ (Vectors) │ │ (Async Jobs) │18 │ - Users │ │ - Embeddings │ │ - AI Tasks │19 │ - Tickets │ │ - Similarity │ │ - Emails │20 │ - Audit │ │ Search │ │ │21 └──────────────┘ └──────────────┘ └──────┬───────┘22 │23 ┌──────────┼────────┐24 │ │ │25 ┌─────▼────┐ ┌──▼─────┐ │26 │ OpenAI │ │ Resend │ │27 │ API │ │ Email │ │28 └──────────┘ └────────┘ │System Components
Backend Layer (
tickmate-backend/):- API Routes: RESTful endpoints organized by resource (auth, tickets, admin)
- Controllers: Business logic for ticket management, user actions, admin operations
- Middleware: Authentication, authorization (RBAC), error handling, CORS
- Services: Database queries, vector operations, AI integration
- Utilities: Vector database helpers, email functions, validation schemas
- Inngest Functions: Async jobs for AI processing and email delivery
Frontend Layer (
tickmate-frontend/):- App Router: File-based routing with nested layouts
- Pages: User dashboard, admin panel, authentication flows, ticket views
- Components: Reusable UI components (auth forms, ticket cards, profile management)
- API Client: Typed functions for backend communication with error handling
- Hooks: Custom React hooks for data fetching and state management
External Services:
- PostgreSQL: Persistent storage for users, tickets, audit logs, magic links, AI usage
- Qdrant: Vector database storing 1536-dimension embeddings for semantic search
- OpenAI: GPT-4.1-mini for ticket analysis, text-embedding-3-small for embeddings
- Inngest: Background job orchestration for email and AI processing
- Resend: Transactional email delivery
Diagram Placeholder
TODO: Add a Mermaid architecture diagram showing the complete system with Next.js frontend, Express backend, PostgreSQL, Qdrant, Inngest, OpenAI, and Resend integrations.
Core Implementation Details
1. Database Schema with Drizzle ORM
The schema implements soft deletes, role-based access, and comprehensive audit logging:
Users Table (
src/db/schema/users.schema.ts):typescript
1export const usersTable = pgTable("users", {2 id: serial("id").primaryKey(),3 username: varchar("username", { length: 50 }).notNull().unique(),4 email: varchar("email", { length: 100 }).notNull().unique(),5 passwordHash: varchar("password_hash", { length: 255 }).notNull(),6 fullName: varchar("full_name", { length: 100 }),7 role: roleEnum("role").notNull().default("user"), // user, moderator, admin8 skills: text("skills").array().default([]),9 isActive: boolean("is_active").notNull().default(true),10 isEmailVerified: boolean("is_email_verified").notNull().default(false),11 emailVerificationToken: varchar("email_verification_token", { length: 255 }),12 emailVerificationExpires: timestamp("email_verification_expires", { mode: "date" }),13 createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(),14 updatedAt: timestamp("updated_at", { mode: "date" }).notNull().defaultNow(),15});Tickets Table (
src/db/schema/tickets.schema.ts):typescript
1export const ticketsTable = pgTable("tickets", {2 id: serial("id").primaryKey(),3 title: varchar("title", { length: 255 }).notNull(),4 description: text("description").notNull(),5 category: categoryEnum("category").notNull(), // Bug, Feature Request, Question, Performance6 priority: priorityEnum("priority").notNull().default("low"), // low, medium, high7 status: statusEnum("status").notNull().default("pending"), // pending, in_progress, completed8 isPublic: boolean("is_public").notNull().default(false),9 helpfulNotes: text("helpful_notes"),10 relatedSkills: text("related_skills").array().default([]),11 createdBy: integer("created_by").notNull().references(() => usersTable.id),12 assignedTo: integer("assigned_to").references(() => usersTable.id),13 deletedAt: timestamp("deleted_at", { mode: "date" }), // Soft delete14 createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(),15 updatedAt: timestamp("updated_at", { mode: "date" }).notNull().defaultNow(),16});Audit Logs Table (
src/db/schema/audit-logs.schema.ts):typescript
1export const auditLogsTable = pgTable("audit_logs", {2 id: serial("id").primaryKey(),3 userId: integer("user_id").references(() => usersTable.id),4 action: varchar("action", { length: 100 }).notNull(),5 entityType: varchar("entity_type", { length: 50 }).notNull(),6 entityId: integer("entity_id").notNull(),7 changes: jsonb("changes"),8 ipAddress: varchar("ip_address", { length: 45 }),9 userAgent: varchar("user_agent", { length: 255 }),10 createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(),11});AI Usage Logs Table (
src/db/schema/ai-usage-logs.schema.ts):typescript
1export const aiUsageLogsTable = pgTable("ai_usage_logs", {2 id: serial("id").primaryKey(),3 ticketId: integer("ticket_id").notNull().references(() => ticketsTable.id),4 userId: integer("user_id").references(() => usersTable.id),5 model: varchar("model", { length: 50 }).notNull(),6 tokensUsed: integer("tokens_used").notNull(),7 cost: decimal("cost", { precision: 10, scale: 6 }),8 purpose: varchar("purpose", { length: 100 }).notNull(), // analysis, embedding9 createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(),10});Magic Links Table (
src/db/schema/magic-links.schema.ts):typescript
1export const magicLinksTable = pgTable("magic_links", {2 id: serial("id").primaryKey(),3 userId: integer("user_id").notNull().references(() => usersTable.id),4 token: varchar("token", { length: 255 }).notNull().unique(),5 expiresAt: timestamp("expires_at", { mode: "date" }).notNull(),6 usedAt: timestamp("used_at", { mode: "date" }),7 createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(),8});The schema provides:
- Type safety — Drizzle generates TypeScript types from schema definitions
- Soft deletes — Tickets marked with
deletedAtrather than hard deleted - Audit trail — Complete change history with user, action, and metadata
- Cost tracking — AI usage logs record OpenAI token consumption
- Email verification — Token-based verification with expiry
2. AI-Powered Ticket Analysis with Structured Output
The system uses LangChain with OpenAI's structured output feature to enforce reliable response formats:
Analysis Schema (
src/schemas/ai-response.schema.ts):typescript
1export const aiAnalysisSchema = z.object({2 suggestedCategory: z.enum(["Bug", "Feature Request", "Question", "Performance"]),3 suggestedPriority: z.enum(["low", "medium", "high"]),4 reasoning: z.string().describe("Explanation for category and priority choices"),5 relatedSkills: z.array(z.string()).describe("Skills needed to resolve this ticket"),6 initialNotes: z.string().describe("Helpful context for moderators"),7 estimatedComplexity: z.enum(["simple", "moderate", "complex"])8});9
10export type AiAnalysis = z.infer<typeof aiAnalysisSchema>;Analysis Chain (
src/utils/ai.utils.ts):typescript
1import { ChatOpenAI } from "@langchain/openai";2import { PromptTemplate } from "@langchain/core/prompts";3
4export async function analyzeTicket(ticket: {5 title: string;6 description: string;7}): Promise<AiAnalysis> {8 const model = new ChatOpenAI({9 modelName: "gpt-4.1-mini",10 temperature: 0.3,11 apiKey: ENV.OPENAI_API_KEY,12 });13
14 const structured = model.withStructuredOutput(aiAnalysisSchema);15
16 const prompt = PromptTemplate.fromTemplate(`17You are a support ticket triage specialist. Analyze the following ticket and provide structured output.18
19TICKET TITLE: {title}20
21TICKET DESCRIPTION: {description}22
23Provide:241. Suggested category (Bug, Feature Request, Question, or Performance)252. Suggested priority (low, medium, high) based on impact and urgency263. Clear reasoning for your choices274. List of skills needed to resolve this (e.g., "React", "PostgreSQL", "Authentication")285. Initial notes that would help moderators understand context296. Estimated complexity30
31Be concise but thorough. Focus on actionable insights.32 `);33
34 const chain = prompt.pipe(structured);35
36 const result = await chain.invoke({37 title: ticket.title,38 description: ticket.description,39 });40
41 return aiAnalysisSchema.parse(result);42}Inngest Function Integration (
src/inngest/functions/on-ticket-create.ts):The AI analysis runs asynchronously via Inngest to avoid blocking the API response:
typescript
1export const onTicketCreate = inngest.createFunction(2 { id: "ticket-created", name: "Handle ticket creation" },3 { event: "ticket/created" },4 async ({ event, step }) => {5 const { ticket } = event.data;6
7 // Step 1: AI Analysis8 const analysis = await step.run("analyze-ticket-with-ai", async () => {9 try {10 const result = await analyzeTicket({11 title: ticket.title,12 description: ticket.description,13 });14
15 // Update ticket with AI suggestions16 await db17 .update(ticketsTable)18 .set({19 category: result.suggestedCategory,20 priority: result.suggestedPriority,21 relatedSkills: result.relatedSkills,22 helpfulNotes: result.initialNotes,23 updatedAt: new Date(),24 })25 .where(eq(ticketsTable.id, ticket.id));26
27 // Log AI usage28 await db.insert(aiUsageLogsTable).values({29 ticketId: ticket.id,30 userId: ticket.createdBy,31 model: "gpt-4.1-mini",32 tokensUsed: 500, // Approximate33 purpose: "analysis",34 });35
36 return result;37 } catch (error) {38 console.error("AI analysis failed:", error);39 throw error;40 }41 });42
43 // Step 2: Send confirmation email44 await step.run("send-confirmation-email", async () => {45 const [user] = await db46 .select({ email: usersTable.email, fullName: usersTable.fullName })47 .from(usersTable)48 .where(eq(usersTable.id, ticket.createdBy));49
50 if (user) {51 await sendEmail({52 to: user.email,53 subject: `Ticket #${ticket.id} Created: ${ticket.title}`,54 html: `<p>Hi ${user.fullName},</p>55 <p>Your ticket has been created successfully.</p>56 <p><strong>Priority:</strong> ${analysis.suggestedPriority}</p>57 <p><strong>Category:</strong> ${analysis.suggestedCategory}</p>`,58 });59 }60 });61
62 // Step 3: Create vector embedding if completed and public63 await step.run("create-vector-embedding-if-completed", async () => {64 try {65 const [currentTicket] = await db66 .select()67 .from(ticketsTable)68 .where(eq(ticketsTable.id, ticket.id));69
70 if (currentTicket?.status === "completed" && currentTicket?.isPublic) {71 await upsertResolvedPublicTicketVector(currentTicket);72 }73 } catch (error) {74 console.error("Vector embedding creation failed:", error);75 }76 });77
78 return { success: true, analysis };79 }80);This architecture provides:
- Non-blocking responses — API returns immediately; AI runs asynchronously
- Structured output — Zod validation ensures type-safe AI responses
- Fault tolerance — Steps can retry independently on failure
- Cost tracking — Every AI call logged with model and token usage
- Email notifications — Users notified asynchronously without blocking
Screenshot Placeholder
TODO: Add screenshot of a ticket detail page showing the AI-generated category, priority, related skills, and helpful notes displayed alongside the ticket content.
3. Semantic Search with Vector Embeddings
The platform uses OpenAI embeddings and Qdrant for finding semantically similar resolved tickets:
Qdrant Configuration (
src/config/vector.config.ts):typescript
1import { QdrantClient } from "@qdrant/js-client-rest";2import { ENV } from "./env.config.js";3
4if (!ENV.QDRANT_API_KEY && ENV.NODE_ENV === "production") {5 throw new Error("QDRANT_API_KEY is required in production");6}7
8if (!ENV.QDRANT_API_KEY) {9 console.warn("QDRANT_API_KEY is not set - using local Qdrant without authentication");10}11
12export const client = new QdrantClient({13 url: ENV.QDRANT_URL,14 ...(ENV.QDRANT_API_KEY && { apiKey: ENV.QDRANT_API_KEY }),15});16
17export const COLLECTION_NAME = "tickmate_db";18export const VECTOR_SIZE = 1536; // text-embedding-3-small19export const DISTANCE_METRIC = "Cosine";Collection Initialization (
src/utils/vector-db.utils.ts):typescript
1export async function initVectorCollection(): Promise<void> {2 try {3 await client.getCollection(COLLECTION_NAME);4 console.log(`Collection '${COLLECTION_NAME}' already exists`);5 } catch (error) {6 console.log(`Creating collection '${COLLECTION_NAME}'...`);7 8 await client.createCollection(COLLECTION_NAME, {9 vectors: {10 size: VECTOR_SIZE,11 distance: DISTANCE_METRIC,12 },13 });14
15 // Create payload indexes for filtering16 await client.createPayloadIndex(COLLECTION_NAME, {17 field_name: "status",18 field_schema: "keyword",19 });20
21 await client.createPayloadIndex(COLLECTION_NAME, {22 field_name: "isPublic",23 field_schema: "bool",24 });25
26 console.log("Collection created successfully");27 }28}Embedding Generation (
src/utils/vector-db.utils.ts):typescript
1import { OpenAIEmbeddings } from "@langchain/openai";2
3const embeddings = new OpenAIEmbeddings({4 modelName: "text-embedding-3-small",5 apiKey: ENV.OPENAI_API_KEY,6});7
8export async function upsertResolvedPublicTicketVector(9 ticket: TicketRecord10): Promise<void> {11 if (ticket.status !== "completed" || !ticket.isPublic) {12 throw new Error("Only completed public tickets should be vectorized");13 }14
15 // Build rich query text with context16 const queryText = [17 `Title: ${ticket.title}`,18 ticket.category ? `Category: ${ticket.category}` : "",19 `Description: ${ticket.description}`,20 ticket.helpfulNotes ? `Notes: ${ticket.helpfulNotes}` : "",21 ticket.relatedSkills?.length ? `Skills: ${ticket.relatedSkills.join(", ")}` : "",22 ]23 .filter(Boolean)24 .join("\n");25
26 // Generate embedding27 const vector = await embeddings.embedQuery(queryText);28
29 // Upsert to Qdrant30 await client.upsert(COLLECTION_NAME, {31 points: [32 {33 id: ticket.id,34 vector,35 payload: {36 ticketId: ticket.id,37 title: ticket.title,38 description: ticket.description,39 category: ticket.category,40 priority: ticket.priority,41 status: ticket.status,42 isPublic: ticket.isPublic,43 helpfulNotes: ticket.helpfulNotes,44 relatedSkills: ticket.relatedSkills,45 createdBy: ticket.createdBy,46 assignedTo: ticket.assignedTo,47 createdAt: ticket.createdAt.toISOString(),48 updatedAt: ticket.updatedAt.toISOString(),49 },50 },51 ],52 });53
54 // Log embedding cost55 await db.insert(aiUsageLogsTable).values({56 ticketId: ticket.id,57 model: "text-embedding-3-small",58 tokensUsed: Math.ceil(queryText.length / 4), // Approximate59 purpose: "embedding",60 });61}Semantic Search (
src/utils/vector-db.utils.ts):typescript
1type SimilarTicketSearchInput = {2 title?: string;3 description: string;4 category?: string;5 limit?: number;6 minScore?: number;7};8
9export async function searchSimilarResolvedPublicTickets(10 input: SimilarTicketSearchInput11): Promise<Array<{ ticket: TicketPayload; score: number }>> {12 const limit = input.limit ?? 5;13 const minScore = input.minScore ?? parseFloat(ENV.SIMILAR_TICKET_MIN_SCORE || "0.65");14
15 // Build query text matching the embedding format16 const queryText = [17 input.title ? `Title: ${input.title}` : "",18 input.category ? `Category: ${input.category}` : "",19 `Description: ${input.description}`,20 ]21 .filter(Boolean)22 .join("\n");23
24 // Generate query embedding25 const queryVector = await embeddings.embedQuery(queryText);26
27 // Search Qdrant with filters28 const results = await client.search(COLLECTION_NAME, {29 vector: queryVector,30 limit,31 filter: {32 must: [33 { key: "status", match: { value: "completed" } },34 { key: "isPublic", match: { value: true } },35 ],36 },37 with_payload: true,38 score_threshold: minScore,39 });40
41 return results.map((result) => ({42 ticket: result.payload as TicketPayload,43 score: result.score,44 }));45}API Endpoint (
src/controllers/tickets.controller.ts):typescript
1export async function findSimilarTickets(req: Request, res: Response) {2 const { title, description, category, limit } = req.body;3
4 // Validate input5 const parsed = z6 .object({7 title: z.string().optional(),8 description: z.string().min(10),9 category: z.enum(["Bug", "Feature Request", "Question", "Performance"]).optional(),10 limit: z.number().int().min(1).max(20).optional(),11 })12 .safeParse({ title, description, category, limit });13
14 if (!parsed.success) {15 return res.status(400).json({16 success: false,17 error: "Invalid input",18 details: parsed.error.format(),19 });20 }21
22 try {23 const results = await searchSimilarResolvedPublicTickets(parsed.data);24
25 return res.status(200).json({26 success: true,27 message: "Similar tickets fetched successfully",28 tickets: results,29 });30 } catch (error) {31 console.error("Similar ticket search failed:", error);32 return res.status(500).json({33 success: false,34 error: "Failed to search similar tickets",35 });36 }37}This implementation provides:
- Semantic understanding — Embeddings capture meaning, not just keywords
- Fast search — Qdrant returns results in 10-50ms with cosine similarity
- Filtered results — Only completed public tickets shown to users
- Tunable threshold —
SIMILAR_TICKET_MIN_SCOREenvironment variable (default 0.65) - Rich context — Title, category, description, notes, and skills included in embeddings
- Cost tracking — Token usage logged for every embedding
Diagram Placeholder
TODO: Add a Mermaid sequence diagram showing the ticket lifecycle: User creates ticket → API stores in PostgreSQL → Inngest triggers AI analysis → Updates ticket with suggestions → Generates embedding if completed+public → Stores in Qdrant → User searches for similar tickets → Qdrant returns matches.
4. Authentication and Authorization
The system implements JWT-based authentication with HttpOnly cookies and role-based access control:
JWT Configuration (
src/config/env.config.ts):typescript
1export const ENV = {2 JWT_SECRET: process.env.JWT_SECRET!,3 JWT_EXPIRY: "1h",4 COOKIE_DOMAIN: process.env.COOKIE_DOMAIN || "http://localhost:3001",5 NODE_ENV: process.env.NODE_ENV || "development",6};7
8if (!ENV.JWT_SECRET) {9 throw new Error("JWT_SECRET is required");10}Login Controller (
src/controllers/auth.controller.ts):typescript
1export async function login(req: Request, res: Response) {2 const { identifier, password } = req.body;3
4 // Validate input5 const parsed = loginSchema.safeParse({ identifier, password });6 if (!parsed.success) {7 return res.status(400).json({8 success: false,9 error: parsed.error.issues[0]?.message,10 });11 }12
13 try {14 // Find user by email or username15 const [user] = await db16 .select()17 .from(usersTable)18 .where(19 or(20 eq(usersTable.email, identifier),21 eq(usersTable.username, identifier)22 )23 );24
25 if (!user) {26 return res.status(401).json({27 success: false,28 error: "Invalid credentials",29 });30 }31
32 // Verify password33 const isValid = await argon2.verify(user.passwordHash, password);34 if (!isValid) {35 return res.status(401).json({36 success: false,37 error: "Invalid credentials",38 });39 }40
41 // Check if user is active42 if (!user.isActive) {43 return res.status(403).json({44 success: false,45 error: "Account is deactivated",46 });47 }48
49 // Generate JWT50 const token = jwt.sign(51 {52 id: user.id,53 email: user.email,54 role: user.role,55 },56 ENV.JWT_SECRET,57 { expiresIn: ENV.JWT_EXPIRY }58 );59
60 // Set HttpOnly cookie61 res.cookie("token", token, {62 httpOnly: true,63 secure: ENV.NODE_ENV === "production",64 sameSite: "strict",65 maxAge: 3600000, // 1 hour66 });67
68 return res.status(200).json({69 success: true,70 message: "User logged in successfully",71 });72 } catch (error) {73 console.error("Login failed:", error);74 return res.status(500).json({75 success: false,76 error: "Login failed",77 });78 }79}Authentication Middleware (
src/middleware/auth.middleware.ts):typescript
1export async function authenticate(2 req: Request,3 res: Response,4 next: NextFunction5) {6 const token = req.cookies.token;7
8 if (!token) {9 return res.status(401).json({10 success: false,11 error: "Authentication required",12 });13 }14
15 try {16 const decoded = jwt.verify(token, ENV.JWT_SECRET) as JwtPayload;17
18 // Verify user still exists and is active19 const [user] = await db20 .select({ id: usersTable.id, role: usersTable.role, isActive: usersTable.isActive })21 .from(usersTable)22 .where(eq(usersTable.id, decoded.id));23
24 if (!user || !user.isActive) {25 return res.status(401).json({26 success: false,27 error: "Invalid authentication",28 });29 }30
31 // Attach user to request32 req.user = { id: user.id, role: user.role };33 next();34 } catch (error) {35 return res.status(401).json({36 success: false,37 error: "Invalid or expired token",38 });39 }40}Authorization Middleware (
src/middleware/auth.middleware.ts):typescript
1export function authorize(...allowedRoles: Role[]) {2 return (req: Request, res: Response, next: NextFunction) => {3 if (!req.user) {4 return res.status(401).json({5 success: false,6 error: "Authentication required",7 });8 }9
10 if (!allowedRoles.includes(req.user.role)) {11 return res.status(403).json({12 success: false,13 error: "Insufficient permissions",14 });15 }16
17 next();18 };19}Route Protection (
src/routes/tickets.routes.ts):typescript
1import { Router } from "express";2import { authenticate, authorize } from "../middleware/auth.middleware.js";3import * as ticketsController from "../controllers/tickets.controller.js";4
5const router = Router();6
7// All routes require authentication8router.use(authenticate);9
10// Users can create tickets11router.post("/", ticketsController.createTicket);12
13// Users can view their own tickets14router.get("/my-tickets", ticketsController.getMyTickets);15
16// Only moderators and admins can update tickets17router.patch("/:id", authorize("moderator", "admin"), ticketsController.updateTicket);18
19// Only admins can delete tickets20router.delete("/:id", authorize("admin"), ticketsController.deleteTicket);21
22export default router;Magic Link Authentication (
src/controllers/magic-link.controller.ts):typescript
1export async function sendMagicLink(req: Request, res: Response) {2 const { email } = req.body;3
4 try {5 const [user] = await db6 .select()7 .from(usersTable)8 .where(eq(usersTable.email, email));9
10 if (!user) {11 // Don't reveal if email exists12 return res.status(200).json({13 success: true,14 message: "If an account exists, a magic link has been sent",15 });16 }17
18 // Generate secure token19 const token = crypto.randomBytes(32).toString("hex");20 const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes21
22 await db.insert(magicLinksTable).values({23 userId: user.id,24 token,25 expiresAt,26 });27
28 // Send email29 await sendEmail({30 to: user.email,31 subject: "Your TickMate Magic Link",32 html: `<p>Click here to log in: <a href="${ENV.APP_URL}/auth/magic-link/${token}">Log In</a></p>33 <p>This link expires in 15 minutes.</p>`,34 });35
36 return res.status(200).json({37 success: true,38 message: "If an account exists, a magic link has been sent",39 });40 } catch (error) {41 console.error("Magic link send failed:", error);42 return res.status(500).json({43 success: false,44 error: "Failed to send magic link",45 });46 }47}48
49export async function verifyMagicLink(req: Request, res: Response) {50 const { token } = req.body;51
52 try {53 const [magicLink] = await db54 .select()55 .from(magicLinksTable)56 .where(eq(magicLinksTable.token, token))57 .innerJoin(usersTable, eq(magicLinksTable.userId, usersTable.id));58
59 if (60 !magicLink ||61 magicLink.magic_links.usedAt ||62 magicLink.magic_links.expiresAt < new Date()63 ) {64 return res.status(400).json({65 success: false,66 error: "Invalid or expired magic link",67 });68 }69
70 // Mark as used71 await db72 .update(magicLinksTable)73 .set({ usedAt: new Date() })74 .where(eq(magicLinksTable.id, magicLink.magic_links.id));75
76 // Generate JWT77 const jwtToken = jwt.sign(78 {79 id: magicLink.users.id,80 email: magicLink.users.email,81 role: magicLink.users.role,82 },83 ENV.JWT_SECRET,84 { expiresIn: ENV.JWT_EXPIRY }85 );86
87 // Set cookie88 res.cookie("token", jwtToken, {89 httpOnly: true,90 secure: ENV.NODE_ENV === "production",91 sameSite: "strict",92 maxAge: 3600000,93 });94
95 return res.status(200).json({96 success: true,97 message: "Logged in successfully",98 });99 } catch (error) {100 console.error("Magic link verification failed:", error);101 return res.status(500).json({102 success: false,103 error: "Verification failed",104 });105 }106}This authentication system provides:
- XSS protection — JWT stored in HttpOnly cookies inaccessible to JavaScript
- Password security — Argon2 hashing resistant to GPU attacks
- Role-based access — Fine-grained permissions with middleware composition
- Magic links — Passwordless authentication for convenience
- Email verification — Token-based verification prevents fake accounts
- Secure defaults — Cookies use Secure, HttpOnly, and SameSite flags in production
Screenshot Placeholder
TODO: Add screenshot of the login page showing the email/password form with a "Send Magic Link" option, demonstrating the dual authentication methods.
5. Audit Logging System
Every significant action is logged for compliance and debugging:
Audit Helper (
src/utils/audit.utils.ts):typescript
1export async function logAudit(params: {2 userId?: number;3 action: string;4 entityType: string;5 entityId: number;6 changes?: Record<string, any>;7 ipAddress?: string;8 userAgent?: string;9}): Promise<void> {10 try {11 await db.insert(auditLogsTable).values({12 userId: params.userId,13 action: params.action,14 entityType: params.entityType,15 entityId: params.entityId,16 changes: params.changes,17 ipAddress: params.ipAddress,18 userAgent: params.userAgent,19 });20 } catch (error) {21 console.error("Audit logging failed:", error);22 // Don't throw - audit failure shouldn't block operations23 }24}Controller Integration (
src/controllers/tickets.controller.ts):typescript
1export async function updateTicket(req: Request, res: Response) {2 const ticketId = parseInt(req.params.id, 10);3 const updates = req.body;4
5 try {6 // Fetch current state7 const [currentTicket] = await db8 .select()9 .from(ticketsTable)10 .where(eq(ticketsTable.id, ticketId));11
12 if (!currentTicket) {13 return res.status(404).json({14 success: false,15 error: "Ticket not found",16 });17 }18
19 // Perform update20 const [updatedTicket] = await db21 .update(ticketsTable)22 .set({ ...updates, updatedAt: new Date() })23 .where(eq(ticketsTable.id, ticketId))24 .returning();25
26 // Log changes27 await logAudit({28 userId: req.user!.id,29 action: "UPDATE",30 entityType: "ticket",31 entityId: ticketId,32 changes: {33 before: currentTicket,34 after: updatedTicket,35 },36 ipAddress: req.ip,37 userAgent: req.get("user-agent"),38 });39
40 return res.status(200).json({41 success: true,42 message: "Ticket updated successfully",43 ticket: updatedTicket,44 });45 } catch (error) {46 console.error("Ticket update failed:", error);47 return res.status(500).json({48 success: false,49 error: "Failed to update ticket",50 });51 }52}Admin Audit Log Viewer (
src/controllers/admin.controller.ts):typescript
1export async function getAuditLogs(req: Request, res: Response) {2 const { entityType, entityId, userId, limit = 100, offset = 0 } = req.query;3
4 try {5 let query = db.select().from(auditLogsTable);6
7 if (entityType) {8 query = query.where(eq(auditLogsTable.entityType, entityType as string));9 }10
11 if (entityId) {12 query = query.where(eq(auditLogsTable.entityId, parseInt(entityId as string, 10)));13 }14
15 if (userId) {16 query = query.where(eq(auditLogsTable.userId, parseInt(userId as string, 10)));17 }18
19 const logs = await query20 .orderBy(desc(auditLogsTable.createdAt))21 .limit(parseInt(limit as string, 10))22 .offset(parseInt(offset as string, 10));23
24 return res.status(200).json({25 success: true,26 logs,27 });28 } catch (error) {29 console.error("Audit log fetch failed:", error);30 return res.status(500).json({31 success: false,32 error: "Failed to fetch audit logs",33 });34 }35}The audit system provides:
- Complete history — Every create, update, delete logged with before/after state
- User attribution — Links actions to specific users
- IP and user agent tracking — Forensic data for security investigations
- Non-blocking — Audit failures don't interrupt business operations
- Queryable — Admin interface for filtering by entity, user, or time range
Security Considerations
1. Password Security
- Hashing: Argon2 (Password Hashing Competition winner) with default configuration
- No plaintext storage: Passwords never stored in raw form
- Password reset: Token-based flow with 10-minute expiry
- Validation: Minimum length enforced by Zod schemas
2. Authentication Security
- HttpOnly cookies: Prevents XSS attacks from stealing tokens
- Secure flag: HTTPS-only transmission in production
- SameSite=Strict: Prevents CSRF attacks
- Token expiry: 1-hour lifetime forces periodic re-authentication
- Active user check: Deactivated accounts rejected even with valid tokens
3. Authorization Security
- Role-based access control: Middleware enforces permissions declaratively
- Principle of least privilege: Users start with minimal permissions
- Resource-level checks: Controllers verify ownership before operations
- Admin separation: Dedicated admin routes and controllers
4. Input Validation
Every API endpoint validates inputs with Zod before database queries:
typescript
1const parsed = createTicketSchema.safeParse(req.body);2if (!parsed.success) {3 return res.status(400).json({4 success: false,5 error: parsed.error.issues[0]?.message,6 details: parsed.error.format(),7 });8}This prevents:
- SQL injection: Drizzle ORM uses parameterized queries
- NoSQL injection: Not applicable (PostgreSQL)
- Type confusion: Runtime validation catches malformed data
- Buffer overflow: File size limits enforced
5. CORS Configuration
typescript
1app.use(2 cors({3 origin: ENV.COOKIE_DOMAIN,4 credentials: true,5 })6);Only the frontend domain can make authenticated requests.
6. Soft Delete Architecture
Tickets use soft deletes (
deletedAt timestamp) rather than hard deletes:- Data recovery: Accidentally deleted tickets can be restored
- Audit trail: Deletion history preserved
- Foreign key integrity: References remain valid
7. Vector Search Filtering
Qdrant queries always filter for completed public tickets:
typescript
1filter: {2 must: [3 { key: "status", match: { value: "completed" } },4 { key: "isPublic", match: { value: true } },5 ],6}Users cannot access private or in-progress ticket embeddings.
Performance Optimizations
1. Database Connection Pooling
The PostgreSQL driver uses a connection pool managed by the
pg library:typescript
1import pg from "pg";2
3const pool = new pg.Pool({4 connectionString: ENV.DATABASE_URL,5});6
7const db = drizzle({ client: pool });This prevents connection exhaustion under load.
2. Qdrant Payload Indexes
Indexes on
status and isPublic accelerate filtered vector searches:typescript
1await client.createPayloadIndex(COLLECTION_NAME, {2 field_name: "status",3 field_schema: "keyword",4});Without indexes, filtering would require scanning all vectors.
3. Async Job Processing
AI analysis and email sending happen asynchronously via Inngest:
- Non-blocking API: Ticket creation returns immediately
- Retry logic: Failed steps automatically retry with exponential backoff
- Observability: Inngest dashboard shows job status and errors
4. Environment-Based Configuration
Development uses relaxed settings (no QDRANT_API_KEY required), while production enforces strict validation:
typescript
1if (!ENV.QDRANT_API_KEY && ENV.NODE_ENV === "production") {2 throw new Error("QDRANT_API_KEY is required in production");3}5. Embedding Cost Optimization
Only completed public tickets generate embeddings:
- Cost reduction: ~80% fewer embeddings vs. indexing all tickets
- Quality improvement: Resolved tickets provide better search results
- Privacy: Private tickets never exposed in search
Developer Experience
1. Monorepo Organization
The project uses a simple monorepo structure:
text
1tickmate/2├── tickmate-backend/ # Express API3│ ├── src/4│ │ ├── config/ # Environment, DB, Vector, Inngest config5│ │ ├── controllers/ # Route handlers6│ │ ├── db/7│ │ │ └── schema/ # Drizzle schema definitions8│ │ ├── inngest/ # Background job functions9│ │ ├── middleware/ # Auth, error handling, CORS10│ │ ├── routes/ # Express routers11│ │ ├── schemas/ # Zod validation schemas12│ │ ├── scripts/ # Seed and test scripts13│ │ ├── utils/ # Helpers (AI, email, vector, audit)14│ │ └── index.ts # Server entry point15│ ├── drizzle.config.ts # Migration configuration16│ └── package.json17├── tickmate-frontend/ # Next.js app18│ ├── app/ # App Router pages19│ │ ├── (auth)/ # Auth route group20│ │ ├── admin/ # Admin dashboard21│ │ ├── user/ # User dashboard22│ │ └── layout.tsx23│ ├── components/24│ │ ├── auth/ # Auth forms25│ │ ├── tickets/ # Ticket components26│ │ ├── profile/ # Profile management27│ │ └── ui/ # shadcn/ui components28│ ├── lib/29│ │ └── api.ts # Typed API client30│ └── package.json31├── CLAUDE.md # Development documentation32├── README.md # Project overview33└── package.json # Root workspace config2. Type Safety Across Layers
TypeScript types flow from database schema to API to frontend:
typescript
1// Backend: Drizzle generates types2export type User = typeof usersTable.$inferSelect;3export type NewUser = typeof usersTable.$inferInsert;4
5// Frontend: API client uses typed functions6async function getCurrentUser(): Promise<User> {7 const response = await fetch(`${API_URL}/users/profile`, {8 credentials: "include",9 });10 return response.json();11}3. Shared Validation Schemas
The backend validation schemas could be exported and reused in the frontend for client-side validation (not currently implemented but architecturally supported).
4. Environment Variable Validation
The application fails fast on startup if required variables are missing:
typescript
1export const ENV = {2 DATABASE_URL: process.env.DATABASE_URL!,3 JWT_SECRET: process.env.JWT_SECRET!,4 OPENAI_API_KEY: process.env.OPENAI_API_KEY!,5 // ...6};7
8Object.entries(ENV).forEach(([key, value]) => {9 if (!value) {10 throw new Error(`Missing required environment variable: ${key}`);11 }12});5. Development Scripts
The backend includes utility scripts for testing and seeding:
Seed Script (
src/scripts/seed.ts):- Creates 8 test users (1 admin, 4 moderators, 3 users)
- Generates 25 realistic tickets across categories
- Automatically creates embeddings for completed public tickets
Search Test Script (
src/scripts/test-search.ts):- Runs sample semantic search queries
- Displays similarity scores and matched tickets
- Validates vector search functionality
Verbose Search Script (
src/scripts/test-search-verbose.ts):- Detailed search output with description previews
- Lower threshold for comprehensive results
bash
1# Seed database2pnpm seed3
4# Test semantic search5pnpm test:searchLessons Learned
1. Vector Search Requires Tuning
Initial similarity threshold of 0.75 yielded almost no results. Lowering to 0.65 improved recall without sacrificing precision significantly.
Takeaway: Semantic search thresholds are dataset-specific. Start low and increase based on user feedback about result quality.
2. Embedding Context Matters
Early versions embedded only the ticket description. Adding title, category, notes, and skills improved search relevance by ~30% in testing.
Takeaway: Richer embedding context improves semantic matching, even at the cost of slightly higher token usage.
3. Async Jobs Require Idempotency
Inngest can retry failed steps, potentially duplicating work. Checking ticket state before generating embeddings prevents duplicate vectors:
typescript
1const [currentTicket] = await db2 .select()3 .from(ticketsTable)4 .where(eq(ticketsTable.id, ticket.id));5
6if (currentTicket?.status === "completed" && currentTicket?.isPublic) {7 await upsertResolvedPublicTicketVector(currentTicket);8}Takeaway: Background jobs should be idempotent. Always check current state before side effects.
4. Drizzle Migration Strategy
Drizzle's
push command is convenient for development but dangerous in production. The project uses generate + migrate for controlled schema changes.Takeaway: Use migration files for production to review SQL changes before applying them.
5. Monorepo Deployment Complexity
The monorepo structure simplifies development but complicates deployment. Frontend and backend must be deployed separately with coordinated environment variables.
Future Improvement: Add Docker Compose configuration for unified local development and deployment.
Diagram Placeholder
TODO: Add a Mermaid flowchart illustrating the complete semantic search flow: User enters query → Frontend sends to backend → Generate query embedding → Qdrant cosine similarity search → Filter completed+public → Return ranked results → Display to user.
Technical Challenges and Trade-offs
Challenge 1: Database Driver Migration
Problem: The project initially used
@neondatabase/serverless for Neon Database, but local PostgreSQL requires the standard pg driver.Solution: Installed
pg driver and updated db.config.ts to use drizzle-orm/node-postgres with connection pooling.Trade-off: Neon serverless driver offers lower latency for cloud deployments. For production on Neon, consider switching back to the serverless driver while maintaining the local development setup with
pg.Challenge 2: Balancing AI Model Cost vs. Quality
Problem: GPT-4 provides superior analysis but costs 10-30x more than GPT-4.1-mini.
Solution: Selected GPT-4.1-mini with temperature 0.3 as a balanced option. Structured output enforcement compensates for the smaller model.
Trade-off: Analysis quality is good but not exceptional. Future versions could offer premium analyses with GPT-4 for power users.
Challenge 3: Qdrant API Key for Local Development
Problem: Qdrant requires an API key in production, but local installations run without authentication.
Solution: Made API key optional in development mode:
typescript
1if (!ENV.QDRANT_API_KEY && ENV.NODE_ENV === "production") {2 throw new Error("QDRANT_API_KEY is required in production");3}4
5export const client = new QdrantClient({6 url: ENV.QDRANT_URL,7 ...(ENV.QDRANT_API_KEY && { apiKey: ENV.QDRANT_API_KEY }),8});Trade-off: Developers must remember to set
NODE_ENV=development locally. Future improvement: Auto-detect Qdrant authentication requirement.Challenge 4: Frontend-Backend Coordination
Problem: JWT tokens in HttpOnly cookies require
credentials: 'include' on every frontend fetch. Missing this flag causes silent authentication failures.Solution: Centralized API client in
lib/api.ts with default configuration:typescript
1async function apiClient(endpoint: string, options?: RequestInit) {2 const response = await fetch(`${API_URL}${endpoint}`, {3 ...options,4 credentials: "include",5 headers: {6 "Content-Type": "application/json",7 ...options?.headers,8 },9 });10 return response.json();11}Trade-off: Every API call goes through the wrapper, adding slight overhead. The consistency and security benefits outweigh the cost.
Challenge 5: Semantic Search False Positives
Problem: Low similarity thresholds return irrelevant tickets; high thresholds return nothing.
Solution: Made threshold configurable via
SIMILAR_TICKET_MIN_SCORE environment variable (default 0.65). Admin can tune based on feedback.Trade-off: Requires manual tuning rather than automatic threshold learning. Future improvement: A/B test different thresholds and track user engagement.
Production Readiness Checklist
The application implements production-grade patterns:
- Environment-based configuration — All secrets in environment variables
- Database connection pooling — Prevents connection exhaustion
- Error handling — Try-catch blocks in all async operations
- Input validation — Zod schemas on all API routes
- Authentication — JWT with HttpOnly cookies
- Authorization — RBAC middleware enforces permissions
- Password security — Argon2 hashing
- Audit logging — Complete action history with user attribution
- Soft deletes — Data recovery for accidentally deleted tickets
- Email verification — Prevents fake accounts
- Background jobs — Inngest handles async processing with retries
- Vector search filtering — Only public completed tickets exposed
- CORS security — Restricts cross-origin requests
- Type safety — TypeScript across entire stack
- Migration management — Drizzle generates SQL files for review
- Deployment configuration — Docker multi-stage build with non-root user
- CI/CD pipeline — GitHub Actions for backend deployment
- Monitoring — No APM integration (future: Sentry, DataDog)
- Rate limiting — Not implemented (future: Express rate-limit middleware)
- Load testing — Not performed (future: k6 or Artillery)
Future Enhancements
Based on the current implementation, logical next steps include:
-
Real-Time Ticket Updates
- Implement WebSocket connections for live ticket status changes
- Notify moderators when new tickets assigned to them
- Show typing indicators in ticket discussions
- Requires Socket.io or native WebSocket integration
-
Advanced Admin Analytics
- Dashboard with ticket volume over time
- Average resolution time by category and priority
- Moderator performance metrics (tickets resolved, response time)
- AI usage cost tracking with cost-per-ticket analysis
- Requires data aggregation queries and charting library (e.g., Recharts)
-
Ticket Discussion Threads
- Allow comments on tickets for clarifications
- Tag users in comments with notifications
- Rich text editor for formatted responses
- Requires new
commentstable and real-time updates
-
Attachment Support
- Upload screenshots and logs to tickets
- Store files in S3 or Cloudflare R2
- Generate presigned URLs for secure downloads
- Requires file upload handling and storage integration
-
Email-to-Ticket Integration
- Users send emails to support@yourdomain.com
- System creates tickets automatically
- Parse email body and subject into ticket fields
- Requires email parsing service (SendGrid Inbound Parse, Postmark)
-
Multi-Tenant Support
- Support multiple organizations in one deployment
- Isolate tickets, users, and audit logs by tenant
- Tenant-specific domains and branding
- Requires
tenantIdforeign key across all tables
-
Ticket Templates
- Predefined templates for common ticket types (bug report, feature request)
- Auto-populate description with structured fields
- Improve AI analysis accuracy with consistent formats
- Requires template management UI and storage
-
SLA Management
- Define SLAs by priority (e.g., high priority tickets must respond within 2 hours)
- Track SLA compliance with countdown timers
- Escalate tickets approaching SLA breach
- Requires background jobs to monitor ticket age
-
Knowledge Base Integration
- Extract resolved tickets into knowledge base articles
- Search knowledge base before creating new tickets
- Link tickets to relevant articles
- Requires content management system or wiki integration
-
Mobile Application
- React Native app for iOS and Android
- Push notifications for ticket updates
- Offline mode for viewing tickets
- Requires mobile-optimized API endpoints
Deployment Considerations
Recommended Platforms
Backend:
- Railway — Simple deployment with automatic scaling and PostgreSQL add-on
- Render — Managed containers with background workers for Inngest
- AWS Elastic Beanstalk — Managed Node.js environment with load balancing
- Docker + VPS — Self-hosted with Docker Compose (Nginx, PostgreSQL, Qdrant, Express)
Frontend:
- Vercel — Zero-config Next.js deployment with automatic HTTPS and CDN
- Netlify — Similar to Vercel with edge functions support
- Cloudflare Pages — Fast global deployment with Workers for edge compute
Pre-Deployment Checklist
-
PostgreSQL Setup
- Create production database (Railway, Neon, Supabase, or self-hosted)
- Run migrations:
pnpm drizzle:migrate - Whitelist backend server IP
- Enable automated backups
-
Qdrant Setup
- Deploy Qdrant Cloud cluster or self-host with Docker
- Set
QDRANT_API_KEYin production environment - Initialize collection on first deploy (backend handles this automatically)
-
OpenAI Configuration
- Create API key with usage limits
- Monitor token usage to avoid unexpected costs
- Consider implementing usage caps per user/tenant
-
Inngest Setup
- Create Inngest Cloud account (free tier supports 50,000 steps/month)
- Set
INNGEST_EVENT_KEYandINNGEST_SIGNING_KEY - Register backend
/api/inngestendpoint in Inngest dashboard - Test webhook delivery
-
Email Configuration
- Verify production domain in Resend
- Update
EMAIL_FROMto production domain - Test verification and notification emails
- Set up SPF and DKIM records for deliverability
-
Environment Variables
- Set
NODE_ENV=production - Use strong random secrets for
JWT_SECRET(openssl rand -base64 64) - Update
COOKIE_DOMAINto production frontend domain - Update
APP_URLto production frontend URL - Set
CORS_ORIGINto match frontend domain
- Set
-
Docker Deployment
- Build backend:
docker build -f tickmate-backend/Dockerfile -t tickmate-backend . - Build frontend:
cd tickmate-frontend && docker build -t tickmate-frontend . - Use multi-stage builds to minimize image size
- Run containers with restart policies
- Set up reverse proxy (Nginx, Traefik, or Caddy)
- Build backend:
-
CI/CD Pipeline
- GitHub Actions workflow deploys backend on push to
main - Runs migrations before deployment
- Performs health checks after deployment
- Rolls back on failure
- GitHub Actions workflow deploys backend on push to
-
Monitoring and Logging
- Set up error tracking (Sentry, Rollbar, or similar)
- Configure log aggregation (Logtail, Papertrail, or ELK stack)
- Monitor database performance with pg_stat_statements
- Track Qdrant query latency
- Monitor OpenAI API costs
-
Security Hardening
- Enable rate limiting (express-rate-limit)
- Add helmet.js for security headers
- Configure CSP (Content Security Policy) headers
- Enable HTTPS with automatic certificate renewal (Let's Encrypt)
- Set up firewall rules (allow only ports 80, 443, SSH)
Screenshot Placeholder
TODO: Add screenshot of the admin dashboard showing ticket statistics, recent activity, user management, and system health metrics.
Key Takeaways
-
Semantic Search Requires Rich Context — Embedding only ticket descriptions yielded poor results. Including title, category, notes, and skills improved relevance significantly. The query text format must match the embedding format for consistent results.
-
Async Processing is Essential for AI Features — Waiting for OpenAI API calls blocks the user experience. Inngest's step-based approach allows retries without reprocessing successful steps, improving reliability.
-
Soft Deletes Enable Graceful Failures — Hard deleting tickets breaks foreign key references and prevents recovery. Soft deletes with
deletedAttimestamps preserve data integrity while supporting restoration. -
Vector Search Filtering Prevents Data Leakage — Without explicit filtering for completed public tickets, private or in-progress tickets could appear in search results. Qdrant's payload indexes make filtering fast.
-
Audit Logging is Non-Negotiable — Complete action history with before/after state enables compliance, debugging, and dispute resolution. The system must never throw on audit failures to avoid blocking operations.
-
Monorepo Simplifies Development, Complicates Deployment — Sharing TypeScript types and utilities between frontend and backend reduces duplication. However, independent deployments require careful coordination of environment variables and API versioning.
Conclusion
TickMate demonstrates how to build a production-grade AI-powered support platform by combining modern frameworks (Express 5, Next.js 16), intelligent features (semantic search with Qdrant, AI analysis with LangChain), and robust patterns (RBAC, audit logging, async jobs).
The architecture balances cost efficiency with user experience: only completed public tickets generate embeddings, AI analysis runs asynchronously to avoid blocking, and structured output enforcement ensures reliable responses. Security is addressed at multiple layers through HttpOnly cookies, Argon2 password hashing, and comprehensive input validation.
While the current implementation is production-ready, there are clear paths for enhancement: real-time updates with WebSockets, advanced admin analytics, ticket discussion threads, and attachment support. These improvements represent iterative refinement rather than architectural overhaul—a sign of solid foundational design.
The platform proves that AI features don't require complex microservices. A well-structured monorepo with clear separation of concerns (API routes, controllers, services, middleware) can deliver sophisticated functionality while remaining maintainable and cost-effective.
For teams building AI-powered SaaS platforms, this case study illustrates the importance of treating LLM outputs as structured data, implementing comprehensive audit trails, and prioritizing security from day one. The code patterns and architectural decisions documented here are applicable across a wide range of AI-enhanced applications.
Interested in building a similar AI-powered support platform? Get in touch to discuss your project.

