TL;DR: I built a full-stack AI chatbot platform for IFDA, an Indian educational institute. It handles course discovery, lead capture, appointment scheduling, WhatsApp messaging, and comes with a complete admin CRM — all deployed on Vercel with a Neon PostgreSQL backend. Here's everything I learned, every system I built, and why I made the choices I did.
Why This Project Exists
Educational institutes spend enormous resources on human admissions counselors. Prospects ask the same questions repeatedly: What courses do you offer? How long is the program? What will I earn afterwards? And every unanswered query at 11 PM is a lost lead.
IFDA needed something smarter than a static FAQ page and cheaper than a 24/7 call center. The solution: a hybrid AI chatbot that could hold intelligent conversations about courses, capture leads, book counseling appointments, and hand off hot prospects to human staff — all while syncing with WhatsApp.
This is the full technical story of how I built it.
The Architecture at a Glance
The project is a Next.js 14 App Router monorepo — one codebase serving the public-facing chatbot widget, the admin CRM dashboard, and all API routes. Every component was chosen deliberately:
| Layer | Technology |
|---|---|
| Framework | Next.js 14 (App Router) |
| Language | TypeScript throughout |
| Database | Neon PostgreSQL + Prisma ORM |
| AI / LLM | OpenAI (GPT) for intent, embeddings, and response generation |
| Deployment | Vercel (Edge-compatible) |
| DoubleTick API | |
| Auth | Custom JWT + RBAC middleware |
| Session | Server-side session store |
The graph of this codebase has 1,259 nodes (files, functions, types) and 3,276 edges (calls, contains, references) distributed across 55+ community clusters — from the bot engine core, to the Prisma edge runtime, to the admin dashboard pages. Let's walk through each major system.
System 1: The Bot Engine (lib/bot/engine.ts)
The heart of the project is processMessage() in lib/bot/engine.ts. This single function is the traffic controller for every incoming message — whether it arrives from the web widget or WhatsApp.
Hybrid Scripted + LLM Architecture
The bot does not send every message to GPT. That would be slow and expensive. Instead, it uses a two-tier approach:
- Scripted funnel stages — structured flows for lead capture (name, phone, course interest, city) where deterministic logic is faster and more reliable than an LLM.
- LLM fallback — for open-ended questions, course comparisons, career queries, or anything outside a defined stage.
The stage management lives in lib/ai/intent.ts:
// intent.ts — simplified
export function resolveStage(session: Session): FunnelStage { ... }
export function getMissingFields(lead: Partial<Lead>): LeadField[] { ... }
export function getNextQuestion(missing: LeadField[]): string { ... }
export function isLeadComplete(lead: Partial<Lead>): boolean { ... }
export function detectIntent(message: string): Intent { ... }
export function analyzeMessage(message: string, session: Session): Analysis { ... }
analyzeMessage() is the decision function. It evaluates the message in context of the current session stage and decides whether to invoke the scripted path, call getAIResponse() from lib/ai/llm.ts, or trigger a carousel/quick-reply block.
Structured Message Components
Responses are not plain strings. They're typed message blocks that the renderer layer converts to channel-specific formats:
// lib/ai/llm.ts — block builders
textBlock(content: string): TextBlock
domainListBlock(domains: Domain[]): DomainListBlock
carouselBlock(items: CourseCarouselItem[]): CarouselBlock
courseDetailBlock(course: Course): CourseDetailBlock
quickRepliesBlock(replies: string[]): QuickRepliesBlock
visitWebsiteBlock(url: string): VisitWebsiteBlock
This block-based design means the same engine output drives both the web UI and WhatsApp — two very different rendering surfaces.
System 2: Dual-Channel Rendering
The renderer layer translates structured message blocks into channel-specific formats.
Web Renderer (lib/bot/renderers/web.ts)
renderToWeb() converts blocks into React-compatible JSON that the frontend StructureMessage.tsx component consumes. The component handles scroll behavior, carousels with checkScroll(), and animated message appearance.
WhatsApp Renderer (lib/bot/renderers/whatsapp.ts)
renderToWhatsApp() maps the same blocks to the DoubleTick API's message format. WhatsApp has strict message type rules — interactive lists, templates, and plain text are separate API calls with different schemas. The renderer handles all of this through the lib/whatsapp/doubletick.ts client:
// doubletick.ts
export async function sendWhatsAppText(phone: string, text: string): Promise<void>
export async function sendWhatsAppTemplate(phone: string, template: TemplateParams): Promise<void>
export async function sendWhatsAppInteractiveList(phone: string, list: InteractiveList): Promise<void>
Incoming WhatsApp messages hit app/api/whatsapp/webhook/route.ts, which validates the HMAC signature via isValidSignature(), parses the payload through lib/whatsapp/messageParser.ts, and feeds it into the same processMessage() bot engine. One engine, two channels.
System 3: Course Intelligence (lib/ai/courseData.ts)
The course catalog is the richest data source in the system. courseData.ts is a comprehensive module with 25+ exported functions that the bot engine calls to build contextually accurate responses:
findCourse(query: string): Course | null
getCourseAbout(course: Course): string
getCoursePrerequisites(course: Course): string
getCourseSyllabus(course: Course): string
getCourseCareerOpportunities(course: Course): string
getCourseCareerGrowthRoadmap(course: Course): string
getCourseProfessionalGrowthLadder(course: Course): string
getCourseTools(course: Course): string
getCourseSkills(course: Course): string
getAllCourseCarouselItems(): CourseCarouselItem[]
getCarouselByIntent(intent: Intent): CourseCarouselItem[]
getLLMCourseMap(): LLMCourseMap // Compact map for GPT context injection
getCourseContext(course: Course): string // Full context string for LLM prompt
findCourse() is the most-called function in the entire application (32 edges in the call graph), supporting fuzzy matching so "digital marketing," "DM course," and "marketing program" all resolve correctly.
getLLMCourseMap() is particularly clever: it generates a compact, token-efficient representation of the course catalog that gets injected into GPT prompts. This gives the LLM accurate knowledge of IFDA's offerings without burning context on verbose descriptions.
System 4: The Knowledge Base (lib/ai/knowledge-parser.ts + Embeddings)
Beyond the structured course catalog, IFDA staff can upload arbitrary knowledge documents — PDFs, CSVs, text files, JSON — through the admin panel. The knowledge pipeline processes these into searchable vector embeddings.
Parsing Pipeline
knowledge-parser.ts handles multi-format ingestion:
parseKnowledgeFile(file: File): Promise<ParsedKnowledge>
parsePdf(buffer: Buffer): Promise<string>
parseCsv(content: string): ParsedRow[]
parseJson(content: string): ParsedKnowledge
parseTxt(content: string): ParsedKnowledge
chunkText(text: string, chunkSize: number): string[]
Semantic Search
lib/ai/embeddings.ts handles the vector layer:
generateEmbedding(text: string): Promise<number[]> // OpenAI text-embedding-3-small
embedAllFaqs(): Promise<void> // Bulk embed on import
embedNewFaqs(): Promise<void> // Incremental update
searchSimilarFaqs(query: string, topK: number): Promise<FAQ[]>
When a user asks a question not covered by the scripted engine, getRelevantKnowledge() fires a vector similarity search against the embedded knowledge base. The top results are injected into the LLM context, giving the bot accurate, up-to-date answers based on admin-uploaded documents — no redeployment required.
System 5: Lead Capture & CRM
Lead capture is deeply woven into the conversation flow. As the bot collects information across multiple turns, it progressively builds a lead profile. extractLeadInfo() in intent.ts extracts structured data from natural language:
// User says: "I'm Rohan from Lucknow, interested in the UI/UX course"
// extractLeadInfo() returns:
{ name: "Rohan", city: "Lucknow", courseInterest: "UI/UX Design" }
verifyHumanName() guards against bot-abuse by rejecting implausible name strings before they reach the database.
Once isLeadComplete() returns true, pushLeadToCRM() fires:
// lib/crm/leads.ts
export async function pushLeadToCRM(lead: CompleteLead): Promise<void>
This writes to the Neon PostgreSQL database via Prisma and optionally triggers a WhatsApp confirmation template to the lead's number.
The admin side exposes full CRUD via:
-
GET/POST /api/admin/leads— list and filter leads -
GET/PATCH/DELETE /api/admin/leads/[id]— individual lead management
The AdminLeadsPage in app/admin/page.tsx provides a CRM interface with stage tracking — counselors can move leads through the admissions funnel manually, and updateLeadStage() persists the change.
System 6: Appointment Scheduling
lib/scheduler/calendar.ts handles the booking flow:
getAvailableDates(): Promise<AvailableDate[]>
isSlotAvailable(date: string, time: string): Promise<boolean>
bookAppointment(lead: Lead, slot: TimeSlot): Promise<Appointment>
When intent detection identifies scheduling intent (handleScheduling() in the chatbot route), the bot presents available slots as a quick-reply carousel. The user picks one, and bookAppointment() writes the appointment to the database and sends a WhatsApp confirmation.
app/api/schedule/route.ts is the public endpoint, and app/api/admin/appointments/route.ts gives admins a view of all upcoming appointments.
System 7: Session Management
The bot is stateful across multiple HTTP requests. lib/session/store.ts manages this:
getSession(sessionId: string): Promise<Session | null>
saveSession(sessionId: string, session: Session): Promise<void>
clearSession(sessionId: string): Promise<void>
On the client side, app/chatbot/hooks/useChat.ts manages the sessionId lifecycle:
export function useChat() {
// Generates and persists a sessionId
// Manages message history
// Handles loading/error states
const { sessionId } = getSessionId()
// ...
}
The session carries funnel stage, partial lead data, conversation history, and the last detected intent — everything processMessage() needs to pick up exactly where the conversation left off.
System 8: Admin Dashboard & RBAC
The admin panel is a full internal application with multiple pages:
| Route | Functionality |
|---|---|
/admin/login |
JWT authentication |
/admin/dashboard |
Analytics charts (BarChart), metrics overview |
/admin/dashboard/members |
Team member management (add, edit, remove) |
/admin |
Lead CRM with stage management |
/admin/conversations |
Full chat history viewer |
/admin/knowledge |
Knowledge file upload/management |
/admin/templates |
WhatsApp template builder with AI generation |
/admin/profile |
Profile management |
RBAC Implementation
The permission system uses a role-permission matrix architecture, documented in implementation_plan.md and implemented in lib/auth/permissions.ts:
export function hasPermission(role: AdminRole, permission: Permission): boolean
export function getDefaultPage(role: AdminRole): string
The middleware chain enforces this at the API level:
// lib/auth/middleware.ts
requireAdmin() // Verifies JWT, rejects unauthenticated requests
requireRole(role: AdminRole) // Enforces role-based access
getAdminFromRequest() // Extracts admin context from token
lib/auth/jwt.ts handles token lifecycle:
signToken(payload: JWTPayload): string
verifyToken(token: string): JWTPayload | null
verifyTokenAsync(token: string): Promise<JWTPayload>
The sidebar in the admin UI uses hasPermission() to filter navigation items — counselors see leads and conversations; superadmins see everything including member management and analytics.
WhatsApp Template Builder
The /admin/templates page deserves special mention. It's an AI-powered editor where admins compose multi-slide WhatsApp broadcast campaigns:
generateWithAI() // GPT generates slide content from a prompt
addSlide() / removeSlide() // Manage campaign structure
buildWhatsAppText() // Compile slides to WhatsApp text format
buildHTMLBlock() // Compile to HTML preview
Admins write a brief like "announce the new UI/UX batch starting June 15," and the AI generates formatted, WhatsApp-compliant message slides they can edit before sending.
System 9: Analytics
app/api/admin/analytics/route.ts aggregates metrics from the database — lead conversion rates, conversation volumes, popular courses, appointment completion rates. The dashboard BarChart component visualizes these in real time for the admin team.
types/analytics.ts defines the typed response schema, and prisma/scripts/testAnalytics.ts was used during development to seed realistic test data and validate aggregation queries.
The Database Schema
All data flows through Prisma ORM on Neon PostgreSQL. Key models include:
- Admin — staff accounts with role and permissions
- Lead — prospect data with funnel stage
- Conversation / Message — full chat history
- KnowledgeFile — uploaded knowledge documents
- FAQ / FAQEmbedding — vector-ready FAQ storage
- Appointment — scheduled counseling sessions
- WhatsAppMessage — outbound message log
- AnalyticsEvent — event-level analytics
Prisma generates a full Edge-compatible client (generated/prisma/) for Vercel's Edge Runtime, enabling low-latency database queries from serverless functions.
Key Engineering Challenges & How I Solved Them
1. GPT Conversation History Corruption
Problem: Multi-turn conversations were getting corrupted when partial lead data was included in the messages array alongside system context. GPT would "remember" previous context injections as user messages.
Solution: Separated session state from conversation history. The messages array sent to GPT contains only actual user/assistant turns. Lead context and session state are injected exclusively in the system prompt, rebuilt fresh on every request from getSession().
2. Edge Runtime + Prisma
Problem: Prisma's standard Node.js client uses modules incompatible with Vercel's Edge Runtime.
Solution: Used Prisma's WASM-based edge client (generated/prisma/edge.js) with the wasm-compiler-edge.js adapter. This required careful configuration in prisma.config.ts and next.config.ts to bundle correctly.
3. Course Carousel → Bot Agent Wiring
Problem: When a user clicked a course card in the carousel, the click event needed to trigger a bot message as if the user had typed the course name — but the component boundaries between CourseCarousel.tsx and useChat.ts made this tricky.
Solution: Exposed a sendMessage() method from useChat() and threaded it down as a prop to CourseCarousel. Carousel card clicks call sendMessage(course.name) directly, entering the course into the conversation and triggering the full bot response pipeline.
4. Suggestion Buttons During Lead Capture
Problem: During lead capture (collecting name, city, etc.), users would go off-script. Raw free text answers led to extraction failures.
Solution: getSuggestionsForNextField() in the chatbot route generates contextual quick-reply buttons for each lead field. If collecting city, the suggestions show major Indian cities. If collecting course interest, they show relevant domains. Users can tap or type — both paths work.
5. WhatsApp Webhook Signature Validation
Problem: Public webhooks are targets for spoofed requests.
Solution: isValidSignature() validates HMAC-SHA256 signatures on every incoming WhatsApp webhook payload using the DoubleTick shared secret. Invalid signatures are rejected with 403 before any processing occurs.
Project Structure Overview (Though it's a private repository)
ifda/
├── app/
│ ├── page.tsx # Public landing + chat widget
│ ├── layout.tsx # Root layout
│ ├── api/
│ │ ├── chatbot/route.ts # Core bot API
│ │ ├── whatsapp/
│ │ │ ├── webhook/route.ts # Incoming WhatsApp
│ │ │ └── send/route.ts # Outbound WhatsApp
│ │ ├── schedule/route.ts # Appointment booking
│ │ ├── faq/route.ts
│ │ ├── embed/route.ts
│ │ └── admin/ # Protected admin APIs
│ ├── chatbot/
│ │ ├── components/
│ │ │ ├── ChatWindow.tsx
│ │ │ ├── InputBox.tsx
│ │ │ ├── CourseCaraousel.tsx
│ │ │ └── StructureMessage.tsx
│ │ └── hooks/useChat.ts
│ └── admin/ # Admin CRM pages
│ ├── login/
│ ├── dashboard/
│ ├── conversations/
│ ├── knowledge/
│ ├── templates/
│ └── profile/
├── lib/
│ ├── ai/
│ │ ├── courseData.ts # Course intelligence
│ │ ├── embeddings.ts # Vector search
│ │ ├── intent.ts # Intent + lead extraction
│ │ ├── knowledge-parser.ts # Multi-format doc parsing
│ │ └── llm.ts # LLM client + block builders
│ ├── auth/
│ │ ├── jwt.ts
│ │ ├── middleware.ts
│ │ └── permissions.ts # RBAC
│ ├── bot/
│ │ ├── engine.ts # Core processMessage()
│ │ └── renderers/
│ │ ├── web.ts
│ │ └── whatsapp.ts
│ ├── crm/leads.ts
│ ├── db/
│ │ ├── prisma.ts
│ │ └── queries.ts
│ ├── scheduler/calendar.ts
│ ├── session/store.ts
│ └── whatsapp/
│ ├── doubletick.ts
│ ├── messageParser.ts
│ └── types.ts
├── config/
│ ├── constants.ts
│ └── prompts.ts # All LLM system prompts
├── types/
│ ├── chat.ts
│ ├── lead.ts
│ └── analytics.ts
└── prisma/
├── schema.prisma
└── scripts/ # Seed + test scripts
Performance Considerations
- Edge-first: All API routes are Edge-compatible. Vercel deploys them to the closest edge node, cutting latency for Indian users significantly vs. a single-region serverless function.
-
Session caching:
getSession()uses a lightweight in-memory cache layer before hitting PostgreSQL, reducing DB round-trips on sequential messages in an active conversation. - LLM cost control: The scripted funnel handles the majority of conversations. GPT is only invoked when the intent engine genuinely can't determine the next step — keeping API costs predictable.
- Embedding on ingest: Knowledge files are embedded at upload time, not at query time. Search is a fast vector similarity lookup, not a synchronous embedding + search chain.
What I'd Do Differently
1. Extract the bot engine into a proper state machine. processMessage() is powerful but has grown organically. Formalizing it as an explicit state machine (with states like COLLECTING_NAME, COLLECTING_CITY, COURSE_DISCOVERY, SCHEDULING) would make the flow easier to reason about and test.
2. Add streaming responses. The current implementation waits for the complete LLM response before sending. Server-Sent Events or the Vercel AI SDK's streaming primitives would make the bot feel dramatically faster.
3. Separate the WhatsApp and web sessions more cleanly. Right now, both channels share the same session store schema. As WhatsApp-specific flows diverge (templates, interactive lists, 24-hour messaging windows), having a channel-aware session type would reduce the number of if (channel === 'whatsapp') checks in the engine.
4. Add a proper evaluation pipeline. Testing chatbot quality is hard. A golden-dataset evaluation pipeline — where known inputs are run through the bot and outputs are checked against expected responses — would catch regressions when the LLM or prompt changes.
Results
The bot handles the full admissions journey end-to-end:
- Course discovery — carousel-driven browsing + natural language course queries
- Lead qualification — multi-turn lead capture with field-level validation
- Knowledge search — semantic search over admin-uploaded documents
- Appointment booking — slot selection and WhatsApp confirmation
- Staff handoff — completed leads surfaced in the admin CRM with full conversation history
- Broadcast campaigns — AI-assisted WhatsApp template creation and dispatch
The admin team went from managing a static form to having a live CRM where every lead arrives pre-qualified with course interest, location, and contact details captured by the bot.
Tech Stack Summary
- Next.js 14 (App Router, Edge Runtime)
- TypeScript (strict mode throughout)
- Prisma ORM + Neon PostgreSQL (WASM edge client)
- OpenAI API (GPT-4o for intent/generation, text-embedding-3-small for semantic search)
- DoubleTick (WhatsApp Business API)
- Vercel (deployment, edge functions)
- JWT (stateless admin auth)
- Tailwind CSS (admin UI and chat widget)
Final Thoughts
The most valuable architecture decision was the block-based message system. By never treating bot responses as raw strings, every part of the system — the engine, the renderers, the frontend components — operates on typed, predictable data structures. Adding a new message type (say, a booking confirmation card) means adding one block builder in llm.ts, one render case in each renderer, and one React component. The engine doesn't change.
The second best decision was keeping the LLM in the hot path as little as possible. GPT is powerful but introduces latency and cost. Using it for intent classification and open-ended generation, while handling the structured lead capture funnel with deterministic code, gives the best of both worlds — intelligent conversation where it matters, reliability and speed where it counts.
If you're building something similar or have questions about any of the systems described here, drop them in the comments. Happy to go deeper on any piece of this.
Built for IFDA — Indian design and technology education institute.
Top comments (0)