<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Asif Alam</title>
    <description>The latest articles on DEV Community by Asif Alam (@asifalam).</description>
    <link>https://dev.to/asifalam</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4222649%2F9af599d8-6d2d-465b-a25f-ed0696b0ec88.png</url>
      <title>DEV Community: Asif Alam</title>
      <link>https://dev.to/asifalam</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/asifalam"/>
    <language>en</language>
    <item>
      <title>Calls that ring when the app is closed</title>
      <dc:creator>Asif Alam</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/asifalam/calls-that-ring-when-the-app-is-closed-1444</link>
      <guid>https://dev.to/asifalam/calls-that-ring-when-the-app-is-closed-1444</guid>
      <description>&lt;p&gt;Fleet is Lebanon's car marketplace. A buyer finds a car, and the obvious next step is to call the dealer. The obvious way to make that happen is to print the dealer's number on the listing. I didn't want that, for two reasons.&lt;/p&gt;

&lt;p&gt;The first is privacy, in both directions. A buyer asking about one car shouldn't have to hand a stranger their number for good, and a dealer shouldn't have to publish theirs to be reachable. The second is that a call that starts inside Fleet stays inside Fleet: it belongs to a conversation, and the admin dashboard can see that it happened.&lt;/p&gt;

&lt;p&gt;So the goal was an in-app voice call. Not a chat with a microphone button: a call that rings. Full screen, answer and decline, on a phone that's locked in a pocket with Fleet nowhere near the foreground.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ringing is the hard part
&lt;/h2&gt;

&lt;p&gt;Getting two open apps to talk is the easy half. LiveKit carries the audio: every call gets a room, each side gets a token to join it, and neither phone ever dials a number.&lt;/p&gt;

&lt;p&gt;The hard half is the phone that isn't listening. When Fleet is closed, nothing of it is running. No socket, no subscription, nothing to receive a message. The only way in is the operating system's own call machinery, and each platform has its own door.&lt;/p&gt;

&lt;p&gt;On iPhone the door is a VoIP push, which the app hands to CallKit, so it gets the same incoming-call screen as an ordinary phone call. Apple is strict about that bargain: a VoIP push is for a call, and the app is expected to show one every time it gets one. On Android it's a data message through Firebase Cloud Messaging, which wakes the app just long enough to put up a full-screen call.&lt;/p&gt;

&lt;p&gt;Under both sits &lt;code&gt;expo-callkit-telecom&lt;/code&gt;, a native module built for exactly this. The rest of the work is making sure the push, the ring, the room and the database all agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  One function owns the call
&lt;/h2&gt;

&lt;p&gt;A call has a handful of states and two phones that each think they know which one it's in. The fastest way to get a phone that rings forever is to let both of them write it.&lt;/p&gt;

&lt;p&gt;So neither of them does. One Supabase Edge Function, &lt;code&gt;calls&lt;/code&gt;, is the only thing allowed to change a call's state. To start a call, the app asks it. The function writes the call row, mints a LiveKit token, and sends the push that wakes the other phone. Answering, declining and hanging up all go through the same function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// who is on the other end is decided here, never sent by the app&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;calleeId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;resolveCallee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;calleeId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;insertCall&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;callerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;calleeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ringing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;roomToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;wake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calleeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// VoIP push on iPhone, data message on Android&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;A sketch of the shape, not Fleet’s code: every change to a call goes through one place&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both apps watch their call's row over Supabase Realtime, and that's the signalling: when the row changes, each side reacts. The app still has a state machine of its own for the screen (with its own tests), but it follows the row. It never argues with it.&lt;/p&gt;

&lt;p&gt;The push code lives in one shared module. Apple and Google each want a signed token before they'll take a push (ES256 for one, RS256 for the other), and signing a fresh one for every call is wasted work, so the function keeps them for 50 minutes, just inside the hour they're good for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers that never leave the server
&lt;/h2&gt;

&lt;p&gt;Privacy isn't a screen you hide the number behind. If the app knows a number, so does anyone curious enough to read the app's network traffic. So the app never asks for one.&lt;/p&gt;

&lt;p&gt;When you tap call, the app sends the conversation you're in: not a person, and certainly not a phone number. The server works out who is on the other side from the conversation itself. The callee is a decision the server makes, not a parameter the client sends, which also means there's no field a modified app could fill in with somebody else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;conversation_participants&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- the caller&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Simplified: the callee is whoever else is in the conversation&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the ringing stop
&lt;/h2&gt;

&lt;p&gt;Here's a bug you only get with native calls. A buyer calls, thinks better of it and hangs up after two rings. Their side is done. But the dealer's phone is ringing through the operating system, not through Fleet, and nobody told the operating system.&lt;/p&gt;

&lt;p&gt;Nothing of Fleet is running on that phone to tell it, either. The calling library could start a ring from a push, but it had no way to stop one. So I patched it: a &lt;code&gt;callEnded&lt;/code&gt; push that the native side handles by ending the ringing call. When the caller hangs up, the server sends it, and the other phone goes quiet.&lt;/p&gt;

&lt;p&gt;Patching a dependency is usually a smell. Here the alternative was a phone ringing for a call that no longer existed, which is worse than a smell. The patch is a small file in the repo, tied to the library's exact version, so an upgrade has to face it on purpose.&lt;/p&gt;

&lt;p&gt;It wasn't the only fix of its kind. The history around calls reads like a list of the ways a phone call can go wrong: answering on iPhone before the app had joined the room, no audio when a call woke an app that had been killed, rings that outlived their calls. Every one of them only shows up on a real phone, in a real state.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a phone just disappears
&lt;/h2&gt;

&lt;p&gt;Either phone can vanish mid-call. An app crashes. A battery dies. Someone drives into a tunnel. Nobody sends a polite "end call" from a phone that's switched off.&lt;/p&gt;

&lt;p&gt;LiveKit notices, though: it sees people leave the room. Its webhook lands on a second function that closes the room behind them, so a call doesn't stay live forever because one side never said goodbye. Behind that there's a sweep, the four-hour safety net for anything the webhook missed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Stale state in a call system doesn't sit quietly in a table. It rings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;p&gt;Pick one writer. Every call bug I could think of started with two things that both believed they were in charge of the same call.&lt;/p&gt;

&lt;p&gt;Let the server decide who. Anything the client sends is a suggestion, and a phone number is a suggestion you can't take back.&lt;/p&gt;

&lt;p&gt;Design for the phone that's gone. Crashes, dead batteries and killed apps aren't the edge of the problem; for calls, they're the middle of it.&lt;/p&gt;

&lt;p&gt;Own the native layer. If a library stands between you and the right behaviour, a small patch pinned to its version beats a workaround in JavaScript that pretends the problem is somewhere else.&lt;/p&gt;

&lt;p&gt;Test the logic away from the phone. The call function's logic and the app's state machine both have tests that run without a device, which leaves the device for the things only a device can tell you.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It's a push, a room, a row and a function. The work is in getting all four to agree on one question, all the time: should a phone be ringing right now?&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/callkit" rel="noopener noreferrer"&gt;Apple: CallKit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/pushkit/responding-to-voip-notifications-from-pushkit" rel="noopener noreferrer"&gt;Apple: Responding to VoIP notifications from PushKit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.android.com/develop/ui/views/notifications/time-sensitive" rel="noopener noreferrer"&gt;Android: Display time-sensitive notifications (full-screen intents)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://firebase.google.com/docs/cloud-messaging/android/receive-messages" rel="noopener noreferrer"&gt;Firebase Cloud Messaging: Receive messages on Android&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.livekit.io/frontends/authentication/tokens/" rel="noopener noreferrer"&gt;LiveKit: Access tokens and grants&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>supabase</category>
      <category>webdev</category>
    </item>
    <item>
      <title>WhatsApp as a control panel</title>
      <dc:creator>Asif Alam</dc:creator>
      <pubDate>Tue, 27 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/asifalam/whatsapp-as-a-control-panel-3h05</link>
      <guid>https://dev.to/asifalam/whatsapp-as-a-control-panel-3h05</guid>
      <description>&lt;p&gt;A booking request is only useful if somebody sees it in time. Plate gives venues a web dashboard and a tablet app, and both do their job. But on a busy night, the person who decides whether a party of six fits at nine is usually on the floor, phone in pocket, with WhatsApp open.&lt;/p&gt;

&lt;p&gt;So I took the decision to where they already were. When a booking needs an answer, the venue gets a WhatsApp message with buttons: accept, decline, or move it to another time. One tap, and the booking changes in the same database the dashboard reads.&lt;/p&gt;

&lt;p&gt;That sounds like a nice shortcut. It's actually a second control panel, sitting in somebody else's app, that I don't control. Most of the work was in treating it that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every tap is untrusted input
&lt;/h2&gt;

&lt;p&gt;A WhatsApp button isn't a form on your own page. The message can sit unread for an hour. It can be tapped twice. By the time anyone taps it, the booking might already have been accepted from the dashboard, moved, or cancelled by the guest.&lt;/p&gt;

&lt;p&gt;Each button carries a small payload that comes back to an Edge Function when it's tapped: &lt;code&gt;accept:&lt;/code&gt; and the booking's ID, or for a move, the booking, a time and an offset. That string is the whole request, and it deserves the same suspicion as anything a stranger types into a form, even though I wrote it.&lt;/p&gt;

&lt;p&gt;So the payloads are parsed by small, pure functions: a string goes in, and either a typed action or nothing comes out. No database, no network, no WhatsApp, which makes them easy to test properly. A malformed ID, an action that doesn't exist or an offset that isn't on the list is a one-line test case, not a production incident.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;bookingId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;move&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;bookingId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bookingId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isUuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bookingId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bookingId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;move&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;OFFSETS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bookingId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="c1"&gt;// anything else is noise, and does nothing&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;A sketch of the idea, not Plate’s code&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Ten rows
&lt;/h2&gt;

&lt;p&gt;Moving a booking needs a new time, and WhatsApp doesn't do time pickers. What it does have is the list message: tap a button, get a list, pick a row. A list holds at most ten rows.&lt;/p&gt;

&lt;p&gt;So the choice of new times is ten offsets from the booking's time, all within 90 minutes either side. There's a floor, too: a moved booking has to be at least 15 minutes away, because a new time that has nearly passed by the time anyone reads it isn't much of a new time.&lt;/p&gt;

&lt;p&gt;It's a constraint I've come to like. The platform decides the shape, and the feature is better for it: nobody on a restaurant floor wants to scroll through every quarter-hour of the night. Ten sensible choices beat a time picker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database says no again
&lt;/h2&gt;

&lt;p&gt;Here's the part that matters most. The parser makes sure a payload is well formed. It can't know whether the move still makes sense.&lt;/p&gt;

&lt;p&gt;Picture it. Early in the evening, a venue gets a message about a booking. Nobody taps it. Later, someone handles the same booking from the dashboard. Hours after that, someone else finds the old message and taps one of its move options.&lt;/p&gt;

&lt;p&gt;The parser is happy: the payload is perfectly valid. It's just old. So the move doesn't stop at the parser. The database enforces the same ±90-minute window again, on its own, at the moment the change is made. It doesn't matter what the edge function already checked. A stale message can't do what the dashboard wouldn't.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two front doors, one rulebook. The button is a convenience; the database is the authority.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Checking twice isn't redundancy for its own sake. The parser's job is to turn garbage away early and cheaply. The database's job is to be right at the moment the booking changes. Only one of them knows what the booking looks like right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Realtime that doesn't die quietly
&lt;/h2&gt;

&lt;p&gt;WhatsApp is one way a venue hears about a booking. The other is the dashboard and the tablet app, which listen for new bookings over Supabase Realtime. And a realtime connection has a nasty way of failing: it can stop without saying so.&lt;/p&gt;

&lt;p&gt;A tablet sleeps. The Wi-Fi drops for ten seconds. A socket closes, and nothing opens it again. The screen still shows the last thing it knew, and looks perfectly healthy. A venue in that state isn't receiving bookings, and nobody there knows it. That's money walking out of the door, quietly.&lt;/p&gt;

&lt;p&gt;So the subscriptions aren't fire-and-forget. They reconnect and recover on their own, and they report when they drop, so a dead connection is something I can see rather than something a venue finds out about from an empty evening.&lt;/p&gt;

&lt;p&gt;The tablet app goes further. It's built to stay listening: it keeps running in the foreground, sends a heartbeat every 30 seconds, and when a new booking lands it sounds an alarm that keeps going until someone acts on it. Not a polite notification that fades away. An alarm.&lt;/p&gt;

&lt;p&gt;It's the least elegant thing in the whole system, and it's exactly as loud as it needs to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  What carries over
&lt;/h2&gt;

&lt;p&gt;Meet people where they work. For restaurant staff that's WhatsApp, so part of the control panel lives there. A dashboard nobody opens on a busy night doesn't help anyone.&lt;/p&gt;

&lt;p&gt;Treat every payload that comes back to you as untrusted, including the ones you generated. Those are the ones you'll forget to check.&lt;/p&gt;

&lt;p&gt;Keep parsers pure. String in, decision out, nothing else. And a test for every way the string can be wrong.&lt;/p&gt;

&lt;p&gt;Let the platform's limits shape the feature. Ten rows was a constraint, and it made a better menu.&lt;/p&gt;

&lt;p&gt;Enforce the rule where the data lives. The window is checked in the database, so it doesn't matter which door a request came through, or how long it took to get there.&lt;/p&gt;

&lt;p&gt;Make silence loud. A connection that fails quietly is worse than one that fails at the top of its voice.&lt;/p&gt;

&lt;p&gt;A control panel doesn't have to be a dashboard. Sometimes it's three buttons in a chat, as long as there's a database behind them that knows when to say no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-list-messages" rel="noopener noreferrer"&gt;WhatsApp Cloud API: Interactive list messages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-reply-buttons-messages" rel="noopener noreferrer"&gt;WhatsApp Cloud API: Interactive reply buttons messages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/realtime" rel="noopener noreferrer"&gt;Supabase: Realtime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/functions" rel="noopener noreferrer"&gt;Supabase: Edge Functions&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>whatsapp</category>
      <category>supabase</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stopping double bookings in the database, not the app</title>
      <dc:creator>Asif Alam</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/asifalam/stopping-double-bookings-in-the-database-not-the-app-45p4</link>
      <guid>https://dev.to/asifalam/stopping-double-bookings-in-the-database-not-the-app-45p4</guid>
      <description>&lt;p&gt;There's one bug a restaurant doesn't forgive: two parties, one table, eight o'clock. The host improvises, somebody waits at the bar, and the booking app gets the blame. Fairly.&lt;/p&gt;

&lt;p&gt;Plate is a restaurant booking platform for Lebanon. Guests book from the iOS and Android app, or from a widget on a venue's own site. Venues run their night from a web dashboard and a tablet app, and staff can act on bookings straight from WhatsApp. That's a lot of front doors into the same tables. This is about where the rule that stops a double booking lives, and why it isn't behind any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app is a suggestion
&lt;/h2&gt;

&lt;p&gt;The guest app works out availability itself (it has to, to show times the moment you open a restaurant), and the code that does it is about two thousand lines long. It's good at its job: showing a guest times that will probably work, quickly.&lt;/p&gt;

&lt;p&gt;"Probably" is the important word. Between the moment the app shows a time and the moment the guest taps it, someone else can book that table. The guest might be on an older version of the app, with older rules in it. And the widget, the dashboard and the WhatsApp flows each have their own idea of what's free.&lt;/p&gt;

&lt;p&gt;None of that is a bug. Clients are allowed to be optimistic. They're just not allowed to decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  One function, one transaction
&lt;/h2&gt;

&lt;p&gt;So the decision lives in the database, in one Postgres function: &lt;code&gt;create_booking_with_tables&lt;/code&gt;. The guest app and the widget don't insert a booking and then attach tables to it in a second request. They call this function, and it creates the booking and its tables together, or not at all.&lt;/p&gt;

&lt;p&gt;It's a security-definer function, so it runs with its own privileges rather than the caller's. The function decides what gets written, not the app that called it. And before it writes anything, it checks the gap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;booking_tables&lt;/span&gt; &lt;span class="n"&gt;bt&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;booking_id&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;bt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_table_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'confirmed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'arrived'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'seated'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p_start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ends_at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt; &lt;span class="c1"&gt;-- both directions,&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;starts_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;p_ends_at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt; &lt;span class="c1"&gt;-- turn time included&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'Table not available'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- illustrative text&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;A sketch of the idea, not Plate’s schema: refuse if a requested table is held too close to the new booking&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap rule, fixed
&lt;/h2&gt;

&lt;p&gt;The rule is easy to say: a table needs room between two bookings. Getting it exactly right took a migration of its own. The check was a 60-minute proximity rule, and when I went through it line by line, it was wrong in four quiet ways.&lt;/p&gt;

&lt;p&gt;It only looked one way. A booking on one side of an existing one was caught; its mirror image wasn't. The fixed check is symmetric.&lt;/p&gt;

&lt;p&gt;It didn't account for turn time: how long a party actually holds the table once they sit down. Now it does.&lt;/p&gt;

&lt;p&gt;It forgot the people already there. Bookings marked arrived or seated didn't count as holding their table, which is exactly when a table is most definitely held. Now they count.&lt;/p&gt;

&lt;p&gt;And it did its time arithmetic in whatever timezone the database session happened to be in. The function now pins its timezone, so eight o'clock means the same thing to every caller.&lt;/p&gt;

&lt;p&gt;None of these would show up in a demo. All of them would on a busy Saturday night.&lt;/p&gt;

&lt;h2&gt;
  
  
  The error message is an API
&lt;/h2&gt;

&lt;p&gt;Here's the part I didn't expect. When the function refuses a booking, it raises an error, and the guest app recognises that error and shows the guest something useful instead of a failure. The way the app recognises it is by matching the text of the message.&lt;/p&gt;

&lt;p&gt;Matching on message text isn't how I'd design it today. But it's how the versions already installed on people's phones work, and those don't change because I merged something. Store releases take time, and not everyone updates the day a new version ships.&lt;/p&gt;

&lt;p&gt;So when I rewrote the check, the error text stayed byte-for-byte identical. Same words, same punctuation, same capitalisation. The logic underneath changed completely; the string the old apps were waiting for didn't change at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;create_booking_with_tables&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/not available/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// change one word on the server and this silently stops matching&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;showPickAnotherTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Illustrative: an app version that is already on someone’s phone&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Once an app is in someone's pocket, every string it reads is part of your API, whether you meant it to be or not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If I were starting fresh, the error would carry a stable code and the app would match on that. But you don't get to start fresh with software people already have. You get to be careful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving a booking without breaking another
&lt;/h2&gt;

&lt;p&gt;Creating a booking is one door. Changing one is another, and it's the one people forget.&lt;/p&gt;

&lt;p&gt;Venues can accept a booking with changes, usually a different time. That goes through its own function, &lt;code&gt;accept_booking_with_modification&lt;/code&gt;, and the first thing it does at the new time is check the tables again. A table that was free at eight might not be free at half past.&lt;/p&gt;

&lt;p&gt;What happens next depends on whose side the change is on. Staff who try a move that would collide are refused, and pick something else. On the guest's side, the conflicting tables are dropped from the booking instead and it's flagged, so the venue sees a booking that needs a table rather than two parties heading for the same one.&lt;/p&gt;

&lt;p&gt;Whatever path a change takes, it meets the same rule at the bottom. That's the whole idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;Put the rule where every path has to pass. For bookings that's the database. Clients can be fast and hopeful; the database is where the answer is final.&lt;/p&gt;

&lt;p&gt;Write it once. One function that creates the booking and its tables together means there is no half-made booking to clean up afterwards.&lt;/p&gt;

&lt;p&gt;Read old rules like a stranger. The gap check had four mistakes in it, and every one of them was quiet.&lt;/p&gt;

&lt;p&gt;Remember the phones you've already shipped. A message, a field, a status: if an installed app reads it, it's a contract.&lt;/p&gt;

&lt;p&gt;Plate has seated more than 115,000 guests. I'd like every one of them to have had a table of their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-createfunction.html" rel="noopener noreferrer"&gt;PostgreSQL: CREATE FUNCTION (SECURITY DEFINER)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/tutorial-transactions.html" rel="noopener noreferrer"&gt;PostgreSQL: Transactions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/database/functions" rel="noopener noreferrer"&gt;Supabase: Database functions&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>database</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
