A phone system with a CRM for a multi-site clinic
A clinic with several sites has one problem that looks small until you run into it: the patient does not know and does not care which site they are calling. They are calling the clinic. Everything after that is your job.
This is the story of the phone platform we built for a dental practice with three locations in Sofia, from the brief to the decisions that turned out to matter. The screens shown are mock-ups on sample data; the architecture and the numbers about the system itself are real.
The brief
Three receptions, three published mobile numbers, no shared history. A patient who rang one site and then another was a stranger the second time. Missed calls were missed; nobody knew how many.
The clinic wanted one number that behaves like one clinic: call history per patient rather than per handset, callback requests as a list somebody owns, and a live view of what is happening on the lines. Two constraints shaped everything else. First, the system had to be self-hosted: a recording of a patient explaining why they are calling is health data, and the clinic did not want it sitting with a rented service. Second, no new desk phones. The receptions answer on the staff mobiles they already carry, and on a computer if that is where they are.
What happens to a call
The exchange first checks whether the clinic is open. Each location has its own hours and holidays, and they differ more than you would guess: one site is closed on Mondays, another has no Saturday hours. The published line follows the widest schedule. Out of hours, the caller hears a recorded message and can leave voicemail; the recording is e-mailed to the clinic as an audio file, so the morning starts with a list rather than with guesswork.
In working hours, every reception rings at once. Each one rings on two legs in parallel: the browser softphone on the reception computer and the staff mobile, through the telecom's line. The first to answer takes the call and the other legs stop. If every line is busy, the caller waits with music, the system tries again every ten seconds, and only after three minutes of nobody being free does it fall back to voicemail. Nobody gets a busy tone.
The menu we built and switched off
The first design had a voice menu: press 2 for an emergency, 3 for orthodontics, otherwise wait for reception. It exists, it is tested, and it is disabled by default.
The reason is how the clinic actually works. The receptions answer on mobiles they already have in their pockets; the fastest route from ring to human beat any menu we could design. The emergency path collapsed into the same behaviour: every call already rings everyone at once, which is what an emergency path is for, and no patient in pain has to find the right digit first. The menu stays in the code for the day volumes justify it. Building it was not wasted; shipping it would have been.
What the operator sees
When a call comes in, a screen pop shows the number, which reception it is heading for, how many times this number has called before and when, and, once the clinic's patient software is connected, the patient's name with a link straight into their record. The previous-calls count is worked out from the last nine digits of the number, so a patient calling from the same phone with a different prefix is still the same patient.
The live board shows the state of every line the moment it changes: ringing, on a call, free, and how many of the telecom's channels are in use. That runs over WebSockets, not on a refresh timer. An operator who can see that a colleague at another site is free transfers the call; one who cannot leaves the patient waiting. Real time is not showmanship, it is which decisions are available at all.
Callback requests are a list with a status, not a note on paper. Every call is recorded once it is answered, and the recording plays in the browser from the call history. The same history feeds a small set of numbers: missed calls per location, average time to pick up, the busiest hours.
Self-hosted, in the cloud
"Self-hosted" usually conjures a server in a cupboard. This one is a virtual server the clinic controls, in a data centre, with a small gateway router in the clinic and an encrypted tunnel between the two.
The telecom delivers the phone line as a network cable. That cable goes into the gateway, not into the clinic's switch: the provider's addresses would collide with the reception computers and take the network down. From the gateway, a WireGuard tunnel carries the phone traffic to the server as if it were local. On the server, Docker runs four things: the exchange itself (Asterisk), a Node.js backend that holds the routing rules, hours and integrations, PostgreSQL for history and configuration, and the React dashboard behind Nginx with TLS.
A few decisions in that box are worth naming. Every phone and the telecom line are held to a single audio codec, so the exchange passes audio through without transcoding it; that keeps a small server quiet and the voice quality high. The login needs a one-time code from an authenticator app, which for a system holding patient call history is not a luxury. Every night, a script dumps the database and archives the recordings folder into one file and keeps seven days of them. Every five minutes, another script checks that the exchange is running, that the database answers and that there is disk left, and sends a Telegram message if any of that fails.
Integrating with a system nobody had named yet
When we started, the clinic's patient-management software was known to exist and not yet known by name. So the integration layer was built provider-neutral: on every ring, the backend asks a configurable address who the number belongs to, waits at most three seconds, and swallows any error. A slow or absent system never delays a ringing call. When the patient software exposes a lookup, one address goes into the configuration and the screen pop starts showing names; no code changes.
In the other direction, every call event (ringing, answered, completed) is posted to any address the clinic configures, signed so the receiving system can verify it came from the exchange. That is how a practice-management system, a dashboard or an automation tool can react to calls without anyone writing a bespoke connector.
Testing a phone system without a phone
Phone systems are hard to test because the interesting behaviour is timing: who rings when, what happens at fifteen seconds, what happens when the fifth line is taken. The backend carries 26 automated tests and the dashboard 16, and they cover exactly the behaviour the clinic asked for: three receptions ringing simultaneously, the caller's number preserved when a call is forwarded to a mobile, a caller waiting in the queue rather than hitting voicemail when the lines are full, the queue expiring after its maximum wait, and the sequential fallback for groups configured that way. Zero uses of any in the TypeScript, which is a rule rather than a boast: a phone system is a poor place to discover a type error.
Status
Built and tested end to end, and not yet live in front of patients. Going live waits on two decisions and one dependency that sit outside the code: which of the three existing numbers becomes the one published number, the telecom contract for the line (how many simultaneous channels, which sets how many callers can be ringing mobiles at once), and the patient software exposing its lookup endpoint. We say so plainly, because the gap between built and in use is exactly what usually gets papered over.
What we carry forward
Three things. Call routing has to reflect how the organisation actually answers, not how a diagram says it should; the ring-all decision came from watching receptions, not from a requirements document. Real time is not decoration. And an integration layer that assumes nothing about the other system is the one that ships, because the other system is never ready when you are.
Running several sites with telephony stretched awkwardly between them? Tell us how it is set up today.