
Cabinet24
A cloud PBX with a light CRM in one browser cabinet, rewritten and optimized into a platform that's ready to go
Cabinet24 is a cloud phone system, a cloud PBX, with a light CRM on top. It's one browser cabinet, and a company points all of its landline and mobile numbers at it, each one staying multichannel. Calls get recorded, a voice menu greets the caller, calls get distributed and transferred between staff, routed by working hours, and forwarded to an outside line when nobody picks up. Internal calls are free. The number itself tells you where the call came from, and every call lands in a history with its statistics.
flowchart LR
N[Every company number] --> RT[Routing by working hours]
RT --> IVR[Voice menu]
IVR --> ST[Staff]
ST --> RC[Call recording]
ST --> CRM[Mini CRM]
W[Callback widget on the site] --> RT
RC & CRM --> AN[History and analytics]It was made by Mobilauto, a Kyiv company that builds taxi-dispatch software, and sold beside the taxi platform as its own product. It was also my first project at Mobilauto. I joined in 2020 after a pretty good interview, and reeeeally honestly: in 2020 I was not yet the developer I am now.
The cabinet in 2020
When I got into the project, the whole team was me, one telephony administrator and one support manager. The developer before me gave me a short handover, and that helped a little. After that I was the one developer on a big platform, and I had to figure everything out almost at once.
And it was a big platform, for sure. The cabinet of that time was plain HTML pages on an older Node backend: Node 10 with Express, Sequelize 5 over MySQL, and socket.io for everything live. SMS went out over an SMPP session bound to the carrier. Live calls are not an easy thing to show. A call lands, and the page has to show it right then, keep the conversation there to listen to afterwards, and let anybody in the company add a comment to it. Then there were tasks, contacts, SMS, reports and much more around that.
Reading that code is how I found out what the product really was. One socket per browser carried all the live work as named subscriptions sitting side by side: incoming calls, the telephone gateway, notifications, task reminders, roles, spreadsheet exports, file uploads and mailboxes. Each one registered itself when the socket opened and took itself off when it closed. So a report that takes a minute to build and an inbox streaming mail travelled over the same connection as the calls.
That generation also had its own hand-built telephone gateway. A local UDP socket forwarded to the switch, a SIP parser adapted from a credited public project read what came back, and a router delivered each datagram to the one browser session that owned it. Every session got a freshly minted TURN credential, from a TURN server running inside the same process as everything else. The first softphone experiments date from that year too.
The move from plain HTML to React started inside that old cabinet, and a separate frontend developer joined us for it.
The rebuild
By 2021 we were close to a stable production version, and over 2021 and 2022 the product was built again. The old HTML and the backend under it were retired, and the new backend went up on a service skeleton I already maintained myself. It's Node.js 16 and Express again, with the live channels moved off socket.io onto plain WebSockets through express-ws. Sequelize 6 over mysql2 held both databases at once, Redis came in underneath through ioredis, and asterisk-manager kept the line to the switch. React, which had been growing beside the old pages, became the whole frontend.
Later a separate backend developer joined, so at its peak there were 3 developers on Cabinet24. I led that team. I reviewed what the other two built, and I wrote code on either side whenever a piece needed me. Most of the backend I maintained myself. I also did the SRE and DevOps side: I set up the Kubernetes cluster the service ran on, and I made sure everything stayed up.
Two databases
The hard part of the backend is that the telephony data doesn't live in the product. Asterisk, the phone switch, sits on the telecom core the company already ran, and I couldn't change how Asterisk stores its information. You don't rewrite that underneath a switch carrying live calls. The product still has to show those calls, with everything the product knows about them.
So one service talks to 2 databases at the same time: the product's own MySQL/MariaDB database and the telecom database. A database join can't cross 2 connections, so the code does the stitching. It reads the call rows from the telecom database, queries the product database in parallel for the clients, the call comments, the departments, the widgets, the extension bindings and the number replacements, and merges each row before the browser ever gets it. That's what makes a call list read as one table, and it's why a call row and its client card come back from the same request. The widget booking path works the same way.
Reports are heavy, and a live phone switch is a bad place for heavy reads. A read replica of the telecom database sits behind ProxySQL, which sends reads to the replica and writes to the primary, so a report across a year of calls never lands on the switch that's carrying them.
Then there's the cache, and actually there are 2 of them with 2 different jobs. The first is a transparent Redis read-through cache on the database models: after somebody reads a record, it stays there for 1 hour. The second is a "working set" per session. The service keeps the filters somebody set, the columns they chose and the page they stopped on, together with the last page the socket sent, keyed by a hash of those filters. While a person doesn't change what they're looking at, the socket answers from that page and doesn't ask either database at all. And when a live event arrives, it goes into that stored page.
flowchart LR
B[Browser request] --> W[Session working set]
W -->|filters unchanged| A[Answer to the browser]
W --> P[Product schema query]
W --> T[Telecom core query]
T --> R[Read replica behind a query-splitting proxy]
P --> S[Stitched in the service, no cross-database join]
T --> S
S --> C[Instance cache, 1 hour]
C --> AFrom a call event to a row on the screen
The phone switch posts each call event over HTTP, signed with a secret the two sides share. A wrong signature gets refused, and so does an unknown account. The service works out which company the call belongs to and goes from there.
The service runs as several copies, 3 in production, and a browser holds its socket to only one of them. So every event goes through Redis publish and subscribe, per company and for the call's employee. That's the only way an event reaches every open browser, whichever copy it sits on.
A live event and a page built a minute ago can disagree, so before an event changes anything, it's checked against every saved view and every open tab of the session: the number, the direction, the call type, the client. If it passes, the new row goes to the top of the stored page, the last row drops off, the total goes up by 1, and the page goes back down the socket.
Beside the calls there's a thinner line for presence. The service keeps an open connection to the Asterisk Manager Interface and follows the phones themselves, which is how one person's screen knows that somebody else's phone is busy.
flowchart LR
SW[Telephone switch] --> EV[Signed event to the service]
EV --> CO[Company lookup]
CO --> BUS[Message bus channel per company]
BUS --> SE[Open browser session with its filters]
SE --> PC[Session page cache updated in place]
PC --> ROW[Call row appears on the page]
MI[Switch management interface] --> PR[Extension presence]
PR --> SERecordings, SMS and the rest
Recordings come from object storage and get converted from WAV to MP3 with sox as they go to whoever pressed play. Nothing is copied onto a disk on the way. There's one catch: a browser won't let you drag through audio until it knows how long the file is, and a conversion still in flight can't tell it. So each playback gathers its converted copy in memory once, the response carries a length, and the listener gets a proper seek bar.
SMS goes out through SMPP sessions to the carriers, and that's an awkward thing to test, because every trial run is a real message landing on somebody's phone. So we tested the whole SMS path against a fake carrier standing in for a real one.
There's Google sign-in, LiqPay payments, email through nodemailer, XLSX exports, and logs with daily rotation. The API itself speaks 3 languages: English, Russian and Ukrainian.
The heavy work moved off the main loop onto worker threads. That approach came over from Cropman, the batch image editor I built in 2019, where worker threads did the editing for a whole set of images at once. At first the threads package did the threading here, and later it ran on my own @node-elion builder plugin.
One issue we did hit was the number of WebSockets. The cabinet opened one per table, and at first glance that's fine. It starts to be a problem once you open other sites in the same browser as well. When we counted how many sockets a new project would open at that pace, it was pretty clear this was no way of going. That count is the reason prpcow started, for the next project.
The service was also built so the next person could pick it up. Every route had its own documentation block, saying what it does, who may call it, what it takes and what it returns, and one command turned those blocks into a reference the service served itself. A settings template with every key named came with the code, and one command started the database and the cache a developer needs, so somebody new could just read a file. The frontend went on after I stopped work on it, and the developer who took it over shipped the rest through the same review process.
The cabinet people worked in
The frontend is React 17 with MUI, Tailwind, i18next, and JsSIP for the phone. We started on Ant Design and finished on MUI. The team and I built it together. My part was the foundations: the layout, the base table that every screen inherits, and the wiring that connects a screen to a live channel. I reviewed what went on top of them.
A day starts on a home dashboard of widgets the person drags into the shape they want, each widget fed by its own live channel, with the arrangement saved under their name on a responsive grid. Then there are calls, the "call from the site" widget, a mini CRM, tasks, contacts, SMS and analytics. Calls come in 3 kinds, and each kind splits into all, mine and missed. Clients have a card and a blocked list. Tasks are grouped by today, tomorrow and overdue. There's email, analytics by calls, by employees and by telephony, and a settings tree with the employees, departments, groups, roles, internal lines, message templates, the site widget, the blacklist and the cloud files. A floating softphone sits above every screen.
The calls screen runs on one socket that carries 9 views, 3 call kinds by 3 tabs, and each view has its own limit, offset and filters. A page change sends a new settings message down the open socket. The socket also arrives already knowing the person and the filters, so the first screen is already "pre-rendered" with all information needed. If a socket drops, it reconnects quietly, and the person sees a message about the lost connection only when the retries run out.
Scale is where the table earned its keep. At the start the product held about 100000 calls in total. A busy day brought up to a few thousand new ones, and by the end it held about a million, with accounts and comments grown to the same order. The shared table is virtualized, so only the rows in view exist on the page. That's what let about a million calls show without trouble, at a time when virtualized tables were only starting to appear. Each person keeps their own column settings on every table, which columns show, in what order and how wide, and they come back at the next sign-in. There's a global keyboard-shortcut engine that holds a stack of listeners, so the shortcut that answers belongs to whatever is open innermost at the time.
Drag and drop we built by hand, and in 2020 that was not an easy task by any means, especially for a task board that needs it everywhere. Most of the components were hand-built too, with a library only where one really fitted. Since this is a product the company sells, so we tried to make it the best we actually could.
Calling from the browser
In 2020 the cabinet could show calls and play them back, but placing a call still meant the IP phone on your desk. A desk phone isn't a subscription. You buy it once, connect it to the existing Ethernet or even a router, and it's yours. The real cost is time. Before a desk phone can talk to Asterisk and back, somebody has to know the exact router configuration in front of it and how many NATs sit before it, and then set up the routing both ways. For every phone, one by one. In 2021 we added in-browser softphone. A person could open it in the tab they were already working in, type a number and call, and answer incoming calls in the same place.
The audio is WebRTC. The signalling is SIP over a secure WebSocket, straight from the browser to the switch, so the voice never goes through the product at all. The phone account arrives over a live channel, and when the account changes, the page rebuilds the phone without anybody being told to reload. The phone works in lines: a new call looks for the first free line and gets its own session there, ending it ends only that line, and hold, unhold, mute and conference act on the active one.
It was tough, but we made it. Later this phone moved on to the next product, Express Soft, and Express Soft resold Cabinet24 telephony, the call-back widget and the mini CRM as paid add-ons.
Mobilauto used Cabinet24 in its own sales work while it sold software to taxi companies, and a lot of taxi services run on that software even now, across Ukraine and beyond. Cabinet24 went out to them gradually, to every service that agreed to take it on, and plenty of them did.
Stack
- Node.js
- Express
- React
- MUI
- Tailwind CSS
- i18next
- Sequelize
- MySQL
- Redis
- ProxySQL
- Asterisk
- JsSIP
- WebRTC
- SMPP
- WebSockets
- S3 storage
- sox
- LiqPay
- Kubernetes
Credits
- Cabinet24
- Wordmark
- Round 24 badge