16 KiB
Remote Tab Control Notes
Goal
Allow a client to view open tabs from home and request that a tab be closed remotely.
The first version does not need realtime push communication. The bar app syncs on startup and on a periodic timer while running, applies any pending commands, and continues normally when there is nothing to do.
Current App Situation
The app is currently local-first:
- Open tabs and products are stored in the local Drift database.
BarTabService.closeTab()performs the close operation locally.- Closing a tab archives its current purchase events into history and clears its active items.
BarScreenViewModelcalls the local service when the bartender closes a tab.ExportServiceonly uploads closed-history snapshots.- The current export URL is a placeholder in
lib/utils/app_config.dart. - There is currently no authenticated server sync or remote command handling.
The existing export service should not be extended into a command system without changing its responsibilities. Export is one-way backup data; remote control needs authenticated, acknowledged commands.
Recommended First Version
Use a server-side command queue and synchronize on startup, on a periodic timer, and on manual request.
Client dashboard
POST a close-tab command
|
v
Server stores command as pending
|
v
Bar app syncs (startup, timer, or manual)
|
v
Bar app receives pending command
|
v
Bar app executes local close transaction
|
v
Bar app notifies the bartender on screen
|
v
Bar app acknowledges success, failure, or conflict
If the server has no commands, the response should be empty and the app should do nothing.
Sync Frequency
Startup-only sync is not sufficient on its own. The bar app typically runs for an entire shift without restarting, so a command issued at 21:00 might not apply until the next day. Remote control needs to take effect within a shift.
Three triggers, all using the same sync code path:
- Startup sync. Runs once after database initialization. Catches anything queued while the app was closed.
- Periodic sync. A repeating timer while the app is running. A 60 to 120 second interval is a reasonable starting point. This is still polling, not realtime, and needs no websocket or push infrastructure.
- Manual sync. A
Sync nowaction in Admin or Settings. Useful for support, for testing, and for a client who is on the phone with the bar and wants the command applied immediately.
Timer rules:
- Skip the tick if a sync is already in flight. Never run two syncs concurrently.
- Pause the timer when the app is backgrounded and run one sync immediately on resume.
- Back off on repeated failures (for example, double the interval up to a cap of ten minutes) and reset the interval after the first success.
- Never block the UI. A tick that fails is logged and retried on the next tick.
The polling interval should be a value in app_config.dart, not a hardcoded constant, so it can be tuned without a rebuild.
Server Responsibilities
The server needs a real database and authenticated API. PostgreSQL, Supabase, or an existing backend service would work.
The minimum server concepts are:
- Venue or customer account.
- Registered bar device.
- Authenticated client/admin user.
- Last-known open-tab snapshot, attributed to the device that reported it.
- Pending remote commands.
- Command acknowledgements.
- Audit log.
Suggested command fields:
id
venueId
deviceId
type: close_tab
tabId
expectedVersion
paymentMethod
requestedBy
createdAt
status: pending | completed | failed | conflict | expired
processedAt
result
Suggested endpoints:
POST /venues/{venueId}/commands
GET /devices/{deviceId}/sync?cursor={cursor}
POST /devices/{deviceId}/commands/{commandId}/ack
POST /devices/{deviceId}/state
The dashboard creates a command. The bar app never accepts an unauthenticated direct database mutation.
Device Targeting
A command carries a deviceId, so the server and the dashboard need to know which device currently owns a given tab. Without this, a venue with two registers will produce commands routed to a device that does not hold the tab, and every such command returns a conflict or a not-found result.
Rules:
- Every open-tab snapshot upload is attributed to the reporting
deviceId. - The server stores tab ownership as part of the snapshot, so
tabId -> deviceIdis always resolvable from the last-known state. - The dashboard never asks the client to pick a device. It resolves the target device from the tab the client selected.
- If a tab appears in snapshots from more than one device, treat the most recent snapshot as authoritative and flag the older one. This should not happen in normal operation and is worth logging.
- If a tab's owning device has not been seen recently, the dashboard warns before allowing a remote close.
For a single-device venue this collapses to the trivial case, but building it in now avoids a schema migration when the second register arrives.
Bar App Sync
Add a RemoteSyncService that runs after database initialization during app startup and then on the periodic timer described above. AppBootstrap is a suitable integration point because it already performs startup work.
The service should:
- Read the device ID, authentication token, and sync cursor from secure storage.
- Call the sync endpoint.
- Ignore an empty command list.
- Process commands one at a time.
- Execute each command using the existing domain service.
- Acknowledge a command only after the local transaction succeeds.
- Persist the new cursor and processed command IDs.
- Surface the outcome to the bartender in the UI.
- Leave the app usable if the network request fails.
The sync request must not block the bar from opening, and a periodic tick must never block interaction. A failed or unavailable server should be logged and retried on the next trigger.
Bartender Notification
A tab that closes itself with no explanation is confusing and looks like a bug. When a remote command is applied, the app must tell the bartender.
- Show a non-blocking toast or snackbar naming the tab and the requester, for example
Tab 12 (Jan) closed remotely by owner. - If the bar screen is currently displaying that tab, dismiss or refresh the view rather than leaving stale item rows on screen.
- Write the event into a locally visible remote-activity list in Admin, so the bartender can check what happened after the toast is gone.
- If a command results in a conflict, the bartender does not need a toast. Nothing changed locally. Log it for the activity list only.
If the open decision below is resolved in favour of bartender confirmation, this notification becomes a prompt with Confirm and Reject actions instead of a passive toast.
Local Database Additions
The app should remember remote processing state locally.
Tab Version Column
Add a version integer (or a reliable updatedAt timestamp) to the tabs table. This is a prerequisite for conflict handling, not a later addition.
- Increment on every mutation: creating the tab, adding an item, changing a quantity, removing an item, and closing.
- Include it in every open-tab snapshot upload.
- The client sends it back as
expectedVersionwhen requesting a close.
Do this migration in the same phase as the snapshot upload, because a snapshot without a version is not usable for safe remote closing.
Sync State
deviceId
serverCursor
lastSuccessfulSyncAt
lastAttemptedSyncAt
consecutiveFailureCount
Processed Remote Commands
commandId
commandType
processedAt
result
The command ID is important. If the app receives the same command twice, it must not close the same tab twice or create duplicate history records. This matters more with periodic sync than with startup-only sync, because retries are more frequent.
Closing a Tab Remotely
The local implementation already has the correct transaction boundary in BarTabService.closeTab(). The remote command handler should call that same method rather than duplicating the database logic.
The remote close flow should verify:
- The tab still exists.
- The tab is still open.
- The tab version matches the
expectedVersionsent by the client. - The command has not already been processed.
- The command has not expired.
If the tab changed after the client requested closure, return a conflict rather than silently closing a newer version of the tab.
Commands should expire. A close_tab command that has been pending for longer than a configured window (24 hours is a reasonable default) should be marked expired by the server and never applied. A day-old close request almost certainly no longer reflects what the client wants.
Payment Decision
Decision: the client must explicitly choose a payment method, and an unpaid remote close requires a dedicated state.
The current model requires every closed tab to have either cash or payconiq as its payment method. Rather than abusing the cash value, add a payment_pending value to the payment method enum.
Behaviour:
- The dashboard requires the client to pick Cash, Payconiq, or Leave unpaid before submitting.
- If the client picks Cash or Payconiq, the remote close records that method and the tab closes normally.
- If the client picks Leave unpaid, the tab closes with
payment_pendingand appears in aPayment pendinglist in the app until a bartender settles it. - The app never infers a payment method on the client's behalf.
This decision needs to land before the command endpoint is built, because it changes the payment method enum, the tab history schema, and any reporting that groups closed tabs by payment method. Check every existing query that assumes exactly two payment methods.
State Synchronization
The server needs enough information for the client to see open tabs. There are two reasonable levels of synchronization:
Minimal Snapshot
- Upload open-tab state on startup and on each periodic tick, alongside the command fetch.
- The client sees the last-known snapshot from the server.
- Remote commands target a tab ID and expected version.
- The bar app applies the command on the next sync.
Since the periodic sync is already making a request, the snapshot can ride along in the same call. This gives the client data that is at most one polling interval stale, which is a significant improvement over the startup-only version at essentially no extra cost.
Outbound Updates After Mutations
- Also upload state immediately after creating tabs, adding items, changing quantities, and closing tabs.
- The client view becomes accurate within seconds rather than within a polling interval.
This is still a useful second phase, but it is less urgent now that the snapshot is refreshed on every tick. Consider it if the polling interval has to be raised for cost or battery reasons.
Offline and Conflict Behavior
If the bar device is offline:
- The dashboard should show the device's last-seen time, sourced from
lastSuccessfulSyncAton the server side. - The dashboard should visibly warn when the last-seen time exceeds the polling interval by a meaningful margin, since it means the snapshot is stale.
- New commands remain pending on the server until they are applied or expire.
- The app processes them on its next successful sync.
If a bartender changes or closes the tab before the remote command arrives:
- The command should become
conflictoralready_completed. - The client should see what happened rather than receiving a false success.
- The app should not overwrite newer local data.
Security
The local regular PIN and Admin PIN are not suitable as remote authentication. They only protect the local device.
Use separate remote authentication with:
- Client accounts scoped to a venue.
- HTTPS for every request, with certificate validation left at default. Do not add certificate exceptions for a development server.
- Server-side role checks on every endpoint, including the sync endpoint.
- Device registration and revocable device tokens.
- Secure storage for the device token on the bar device.
- Two-factor authentication for client accounts if appropriate.
- An audit log containing who requested each remote close, which device applied it, and when.
- No public endpoint that accepts a tab ID without authentication.
Rate Limiting
- Limit command creation per client account, for example 10 commands per minute and 100 per day. A client has no legitimate reason to exceed this.
- Limit sync polling per device to somewhat above the configured interval, so a misbehaving or misconfigured client cannot hammer the endpoint.
- Limit authentication attempts per account and per IP, with lockout and alerting.
- Return
429with aRetry-Afterheader. The app should respect it and extend its backoff accordingly.
Token Revocation
Revocation needs to be usable by a venue owner under pressure, not just technically possible.
- The dashboard lists registered devices with name, last-seen time, and a Revoke button.
- Revoking a device invalidates its token immediately on the server. The next sync from that device returns
401. - On
401, the app clears the stored token, stops the sync timer, and shows a re-registration prompt in Admin. It must keep working as a local-first bar app in the meantime. Losing remote access must never stop the bar from serving. - Re-registration requires an Admin PIN on the device plus an authenticated action from the dashboard, so a stolen device cannot re-enrol itself.
- Device tokens should have an expiry and a refresh path, so a device that quietly disappears stops being valid on its own.
- Revocation events go in the audit log.
Suggested Implementation Phases
- Build the server database and authenticated device registration.
- Add the tab
versioncolumn and increment it on every mutation. - Add an endpoint for uploading the current open-tab snapshot, including version and owning device.
- Add a client dashboard showing last-known open tabs and device last-seen times.
- Add the
payment_pendingpayment method to the data model and update affected queries and reports. - Add the pending
close_tabcommand endpoint with explicit payment method selection. - Add startup sync to the Flutter app.
- Add local command deduplication and acknowledgement handling.
- Add expected-version conflict handling and command expiry.
- Add the periodic sync timer with backoff and in-flight guarding.
- Add bartender notification and the local remote-activity list.
- Add a manual
Sync nowaction. - Add rate limiting and the device revocation flow.
- Add outbound updates after local tab mutations if the periodic snapshot is still too stale.
Phases 2 and 5 are schema changes and should land before anything depends on them. Phase 13 should not be deferred past first production use.
Open Decisions
- Should remote closing happen automatically on the next sync, or should the bartender confirm it? This changes the notification in the Bartender Notification section from a toast to a prompt.
- How many bars or devices need to be supported? This affects how much of the device targeting work is needed in the first version.
- Is a separate web dashboard preferred, or should remote control be another Flutter app?
- What polling interval balances freshness against server cost? The starting value of 60 to 120 seconds is a guess, not a measurement.
- Should a
payment_pendingtab block end-of-day reporting until it is settled, or appear as its own reporting category?
Resolved
- Does the client choose the payment method remotely? Yes. Explicit selection is required. See Payment Decision.
- Should a remote close support unpaid tabs? Yes, through a dedicated
payment_pendingstate rather than by recording it as cash. - How stale may the client's open-tab data be before the dashboard warns them? The snapshot refreshes on every sync tick, so the dashboard warns when the device's last-seen time exceeds roughly three polling intervals.