In broadcast, nothing goes to air on one person's word. There's a producer, there's a technical director, there's someone in master control who actually pushes the button. Layers of sign-off aren't bureaucracy for its own sake — they're how you keep a bad decision from reaching the transmitter. So when I sat down to add a second approval layer to ATLAS, our trip ticket system, it felt less like a feature request and more like fixing something that should have been there from day one.
ATLAS is a small system I built for our team: someone requests a vehicle trip, a supervisor approves it, and the system generates a printable trip ticket PDF that the driver carries and the guard on duty signs off. It has a web dashboard, a NestJS API, a Postgres database, and a Telegram bot so people can create and approve trips from their phones. It runs on my homelab. And until this week, it had exactly one approval step.
The first surprise: the names were backwards
The request was simple on paper: after the current approver signs off, send it to a second person for final approval, and put his signature on the PDF. Before touching anything, I had Claude Code walk the codebase and map how approval actually worked. That's when the first surprise showed up.
In the code, the one existing approval step belonged to the department supervisor. On the printed ticket, that same person appeared as "Noted by." The person I was calling "the supervisor" in real life was the one whose name was printed under "Approved by" — hardcoded, as a fixed string in an environment variable, with no signature at all. And the "Noted by" signature? Also hardcoded. A single PNG of my signature, stamped on every ticket regardless of who actually did anything.
So the words "approver" and "supervisor" meant opposite things in my head and in the code. If we'd just started building, we'd have wired the new layer in the wrong direction. Getting that straight took one exchange — "the approver is the Noted by, the supervisor is the Approved by, the order is reversed" — and it saved a whole rebuild.
Two steps, in order, no skipping
The nice part was that the database already anticipated this. The approvals table had a sequence_no column and a unique constraint on trip plus sequence. Nobody had ever used sequence 2, but the slot was there. No migration needed.
The rules we settled on:
Every new trip gets two approval rows — step 1 for me (Noted by), step 2 for the final approver (Approved by).
Steps go strictly in order. If the final approver tries to act first, he gets a clear "still waiting for Rommell's approval" instead of a vague "not authorized."
The trip only becomes APPROVED after the last step. That matters because approval is what triggers the PDF to be generated and sent to the requester on Telegram. Send it after step 1 and you've handed out a ticket that isn't actually approved.
Old trips stay old. Anything created before the change only has one approval row, so it finishes the old way. No retroactive second signature on tickets that were already out in the world.
"It shows pending, but there's no button"
We deployed, I approved a test trip, and the report came back: on his side, the trip showed as pending — but there was no approve button anywhere.
This is the kind of bug that makes you doubt the whole backend. Is the query wrong? Is the role check wrong? It turned out to be neither. In ATLAS, the approve button lives on exactly one page, Approvals. The link to that page in the sidebar was wrapped in user.roles.includes('ADMIN'). I'm an admin, so I'd always seen it. He isn't, so he never had. The page itself worked fine — if he'd typed /approvals into the address bar, it was all there.
Twenty years of broadcast troubleshooting teaches you this over and over: the signal is often fine; somebody just unplugged the monitor. One line changed so supervisors and approvers see the link too, and while we were there, the trip list's status badge got fixed to show whoever the trip is currently waiting on, instead of always showing step 1.
Telegram, and the problem with logging in
Next ask: send him Approve and Reject buttons on Telegram. The bot already did this for step 1 — but only for people who had run /link, a one-time command where you type your ATLAS password into the bot so it can act as you. The bot stores a login token, not the password.
The pushback was fair: "we need to log in every now and then" — can't we just hardcode his Telegram ID?
Hardcoding the ID to send him a message is trivial. The hard part is what happens when he taps ✅. That tap has to approve the trip as him, with his name in the audit log, and the bot needs some way to prove who he is to the API. So we laid out three options: keep /link and make the token last longer, send a button that just opens the web page, or make the Telegram ID itself trusted. We went with the third, done carefully:
The API keeps a small mapping of Telegram ID → employee number in its own config. The bot gets a shared service key. When someone taps a button, the bot forwards the Telegram user ID that Telegram itself reports — users can't fake that — along with the key. The API checks both, then runs the exact same approve logic as the web, as that employee. Every ordering rule still applies; the key alone can't approve as anyone who isn't on the list.
The "needs your approval" message itself goes through n8n, the same way approved PDFs already did: the API calls a webhook, n8n sends a Telegram message with the two buttons. And here's where an old scar paid off. A week earlier, a different n8n workflow had quietly reused this same bot's token and knocked the bot offline for three days with 409: Conflict errors, because Telegram only allows one listener per token. So the new workflow is send-only: no Telegram trigger, just a sender. Taps still go to the bot, which is the only thing listening.
One small gotcha worth knowing if you build this yourself: a Telegram bot can't message someone who has never pressed Start on it. You can have the right ID and a perfect workflow, and the message will still bounce until the person taps Start once.
Signatures that belong to people, not config files
The last piece was the one that started it all: signatures. The first pass stamped the final approver's signature from a fixed file path, same pattern as my old hardcoded one. The feedback was immediate — not hardcoded. Both of our signatures should come from our accounts, so that when a trip ticket is complete, it's already signed by the people who actually approved it.
So now an admin uploads each person's signature from the Employees page. The file is saved under that employee's ID. The upload checks the file's actual bytes — a PNG has to start with the PNG signature — rather than trusting the file extension. On the PDF, "Noted by" and "Approved by" now print the actual approver of each step: their name, their department, the date they approved, and their signature. Each signature is stamped only after that step is really approved. No approval, no signature. Reject it, and it stays blank.
For my own signature, we just reused the PNG that had been hardcoded since August — copied into my employee slot, byte-for-byte identical.
What I'd tell someone doing this
Map the terms before the code. The biggest risk in this whole project wasn't technical. It was two people using "supervisor" to mean different jobs.
Hardcoding is a debt you notice at the worst time. Every fixed name and signature file was fine until the org chart moved.
Security shortcuts can be made honest. "Just trust the Telegram ID" sounds scary, but with the mapping held server-side, a service key, and the same business rules on every path, it's a reasoned tradeoff instead of a hole — and I wrote the risks down so it stays a decision, not an accident.
Commit history should tell the truth. When it was time to commit, there was older unfinished work mixed into the same files. We split it into its own commit first, so the history shows what came from where.
In broadcast we say the show isn't done until the logs are clean. Same here: two approvals, two real signatures, and a trip ticket that means what it says.
Related reading
- Teaching a Telegram Bot to File Trip Tickets
- Teaching Our Trip-Ticket Bot to Listen: Six Walls We Hit Along the Way