# backend

Node.js (TypeScript) API + realtime server.

Stack: Express (REST API shell), Socket.io (realtime), Redis (adapter/cache),
PostgreSQL via Prisma ORM.

## Status

**Implemented**: DB connection (Prisma), Socket.io server with widget/agent
connection auth, conversation Rooms, the `client:*` / `agent:*` / `chat:*`
event set, Redis pub/sub wiring for horizontal scaling (`@socket.io/redis-adapter`),
a REST API slice used by the dashboard, AI auto-reply, and file attachments:

- `POST /api/v1/auth/login` — email+password (per-tenant), returns a JWT + `DashboardUser`
- `GET /api/v1/public/widget-settings/:widgetKey` — unauthenticated, used by the widget
- `GET /api/v1/conversations`, `GET /api/v1/conversations/:id` — requires `Authorization: Bearer <jwt>`
- `GET /api/v1/widget-settings`, `PATCH /api/v1/widget-settings` — requires `Authorization: Bearer <jwt>` + OWNER/ADMIN role
- `GET /api/v1/ai-config`, `PATCH /api/v1/ai-config` — per-tenant AI behavior (system prompt, knowledge base, provider, on/off); PATCH requires OWNER/ADMIN
- `POST /api/v1/uploads/presign` (agent) and `POST /api/v1/public/uploads/presign` (widget, scoped by widgetKey) — presigned S3/R2 PUT URLs for chat attachments

**AI auto-reply** (`src/services/ai.service.ts`): when a visitor sends a
message, and the tenant's `AiConfig.isEnabled` is true, and no human agent
has taken the conversation, and (in the default `WHEN_AGENT_OFFLINE` mode)
no agent is currently connected for that tenant — OpenAI or Anthropic is
called with the tenant's system prompt + knowledge base + recent history,
and the reply is saved/broadcast as a normal `Message(senderType: 'AI')`.
Needs `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` set — otherwise a tenant simply
cannot enable it (the PATCH is rejected with a clear error).

**File attachments** (`src/services/upload.service.ts`): presigned PUT URLs
against AWS S3 or an S3-compatible store (Cloudflare R2, MinIO — set
`S3_ENDPOINT`). The file itself never passes through this backend. Needs
`S3_BUCKET` + `S3_ACCESS_KEY_ID` + `S3_SECRET_ACCESS_KEY` — otherwise the
presign endpoints return 503.

**Chat ownership, handoff & auto-messages** (added 2026-08-31, see root
`CLAUDE.md` §18): `agent:send_message` now rejects writes from any agent
other than `conversation.assignedAgentId` once one is assigned — a second
agent can still `agent:join_conversation` and read history, but must
`agent:request_takeover` and have the owner `agent:respond_takeover` before
they can write. The visitor sees a `AGENT_JOINED` system message the moment
an agent's first reply assigns them the conversation, and an
`ALL_AGENTS_BUSY` system message if 30s pass after their own message with
no agent/AI reply (`src/sockets/busy-timer.service.ts`).

**Not implemented yet**: real payment collection (Stripe — entitlements/plan
switching exist and gate features correctly, but nothing actually charges a
card yet), a real vector-search knowledge base (the current AI knowledge
base is a single free-form text field dropped straight into the system
prompt — fine for FAQ-sized content, not for large docs), omnichannel
(WhatsApp/Email inbox), and white-label/reseller mode — see root `CLAUDE.md`
§7, §14, and §18 for what exists today.

## Structure

```
backend/
├── prisma/
│   ├── schema.prisma       # DB schema
│   └── seed.ts             # creates a demo tenant + widget + agent + test JWT
├── scripts/
│   ├── test-widget-client.ts   # manual smoke test — visitor side
│   └── test-agent-client.ts    # manual smoke test — agent side
├── src/
│   ├── config/
│   │   └── env.ts           # zod-validated environment variables
│   ├── lib/
│   │   ├── prisma.ts        # PrismaClient singleton
│   │   ├── redis.ts         # ioredis clients (general + adapter pub/sub)
│   │   ├── socket-adapter.ts # @socket.io/redis-adapter wiring
│   │   └── jwt.ts           # verify/sign agent access tokens
│   ├── services/
│   │   ├── widget-settings.service.ts  # widgetKey lookup + origin allow-list check
│   │   ├── visitor.service.ts
│   │   ├── conversation.service.ts
│   │   ├── message.service.ts
│   │   ├── presence.service.ts    # Redis-backed "is any agent online for this tenant" (AI reply-mode gate)
│   │   ├── upload.service.ts      # S3/R2 presigned PUT URL generation
│   │   ├── ai.service.ts          # orchestrates the AI auto-reply flow end to end
│   │   └── ai/
│   │       ├── types.ts               # AiChatMessage
│   │       ├── openai.provider.ts     # raw fetch call to OpenAI Chat Completions
│   │       └── anthropic.provider.ts  # raw fetch call to Anthropic Messages
│   ├── sockets/
│   │   ├── types.ts           # AppServer/AppSocket + socket.data shapes
│   │   ├── events.types.ts    # typed ClientToServerEvents/ServerToClientEvents
│   │   ├── rooms.ts           # room-naming helpers
│   │   ├── schemas.ts         # zod payload validation
│   │   ├── serializers.ts     # Prisma model -> shared-types wire format
│   │   ├── auth.middleware.ts # widget_key+domain OR JWT handshake auth
│   │   ├── typing.handler.ts  # shared chat:typing handler
│   │   ├── widget.handlers.ts # client:join_chat / client:send_message
│   │   ├── agent.handlers.ts  # agent:join_conversation / agent:send_message / agent:leave_conversation
│   │   └── index.ts           # registerSocketServer(io)
│   ├── middleware/
│   │   ├── error-handler.ts   # centralized Express error -> HTTP response (incl. ZodError -> 400)
│   │   ├── async-handler.ts   # wraps async route handlers so rejections reach error-handler
│   │   └── require-auth.ts    # requireAuth (JWT) + requireRole(...roles)
│   ├── types/
│   │   └── express.d.ts       # augments Express.Request with `auth?: AccessTokenPayload`
│   ├── modules/
│   │   ├── auth/
│   │   │   └── auth.routes.ts             # POST /login
│   │   ├── conversations/
│   │   │   └── conversations.routes.ts    # GET / , GET /:id
│   │   ├── widget-settings/
│   │   │   ├── public.routes.ts           # GET /:widgetKey (no auth — used by the widget)
│   │   │   └── widget-settings.routes.ts  # GET / , PATCH / (auth + role required)
│   │   ├── ai-config/
│   │   │   └── ai-config.routes.ts        # GET / , PATCH / (auth; PATCH needs OWNER/ADMIN)
│   │   └── uploads/
│   │       ├── uploads.routes.ts          # POST /presign (auth — dashboard/agent)
│   │       └── public.routes.ts           # POST /presign (no auth, widgetKey-scoped — widget)
│   └── server.ts             # Express + http + Socket.io bootstrap; mounts the modules above
├── package.json
└── tsconfig.json
```

Still-planned modules (`tenants`, `agents`, `subscriptions`) will each get
their own `controller` + `service` + `routes` + `schema` under
`src/modules/<name>` once built — see root `CLAUDE.md` §7 and §11.

## Running it locally

```bash
cp backend/.env.example backend/.env   # adjust if your Postgres/Redis differ
docker compose up -d                    # from repo root — local Postgres + Redis
pnpm install                            # from repo root

pnpm --filter @support-system/shared-types build

pnpm --filter backend prisma:generate
pnpm --filter backend prisma:migrate    # creates tables
pnpm --filter backend db:seed           # prints a widgetKey + a test agent JWT

pnpm --filter backend dev               # starts the server on :4000
```

In two more terminals, using the values the seed script printed:

```bash
WIDGET_KEY=<widgetKey> pnpm --filter backend test:widget
pnpm --filter backend test:agent -- "<jwt>" <conversationId>   # conversationId from the widget script's output
```

You should see the visitor's message arrive in the agent terminal via
`chat:new_message`, and any reply typed into the agent terminal (once
`conversationId` is known) arrive back in the widget terminal.

## Allowed domains & the Direct Chat Page

`WidgetSettings.allowedDomains` is enforced at two layers, both via
`isOriginAllowed()` in `src/services/widget-settings.service.ts`: the
Socket.io handshake (`sockets/auth.middleware.ts`) and the two public REST
endpoints (`GET /api/v1/public/widget-settings/:widgetKey`,
`POST /api/v1/public/uploads/presign`), which now check `Origin` (falling
back to `Referer`) and return `403 ORIGIN_NOT_ALLOWED` on a mismatch. An
empty `allowedDomains` list still means "no restriction" — set at least one
domain to actually gate anything.

Set `DIRECT_CHAT_BASE_URL` (e.g. `https://chat.yourdomain.com`) to the
domain the dashboard's standalone `/c/[widgetKey]` route is deployed on —
requests from that origin bypass the `allowedDomains` check entirely, since
a Direct Chat Link visitor was never embedded on the tenant's own site.
