Booking a doctor from your insurance account
Booking a doctor on a corporate medical policy meant messaging a Telegram bot. I moved that into the cabinet — a five-step form that reaches the operators' group in seconds. The interesting part was deciding how little the browser is allowed to say.
If you had corporate medical insurance and needed to see a doctor, the process was: open Telegram, find the right bot, and answer its questions one message at a time. The bot passed your answers to the company's CRM, and the CRM announced the request in the operators' group. Someone called you back.
It worked. It was also the only part of your insurance that lived outside your account — and it asked you for things the company already knew about you.
The job was to move the collecting step into the cabinet. Not the whole chain: only the part that asks the questions.
Knowing what not to build
The first useful decision was refusing scope.
The chain after the CRM already worked: it numbers each request, posts it to the operators' group, tracks who picked it up. Rebuilding any of that would have meant two systems doing the same job and disagreeing within a week.
So the notification isn't ours. We don't hold a bot token, we don't know the group id, we never call Telegram. We hand the request to the CRM exactly as the bot did, and everything downstream carries on unchanged. The message that appears in the operators' group is the same message it always was.
What replaced the bot is one endpoint and one form.
Six fields, and not one more
The form asks for region, clinic, specialist, what's wrong, and when. Five answers, six fields on the wire once the date and time separate.
Everything else on the request — full name, phone number, national ID, date of birth, age, the policy number — is filled in by the server from the profile the person already verified through the national identity service. The browser doesn't send them, and if it did, they'd be ignored: the request model has no such fields, so anything arriving under those names is dropped before the code runs.
That's not tidiness. Those fields decide whose appointment gets created. If the browser could set them, a valid session would be enough to book a doctor in someone else's name against someone else's policy.
The one exception is the policy number, and it's exception-shaped: the page may send it, and the server compares it with the card it resolved for the caller. Match, or the request is refused. It can never redirect the appointment — it only catches a stale tab whose card has changed since it loaded.
Two strings instead of one timestamp
The form has a single date-and-time picker, because a person picks one moment. On the wire it splits into a date and a clock time as separate strings.
That looks like extra work and it prevents a specific, nasty class of bug. A combined timestamp invites something in the chain to parse and re-serialise it, and the moment it does, 15:00 becomes 10:00 UTC and an operator calls at the wrong hour about an appointment nobody can attend. Two plain strings have no timezone to lose.
Inside the app the value stays a single moment, and the split happens at the last possible step — one decision for the user, two fields for the wire.
Suggestions that know which doctor you picked
There is no list of clinics anywhere in the company, so clinic, specialist and complaint are free text. Free text with nothing around it is a bad form, though, so each field offers suggestion chips that fill it in.
The complaints are filtered by the specialist you chose. Pick a dentist and you're offered toothache, injury, routine check-up. Pick a cardiologist and toothache disappears. It's keyed on stable identifiers rather than the visible labels, so translating a chip can't break the mapping, and "Other" stays available everywhere — including when someone types a specialist by hand, where the filter deliberately stops narrowing anything.
The wizard also survives a reload. Answers are kept for the tab and restored on the way back, tagged with the account they belong to so a different person's leftovers are discarded rather than restored.
Keeping a copy of something we don't own
The CRM owns the appointment: it assigns the number, tracks the status, decides who handles it. But the cabinet needs to show you what you booked, and the CRM has no endpoint to read it back.
So after a successful submit — and only then — we store a copy on our side. It carries the number the CRM returned, which is the number the operator will quote, and it's tied to the card rather than the account that submitted it. One card covers a family, so a request made on behalf of a relative still belongs on that card.
If saving that copy fails, the request still succeeds. The appointment exists in the CRM either way, and refusing to confirm it because our own bookkeeping stumbled would be the wrong trade. The failure goes to the log instead.
The failure that couldn't explain itself
Then a request was rejected, and the screen said "an unexpected error occurred".
That message is technically true and completely useless. Worse, the log was no better: it recorded the CRM's reply but not what we had sent, so reproducing it meant guessing at the form.
The CRM had actually given a clear reason. We were discarding it. Now, when the CRM refuses, its own wording is what the person sees — and the log records both halves, our request beside their answer, with the national ID and phone number masked and the name left out. A log shouldn't need someone's identity to debug a form field.
Then a second layer of the same problem. The CRM sometimes answers "this person already has a request open", which is a normal outcome, not a fault — the page should show the existing number calmly. The code did exactly that, and the person still saw a server error.
A shared response helper filters status codes against a list of allowed ones, and the code for "conflict" wasn't on it. Anything unrecognised became "internal server error". Five other flows were already suffering from it: registering with an existing number, a mismatched identity check — all reported to users as if the server had crashed. One missing entry in one list.
Deciding what counts as success
The last rule is the one worth stating plainly: the request is only successful if the CRM says so and hands back a number.
Not if the call returned quickly. Not if the response looked shaped correctly. If the CRM doesn't confirm, the person is told it failed — because the alternative is a confirmation screen for an appointment nobody is going to keep. And the call is never retried automatically, since a retry after a timeout is how you get two doctors expecting the same patient.
Why I'm happy with it
The person books from the account where their policy already lives, using the five things only they know, and the operators get the same message in the same group they've always watched.
The best measure of the work is how little of the existing chain had to change: one form, one endpoint, one row for our own records. Everything the company already had, we left alone.