Small, production-lean unified inbox for SMS + WhatsApp with scheduling, notes, realtime, analytics, role-based access, and a clear trial-mode UX — built on Next.js 16, Prisma/Postgres, Twilio, Pusher, Better Auth, Zod, and React Query.
- Goal: Centralize outreach and replies across SMS + WhatsApp with history, notes, and live collaboration in one place.
- Timeframe: 10–15 hours (completed)
- Live demo script: see the “Demo walkthrough” section below.
- Frontend: Next.js 16 (App Router, TypeScript), React 18, Tailwind CSS v4,
next-themes - Server: Next.js API routes (Node runtime)
- Database: PostgreSQL via Prisma ORM
- Auth: Better Auth (email/password + Google), role claims (
admin,agent) - Realtime: Pusher (public key subscribe for this slice)
- Messaging: Twilio (SMS + WhatsApp Sandbox)
- Validation: Zod
- Data fetching/state: React Query (optimistic updates)
- Multi-tenancy:
Teammodel with tenant scoping viateamIdonUser,Contact,Thread,Message,Note,EventLog; guards return{ userId, role, teamId }and all loaders/queries are filtered byteamId.
Design highlights:
- Normalized message model across channels; threads by contact
- Clear roles and protected routes (middleware + guards)
- Webhook-first ingestion with signature validation
- Simple “send later” scheduler with a cron-safe GET endpoint
- Minimal, scalable components and lib modules
- Tenant isolation by
teamIdand composite uniques on Contact/Thread for data integrity
- Sign In / Sign Up (credentials) + Google OAuth.
- Role claims:
admin,agent./settingsis admin-only. - Middleware enforces auth; unauthorized users are redirected to
/auth/sign-in. - Sessions persisted in DB.
- Team assignment: Users belong to a Team;
teamIdis captured on sign-up (client passes it) or defaulted server-side via Better Auth hooks; all protected pages use a guard that returns{ userId, role, teamId }for tenant-scoped access.
- Route:
/inbox - Left: ThreadList with filters (Unread / Scheduled / Channel) + search.
- Middle: ThreadView with compact bubbles, direction alignment, lazy media preview for WA images.
- Right: ContactPanel: contact info + notes timeline (public/private).
- Tenant scope:
getThreads,getThread, and sidebar loaders filter byteamId. - Next 15/16:
searchParamsare async in Server Components; the page awaits them to avoid build/runtime errors.
-
POST /api/sendvalidates input with Zod and:- Sends immediately via Twilio (status
sent) or - Persists
status='scheduled'whenscheduleAtis provided (no Twilio call yet).
- Sends immediately via Twilio (status
-
Tenant scope: API stamps
teamIdon messages; ifthreadIdisn’t provided, the route upserts a thread by composite unique(teamId, contactId, channel). -
Trial guard: server enforces
VERIFIED_NUMBERS(Twilio trial); UI explains trial limits. -
Composer supports channel switch (SMS/WA), body input, media URL for WA, Send & Send later; client simply resets
scheduleAtafter success.
POST /api/webhooks/twilio- Validates Twilio signature; upserts Contact with composite unique
(teamId, phone), ensures Thread(teamId, contactId, channel), inserts inbound Message, bumpslastMessageAtandunreadCount. - WA media URLs saved to
Message.mediaJSON. - Returns 200 quickly.
- Tenant scope: inbound rows are created with the correct
teamId.
-
DB keeps
Message.scheduledAtandstatus='scheduled'. -
GET /api/schedule/run:- Finds due messages,
- Sends via Twilio,
- Updates
status='sent',sentAt=now(), - Emits
message.createdto subscribers.
-
Operates safely with
teamIdstamped on all messages.
- Channel:
thread-{threadId} - Events:
message.created,note.created - Hook merges events into React Query cache for live updates in ThreadView/ContactPanel.
-
/dashboardtiles:- Messages by channel (7d)
- Avg first response time (24h)
-
Export CSV for the above.
/settings(admin): displays Twilio trial number and verified-contacts requirement; link to buy a number.
root
├─ README.md
├─ eslint.config.mjs
├─ tsconfig.json
├─ prisma.config.ts
├─ next.config.mjs
├─ postcss.config.mjs
├─ package.json
├─ package-lock.json
├─ public/
│ ├─ light.svg
│ └─ night.svg
└─ src/
├─ app/
│ ├─ favicon.ico
│ ├─ globals.css
│ ├─ layout.tsx
│ ├─ page.tsx
│ ├─ auth/sign-in/page.tsx
│ ├─ inbox/page.tsx
│ ├─ dashboard/page.tsx
│ ├─ settings/page.tsx
│ ├─ hooks/useThreadRealtime.ts
│ ├─ api/send/route.ts
│ ├─ api/notes/route.ts
│ ├─ api/schedule/run/route.ts
│ ├─ api/webhooks/twilio/route.ts
│ ├─ api/threads/[threadId]/route.ts
│ ├─ api/threads/[threadId]/sidebar/route.ts
│ ├─ api/auth/[...all]/route.ts
│ ├─ api/analytics/export.csv/route.ts
│ └─ api/analytics/summary/route.ts
├─ components/
│ ├─ ThreadList.tsx
│ ├─ ThreadView.tsx
│ ├─ ContactPanel.tsx
│ ├─ Composer.tsx
│ ├─ Header.tsx
│ ├─ theme/ThemeProvider.tsx
│ └─ theme/ThemeToggle.tsx
├─ lib/
│ ├─ db.ts
│ ├─ auth.ts
│ ├─ auth-client.ts
│ ├─ auth/guards.ts
│ ├─ validators/message.ts
│ ├─ validators/note.ts
│ ├─ validators/webhook.ts
│ ├─ integrations/twilio.ts
│ ├─ realtime/pusher.ts
│ ├─ analytics/logger.ts
│ ├─ analytics/queries.ts
│ ├─ query.tsx
│ └─ trial.ts
├─ middleware.ts
└─ prisma/
├─ schema.prisma
├─ seed.ts
└─ migrations/
erDiagram
Team ||--o{ User : has
Team ||--o{ Contact : has
Team ||--o{ Thread : has
Team ||--o{ Message : has
Team ||--o{ Note : has
Team ||--o{ EventLog : has
User ||--o{ Thread : owns
User ||--o{ Message : "author (optional)"
User ||--o{ Note : writes
Contact ||--o{ Thread : has
Thread ||--o{ Message : contains
Thread ||--o{ Note : contains
Team {
String id PK
String name
DateTime createdAt
}
User {
String id PK
String email "unique"
String name
String image
String role "admin|agent"
String teamId FK
DateTime emailVerified
DateTime createdAt
DateTime updatedAt
"Index: teamId"
}
Contact {
String id PK
String teamId FK
String name
String phone
Boolean waOptIn
DateTime createdAt
DateTime updatedAt
"Unique: (teamId, phone)"
"Index: teamId"
}
Thread {
String id PK
String teamId FK
String contactId FK
String ownerId "optional"
String channel "sms|whatsapp"
DateTime lastMessageAt
Int unreadCount
DateTime createdAt
DateTime updatedAt
"Unique: (teamId, contactId, channel)"
"Indexes: (teamId, lastMessageAt), (teamId, channel)"
}
Message {
String id PK
String teamId FK
String threadId FK
String authorId "optional"
String channel
String direction "inbound|outbound"
String body
Json media "optional"
String status "draft|scheduled|sent|delivered|failed|read"
String providerId "optional"
DateTime scheduledAt "optional"
DateTime sentAt "optional"
DateTime deliveredAt "optional"
DateTime readAt "optional"
DateTime createdAt
"Indexes: (teamId, status, scheduledAt), (threadId, createdAt)"
}
Note {
String id PK
String teamId FK
String threadId FK
String authorId FK
String visibility "public|private"
String body
DateTime createdAt
"Indexes: (teamId, threadId), (threadId, createdAt)"
}
EventLog {
String id PK
String teamId FK
String type
Json payload
DateTime createdAt
"Index: (teamId, type, createdAt)"
}
-
Node.js 20 LTS
-
npm 10+ (or pnpm/yarn if preferred)
-
PostgreSQL 14+ (local or cloud; Docker ok)
-
Twilio account with:
- One SMS number (trial is fine)
- WhatsApp Sandbox enabled for testing
-
Ngrok (for public webhook URL in dev)
-
Pusher account (free tier is fine)
-
Google OAuth (for optional sign-in)
Create .env at project root (see example below). Copy .env.example and fill secrets.
# Database
DATABASE_URL=postgres://user:pass@localhost:5432/unified_box
# Twilio (SMS + WhatsApp)
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_SMS_FROM=+1XXXXXXXXXX
TWILIO_WA_FROM=whatsapp:+14155238886 # or your WA-enabled number
# Twilio trial guard: only these E.164 numbers are allowed for outbound
VERIFIED_NUMBERS=+91XXXXXXXXXX,+1YYYYYYYYYY
# Realtime (Pusher)
PUSHER_APP_ID=
PUSHER_KEY=
PUSHER_SECRET=
PUSHER_CLUSTER=
NEXT_PUBLIC_PUSHER_KEY=
NEXT_PUBLIC_PUSHER_CLUSTER=
# Public base URL used for webhooks (ngrok URL in dev)
PUBLIC_BASE_URL=https://<your-ngrok-subdomain>.ngrok.app
# Auth (Google OAuth is optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Tenancy defaults (used by signup/hook and seed)
DEFAULT_TEAM_ID=default-team
DEFAULT_TEAM_NAME="Default Team"
NEXT_PUBLIC_DEFAULT_TEAM_ID=default-teamNotes:
- VERIFIED_NUMBERS: For Twilio trial, add the E.164 caller IDs you verified in Twilio Console (SMS) and the phone(s) that joined the WhatsApp Sandbox. This is enforced server-side.
- PUBLIC_BASE_URL must be HTTPS and reachable by Twilio (ngrok recommended in dev).
- DEFAULT_TEAM_ID is used to seed/ensure a default Team and to backfill
teamIdwhen a client doesn’t pass it during sign-up.
# 1) Install deps
npm i
# 2) Start Postgres (example via Docker)
docker run --name unified_box_pg -e POSTGRES_PASSWORD=pass -e POSTGRES_USER=user \
-e POSTGRES_DB=unified_box -p 5432:5432 -d postgres:14
# 3) Migrate & seed
npx prisma migrate dev -n init
tsx prisma/seed.ts
# After introducing tenancy (Team + teamId):
# If you’re upgrading an existing DB, reset & re-seed:
npx prisma migrate reset
tsx prisma/seed.ts
# 4) Dev server
npm run devHelpful scripts:
npx prisma migrate reset– reset DB and re-seednpm run lint/npm run format
-
Start ngrok (replace 3000 if your dev port differs):
ngrok http https://localhost:3000
-
Copy the ngrok URL into
.envasPUBLIC_BASE_URL. -
Configure Twilio webhooks:
- SMS: Set Messaging webhook to
POST ${PUBLIC_BASE_URL}/api/webhooks/twilio - WhatsApp Sandbox: In Twilio Console under Messaging → Try it out → WhatsApp Sandbox, set When a message comes in to the same URL and join the sandbox from your phone per Twilio’s instructions.
- SMS: Set Messaging webhook to
-
Send yourself an SMS/WA and reply from your phone — inbound messages should hit the webhook and appear in the thread.
In India, carrier policies can block trial inbound SMS; WhatsApp Sandbox is the reliable path for end-to-end demo during development.
- Ensure
PUSHER_*andNEXT_PUBLIC_PUSHER_*match and thePUSHER_CLUSTERis correct. - The app subscribes to
thread-{threadId}channels and listens formessage.created/note.created.
-
Sign in
-
Visit
/auth/sign-in. Use credentials or Google OAuth. -
Role enforcement:
/settings→ admin only/inbox,/dashboard→ agent/admin
-
Tenant: users are attached to a Team; all subsequent requests/pages are team-scoped via guards.
-
-
Inbox
/inboxloads with a selected thread (or the first available) to avoid 404s in side panels.- Uses async
searchParamsto comply with Next 15/16 Server Components. - Use filters (Unread, Scheduled, Channel) and search.
-
Send / Send later
- Use the Composer to choose SMS or WhatsApp.
- Add a media URL for WhatsApp images.
- Click Send for immediate delivery (subject to trial guard) or Send later and pick a time.
-
Run the scheduler in dev
-
Manually trigger due sends:
curl -sS "${PUBLIC_BASE_URL}/api/schedule/run"
-
-
Notes
- Add public or private notes in the right panel; appears optimistically and then via realtime events.
-
Realtime
- Open the same thread in two tabs; sends and notes append live.
-
Analytics
/dashboardshows tiles; click Export CSV to download the data behind them.
-
Settings
/settings(admin) shows your trial number and the verified-contacts requirement.
| Method | Path | Description |
|---|---|---|
| POST | /api/send |
Validate + send or schedule SMS/WA (tenant-stamped, thread upsert by composite) |
| GET | /api/schedule/run |
Process due scheduled messages; update status/sentAt; emit realtime |
| POST | /api/webhooks/twilio |
Inbound SMS/WA webhook (signature verified); tenant-scoped upserts |
| POST | /api/notes |
Create note (threadId, body, visibility) |
| GET | /api/threads/[threadId] |
Fetch thread + messages (ordered asc), team-scoped |
| GET | /api/threads/[threadId]/sidebar |
Fetch contact + notes for right panel, team-scoped |
| GET | /api/analytics/export |
CSV export for dashboard tiles |
| ALL | /api/auth/[...all] |
Better Auth routes |
- Messages by channel (7d) — grouped counts for SMS vs WA.
- Avg first response time (24h) — computed with SQL (CTEs/windowing).
- Export:
GET /api/analytics/exportproduces a CSV of the tile data.
- AuthN/Z: Better Auth with role claims; middleware route protection;
/settingsis admin-only. - Tenancy: All DB reads/writes filter by
teamIdobtained from guards; Contact/Thread use composite uniques to prevent cross-tenant collisions. - Validation: All external input is Zod-validated before processing.
- Webhook security: Twilio signature validation occurs before DB mutations.
- Indexes: Critical fields indexed (
Thread.lastMessageAt,Message.status/scheduledAt, etc.) withteamIdprefixes where appropriate. - Lint/format: ESLint + Prettier. CI-friendly.
- Event logging: Optional
EventLogrecords key events per team for debugging/auditing.
Indicative, high-level characteristics. Costs vary by region/account and can change; consult provider pricing pages when deploying.
| Channel / Service | Latency (typical) | Cost (indicative) | Reliability notes | Notes |
|---|---|---|---|---|
| SMS (Twilio) | Seconds (carrier dependent) | Per-message billing; country-specific | Dependent on carrier routes and sender compliance | In trial, only verified caller IDs; India inbound trial may be restricted. |
| WhatsApp (Twilio) | Sub-second to seconds | Per-message/session-based by template type/region | Highly reliable via Meta WA Business API | Use Sandbox in dev; join code required on tester device. |
| Pusher (Realtime) | Sub-200ms publish→subscribe | Free tier available; paid scales by connections/events | Managed websockets with global edges | Use private/auth channels in production. |
Key decisions:
- Normalize messages across channels in a single
Messagetable for consistent querying. - Keep scheduling simple (DB + GET runner) to be cron-friendly on any platform.
- Use optimistic UI + realtime to keep threads consistent across tabs/sessions.
- Enforce tenant isolation by
teamIdend-to-end.
- Local dev scheduler requires a manual
GET /api/schedule/runor an external cron. - Trial-mode SMS inbound is restricted in some regions (notably India); WhatsApp Sandbox is recommended for the full demo loop.
- Media is URL-based (no local uploads in this slice).
- Pusher uses public subscribe for this slice; production should secure channels with an auth endpoint.
- File uploads to object storage (S3) with signed URLs.
- Secure realtime with private channels + auth endpoint; presence & typing indicators.
- Mentions/cursors/collab via Yjs.
- Deeper analytics (per-agent SLA, delivery rates, WA template performance).
- Org/team management UI (invite users, assign roles).
- E2E tests (Playwright) and unit tests.
- Deployment and cron automation (e.g., Vercel cron, GitHub Actions).
- Sign in as admin.
- Open /inbox and select a thread.
- Send a WhatsApp message (optionally add an image URL).
- Reply from your phone (WA Sandbox) → see live update in the thread.
- Add a public and a private note → both appear; the public one is visible to all.
- Schedule a message for +1 min → hit
/api/schedule/run→ message is delivered. - Visit /dashboard and Export CSV.
- Open /settings to show the trial constraints and verified contacts tip.
- Assignment brief and requirements.
# Migrate with a name
npx prisma migrate dev -n <name>
# Reset DB (use when switching to team-based schema)
npx prisma migrate reset
# Seed data (creates default team and sample data)
tsx prisma/seed.ts
# Lint & format
npm run lint
npm run format
# Dev server
npm run devLicense: Internal assignment prototype. Use at your own discretion for further development.