Bani

A voice operations console for a company whose telephony lives on two different phone platforms. It stitches the call records of both into single calls and rebuilds who transferred to whom, finds and plays the recording, backs recordings up and proves the backups are complete, staffs contact-centre agents and outbound campaigns, hands out phone extensions, and watches the health of the whole estate — with eight roles that each see only what they should. We built it end to end.

Year: 2026 · Category: voice operations

Our role

Full-stack. The call-record merge and transfer logic, the archive and verify jobs, the permission system, the contact-centre editors and the front end.

The problem

A company that runs its voice on two phone platforms ends up with two of everything. Two sets of call records, in different shapes, that describe the same afternoon differently. Two places recordings are kept, and a copy job nobody can prove has finished. A contact centre whose agents and campaigns are edited in a vendor tool built for one person at a time. And a list of who may touch each of those that lives in somebody's head.

The cost shows up as questions that should take a minute. Who did the customer actually speak to after the transfer? Is Tuesday morning's recording on the archive, or only on a box that has since been rebuilt? Which of the thirty agents staff the Georgian-language queue, and at what level? Each answer meant a different tool, a different login and a different person's permission.

The approach

The console puts one front on both platforms and keeps the rules in code that can be read.

Call records keep their own shapes underneath — one record per leg on the enterprise platform, legs linked by a shared id on the open-source PBX — and are brought together on read: legs are stitched into logical calls, and the party chain of every transfer is rebuilt from them. A search for a number is one search with one answer, and a call that bounced between three desks is one row you can open.

Recordings and their backups are treated as something to prove, not to hope for. A daily job copies recordings to a file archive with a trailing retry window, and a verify pass compares source and archive day by day, names what is missing on either side, and backfills the gap as a backup run of its own. Around that sit the contact-centre editors, the extension desk, the platform status page and the settings for every integration — and one permission table, with overrides an administrator can edit, that decides which of eight roles sees which of them.

Highlights

  • Two phone platforms, one search: legs are stitched into logical calls and every transfer's party chain is rebuilt, in plain TypeScript the demo runs unchanged.
  • Backups that prove themselves: a verify pass compares source and archive day by day and backfills only what is missing, as a run linked to the verify that found it.
  • Eight roles over eleven features, edited at runtime on a permission matrix; the navigation, the route guards and the API all read the same function.
  • Reserve-then-create extensions: a 10-minute lock with a live countdown, released on expiry, every step in an audit trail.
  • Bulk staffing across dozens of agents with a confirm step and a per-agent failure report, because the underlying API writes one agent at a time.
  • Real downloads without a library: a hand-written ZIP writer builds both the recordings archive and the `.xlsx` export.
  • Bilingual throughout: about 1,360 English and 1,390 Georgian strings, with Georgian type designed in rather than fallen back to.

Architecture

  • Console: Next.js App Router. Server components check the feature before a page renders and redirect away from what a role may not open; client pages check again through a permissions endpoint, so a deep link cannot get around the navigation. Call access is also clamped per platform: a contact-centre role never sees the other platform's records.
  • Call records: Two sources read as one. The enterprise platform's call-detail files arrive over SFTP and are imported into PostgreSQL by a cron job (with reconcile and full-history modes); the open-source PBX's own database is mirrored on an interval and checked range by range. Merging legs into calls and rebuilding transfers is plain TypeScript.
  • Recordings and archive: Lookup, streaming and ZIP download from the voice-archive server. A daily cron with a seven-day trailing retry copies recordings to an SMB file archive; a verify pass compares both sides per day and backfills what is missing; an event log records every copy, skip and timeout.
  • Contact centre: The contact-centre platform's XML admin API, proxied by Next API routes and parsed in the client. The API changes one agent at a time, so writes are read-modify-write per agent and a bulk change reports failures per agent. A node monitor reads CPU, memory and uptime over SNMP.
  • Extensions: A lock table with a 10-minute TTL, swept on read, merged with the PBX's own extension list. Reserve-then-create with a countdown on the page, and an audit trail with a purge.
  • Platform health: An in-process collector samples the host every five seconds, writes 60-second buckets (average, minimum, maximum) kept for 30 days, and pings each configured backend. An hourly cron runs the self-test suite and stores every run in PostgreSQL.
  • Identity and permissions: Auth.js with a credentials provider and a JWT session carrying role and username. Directory users come from an LDAP bind with group-to-role mappings; local and API accounts sit beside them. One feature table with administrator overrides is enforced in the layout, the pages and the API.
  • This demo: The server, the databases, the directory, the file shares and SNMP are replaced by an in-browser mock API over seeded data, in a Vite SPA. The merge and transfer code is the same; the jobs are timers.

In numbers

Roles: 8 · Screens: 12 · Mocked routes: 67 · Seeded call legs: 425 · Front-end lines: 22k · Languages: 2

Results

  • A search for a number answers across both platforms at once, and a transferred call is one row with its chain of parties instead of a pile of fragments.
  • A recording is a lookup, a player and a download — and "is it safely archived?" has an answer that is checked day by day instead of assumed.
  • Staffing a queue, changing a campaign and issuing an extension sit in one console with an audit trail, instead of in separate single-user vendor tools.
  • What each person can reach is a table an administrator edits and one function enforces, not an arrangement kept in someone's head.
  • The state of the whole voice estate — hosts, links, the self-test — is one page with history, so "was it like this yesterday?" has an answer.

What is used

  • Application: Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS 4, Auth.js (credentials + JWT)
  • Data: PostgreSQL (call-record import, self-test runs, locks, activity logs, settings), Prisma, MySQL (read access to the PBX database, mirrored)
  • Integrations: LDAP directory bind and group mappings, SNMP (contact-centre node metrics), SFTP (call-detail file drop), SMB file archive, Phone-platform and contact-centre admin APIs (REST, XML), fast-xml-parser
  • Scheduled work: cron: record import, mirror interval, daily recording backup with a 7-day trailing retry, hourly self-test, In-process host collector (5 s samples, 60 s buckets, 30-day retention)
  • Demo build: Vite 6, React 18, React Router 6, TanStack Query 5, i18next (KA / EN), Tailwind CSS 4, Radix UI, Recharts 2, Framer Motion, cmdk, React Hook Form + Zod, sonner, @portfolio/demo-kit

The demo

A fictional re-skin of a real product Boulder built: the company, the people, the numbers and the recordings are invented, and the product wears a new Georgian brand. There is no backend — an in-browser mock API answers every call, syncs, backups and self-tests are simulations on timers, and the three recordings are synthetic clips. Data lives in memory and resets on reload. Switch persona in the corner to see what each of the eight roles can reach.