Dareke
An outbound contact centre in Georgian and English, for three kinds of people. Operators work a campaign from one screen: they pick a status, press Next lead or take an auto-dialled call, talk, and must record a disposition before the next call. Supervisors get a live wall of agent tiles with running timers, campaign progress, a call feed and the auto-dialler's pacing, plus reports with CSV export. Admins run campaigns and lead lists, users and roles, agent statuses, dispositions, the Do-Not-Call list and the dialler's settings, and a super admin also sees the PBX and directory integrations. We built it end to end: a Fastify API on PostgreSQL and Redis, an Asterisk/FreePBX telephony bridge, and the React front end with its SIP softphone.
Year: 2026 · Category: contact centre
Our role
Full-stack. The API and its data model, the dialler and its pacing, the PBX integration, the realtime channel, the browser softphone and all three workspaces.
The problem
An outbound campaign is a numbers game played by people. A list of customers has to be worked in order, retried when nobody picks up, and kept away from anyone who asked not to be called. Without a dialler, an operator types digits and loses the minutes between calls; with a naive one, calls ring out to nobody or connect to a customer while every agent is busy.
The floor around the operator has the same problem. A supervisor needs to see who is on a call, who is in wrap-up and which campaign is falling behind while it is happening, and an administrator needs to change campaigns, lists and permissions without touching the phone system that carries the calls.
The approach
We treated the dialler as the product and everything else as the surface for it. Calls are originated on the PBX, the customer is placed in the campaign's queue once they answer, and the PBX rings the operator's extension, which the operator answers in the browser over SIP — so there is no desk phone and nothing to install. Queue membership follows the agent's status: working unpauses, a break pauses with a reason, offline removes.
The dialler is one in-process loop with ratio pacing per campaign, round-robin assignment, retries for no-answer and busy leads after a delay, working-hours and DNC checks, and backoff on errors. Realtime state travels over Redis and a single WebSocket, so the supervisor's wall changes when the floor does.
Around it sits the rest of a contact centre: campaigns with a start, pause and stop lifecycle, lead lists that import from CSV, dispositions that decide what happens to a lead next, a global Do-Not-Call list, and an admin console whose PBX and directory settings are stored encrypted and tested from the screen.
Highlights
- Four roles — operator, supervisor, admin and super admin — each with its own home screen; a wrong role is redirected rather than shown an error, admins cannot see super admins, and the Integrations page and Grant super admin are super admin only.
- Every route guard is backed by a role check inside the handler, so the guard is never the only gate.
- Forced disposition: no new call until the last one has an outcome, with callback and Do-Not-Call rules that move the lead's status.
- Manual and auto dialling, with ratio pacing, round-robin hand-off to free agents, abandoned-call handling and error backoff.
- A supervisor wall that refreshes from change events, not on a timer: agent tiles, campaign progress, a live feed, calls by hour and the auto-dialler panel.
- Campaign lifecycle with validation (a draft with no agents or no pending leads will not start), real CSV lead import and a global DNC list.
- Fully bilingual: 1,383 translation keys per language, English by default and `?lang=ka` for Georgian.
Architecture
- Front end: A React 18 and Vite SPA in TypeScript, styled with Tailwind and shadcn/ui on Radix, with TanStack Query, React Router, Recharts and react-i18next. It loads a public runtime config (API URL, WebSocket URL, WebRTC switch, SIP WSS URL and domain, ICE servers) from the back end before it renders, and uses SIP.js for the browser softphone.
- API: Node 20 and Fastify with Zod validation, `@fastify/jwt` and `@fastify/websocket`, and pino logging. Twelve modules — auth, users, campaigns, agents, calls, dialer, dispositions, DNC, recordings, dashboard, settings and system config — sit behind more than a hundred routes.
- Authentication and roles: JWT sessions with local bcrypt passwords, or an LDAP / Active Directory bind that auto-provisions the user on first sign-in. Four roles: operator, supervisor, admin and super admin.
- Data: PostgreSQL 16 through Prisma: 14 models covering users, agent statuses and sessions, agent state logs, campaigns and their agents and numbers, disposition codes, call records, the DNC list, system settings, encrypted system config and audit logs. Lead rows are unique on campaign, phone and instance number, which is how duplicates are kept on purpose.
- Realtime: Redis 7 pub/sub channels for agent state changes, PBX hangup, bridge and hold events, and campaign and call updates, fanned out to one authenticated `/ws` WebSocket. Clients receive a snapshot of agent states on connect, are subscribed by role, and ping every 30 seconds.
- Telephony: Asterisk / FreePBX driven over AMI with auto-reconnect: Originate, Hangup, Redirect for transfer and the Queue actions. The customer is called first and bridged to the operator's PJSIP extension once answered, and the operator answers in the browser over SIP on WSS. A built-in mock mode simulates calls when no PBX is attached.
- Dialler: An in-process 1-second loop with ratio pacing per campaign, round-robin agent assignment, delayed retries for no-answer and busy leads, working-hours and DNC checks, and error backoff.
- Integrations config: PBX and directory settings stored encrypted in the database by a super admin, overriding environment variables, each with a connection test. Deployed with Docker Compose.
In numbers
Roles: 4 · Routed screens: 14 · Mocked API handlers: 81 · Languages: 2 · Translation keys per language: 1,383 · Lines of source: 21k
Results
- One workspace for the whole call: lead, history, status, dial controls and disposition, with keyboard shortcuts for the common actions.
- Supervisors see the floor as it is — who is talking, for how long, and which campaign is behind — without asking anyone.
- Campaigns, lead lists, users, statuses, dispositions, the DNC list and dialler pacing became admin actions rather than database edits.
- PBX and directory connections are configured and tested from the console, with secrets stored encrypted and shown only as set.
- One interface in Georgian and English across all three workspaces.
What is used
- API: Node 20, Fastify, Zod validation, @fastify/jwt, @fastify/websocket, pino logging, bcrypt passwords, LDAP / Active Directory bind
- Data and realtime: PostgreSQL 16, Prisma (14 models, 3 migrations), Redis 7 pub/sub, One authenticated WebSocket
- Telephony: Asterisk / FreePBX, AMI (Originate, Hangup, Redirect, Queue*), PJSIP extensions, SIP.js softphone over WSS, WebRTC
- Front end: React 18, TypeScript, Vite, Tailwind CSS, shadcn/ui on Radix, TanStack Query, React Router, framer-motion, Recharts, react-i18next, sonner, date-fns
- Delivery: Docker Compose (PostgreSQL, Redis, API, front end), Setup guides for the PBX, the WebRTC path and the dialler
- Demo build: @portfolio/demo-kit mock API, store and session, Seed built at boot from a seeded generator, BroadcastChannel for cross-tab sync, i18next, English and Georgian, @fontsource, bundled fonts
The demo
A fictional re-skin of a real product Boulder built, running with its backend cut off. The telephone network, the PBX and the softphone's audio are simulated; an in-browser mock answers the 81 API paths and a set of simulators play the network and a floor of colleagues. All data lives in memory and a reload restores the pristine seed; open it in two tabs to see the operator's calls on the supervisor's wall. The call-script pane beside the lead card is the demo's own addition. The names, companies and phone numbers are invented, and nothing leaves your browser.