#TradeForce — system architecture
#Purpose
TradeForce is a shift marketplace where companies post construction jobs, contractors claim and work them, both sides track time and approvals, and platform admins verify people and rates. This document describes the running target application: a React SPA under app/ talking to Supabase Auth, Postgres with row-level security, and Edge Functions for secrets and privileged orchestration.
The source product was a Bubble app. The migration keeps product journeys and data meaning, but does not copy Bubble’s browser-as-database trust model. Assessment finding LOG-CLIENT-WRITES (about 97% of database writes lived in client workflows) is an architecture law here: every persistent write lands behind authenticated server authority (RLS policies and/or SECURITY DEFINER RPCs / Edge Functions under app/supabase). Money-adjacent mutations (rates, paid amounts, approval flags) additionally follow MON-CLIENT-WRITES: transactional server authority, never trust the browser as the writer of record.
Programme boundary MON-MARKETPLACE fences Stripe Connect, KYC, live balances, and payouts as separate scope until answered. Auth cutover ACC-AUTH-SURFACE chooses how existing Bubble identities move onto Supabase Auth.
#Runtime context
Target stack: React + Vite + TypeScript SPA (app/) and Supabase backend (app/supabase/ — migrations, RLS, Edge Functions). The browser holds only the anon key and the user JWT. Service-role keys and third-party secrets stay on the server.
Delivery is by vertical slice: each product journey ships UI, client calls, domain rules, and any backend surface together. There is no separate “build the API first” phase.
#SPA modules
Capability modules map to authenticated host routes inside a shared cabinet workspace (header + role sidebar + main outlet). Auth and onboarding sit outside that chrome.
| Module | Role in runtime | Primary host surfaces |
|---|---|---|
| Sign-in and account access | Public auth index and password reset; creates session and profiles routing fields |
/, password-reset route |
| Workspace chrome | Header, contractor/company/admin side navigation, role-gated menus | Mounted on authenticated hosts below |
| Role onboarding | Profile, skills, documents, location before marketplace entry | /onboarding |
| Company shift dashboard | Post/manage shifts, applicants, job detail, end-job, company payment approvals | /company/shifts (+ tab contract) |
| Contractor shifts and profile | Browse/claim shifts, MyShifts, contractor profile docs | /contractor/shifts |
| Time tracking and approvals | Live tracking, reports, company review; queue redesign for recursive approve | /contractor/time-tracking (+ company review surfaces) |
| Contractor payments | Schedules and payment history; Stripe fields read-only pending marketplace programme | /contractor/payments |
| Admin verification and ops | Verify companies/contractors, rates, shifts, invoices/schedules | /admin |
| Messaging | Threads, send, new-message email | /chat |
Shared job UI (detailed job view, end-job popup, shift row actions) has a single implement owner in the company-shifts module. Contractor and time-tracking modules reuse those components; they do not fork alternate implementations.
The Bubble stub 404 page is out of product scope.
#Auth and session
Identity target is Supabase Auth (email/password), with a profiles row created on signup for role, active, onboarding-complete, and approved flags used by post-login routing and gates.
Public flows:
- Sign in → session JWT → redirect by role and profile flags (onboarding vs role home).
- Sign up with contractor/company role → profile defaults (inactive until activation path) → verify / onboarding.
- Request reset email → confirm new password on the reset surface → return to sign-in.
Session client lives under app/src/integrations/supabase with the anon key only. Auth errors map once at the boundary to stable domain errors shown in the UI.
ACC-AUTH-SURFACE is still open: the export uses Bubble native login/signup/reset, which do not transfer sessions or password material. An ADR must record (a) credential cutover for existing users (forced reset, dual-run, or equivalent — not a silent password migration) and (b) whether signup activation preserves the active=yes deep-link semantics or replaces them with Supabase confirm-email. Until that ADR is accepted, auth foundation work remains blocked on those decisions (see HQ-1).
Unauthenticated hits to authenticated host routes redirect to the auth index. Chrome is not shown without a session.
#Data access
Schema authority is docs/DATA-MODEL.md (idiomatic Postgres names) realized as migrations under app/supabase/migrations/. The SPA uses the user JWT against PostgREST and RPCs. RLS is the default read/write gate; the SPA must not ship a service-role key or reimplement authorization in the client.
#Server authority (LOG-CLIENT-WRITES / MON-CLIENT-WRITES)
| Class of mutation | Authority rule |
|---|---|
| Ordinary persistent creates/updates/deletes (shifts, profiles lists, chat, documents, admin edits) | RLS allowing the actor, or an authenticated RPC/Edge path with explicit authz — never “browser wrote the row unchecked” |
| Money-adjacent fields (rates, paid amounts, send-paid / approve-company, computed payment totals) | Transactional server RPC (or Edge) that validates amounts and commits; SPA only requests the change |
| Secrets and vendor APIs (Postmark, Country State City, future Connect secrets) | Edge Functions only |
| Background tracking orchestration (create tracking lists, approve fan-out, delay helper) | Server jobs / Edge / queue — not a line-by-line port of self-scheduling client workflows |
Source backend events that ran with privacy rules disabled (create_list_tracking, send_tracking_approve) have no automatic RLS equivalent. Their elevated behaviour must be expressed as tightly scoped privileged server paths or redesigned under ordinary RLS (see HQ-3).
#Core entity families (target-oriented)
| Family | Purpose |
|---|---|
| Profiles (← source user) | Role, activation, onboarding, approval, rate fields, Stripe account display fields |
| Shifts / jobs | Posted marketplace work, applicants, stop-hiring / close transitions |
| Time entries | Hours, breaks, company approval, paid/send flags |
| Documents / skill sets | Onboarding and profile credentials |
| Chat / messages / notifications | Messaging and in-app alerts |
| Custom rates | Admin and company skill-rate history |
Detailed columns and naming maps belong in the data-model document, not here.
#External systems
| System | Used for | Target placement |
|---|---|---|
| Postmark | Canceled shifts, new message, new time-tracking report, upcoming payment cycle (and related templates) | Edge Function with API secret; SPA never holds the token |
| Country State City API | States/cities during onboarding location | Edge proxy; SPA calls the function with user JWT |
| Stripe | Connected-account client id / public key present in export; user fields for account id and display name | Read-only display and optional dashboard link in-app until MON-MARKETPLACE is decided. No Connect onboarding, KYC, live balance fetch, or payouts in core migration scope |
| Bubble platform email (SendGrid) | The auth mail nobody else sends: the custom “Verify” signup email (bTHqT1) and the native password-reset mail (bTHqz1) run through Bubble's built-in sender — app facts use_sendgrid: true, sendgrid_verified: false on domain tradeforce.dev. It is a separate channel from the Postmark templates above. |
Chosen per ADR-002: either Supabase Auth's own templates or an Edge send. No Bubble sender survives cutover, and the unverified SendGrid domain does not carry over — the target needs its own verified sender. |
| Browser plugins (upload, select, icons, etc.) | UI affordances in source | Replaced with SPA libraries; not separate runtime backends |
#Failure modes
| Failure | User-visible behaviour | Server behaviour |
|---|---|---|
| Auth credentials invalid / session expired | Inline auth error; redirect to sign-in when session required | No data mutation |
| RLS / RPC denies write | Clear domain error on the control that tried to save | Reject; no partial money commit |
| Postmark or location API unavailable | Message send / location lookup shows unavailable-style error; core CRUD still possible where independent | Edge returns typed unavailable; timeouts required |
| Tracking approve / delay job fails | Approval state stays pending; operator can retry; no silent success | Queue/job records failure; no unbounded recursive self-schedule |
| Stripe Connect not in programme | “Connect” control disabled or interim message; existing account id may still open dashboard | No Connect plugin writes; no balance API with Stripe-Account header |
| Missing ACC-AUTH-SURFACE ADR | Auth implement blocked | No invented silent password migration or third activation path |
#Traceability
| Product concept | Target (route / component / table) | Bubble evidence |
|---|---|---|
| Auth index and reset | /, reset-password page; Supabase Auth; profiles |
bTGYf, AAL |
| Workspace chrome | Header, SideBar, SideMenuCompany; cabinet hosts | bTHOe, bTHMl, bTMyh2 |
| Onboarding | /onboarding; CSC edge |
bTHDn0, external_ns:bTKtr |
| Company shifts | /company/shifts; shifts; shared job/end-job/RG action |
bTITd, bTIgC, bTHkx, bTHTn, bTMBT |
| Contractor shifts | /contractor/shifts |
bTHMg |
| Time tracking | /contractor/time-tracking; time entry RPCs/jobs |
bTJFi0, bTLsx, bTMBg, bTKPV |
| Contractor payments | /contractor/payments; read models; Stripe fields |
bTIzV0, stripe_account_id_text, stripe_name_text |
| Admin | /admin |
bTHun0 |
| Chat | /chat; Postmark new-message |
bTJQD0, bTJQB0, external_ns:bTNMZ |
| Client write → server authority | RLS/RPC/Edge under app/supabase |
Module-map LOG-CLIENT-WRITES / MON-CLIENT-WRITES; assessment write census |
| Marketplace programme fence | Payments Stripe affordance only | Module-map MON-MARKETPLACE; public_key:stripe_client_id_test |
| Auth provider ADR | docs/adr/ADR-002-acc-auth-surface.md (status open — to accept) |
Module-map ACC-AUTH-SURFACE |
#IR evidence
#Module evidence roots and pages
| Module | Inventory roots | Page / definition pointers |
|---|---|---|
| MOD-AUTH | bTGYf, AAL |
/pages/bTGbC (index), /pages/AAW (reset_pw) |
| MOD-SHELL | bTHOe, bTHMl, bTMyh2 |
/element_definitions/bTHOf, /element_definitions/bTHMm, /element_definitions/bTNEP2 |
| MOD-ONBOARDING | bTHDn0 |
/pages/bTHDr0 |
| MOD-COMPANY-SHIFTS | bTITd, bTIgC, bTHkx, bTHTn, bTMBT |
/pages/bTIbd; reusables /element_definitions/bTIgD, bTHky, bTHTo; api /api/bTMBU (shift) |
| MOD-CONTRACTOR-SHIFTS | bTHMg |
/pages/bTHMh |
| MOD-TIME-TRACKING | bTJFi0, bTLsx, bTMBg, bTKPV |
/pages/bTJIF0; api /api/bTLsy (create_list_tracking), /api/bTMBh (send_tracking_approve), /api/bTKPZ (15_minuts_delay) |
| MOD-PAYMENTS | bTIzV0 |
/pages/bTIzW0 |
| MOD-ADMIN | bTHun0 |
/pages/bTHuo0 |
| MOD-CHAT | bTJQD0, bTJQB0 |
/pages/bTJSr0; /element_definitions/bTJQC0 |
Supporting slices (prepared bundle): ir/slices/bTGbC____root.json, AAW____root.json, bTHDr0____root.json, bTHMh*, bTIbd*, bTJIF0____root.json, bTIzW0____root.json, bTHuo0*, bTJSr0____root.json, edef_* for shell/reusables, styles____part_all.json.
Path-key aliases: inventory id → prefer inventory pointer (e.g. page id bTGYf → /pages/bTGbC). API event ids resolve to wf_name in inventory meta (create_list_tracking, send_tracking_approve, 15_minuts_delay, shift).
#Backend API events
| Inventory id | wf_name |
Notes for target |
|---|---|---|
bTLsx |
create_list_tracking |
ignore_privacy_rules: true — elevated path (ACC-IGNORE-PRIVACY) |
bTMBg |
send_tracking_approve |
Self-scheduling in source (LOG-RECURSIVE); rewrite as queue/cursor job; also ignore-privacy |
bTKPV |
15_minuts_delay |
Delay helper for tracking flows |
bTMBT |
shift |
Company post/shift backend event |
#Privacy and authorization
- Source ships 25
privacy_roleentries across types; deepest conditions are non-trivial — re-express as Postgres RLS / policy helpers, not client checks. - Assessment ACC-NO-RULES hits no live type: the five types with no privacy roles (
company,job,rates,time_tracking,current_contractors_info) aredeleted: truein the export and are not migrated, so there is no unprotected table to model. All nine surviving types carry rules. One of them needs a deliberate call rather than a copy:privacy_role:user:everyoneis the onlyeveryonerole with a permission set (search_for: true), i.e. the source lets unauthenticated visitors search User records. - Ignore-privacy API events above must become privileged Edge/RPC with hard authz or redesigned under normal RLS (HQ-3).
#External HTTP namespaces
external_ns:bTNMZ— Postmark templates (canceled shifts, new message, time-tracking report, upcoming payment cycle).external_ns:bTKtr— Country State City (states,citiescalls).- Stripe:
public_key:stripe_client_id_test,public_key:stripe_public_key_test, secret refstripe_private_key_test; user fieldsstripe_account_id_text,stripe_name_text.
#Secrets to re-provision (values are redacted in the sanitized export)
The export carries 14 secret_ref keys. None transfers; each is either re-issued in Supabase/Edge config or retired with the surface it served. Named here because nothing else in the docs claims them:
| Secret ref | Serves | Target |
|---|---|---|
apiconnector2 (10 stored values) |
API Connector auth — the Postmark server token and the Country State City key, including the three private shared headers on external_ns:bTNMZ and the private call headers on external_call:bTKtr:bTKts / external_call:bTKtr:bTKvG |
Edge Function env; never reaches the browser |
stripe_private_key_test |
Stripe secret key (test) | Out of core scope under MON-MARKETPLACE; re-issue only when that programme starts |
1658072861502x…_appsecret / _appsecret_test / _AAu / _AAu_test, 1491714407143x…_BQI_test, 1491714407143x…_shared_headers_BEl_test, 1623670386921x…_appsecret_test |
Plugin-scoped secrets for installed Bubble plugins | Retire with the plugins they serve (plugins are replaced by SPA libraries) |
username, password, general_keys, cRb |
Legacy app-level keys with one stored value each; no surface in this export claims them | Retire unless a caller is found before cutover |
use_algolia_integration |
Zero stored values — Algolia was never wired | Nothing to migrate |
#Open risks (module-map)
| Id | Bound module | Architecture implication |
|---|---|---|
| LOG-CLIENT-WRITES | Company shifts (+ admin, payments, time-tracking) | All persistent writes → server/RLS/Edge |
| MON-CLIENT-WRITES | Company shifts (+ money UIs) | Money fields → transactional server RPC |
| MON-MARKETPLACE | Payments | Separate Connect/payouts programme (HQ-2) |
| ACC-AUTH-SURFACE | Auth | Provider + cutover + activation ADR (HQ-1) |
| LOG-RECURSIVE | Time tracking | Queue redesign for send_tracking_approve |
#Needs human clarification
| Id | Question | Why unclear (plain + evidence) | Impact |
|---|---|---|---|
| HQ-1 | How should existing Bubble users authenticate on Supabase, and which signup activation path do we ship? | Plain: Sessions and password hashes do not transfer from Bubble native auth. Decompose assumes Supabase Auth but still requires an accepted ADR for cutover (forced reset vs dual-run) and for activation (preserve active=yes deep-link vs Supabase confirm-email). Evidence: chased catalog pages bTGYf / AAL and assess seed ACC-AUTH-SURFACE; inventory shows native LogIn/SignUp/Reset workflows — no portable credential material. |
blocks_implement |
| HQ-2 | Is Stripe Connect / KYC / balances / payouts a separate programme after core cutover, or in the same migration scope? | Plain: Export shows connected-account configuration and user Stripe id/name fields, but not a complete product decision on live Connect onboarding. Core architecture fences Connect as out of scope until answered; payments may still show read-only fields. Evidence: stripe_account_id_text, stripe_name_text, public_key:stripe_client_id_test, page bTIzV0; module-map MON-MARKETPLACE. |
blocks_implement |
| HQ-3 | Should ignore-privacy tracking API events become privileged service-role endpoints or be redesigned under ordinary RLS? | Plain: Two backend workflows run with privacy rules disabled; Postgres RLS has no automatic “ignore rules” switch. Time-tracking queue redesign must pick elevated RPC/Edge vs rewrite under normal policies. Evidence: inventory meta on bTLsx / bTMBg (ignore_privacy_rules: true, wf_name create_list_tracking / send_tracking_approve); assess seed ACC-IGNORE-PRIVACY. |
blocks_implement |