├── .gitignore ├── migrations ├── meta │ ├── _journal.json │ └── 0000_snapshot.json └── 0000_sleepy_abomination.sql ├── drizzle.config.ts ├── src ├── config │ └── database.ts ├── db │ └── schema.ts └── server.ts ├── package.json ├── tsconfig.json └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env -------------------------------------------------------------------------------- /migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1742940384587, 9 | "tag": "0000_sleepy_abomination", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import { defineConfig } from 'drizzle-kit'; 3 | 4 | config({ path: '.env' }); 5 | 6 | export default defineConfig({ 7 | schema: './src/db/schema.ts', 8 | out: './migrations', 9 | dialect: 'postgresql', 10 | dbCredentials: { 11 | url: process.env.DATABASE_URL!, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /migrations/0000_sleepy_abomination.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "chats" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "user_id" text NOT NULL, 4 | "message" text NOT NULL, 5 | "reply" text NOT NULL, 6 | "created_at" timestamp DEFAULT now() NOT NULL 7 | ); 8 | --> statement-breakpoint 9 | CREATE TABLE "users" ( 10 | "user_id" text PRIMARY KEY NOT NULL, 11 | "name" text NOT NULL, 12 | "email" text NOT NULL, 13 | "created_at" timestamp DEFAULT now() NOT NULL 14 | ); 15 | -------------------------------------------------------------------------------- /src/config/database.ts: -------------------------------------------------------------------------------- 1 | import { neon } from '@neondatabase/serverless'; 2 | import { drizzle } from 'drizzle-orm/neon-http'; 3 | import { config } from 'dotenv'; 4 | 5 | // Load env vars 6 | config({ path: '.env' }); 7 | 8 | if (!process.env.DATABASE_URL) { 9 | throw new Error('DATABASE_URL is undefined'); 10 | } 11 | 12 | // Init Neon client 13 | const sql = neon(process.env.DATABASE_URL); 14 | 15 | // Init Drizzle 16 | export const db = drizzle(sql); 17 | -------------------------------------------------------------------------------- /src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; 2 | 3 | export const chats = pgTable('chats', { 4 | id: serial('id').primaryKey(), 5 | userId: text('user_id').notNull(), 6 | message: text('message').notNull(), 7 | reply: text('reply').notNull(), 8 | createdAt: timestamp('created_at').defaultNow().notNull(), 9 | }); 10 | 11 | export const users = pgTable('users', { 12 | userId: text('user_id').primaryKey(), 13 | name: text('name').notNull(), 14 | email: text('email').notNull(), 15 | createdAt: timestamp('created_at').defaultNow().notNull(), 16 | }); 17 | 18 | // Type inference for Drizzle queries 19 | export type ChatInsert = typeof chats.$inferInsert; 20 | export type ChatSelect = typeof chats.$inferSelect; 21 | export type UserInsert = typeof users.$inferInsert; 22 | export type UserSelect = typeof users.$inferSelect; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-ai-api", 3 | "version": "1.0.0", 4 | "description": "Backend for an ai chat application", 5 | "license": "MIT", 6 | "author": "Brad Traversy", 7 | "type": "module", 8 | "main": "server.js", 9 | "scripts": { 10 | "dev": "tsc --noEmit && tsx --watch src/server.ts", 11 | "build": "tsc", 12 | "start": "node dist/server.js" 13 | }, 14 | "dependencies": { 15 | "@neondatabase/serverless": "^1.0.0", 16 | "cors": "^2.8.5", 17 | "dotenv": "^16.4.7", 18 | "drizzle-orm": "^0.41.0", 19 | "express": "^4.21.2", 20 | "openai": "^4.89.0", 21 | "stream-chat": "^8.57.6" 22 | }, 23 | "devDependencies": { 24 | "@types/cors": "^2.8.17", 25 | "@types/express": "^5.0.1", 26 | "@types/node": "^22.13.13", 27 | "drizzle-kit": "^0.30.5", 28 | "tsx": "^4.19.3", 29 | "typescript": "^5.8.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Use ECMAScript Modules (ESM) with Node.js compatibility. 4 | "module": "NodeNext", 5 | // Controls how modules are resolved. "NodeNext" enables native ESM support. 6 | "moduleResolution": "NodeNext", 7 | // Target ECMAScript Next to enable the latest JavaScript features. 8 | "target": "ESNext", 9 | // Output directory for compiled JavaScript files (only used if "noEmit" is false). 10 | "outDir": "./dist", 11 | // Root directory for TypeScript source files. 12 | "rootDir": "./src", 13 | // Enable all strict type-checking options. 14 | "strict": true, 15 | // Allow importing CommonJS modules in an ES module project. 16 | "esModuleInterop": true, 17 | // Allow importing JSON files directly as modules. 18 | "resolveJsonModule": true, 19 | // Skip checking declaration files in `node_modules` to speed up compilation. 20 | "skipLibCheck": true 21 | }, 22 | "exclude": ["drizzle.config.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Chat AI API 2 | 3 | This is the backend for the Chat AI application. It is a Node/Express/TypeScript API that uses [Stream](https://www.getStream.io) for chat, chat history, and user management. It also uses a PostgreSQL database from [Neon](https://www.neon.tech) to store user information and chat history. We use the Drizzle ORM to interact with the database. [Open AI](https://platform.openai.com/) is used for the AI chatbot. 4 | 5 | The Vue.js frontend for this application can be found [here](https://github.com/bradtraversy/chat-ai-ui). 6 | 7 | ## Installation 8 | 9 | 1. Clone the repository 10 | 2. Run `npm install` 11 | 3. Create a `.env` file in the root directory and add the following environment variables: 12 | 13 | ``` 14 | PORT=5000 15 | STREAM_API_KEY="" 16 | STREAM_API_SECRET="" 17 | OPENAI_API_KEY="" 18 | DATABASE_URL="postgresql://username:password@localhost:5432/dbname" 19 | ``` 20 | 21 | You can get these keys by signing up for Stream, Open AI, and Neon. 22 | 23 | 4. Run database migrations with Drizzle Kit: 24 | 25 | ``` 26 | npx drizzle-kit generate 27 | npx drizzle-kit migrate 28 | ``` 29 | 30 | This will create the necessary tables in your database. 31 | 32 | 5. Run the server with `npm run dev` and open on `http://localhost:5000` 33 | 34 | ## Endpoints 35 | 36 | - POST `/register-user` - Create a user in Stream chat and in our own database 37 | - POST `/chat` - Creates a new Stream chat channel, sends a request to Open AI to generate a response, and saves the chat history in our database 38 | - POST `/get-messages` - Get's the chat history for a specific user 39 | 40 | ## Building For Production 41 | 42 | This is a TypeScript project, so you will need to build the project before running in production. Run `npm run build` to build the project. You can then run the server with `npm start`. The files will be in the `dist` directory. 43 | -------------------------------------------------------------------------------- /migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "f46c6296-76ef-4ac6-9a8d-1cf4bff25d3e", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.chats": { 8 | "name": "chats", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "message": { 24 | "name": "message", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "reply": { 30 | "name": "reply", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "created_at": { 36 | "name": "created_at", 37 | "type": "timestamp", 38 | "primaryKey": false, 39 | "notNull": true, 40 | "default": "now()" 41 | } 42 | }, 43 | "indexes": {}, 44 | "foreignKeys": {}, 45 | "compositePrimaryKeys": {}, 46 | "uniqueConstraints": {}, 47 | "policies": {}, 48 | "checkConstraints": {}, 49 | "isRLSEnabled": false 50 | }, 51 | "public.users": { 52 | "name": "users", 53 | "schema": "", 54 | "columns": { 55 | "user_id": { 56 | "name": "user_id", 57 | "type": "text", 58 | "primaryKey": true, 59 | "notNull": true 60 | }, 61 | "name": { 62 | "name": "name", 63 | "type": "text", 64 | "primaryKey": false, 65 | "notNull": true 66 | }, 67 | "email": { 68 | "name": "email", 69 | "type": "text", 70 | "primaryKey": false, 71 | "notNull": true 72 | }, 73 | "created_at": { 74 | "name": "created_at", 75 | "type": "timestamp", 76 | "primaryKey": false, 77 | "notNull": true, 78 | "default": "now()" 79 | } 80 | }, 81 | "indexes": {}, 82 | "foreignKeys": {}, 83 | "compositePrimaryKeys": {}, 84 | "uniqueConstraints": {}, 85 | "policies": {}, 86 | "checkConstraints": {}, 87 | "isRLSEnabled": false 88 | } 89 | }, 90 | "enums": {}, 91 | "schemas": {}, 92 | "sequences": {}, 93 | "roles": {}, 94 | "policies": {}, 95 | "views": {}, 96 | "_meta": { 97 | "columns": {}, 98 | "schemas": {}, 99 | "tables": {} 100 | } 101 | } -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import cors from 'cors'; 3 | import dotenv from 'dotenv'; 4 | import { StreamChat } from 'stream-chat'; 5 | import OpenAI from 'openai'; 6 | import { db } from './config/database.js'; 7 | import { chats, users } from './db/schema.js'; 8 | import { eq } from 'drizzle-orm'; 9 | import { ChatCompletionMessageParam } from 'openai/resources'; 10 | 11 | dotenv.config(); 12 | 13 | const app = express(); 14 | 15 | app.use(cors()); 16 | app.use(express.json()); 17 | app.use(express.urlencoded({ extended: false })); 18 | 19 | // Initialize Stream Client 20 | const chatClient = StreamChat.getInstance( 21 | process.env.STREAM_API_KEY!, 22 | process.env.STREAM_API_SECRET! 23 | ); 24 | 25 | // Initialize Open AI 26 | const openai = new OpenAI({ 27 | apiKey: process.env.OPENAI_API_KEY, 28 | }); 29 | 30 | // Register user with Stream Chat 31 | app.post( 32 | '/register-user', 33 | async (req: Request, res: Response): Promise => { 34 | const { name, email } = req.body; 35 | 36 | if (!name || !email) { 37 | return res.status(400).json({ error: 'Name and email are required' }); 38 | } 39 | 40 | try { 41 | const userId = email.replace(/[^a-zA-Z0-9_-]/g, '_'); 42 | 43 | // Check if user exists 44 | const userResponse = await chatClient.queryUsers({ id: { $eq: userId } }); 45 | 46 | if (!userResponse.users.length) { 47 | // Add new user to stream 48 | await chatClient.upsertUser({ 49 | id: userId, 50 | name: name, 51 | email: email, 52 | role: 'user', 53 | }); 54 | } 55 | 56 | // Check for existing user in database 57 | const existingUser = await db 58 | .select() 59 | .from(users) 60 | .where(eq(users.userId, userId)); 61 | 62 | if (!existingUser.length) { 63 | console.log( 64 | `User ${userId} does not exist in the database. Adding them...` 65 | ); 66 | await db.insert(users).values({ userId, name, email }); 67 | } 68 | 69 | res.status(200).json({ userId, name, email }); 70 | } catch (error) { 71 | res.status(500).json({ error: 'Internal Server Error' }); 72 | } 73 | } 74 | ); 75 | 76 | // Send message to AI 77 | app.post('/chat', async (req: Request, res: Response): Promise => { 78 | const { message, userId } = req.body; 79 | 80 | if (!message || !userId) { 81 | return res.status(400).json({ error: 'Message and user are required' }); 82 | } 83 | 84 | try { 85 | // Verify user exists 86 | const userResponse = await chatClient.queryUsers({ id: userId }); 87 | 88 | if (!userResponse.users.length) { 89 | return res 90 | .status(404) 91 | .json({ error: 'user not found. Please register first' }); 92 | } 93 | 94 | // Check user in database 95 | const existingUser = await db 96 | .select() 97 | .from(users) 98 | .where(eq(users.userId, userId)); 99 | 100 | if (!existingUser.length) { 101 | return res 102 | .status(404) 103 | .json({ error: 'User not found in database, please register' }); 104 | } 105 | 106 | // Fetch users past messages for context 107 | const chatHistory = await db 108 | .select() 109 | .from(chats) 110 | .where(eq(chats.userId, userId)) 111 | .orderBy(chats.createdAt) 112 | .limit(10); 113 | 114 | // Format chat history for Open AI 115 | const conversation: ChatCompletionMessageParam[] = chatHistory.flatMap( 116 | (chat) => [ 117 | { role: 'user', content: chat.message }, 118 | { role: 'assistant', content: chat.reply }, 119 | ] 120 | ); 121 | 122 | // Add latest user messages to the conversation 123 | conversation.push({ role: 'user', content: message }); 124 | 125 | // Send message to OpenAI GPT-4 126 | const response = await openai.chat.completions.create({ 127 | model: 'gpt-4', 128 | messages: conversation as ChatCompletionMessageParam[], 129 | }); 130 | 131 | const aiMessage: string = 132 | response.choices[0].message?.content ?? 'No response from AI'; 133 | 134 | // Save chat to database 135 | await db.insert(chats).values({ userId, message, reply: aiMessage }); 136 | 137 | // Create or get channel 138 | const channel = chatClient.channel('messaging', `chat-${userId}`, { 139 | name: 'AI Chat', 140 | created_by_id: 'ai_bot', 141 | }); 142 | 143 | await channel.create(); 144 | await channel.sendMessage({ text: aiMessage, user_id: 'ai_bot' }); 145 | 146 | res.status(200).json({ reply: aiMessage }); 147 | } catch (error) { 148 | console.log('Error generating AI response', error); 149 | return res.status(500).json({ error: 'Internal Server Error' }); 150 | } 151 | }); 152 | 153 | // Get chat history for a user 154 | app.post('/get-messages', async (req: Request, res: Response): Promise => { 155 | const { userId } = req.body; 156 | 157 | if (!userId) { 158 | return res.status(400).json({ error: 'User ID is required' }); 159 | } 160 | 161 | try { 162 | const chatHistory = await db 163 | .select() 164 | .from(chats) 165 | .where(eq(chats.userId, userId)); 166 | 167 | res.status(200).json({ messages: chatHistory }); 168 | } catch (error) { 169 | console.log('Error fetching chat history', error); 170 | res.status(500).json({ error: 'Internal Server Error' }); 171 | } 172 | }); 173 | 174 | const PORT = process.env.PORT || 5000; 175 | 176 | app.listen(PORT, () => console.log(`Server running on ${PORT}`)); 177 | --------------------------------------------------------------------------------