Source: the live test pass of 2 September 2026 (TEST_BUSINESSMADE_2026-09-02.md at the app root), driven from Chrome against a local appengine with the demo organization and location garden-bar-brooklyn. All fixes below are in the working tree; none were committed or pushed as part of that pass.
Fixed on 2026-09-02
| Area | Symptom | Cause and fix |
|---|---|---|
| Tabs | "No open tabs" while the server held 200 | One order had amountPaid: "5368" as a string; an as num cast threw and emptied the list. Every money and quantity field now goes through a tolerant parser (models/pos_order_model.dart) |
| Tabs | Every card showed $0.00 | Open tabs carry subtotal only; cards now show displayTotal |
| Take payment | Charge blocked with "Save the tab before settling"; order kept 0 items | The ad-hoc line was written with key productItems (landed at the record root) and the boolean response was parsed as an unsaved order. Key is now data.productItems with data.subtotal/data.total, and the order is re-fetched when the response has no sk (services/pos_service.dart) |
| Book reservation | Continue disabled forever on a definition with no services | The app now synthesizes the implicit service the server uses (book_reservation_screen.dart) |
| Book reservation | "Invalid service selected" on every booking | Server: validateReservation matched by id while services are keyed by name; now matches either (crm/reservations.service.ts) |
| Book reservation | "Not a new metrics" | App posted a wrapped {datatype, data} body; toCreatePayload() sends flat fields |
| Check-in | Assign returned 500; Notify returned 500 | Server resolved the point by name and then updated by that name; it now writes back with the resolved sk, and the app sends the sk first (checkin/checkin.service.ts, checkin_screen.dart). Same latent bug fixed in clearServicePoint |
| Reservations | Rows had no date or time; seated rows printed a raw id | Rows show "Fri, Sep 11 · 8:30 PM – 9:00 PM" and the spot's display name; service points load on screen init |
| Reservations | List was every booking ever | Grouped as Today (including walk-ins with no start time), Upcoming and Past (folded) |
| Reservations | Slot bookings shown 5 hours off | Slot bookings store a UTC instant, older records a zone-less wall clock; only zoned values are converted |
| Check-in from a reservation | Task created without a location so it never appeared in the scoped queue; row kept offering Check in | Server takes businessLocationId (the picker wins), stamps checkedInAt/checkInTaskId on the booking and answers 409 while the task is live; the row shows "In check-in queue" |
| Take order after seating | Would have opened a stray cart | POS keys tabs by the table's name; the picker now sends the id to assign and the name to POS, and POS also matches on the display label |
| Web | Lists did not scroll with a mouse drag | MaterialApp.scrollBehavior accepts mouse, trackpad and stylus |
| Web | Build impossible because of flutter_thermal_printer FFI | Conditional-import shim under services/thermal/ |
Fixed on 2026-09-03 (events)
Driven from Chrome against the same local appengine, event oss-ai-meetup-sf-2026. Also uncommitted.
| Area | Symptom | Cause and fix |
|---|---|---|
| Scanner, Check Out | Every camera check-out answered "Ticket not found" | App sent {code}; server checkOut read only ticketId. Server now resolves code the same way as check-in (dynamic QR → static code → credential) and still accepts ticketId (event-checkin.service.ts, events.controller.ts) |
| Scanner | No way to send a zone, so occupancy never moved | Zone chip row (No zone + one per event zone) under the mode chips; the selection is sent as zone with every scan and manual entry (scanner_screen.dart) |
| Scanner, Manual Lookup | Sent everything as an email filter and showed the raw lookup response | Replaced by manual check-in/out: a code goes straight through the scan path; an email is looked up and the single (or picked) live ticket's code is sent. Result card now shows the server's reason, not only transport errors, and the ticket type name |
| Server | GET /events/tickets/:id/qr returned 500 for imported tickets | HMAC was signed with an undefined codeSecret; a secret is minted on first use (event-ticket.service.ts getTicketWithFreshQR) |
| Server | Static ticket codes matched upper-case only | Name lookup now matches the code as typed, upper- and lower-cased (resolveTicketFromCode) |
| Live Dashboard, Activity Log, Overview, Home card | "Checked In" always 0; zone list always "No zone data available" | Screens read checkedIn/totalCheckedIn and a zones array; the endpoints return uniqueAttendees/successful/total/denied and an object keyed by zone id. Provider exposes checkedInCount; dashboard parses the real shape and shows Scans / Denied tiles and per-zone "current / capacity" (events_provider.dart, event_dashboard_screen.dart) |
| Issue, Comp, Walk-in | Server refused every request (ticketTypeId required) | Required ticket-type dropdown from GET /events/:id/ticket-types (ticket_type_field.dart); auto-selected for a single type, inline notice when the event has none; correct bodies for issue, comp (items[{ticketTypeId, quantity:1, …}]) and walkIn |
| Tickets tab | Stat tiles overflowed by 34 px; rows showed the ticket-type id and "?" for holderless tickets | Fixed-height card removed; rows show the type's title from the loaded types and fall back to email / ticket code |
| Home, event mode | Tickets / Schedule / People / Manage tiles all opened the shell on Overview | EventShellScreen(initialTab:) — 2 / 3 / 4 / 5 |
| Home | Header overflowed with a long location name | Greeting and name truncate inside a Flexible |
Still open
- Web-only tap dead zone. After a browser reload some header and back taps stop registering until the next reload, and back on a top-level screen reloads the page. Not reproduced on a device.
- Rate limiter. Scripted checks from
127.0.0.1hit 429 after a burst while the app on the LAN IP was unaffected. Expected behaviour, but it will bite anyone running API scripts next to the app. - Demo data limits.
austinhas no service points; the only workflow in the demo org is Stowbo's Custody Pipeline (so Pipelines shows those tasks); there are nobm_location_productrecords, so day-parts and 86 have no visible effect there. - Not exercised. Tasks, Support and the More tiles were not reached in that pass because of the web tap issue. The camera itself was not exercised on web; scan resolution, check-in and check-out were verified through the API and the manual path.
- Events, still missing screens. Sell is a "coming soon" snackbar; the phone never sends a scan point (
checkpoint); no badge printing or mark-printed; no credential assignment, perks, verify-scan, transfer, cancel, refund or go-live; More → Media does nothing; no scan-to-lead; no display or kiosk mode. A ticket keeps statuschecked_inafter a check-out, so the Tickets tab "In" count is people who ever entered, not people inside.
Sign-in is email first (2026-09-17)
Nobody types an organization any more. POST /profile/user/directory/lookup is public, takes {email} and answers {orgs: [{orgId, displayName}]} most-recently-used first; the app uses it as step one:
| Result | What the screen does |
|---|---|
| Empty | Stays on the email step: No account found for |
| One org | Straight to the password. The organization is never shown |
| Several | An Organization picker above the password, first entry preselected |
The Site Name field and its .appmint.app suffix are gone, along with the remembered-org lock and its Change button. remembered_org is still written on every sign-in, because POS quick sign-in runs on it: that screen no longer asks for an organization and refuses to work until somebody has signed in with an email and password on the device — Sign in with your email and password first. Changing a device's organization is the same act.
Magic-code sign-in resolves the organization through the same lookup, asking in a dialog when there is more than one.
Verification challenges: fixed, and what it took
Both apps answer the challenge now. Appmint Mobile and Stowbo detect requiresTwoFactor, show a code sheet with resend and a method switch, and post to POST /profile/security/challenge/verify with trustDevice: true. A tokenless sign-in response of any other shape is rejected rather than becoming an empty session. event_app and dfw_errand always handled it.
What the server does. POST /profile/user/signin (and the customer equivalent Stowbo uses) returns no tokens when a challenge is due:
{ "requiresTwoFactor": true, "challengeToken": "…", "twoFactorMethod": "email", "message": "Verification code sent to your email" }Three things trigger it, and only the second is opt-in:
| Trigger | Setting | Who it challenges |
|---|---|---|
| The organization requires 2FA | securitySettings.enableTwoFactorForUsers / …ForCustomers | Every user or customer |
| The person enabled 2FA themselves | Their own 2FA status | Just them |
| New-device verification | securitySettings.enableNewDeviceAuthentication | Anyone signing in on an unrecognised device — no 2FA needed |
What it used to do, and what to expect from a build older than this: AuthResponse.fromJson defaulted accessToken to '' and AuthProvider.login set the status to authenticated regardless, so the app entered the signed-in state holding an empty token, 401'd on the first request, and forced a sign-out. The person saw Session expired. Please sign in again. forever and was never asked for the code they had been emailed. It reads as a flapping session, not as a missing verification step — which is why it gets reported as "login is broken".
Two paths still skip the challenge server-side, which is a security note rather than a workaround now:
- Quick sign-in (employee id + passcode, or an NFC card).
finishQuickLogindeliberately bypasses the 2FA and new-device challenge so a busy terminal is not interrupted. - Magic code. The magic-link strategy performs no 2FA check at all. A factor that an alternate sign-in path ignores is not enforcing much.
Three defects in the fix itself were only visible when it was run, each found in a browser against a live server:
- A
TextEditingControllerdisposed while the dialog was still animating out. The code sheet owns its controller in aStatefulWidget. AuthStatus.twoFactorRequiredfell through Appmint Mobile's auth gate inmain.dart, rendering the dashboard behind the dialog with no token.- The dialog never opened at all (found 2026-09-17, fixed). Signing in passes through
AuthStatus.loading, and the gate swapsLoginScreenfor a spinner — which destroys its state. The instance that came back was a new one, so theawait showDialogin the old one was dead code, and the challenge message rendered as a red error on a reset form. The pending challenge now lives on the provider (twoFactorEmail), and whichever login screen is mounted raises the sheet from a post-frame check.
Server-side faults found while testing this (2026-09-11)
Testing the client fix meant provoking a real challenge, which is how these surfaced. The first one means nobody has ever completed an emailed 2FA sign-in on this platform.
1. Every emailed or texted sign-in code was rejected as wrong. (Fixed.) TwoFactorService is listed in the providers of both UsersModule and SecurityModule, so Nest built one instance per module — each with its own in-memory verificationCodes map. Sign-in stored the code on the Users copy; POST /profile/security/challenge/verify, which lives in SecurityModule, looked in the Security copy, found nothing, and answered That code is not right. A code obtained by calling challenge/send first did work, because that route stores and verifies on the same instance — which is the tell.
The store is now static, so the split cannot matter however the modules are wired. Verified: sign-in → emailed code → verified → working session. Note it remains process-local: with more than one replica, a code sent by one and verified by another will not be found. Redis is the real fix for a clustered deployment.
2. A staff account with no customer record cannot manage 2FA at all. Every /profile/security/* route answers 404 customer {"email":"…"} not found for a staff-only user. It works for [email protected] only because a customer record happens to share that address — confirmed by creating a throwaway user, watching it 404, then creating a customer with the same email and watching the same routes return 200. The consequence: staff cannot enrol a second factor, see their devices, or read their login history, and per-user 2FA for staff is unreachable. The org-wide enableTwoFactorForUsers switch is the only way staff get challenged.
3. Org-enforced 2FA once accepted any code from anyone who had not enrolled. (Fixed — not by this work.) verifyTwoFactorCode used to return true outright when the account's security record had twoFactorEnabled false, so with enableTwoFactorForUsers on, an account that never enrolled could be signed into with any six digits. The method now checks the emailed or texted code in that case. Reproduced against a live server after the change: a brand-new account with nothing enrolled, challenged org-wide, was refused with a made-up code.
4. SMS 2FA refused itself everywhere. (Fixed.) sendCodeSms gated on a config record with data.type === 'twilio' — a shape nothing in the platform writes. Integrations are config records keyed on data.provider (TwilioProvider), so every organization got SMS not configured, including ones sending SMS happily through other features. The check now looks in the right place and, finding nothing locally, warns and proceeds rather than refusing — the notification pipeline resolves the shared org's gateway, which is how most orgs send.
Also noted: DEV_TWO_FACTOR_OVERRIDE=000000 in development.env accepts a fixed code for any account. It is correctly gated on NODE_ENV=development and logs a warning when used, but it is in a tracked env file — worth knowing it exists before anyone copies that file toward production.
Found while writing the user docs (2026-09-10)
Verified by reading the screens, not by report. Each is user-visible and each is documented in the user pages as behaviour, so the docs and the code agree until these are fixed.
| Item | Where | Effect |
|---|---|---|
| Sign-out wipes the device configuration | StorageService.clearAll snapshots only remembered_org and saved_sessions around prefs.clear() | Printer, card reader (and Terminal location), scanner, NFC reader, selected location, home layout and the softphone offline flag are all lost on logout. The worst operational bug on this list — a till has to be re-paired to trade. |
ProfileScreen is unreachable | No route or push anywhere in lib/ | Privacy policy, terms, support, about and Delete Account have no entry point. Account deletion is an App Store 5.1.1(v) requirement. |
| Email composer cannot send | email_composer_screen.dart calls context.read<ApiService>(); ApiService is not a registered provider | Send fails with a provider exception surfaced as Failed to send email: …. The inbox reply path instantiates ApiService() directly and works. |
| Sign Up tab is inert | login_screen.dart sets _selectedTab but never branches on it; RegisterScreen is never referenced | The tab clears the fields and shows the login form. No self-registration exists. |
| Chat console actions are silent no-ops when the socket is down | every emit in chat_service.dart starts if (_socket == null || !_isConnected) return; | Status, takeover, resume, assist, transfer and close appear to work and do nothing. Only the header dot hints at it. |
pickNext reports nothing | chat_provider.dart only debugPrints on empty queue and failure | Pick Next and Pick Up look broken when the queue is empty. Pick Up also ignores the row and asks the server for the next one. |
| Transfer accepts any string | chat_conversation_screen.dart | A mistyped agent email pops the screen exactly like a success. |
| Live Presence does not poll | startPolling() is a legacy alias doing a single load | The board silently goes stale; the agent dot reflects socket count, not status. |
| SMS Send enables without a recipient | sms_composer_screen.dart guards on the body only, and _sendSms returns silently with an empty To | The button looks live and nothing happens. All SMS failures share one string, Failed to send SMS. |
| iPad has no calling and says nothing | softphone_service.dart skips native voice binding on iPad | Registration succeeds, the dot goes green, calls never ring or place. |
event_detail_screen.dart is dead code | Never constructed | Its "QR scanner coming soon" and 4-tab layout must not be documented. |
| Scanner and Accreditation from the home grid have no active event | dashboard_screen.dart | Zone chips are empty and an empty event id is sent. Users must open the event first — documented as a warning. |
| Destructive actions with no confirmation | Access cards Revoke / Mark lost, participant Remove, More → Logout, Publish Event | One tap, no undo prompt, and Publish Event gives no result message either. |
Type DELETE to confirm fails silently | profile_screen.dart | A mismatch disables nothing and shows nothing; the button reads as broken. |
Declared but not implemented
These come from reading the code against the dependencies and the root README, and they matter for anyone estimating work or writing user-facing copy.
| Item | State |
|---|---|
| Offline caching and queued actions | hive is declared; nothing under lib/ uses it. Persistence is SharedPreferences and secure storage |
| Biometrics | local_auth is declared and unused |
| Two-factor authentication | Handled: the app detects requiresTwoFactor, shows a code sheet with resend and method switch, and posts to /profile/security/challenge/verify. Enrolment is still web-only |
| Social sign-in | None |
| Payment links | None; card present, cash and manual tenders only |
| General push notifications | Only the softphone's VoIP wake-up (FCM on Android, PushKit on iOS). More → Notifications is a stub |
| Forgot password | Shows success without calling an endpoint |
| Dark mode | darkTheme returns the light theme |
| More → Preferences, Language | Stubs |
| Contact edit and add-note | "Coming soon" stubs on ContactDetailScreen |
bm_location_product | Read and written by the app, but absent from the SDK and unused by the server |
Security notes
/storefrontroutes are public on the server (class-level@PublicRoute()); see Endpoints.service_point.statusis an SDK enum but is written as a free string by the storefront status route, while check-in's assign rejects exactlyoccupiedandreserved. A client that writes a custom status bypasses the seating guard.- Dev credentials are hardcoded behind
kDebugModeinlogin_screen.dart. - Magic-code sign-in performs no 2FA check, so an organization that requires 2FA is not enforcing it on that path.
certs/holds committed private keys and a Firebase admin JSON.