A multi-tenant collaborative AI chat platform where multiple users inside the same organization can chat in shared rooms and invoke Gemini AI using @Gemini or @AI. The AI understands full conversation history with speaker attribution and streams its response back to all room members in real-time.
| Layer | Technology |
|---|---|
| Frontend | React + TypeScript + Vite (Developer 1) |
| Backend | Node.js + TypeScript + Express + Socket.IO |
| Auth | Firebase Authentication |
| Database | Cloud Firestore (persistence only) |
| AI | Vertex AI — Gemini 1.5 Flash |
| Backend hosting | Google Cloud Run |
| Frontend hosting | Firebase Hosting |
| Admin SDK | Firebase Admin SDK v12 |
┌──────────────────────────────────────────────────────────────┐
│ Browser (Firebase Hosting) │
│ React + TypeScript + Socket.IO client │
│ │
│ 1. Sign in ──────────────────────────► Firebase Auth │
│ 2. GET /api/me, /api/rooms ──────────► Backend REST │
│ 3. GET /api/rooms/:id/messages ──────► Backend REST │
│ 4. socket.io connect (ID token) ─────► Backend WebSocket │
│ room:join / message:send / typing:start ... │
│ ◄─── message:new / ai:message_chunk / presence:update ─── │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Cloud Run Backend (Express + Socket.IO) │
│ │
│ authMiddleware verifyIdToken ─────► Firebase Auth │
│ socketAuth verifyIdToken ─────► Firebase Auth │
│ getUserByUid ──────► Firestore │
│ │
│ roomManager in-memory presence & typing state │
│ │
│ message:send ──────────────────────── saveMessage ─────────► Firestore │
│ ──────────────────────── broadcast ──────────► All clients │
│ ──── if @Gemini ──────── Vertex AI stream │
│ updateMessage ────────► Firestore │
│ ai:message_chunk ────► All clients │
└──────────────────────────────────────────────────────────────┘
The frontend does not use onSnapshot or any Firestore real-time listener for chat. All real-time events (messages, typing, presence, AI streaming) flow through a centralized Socket.IO server running inside the Cloud Run backend.
- Frontend connects once via Socket.IO with a Firebase ID token.
- The backend verifies the token, loads the user profile, and authorizes all events.
- Messages are persisted to Firestore by the backend, then broadcast to connected clients.
- The frontend reads Firestore only for the initial history load via
GET /api/rooms/:roomId/messages.
users/{uid} ← global index for fast socket auth
uid, email, name, orgId, orgName, role
organizations/{orgId}
id, name, slug, createdAt
users/{userId}
uid, name, email, orgId, orgName, role, createdAt
rooms/{roomId}
id, orgId, name, description, memberIds[], createdBy, createdAt
messages/{messageId}
id, orgId, roomId, senderId, senderName,
senderType ("user"|"ai"|"system"),
content, status ("streaming"|"complete"|"error"), createdAt
typing/{userId} ← not used by clients; managed in backend memory
presence/{userId} ← not used by clients; managed in backend memory
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
none | Health check |
GET |
/api/me |
Bearer token | Returns current user profile |
GET |
/api/rooms |
Bearer token | Lists rooms user is member of |
GET |
/api/rooms/:roomId/messages |
Bearer token | Last 50 messages (asc) |
POST |
/api/rooms |
Bearer token (admin) | Creates a new room |
| Event | Payload | Description |
|---|---|---|
room:join |
{ roomId } |
Join a room (auth + membership verified) |
room:leave |
{ roomId } |
Leave a room |
message:send |
{ roomId, content } |
Send a message; triggers Gemini if @Gemini/@AI |
typing:start |
{ roomId } |
Start typing indicator |
typing:stop |
{ roomId } |
Stop typing indicator |
| Event | Payload | Description |
|---|---|---|
socket:ready |
{ userId, orgId } |
Emitted after successful connection |
message:new |
{ id, orgId, roomId, senderId, senderName, senderType, content, status, createdAt } |
New user message broadcast |
typing:update |
{ roomId, users: [{userId, name}] } |
Current typing users |
presence:update |
{ roomId, onlineUsers: [{userId, name}] } |
Current online users in room |
ai:message_started |
{ id, orgId, roomId, senderId, senderName, senderType, content, status, createdAt } |
Gemini starts responding |
ai:message_chunk |
{ id, roomId, chunk, content } |
Streaming chunk + accumulated text |
ai:message_completed |
{ id, roomId, status } |
Gemini response complete |
ai:error |
{ roomId, message } |
Gemini failed |
error |
{ message } |
General socket error |
org:${orgId}:room:${roomId}
This guarantees tenant isolation at the Socket.IO room level. Even if two organizations both have a room called general, they are always separate Socket.IO rooms.
| Field | Source | Trusted? |
|---|---|---|
uid |
Firebase ID token (verified by Admin SDK) | Yes |
orgId |
Backend Firestore profile (loaded by uid) | Yes |
senderName |
Backend Firestore profile | Yes |
role |
Backend Firestore profile | Yes |
orgId from request body/payload |
Frontend | Never |
senderId from client |
Frontend | Never |
Since all writes go through the backend Admin SDK (which bypasses rules), the client-facing rules are deliberately strict:
users/{uid}— user can read only their own global profileorganizations/{orgId}— org members can readorganizations/{orgId}/users— org members can read; no client writesorganizations/{orgId}/rooms/{roomId}— room members can read; no client writesorganizations/{orgId}/rooms/{roomId}/messages— room members can read only; no client writestyping/presence— deny all client reads and writes (managed in backend memory)
- Node.js 20+
- Firebase CLI:
npm install -g firebase-tools - Firebase project with Firestore and Authentication (Email/Password) enabled
- GCP project with Vertex AI API enabled
- Service account key downloaded locally (for local dev — do not commit)
cd backend
cp .env.example .env
# Fill in GOOGLE_CLOUD_PROJECT, FIREBASE_PROJECT_ID, GOOGLE_APPLICATION_CREDENTIALS
npm install
npm run devServer starts at http://localhost:8080 with WebSocket on the same port.
Verify:
curl http://localhost:8080/health
# { "status": "ok", "service": "teamchat-ai-backend" }cd frontend
cp .env.example .env
# Set VITE_BACKEND_URL=http://localhost:8080
npm install
npm run dev| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
No | 8080 | Server port |
NODE_ENV |
No | development | development or production |
GOOGLE_CLOUD_PROJECT |
Yes | — | GCP project ID (Vertex AI) |
FIREBASE_PROJECT_ID |
Yes | — | Firebase project ID |
GOOGLE_APPLICATION_CREDENTIALS |
Local dev only | — | Path to service account key JSON |
VERTEX_LOCATION |
No | us-central1 | Vertex AI region |
GEMINI_MODEL |
No | gemini-1.5-flash | Gemini model name |
FRONTEND_ORIGIN |
No | http://localhost:5173 | CORS-allowed frontend URL |
cd backend
GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json \
FIREBASE_PROJECT_ID=your-project-id \
GOOGLE_CLOUD_PROJECT=your-project-id \
npm run seedCreates:
- Firebase Auth users for all 6 test accounts
organizations/{orgId}/users/{uid}profile documentsusers/{uid}global index documents- Rooms and sample messages for both orgs
The script is idempotent — safe to run multiple times.
gcloud services enable \
run.googleapis.com \
aiplatform.googleapis.com \
firestore.googleapis.com \
--project YOUR_PROJECT_IDfirebase deploy --only firestore --project YOUR_PROJECT_IDcd backend
# Build and push image
gcloud builds submit \
--tag gcr.io/YOUR_PROJECT_ID/teamchat-ai-backend \
--project YOUR_PROJECT_ID
# Deploy — max-instances=1 is required for the in-memory WebSocket state
gcloud run deploy teamchat-ai-backend \
--image gcr.io/YOUR_PROJECT_ID/teamchat-ai-backend \
--platform managed \
--region us-central1 \
--allow-unauthenticated \
--max-instances 1 \
--min-instances 1 \
--set-env-vars GOOGLE_CLOUD_PROJECT=YOUR_PROJECT_ID,FIREBASE_PROJECT_ID=YOUR_PROJECT_ID,VERTEX_LOCATION=us-central1,GEMINI_MODEL=gemini-1.5-flash,FRONTEND_ORIGIN=https://YOUR_PROJECT_ID.web.app \
--service-account YOUR_SA@YOUR_PROJECT_ID.iam.gserviceaccount.com \
--project YOUR_PROJECT_IDService account IAM roles needed:
roles/aiplatform.userroles/datastore.userroles/firebase.sdkAdminServiceAgent
cd backend
FIREBASE_PROJECT_ID=YOUR_PROJECT_ID \
GOOGLE_CLOUD_PROJECT=YOUR_PROJECT_ID \
GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json \
npm run seedcd frontend
VITE_BACKEND_URL=https://YOUR_CLOUD_RUN_URL npm run build
cd ..
firebase deploy --only hosting --project YOUR_PROJECT_IDMVP constraint: This deployment uses
--max-instances 1. The in-memoryroomManager(presence, typing) is local to the process. With multiple instances, users on different instances would not see each other's presence or typing.Production scaling path: Use the Socket.IO Redis adapter (
@socket.io/redis-adapter) with Google Cloud Memorystore (Redis), which allows horizontal scaling without losing shared state.
URL: [Add your deployed Firebase Hosting URL here]
Backend health: [Add your Cloud Run URL here]
/health
- Open Browser A. Log in as
sarah@acme.com/Test@123. - Confirm only Acme Corp rooms (
general,engineering) are listed. - Join the
engineeringroom and send a message. - Open Browser B (Incognito). Log in as
mike@acme.com/Test@123. - Confirm Mike receives Sarah's message through WebSocket in real-time.
- Send
@Gemini what do you recommend?— confirm the AI response streams to both browsers. - Open Browser C (another Incognito). Log in as
john@beta.com/Test@123. - Confirm John sees only Beta Labs rooms (
general,product). - John cannot receive any Acme Corp messages — they are on a different Socket.IO room key.
- Any attempt to emit
room:joinwith an Acme room ID from John's socket will be rejected with an error event.
- Single Cloud Run instance required for MVP — in-memory presence/typing state is not shared across instances. Set
--max-instances 1. - No rate limiting — add
express-rate-limitbefore production. - Typing indicators are not persisted to Firestore — they only exist in memory and are lost on server restart.
- Firebase Auth password reset flows are out of scope.
- Room listing uses a Firestore
array-containsquery — performs well for up to ~100 rooms per org.