48.7 KiB · text History a2252b9

Waxx Android client — design doc

Status: draft, pre-implementation. Owner: gmp@h4xmb.org. Goal: native Android client with full feature parity to the web LiveView, sharing the existing Phoenix backend.

This doc is the contract for the work. Implementation lands in phases (see Phase plan); each phase has its own PR(s) and updates this doc with what shipped.


1. Goals & non-goals

Goals

  • Native Kotlin/Compose Android app, distributed via Play Store eventually.
  • Full parity with the web UI: boards, kanban DnD, templates, settings, invites, history, subboards, themes, label/field/assignee management.
  • Real-time multi-device updates — a drag on web is visible on Android within the same second a drag on web is visible to another web tab.
  • Two auth paths into the app: tap-an-email-link (magic-link via Android App Links) or scan-a-QR generated by the logged-in web user (mirrors the pairing flow in ../dms). Either alone is sufficient — most users will use the QR path on self-hosted installs where SMTP isn’t wired up.
  • Server stays the source of truth for transition enforcement, drift rules, and propagation logic — none of those rules get reimplemented on the client.

Non-goals (v1)

  • iOS. The architecture should not preclude it, but no iOS work in v1.
  • Offline-first writes. Read caching is fine; mutations require a live connection. Adding queued offline writes is post-v1.
  • Push notifications. Possible later via FCM, not in this scope.
  • A public/third-party API. The endpoints exist for the first-party Android client only — stability guarantees are weak until v2.

2. Architecture overview

+-------------------+    HTTPS/JSON     +-----------------------------+
|  Android client   | <---------------> |  WaxxWeb.Api.* (Phase 0)    |
|  (Kotlin/Compose) |                   |   - Plug pipeline :api      |
|                   |    WSS/Channels   |   - bearer-token auth       |
|                   | <---------------> |  WaxxWeb.BoardChannel       |
+-------------------+                   |   (Phase 3)                 |
                                        +--------------+--------------+
                                                       |
                                                       v
                                        +-----------------------------+
                                        | Waxx.{Accounts,Kanban,      |
                                        |   Workflows} contexts       |
                                        |   (UNCHANGED business logic)|
                                        +-----------------------------+

The server gains two new surfaces — an /api/v1 HTTP scope and a /socket user socket with a BoardChannel. Both call into the existing contexts. No business logic moves; only thin presentation/transport layers are added.

The Android app is a single Gradle project under /android, structured as a few feature modules around a shared :core module that owns the HTTP/socket client, models, and auth state.


3. HTTP API design

Conventions

  • Scope: /api/v1/.... Major version bumps for breaking changes; additive fields are not breaking.

  • Auth: Authorization: Bearer <token> on every request except the session-redemption endpoints. Tokens are minted in the Waxx.Accounts.UserToken table with a new context "api".

  • Content type: application/json on requests and responses. snake_case field names (matches the existing schemas).

  • Timestamps: ISO-8601 UTC strings ("2026-05-24T10:30:00Z").

  • IDs: UUIDv7 strings as-is — the client treats them as opaque.

  • Errors: a uniform shape, never a bare 500 with HTML.

    { "error": { "code": "invalid_transition",
                 "message": "Cards in 'Done' cannot move to 'Backlog'",
                 "details": { "from_stage_id": "...", "to_stage_id": "..." } } }

    Standard codes: unauthenticated, forbidden, not_found, validation_failed (details = changeset errors), invalid_transition, conflict (e.g. stale-write), rate_limited.

  • Pagination: cursor-based (?cursor=<opaque>&limit=50); response includes next_cursor (null when done). UUIDv7 IDs make id > cursor pagination trivially time-ordered.

  • Concurrency: writes that mutate a card include If-Match: <card_id>:<lock_version> once we add a lock_version column (Phase 4). Phase 2 read-only is fine without it.

Endpoints (v1 surface)

Auth

  • POST /api/v1/sessions/request_magic_link {email} → 204
  • POST /api/v1/sessions/redeem {token} → 200 {api_token, user}
  • DELETE /api/v1/sessions/current → 204
  • GET /api/v1/users/me → 200 {id, email, preferences}
  • PATCH /api/v1/users/me/preferences {key, value} → 200

Boards

  • GET /api/v1/boards → list user’s boards (with role, last activity)
  • POST /api/v1/boards {template_id, name} → 201 board
  • GET /api/v1/boards/:id → board metadata + memberships + settings
  • GET /api/v1/boards/:id/workflow → stages + transitions + labels + fields + subboards (the static structure)
  • GET /api/v1/boards/:id/cards → cards in the board (with assignees, labels, field values, stage_id, subboard_id, position)
  • PATCH /api/v1/boards/:id → settings (name, archive days, etc.)
  • DELETE /api/v1/boards/:id
  • GET /api/v1/boards/:id/history?cursor=...
  • GET /api/v1/boards/:id/memberships
  • POST /api/v1/boards/:id/invites {role} → returns invite URL
  • DELETE /api/v1/boards/:id/memberships/:user_id

Cards

  • POST /api/v1/boards/:id/cards → create
  • PATCH /api/v1/cards/:id → title/description/field values/assignees/labels
  • POST /api/v1/cards/:id/move {stage_id, subboard_id, position} → calls Kanban.move_card/3; returns 422 invalid_transition if illegal
  • DELETE /api/v1/cards/:id
  • POST /api/v1/cards/:id/notes {body} → card notes (chat thread)
  • GET /api/v1/cards/:id → full card with notes, history hints

Templates

  • GET /api/v1/workflow_templates
  • POST /api/v1/workflow_templates {name}
  • GET /api/v1/workflow_templates/:id → stages, transitions, labels, fields
  • PATCH /api/v1/workflow_templates/:id
  • DELETE /api/v1/workflow_templates/:id
  • POST /api/v1/workflow_templates/:id/stages
  • PATCH /api/v1/workflow_templates/:id/stages/:stage_id
  • DELETE /api/v1/workflow_templates/:id/stages/:stage_id
  • (same shape for transitions, labels, fields)

Invites / accounts admin

  • GET /api/v1/users/invites
  • POST /api/v1/users/invites {email} → 201 {invite_url}
  • DELETE /api/v1/users/invites/:id

View modules

Phoenix views are JSON-only here. Layout in code:

lib/waxx_web/
  api/
    auth.ex                # bearer-token plug + scope assign
    error_handler.ex       # falls back FallbackController for {:error, _}
    fallback_controller.ex
    json/                  # render helpers; one module per resource
      board_json.ex
      card_json.ex
      workflow_json.ex
      ...
    v1/
      session_controller.ex
      board_controller.ex
      card_controller.ex
      template_controller.ex
      ...

Router additions (sketch):

pipeline :api do
  plug :accepts, ["json"]
  plug WaxxWeb.Api.Auth                 # parses Bearer, assigns :current_scope
end

pipeline :api_public do
  plug :accepts, ["json"]
end

scope "/api/v1", WaxxWeb.Api.V1, as: :api_v1 do
  pipe_through :api_public
  post "/sessions/request_magic_link", SessionController, :request_magic_link
  post "/sessions/redeem", SessionController, :redeem
end

scope "/api/v1", WaxxWeb.Api.V1, as: :api_v1 do
  pipe_through :api
  delete "/sessions/current", SessionController, :delete
  resources "/boards", BoardController, except: [:new, :edit] do
    get "/workflow", BoardController, :workflow
    get "/cards", BoardController, :cards
    get "/history", BoardController, :history
    resources "/memberships", MembershipController, only: [:index, :delete]
    resources "/invites", BoardInviteController, only: [:create]
    post "/cards", CardController, :create, as: :card
  end
  patch "/cards/:id", CardController, :update
  delete "/cards/:id", CardController, :delete
  post "/cards/:id/move", CardController, :move
  # ...templates, invites, users
end

4. Auth flows

Two independent paths get an api-context bearer token onto the device. Both produce the same kind of token — once the app holds it, every subsequent request looks identical.

[Android]                  [Phoenix server]               [Mail / browser]
   |                              |                              |
   |-- POST /api/v1/sessions/     |                              |
   |   request_magic_link (email) |                              |
   |----------------------------->|--- send mail w/ link ------->|
   |   204                        |                              |
   |<-----------------------------|                              |
   |                              |                              |
   |                              |    user taps link in mail    |
   |                              |             |                |
   |   Android opens via App Link (https://waxx.example/m/<tok>) |
   |<--- intent (data URI) ---------------------------------------|
   |                              |                              |
   |-- POST /api/v1/sessions/     |                              |
   |   redeem {token}             |                              |
   |----------------------------->|                              |
   |   200 {api_token, user}      |                              |
   |<-----------------------------|                              |
   |                              |                              |
   |-- everything after carries   |                              |
   |   Authorization: Bearer ...  |                              |

Key details:

  • The magic-link URL becomes https://waxx.example/m/<token> (new route, not the existing /users/log-in/<token> web flow — the web flow logs the user in directly to a browser session, we want a redirect that’s also claimable by the Android intent filter).
  • Server-side, GET /m/:token does an HTML redirect to the web login flow if the request comes from a browser (no Android handoff happened). For the Android path, the intent filter on the app intercepts before the browser ever loads anything, and the app immediately calls POST /api/v1/sessions/redeem.
  • redeem invokes the existing Waxx.Accounts.login_user_by_magic_link/1 to consume the magic-link token, then mints a new UserToken with context "api" (long-lived; 90-day rolling validity, renewed on use).
  • API tokens are revocable individually from /users/settings (“Connected devices” section, new in Phase 1) and bulk-revoked when the user changes email/password.

Why not the web /users/log-in/<token> route?

That route creates a Plug session cookie and redirects to a HEEx page. It assumes a browser. Reusing it for Android would mean parsing HTML or hijacking the redirect. A purpose-built /m/:token + POST /redeem pair is cleaner and lets the web fallback continue working untouched.

  • Domain: TBD (user has a domain; needs to be the same one Phoenix serves the API from, e.g. waxx.example.com).
  • File: https://waxx.example.com/.well-known/assetlinks.json, served by Phoenix as a static asset (add to priv/static/.well-known/ or serve via a controller).
  • Contents:
    [{
      "relation": ["delegate_permission/common.handle_all_urls"],
      "target": {
        "namespace": "android_app",
        "package_name": "com.waxx.app",
        "sha256_cert_fingerprints": ["AB:CD:..."]
      }
    }]
  • Two fingerprints listed: the debug keystore and the release keystore.
  • Keystore generation (Phase 0): we generate both, store the release keystore + password in a secret manager (1Password / Bitwarden), and commit only the debug one to android/keys/.

Token lifecycle

  • api tokens: 90 day inactivity expiry, refreshed (inserted_at bumped) on every successful authenticated request.
  • Revocation: server-side delete from users_tokens; any in-flight request gets 401 unauthenticated.
  • The Android app stores the token in EncryptedSharedPreferences (AES via the Android Keystore).

4b. Pair via QR (mirrors ../dms)

The web user — already authenticated in a browser session — generates an API token from /users/settings (“Connected devices” panel). The server renders a QR encoding everything the app needs (server URL + token), the phone scans it, and the phone is paired. No email, no SMTP, no App Links required.

[Browser, logged in]                    [Phoenix server]                [Phone]
       |                                       |                          |
       |-- POST /users/settings/api_tokens     |                          |
       |   (LiveView form: label, ttl)         |                          |
       |-------------------------------------->|                          |
       |                                       |-- Accounts.create_api_   |
       |                                       |   token/2                |
       |                                       |                          |
       |   200 (LiveView re-renders with the   |                          |
       |   "token created" panel)              |                          |
       |<--------------------------------------|                          |
       |                                       |                          |
       |   panel shows QR + URI + raw token    |                          |
       |   (only time the raw token is shown)  |                          |
       |                                       |                          |
       |   user opens app on phone,            |                          |
       |   taps "Scan QR" on Pair screen ----->|------------------------->|
       |                                       |                          |
       |   phone parses payload, calls         |                          |
       |   GET /api/v1/users/me with bearer    |                          |
       |   ------------------------------------|<-------------------------|
       |                                       |   200 {user}             |
       |                                       |------------------------->|
       |                                       |                          |
       |                                       |   phone stores (base,    |
       |                                       |   token), lands on app   |

QR payload format

waxx://pair?base=<url-encoded server URL>&token=<url-encoded token>
  • base is the full origin the app should talk to, e.g. https://waxx.example.com or http://192.168.1.50:4000 for LAN.
  • token is the raw bearer token (same string the user would paste into Authorization: Bearer …).
  • The custom waxx:// scheme also lets the user tap the URI from a note or chat to deep-link straight into the Pair screen with both fields prefilled (intent filter on waxx://pair in the manifest).

Server-side base URL derivation

Mirrors derive_base_url/2 in dms/src/handlers/api_tokens.rs so self-hosted installs behind a reverse proxy don’t bake a bind address into the QR. Priority:

  1. WAXX_PUBLIC_URL env (explicit override).
  2. X-Forwarded-Proto + X-Forwarded-Host (standard proxy headers).
  3. The request’s Host header (covers LAN access on 0.0.0.0).
  4. The endpoint config as a last resort.

Web “Connected devices” panel

New LiveView surface inside the existing /users/settings page (mounts into UserLive.Settings as another section). Lists active API tokens with sent_to, authenticated_at, inserted_at, and a Revoke action. The Generate form sits at the top; submitting it inserts the token row and re-renders with the QR panel inline — the same idiom as dms’s api_tokens/created.html template.

Server dependencies

  • QR rendering: add :eqrcode to mix.exs (pure-Elixir, SVG output). Roughly: {:eqrcode, "~> 0.2.1"}.
  • No other new deps. The token-minting path (Accounts.create_api_token/1) already exists from Phase 0.

Android dependencies and screen

  • Scanner: journeyapps:zxing-android-embedded (same lib as dms), via the ScanContract activity-result API.
  • Pair screen: feature:auth/PairScreen.kt — three states (Idle, Verifying, Success/Error). Scan button launches the scanner; manual base+token fields below it for paste/typing fallbacks. On submit, calls GET /api/v1/users/me with the candidate bearer; on success, writes to TokenStore (EncryptedSharedPreferences) and navigates to the Boards list.

Why both 4a and 4b?

  • QR (4b) is the easier UX when the user already has a logged-in web session and physical access to the screen, especially for self-hosted Waxx without SMTP wired up. No App Links setup needed.
  • Magic link (4a) is the answer for “I’m only on my phone, I’ve never logged in on a desktop.” Requires the SMTP path and App Links for the nicest UX (or falls back to the custom scheme).

The Android Login screen offers both as top-level buttons; users pick whichever fits their situation. The backend treats both identically once a bearer token is in hand.


5. Real-time channel

Server: WaxxWeb.UserSocket + BoardChannel

WaxxWeb.UserSocket
  /socket connect: ?token=<api_token> → verifies via UserToken (context "api")
  channel "board:*", BoardChannel

WaxxWeb.BoardChannel
  join("board:<id>"):
    - assert membership via Kanban.member?/2
    - Kanban.subscribe(id)             # joins the existing PubSub topic
    - push("snapshot", workflow + cards)
  handle_info({:cards_changed, _}, socket):
    push("cards_changed", %{board_id: ...})    # client refetches /cards
  handle_info({:workflow_changed, _}, socket):
    push("workflow_changed", %{board_id: ...}) # client refetches /workflow

Channel pushes are intentionally lightweight notifications, not deltas — the client re-fetches the affected resource. Rationale:

  • The existing PubSub events (:cards_changed, :workflow_changed) already match this contract.
  • Avoids re-serialising state into channel payloads with their own schema drift.
  • Re-fetch latency is small (one HTTP round-trip) and we can move to delta payloads later without changing the channel API surface.

The Android client uses [phoenix-channels-client (kotlin)] or rolls a small wrapper around OkHttp WebSocket; decision deferred to Phase 3.

Channel auth

UserSocket.connect/3 takes ?token=..., verifies it through Waxx.Accounts.UserToken with context "api", assigns the user_id to the socket. The board channel re-checks membership on every join.


6. Android app architecture

Stack

  • Language: Kotlin 2.x
  • UI: Jetpack Compose + Material 3
  • DI: Hilt
  • HTTP: Retrofit + OkHttp + Moshi (codegen)
  • QR scanner: com.journeyapps:zxing-android-embedded (matches dms; ScanContract activity-result API)
  • WebSocket / Channels: phoenix-channels-client (Kotlin) — if mature enough; otherwise a thin wrapper over OkHttp’s WebSocket (decided Phase 3).
  • Navigation: Compose Navigation with type-safe routes
  • State: ViewModel + StateFlow; no Redux/MVI framework
  • Persistence: Room for caching boards/cards/workflow; auth token in EncryptedSharedPreferences
  • Image loading: Coil (for user avatars if/when added)
  • Build: Gradle 8.x with Kotlin DSL, version catalog (gradle/libs.versions.toml)
  • Min SDK: 26 (Android 8). Target SDK: current.

Module layout

android/
  app/                     # Entry point, Application, navigation host
  core/
    network/               # Retrofit interfaces, OkHttp interceptors,
                           # auth header injection, channel client
    auth/                  # Token storage (EncryptedSharedPreferences),
                           # magic-link redemption + QR pairing flows,
                           # ApplinkActivity (https://host/m/:token) +
                           # PairActivity (waxx://pair?...)
    model/                 # Data classes mirroring API JSON
    db/                    # Room database, DAOs, mappers
    ui/                    # Shared composables, theme (mirrors daisyUI
                           # palettes), error surfaces
  feature/
    boards/                # /boards list + create
    board/                 # /boards/:id kanban view + DnD + card sheet
    template/              # Template editor (tabs: workflow/labels/fields)
    settings/              # Board settings, user settings, invites
    history/               # Per-board history
  build-logic/             # Convention plugins for shared Gradle config
  keys/                    # Debug keystore (committed). Release keystore
                           # NOT committed.

Navigation graph

Login
  ├─ Email me a link  (magic-link, App Link redemption)
  └─ Scan QR / paste  (QR pairing, opens PairScreen)
       │
       v
Boards list (root after auth)
      ├─ Board detail (kanban grid)
      │   ├─ Card detail sheet
      │   ├─ Board settings
      │   ├─ Board history
      │   └─ Board members & invites
      ├─ Workflow templates list
      │   └─ Template editor (workflow / labels / fields tabs)
      └─ User settings
          ├─ Email change
          ├─ Connected devices (list active API tokens, revoke)
          └─ App invites

(The “Connected devices” panel also exists on the web under /users/settings — that’s where the QR generator lives, since the phone is the consumer side of the pairing.)

Drag and drop

Compose has no first-class kanban DnD. Realistic options:

  1. reorderable library (sh-no-rebola) — built for kanban-style reordering, supports cross-list drops.
  2. Custom drag detection with pointerInput + a shared overlay layer for the drag avatar.

Plan: try (1) first; fall back to (2) if the constraints don’t fit (green/red drop highlighting tied to workflow edges, drag avatars that respect the card-chip layout). Decision in Phase 4.

The transition rules come from the workflow snapshot fetched at board load and refreshed on workflow_changed. The client greys out illegal drop targets, but the server is still authoritative — POST /cards/:id/move will return 422 invalid_transition if the client is wrong (stale workflow, bug), and the UI rolls back optimistically.

Theming

Port the eight daisyUI themes (six neiam-co + blueprint + default) to Material 3 ColorSchemes. Lives in core/ui/Themes.kt. Same persistence model as web — preferences blob in users.preferences under the theme_default key, synced on login.

Error handling

  • Network errors surface as Compose snackbars with a Retry action.
  • 401: drop the stored token, navigate to Login.
  • 403: snackbar “you don’t have access”.
  • 422 invalid_transition: silent (DnD already blocked it client-side; if we see this, log to Sentry — it’s a workflow-snapshot drift bug).
  • 5xx: snackbar “server error” + Sentry breadcrumb.

7. Build & release

Local dev

  • ./gradlew :app:installDebug from /android.
  • API base URL is a BuildConfig field, defaulting to http://10.0.2.2:4000 for the emulator pointing at the host’s mix phx.server.

CI

  • Add an android job to .gitlab-ci.yml that runs on changes under /android/**: ./gradlew :app:assembleDebug :app:testDebugUnitTest.
  • Release builds (Play Store) come later — out of scope for the design doc, but the keystore + signing-config story needs to be settled before we ship publicly.

Versioning

  • Android versionCode/versionName decoupled from the Phoenix release tag. The app advertises X-Waxx-Client: android/<versionName> so the server can log/refuse old clients if needed.

8. Phase plan

Each phase is a separate PR (or small stack of PRs). The doc gets updated with what shipped before moving on.

Phase 0 — Backend foundation (no Android code) ✅ shipped

  • WaxxWeb.Api.Auth plug + :api_authenticated pipeline.
  • UserToken gains "api" context with mint/verify/refresh/revoke.
  • WaxxWeb.Api.FallbackController + ErrorJSON envelope.
  • WaxxWeb.Api.V1.SessionController with request_magic_link, redeem, delete.
  • /m/:token route handing off to the web login flow (Android intent filter intercepts before this fires).
  • 55 tests passing.

Phase 1a — Web “Connected devices” + QR generation ✅ shipped

  • :eqrcode ~> 0.2.0 added to mix.exs.
  • Migration 20260524120000_add_label_to_users_tokens adds nullable label :string to users_tokens.
  • Waxx.Accounts.UserToken.build_api_token/2 and Accounts.create_api_token/2 accept an optional label (trimmed, empty→nil, truncated to 80 chars). Accounts.list_api_tokens/1 returns it.
  • WaxxWeb.PublicUrl.derive/1 implements the four-tier precedence (WAXX_PUBLIC_URL → X-Forwarded-* → conn host/scheme/port → endpoint URL). Two-arity dispatch: takes a Plug.Conn or a LiveView.Socket. The socket path requires :uri + :x_headers in the endpoint connect_info, and must be called from mount/3 (LiveView destroys connect_info after mount), so callers stash the result in assigns.
  • WaxxWeb.QR.svg/2 wraps EQRCode and returns an SVG string suitable for inline raw/1 rendering.
  • UserLive.Settings grew a “Connected devices” section: label form → Generate → one-time QR panel (SVG + base URL + raw token + the waxx://pair?... URI) → list of active tokens with Revoke buttons. Sudo mode is required (inherited from the page’s on_mount).
  • 22 new tests covering: label handling on Accounts, derive precedence on Plug.Conn, QR output shape, LiveView interactions (empty state, generate, revoke, dismiss).
  • ConnCase.log_in_user/2 helper added for future LiveView tests.

Phase 1b — Android scaffold + both login paths ✅ shipped

  • /android Gradle project under org.neiam.waxx.app (single-module to match ../dms/android’s shape; multi-module split deferred until the screen count justifies it). Kotlin 2.0 · Compose · AGP 8.5.2 · Gradle 9.0 · minSdk 26 · targetSdk 34.
  • :app:assembleDebug produces an 18 MB APK with no warnings.
  • GET /api/v1/users/me endpoint (WaxxWeb.Api.V1.UserController) pulled forward from Phase 2 as the Pair-screen / Boards-stub probe.
  • .well-known/assetlinks.json served from Phoenix (priv/static/.well-known/, added to WaxxWeb.static_paths). Placeholders for debug + release SHA-256 fingerprints; instructions in android/README.md.
  • Manifest registers two intent filters:
    • waxx://pair?base=…&token=… (custom scheme, no autoVerify — works in any debug build immediately).
    • https://${appLinksHost}/m/<token> (autoVerify=true — needs a real domain + matching assetlinks.json to take effect; falls back to the OS chooser otherwise).
  • Three screens: LoginScreen (two-button picker), PairScreen (QR scan + paste fallback, ports the dms equivalent), MagicLinkScreen (send + paste-link / wait-for-deep-link).
  • BoardsScreen is a stub that calls /api/v1/users/me to confirm the token is good end-to-end; Phase 2 replaces it with the boards list.
  • TokenStore uses EncryptedSharedPreferences (AES via Android Keystore); data_extraction_rules.xml excludes the secure-prefs file from cloud backup + device transfer.
  • README documents the local dev workflow (emulator → 10.0.2.2:4000, WAXX_PUBLIC_URL override for QR), keystore generation, and the App Links handover process.
  • Backend now at 81 tests (+4 for /users/me and assetlinks serving).

Deferred from Phase 1b:

  • Release keystore (documented; not generated — needs an out-of-repo secret store).
  • GitLab CI job for :app:assembleDebug + tests (needs an Android-SDK image; deferred to a dedicated CI PR).
  • Hilt / multi-module split (revisit when screen count grows).

Phase 2 — Read-only boards & kanban ✅ shipped

  • WaxxWeb.Api.V1.BoardController with five actions backed by the existing Kanban context — every action runs Kanban.get_board_for_user/2 first, which 404s for non-members (no leak of board existence):
    • GET /api/v1/boards → list with role + timestamps
    • GET /api/v1/boards/:id → board metadata + members
    • GET /api/v1/boards/:id/workflow → stages, transitions, labels, fields, subboards (the static structure)
    • GET /api/v1/boards/:id/cards → cards with assignee_ids, label_ids, field_values (already runs the archive-terminal filter)
    • GET /api/v1/boards/:id/history?limit=N → activities newest-first, limit clamped to [1, 500], default 200
  • WaxxWeb.Api.V1.BoardJSON — pure serialization, takes Ecto structs and returns plain maps. Handles Ecto.Association.NotLoaded so a missing preload doesn’t crash.
  • Waxx.KanbanFixtures (test/support) — template_fixture, board_fixture, card_fixture so controller tests don’t have to rebuild graphs inline.
  • Android replaces the v1b stub with a three-screen flow:
    • BoardsListScreen — calls /boards, taps open BoardScreen.
    • BoardScreen — calls /board/:id, /workflow, /cards in parallel-fetch fashion; renders a horizontally-scrolling kanban of StageColumns with card chips. Top-right History button opens HistoryScreen. Card taps surface a CardSheet (ModalBottomSheet).
    • HistoryScreen — calls /history, renders newest-first.
  • Activity meta (jsonb) is intentionally dropped on the Android side for now — ignoreUnknownKeys = true on the kotlinx.serialization config makes this safe. The web LiveView still renders it per-action; porting that formatter is deferred.
  • Backend: 10 new controller tests, 91 tests total green.
  • Android: :app:assembleDebug clean (no warnings).

Deferred from Phase 2 (tracked):

  • Room caching with stale-while-revalidate: every screen currently re-fetches on entry. The data model is small enough (single board ≤ ~1 MB JSON) that the perceived latency is mostly the request itself, not parsing. Add Room when there’s evidence — e.g., users tapping back/forward across boards in poor connectivity.
  • Subboards in the kanban view: the API returns subboard_id on cards and the workflow’s subboards list, but BoardScreen doesn’t group rows yet. Add when Phase 6 lands.
  • Activity meta rendering: needs a per-action formatter ported from WaxxWeb.BoardLive.History. Deferred to a polish PR.

Phase 3 — Real-time ✅ shipped

  • WaxxWeb.UserSocket — token-authenticated Phoenix socket; the same api-context bearer used by HTTP. Connect rejects bad/missing tokens; id/1 returns "user_socket:#{user_id}" so future server- side Endpoint.broadcast("user_socket:<id>", "disconnect", %{}) calls can force-evict a logged-out device.
  • WaxxWeb.BoardChannel — topic "board:<id>". Join asserts the caller has a Membership row on the board; non-members get {:error, "forbidden"}. On join the channel subscribes to the existing Kanban PubSub topic and forwards :cards_changed / :workflow_changed as lightweight channel pushes (no payload deltas — the client refetches the affected resource via HTTP).
  • Endpoint registers socket "/socket", WaxxWeb.UserSocket alongside the existing LiveView socket.
  • 6 channel tests: connect (good/bad/missing token), join (member / non-member), end-to-end push forwarding via card_fixture/3.
  • Android adds com.github.dsrees:JavaPhoenixClient:0.3.4 (jitpack; newer 1.x versions all show “Error” on jitpack right now).
  • WaxxSocket.subscribeBoard(creds, boardId) returns a Flow<BoardEvent>: Connected | Joined | Disconnected | CardsChanged | WorkflowChanged | Error. One socket per Flow keeps lifecycle simple (the lib’s 0.3.x API doesn’t expose off() on socket-level listeners).
  • BoardScreen runs a DisposableEffect that launches the subscription; refetches on cards_changed / workflow_changed; tracks connection state for the new top banner (“Connecting…” / “Offline — changes won’t sync until reconnect.”) shown when not Live.

Deferred:

  • Force-disconnect on token revocation. Hook Endpoint.broadcast("user_socket:#{user_id}", "disconnect", %{}) into Accounts.delete_api_token/2 so revoking a device from /users/settings kicks the live socket too. Currently the revoked bearer becomes 401 on next HTTP call but the existing channel stays open until reconnect.
  • Per-channel resubscribe metrics / Sentry breadcrumbs. Worth doing when we have telemetry plumbed but not load-bearing now.

Phase 4 — Card writes (core) ✅ shipped

(Scope trimmed — see Phase 4b for the rest.)

  • WaxxWeb.Api.V1.CardController with four actions, all gated on owner/editor role on the card’s board:
    • POST /api/v1/boards/:board_id/cards {title, description?, board_stage_id?, subboard_id?, position?}
    • PATCH /api/v1/cards/:id {title?, description?}
    • POST /api/v1/cards/:id/move {board_stage_id, position?}
    • DELETE /api/v1/cards/:id
  • 404 for non-members, 403 for members without edit permission, 422 for validation / invalid_transition / invalid_stage.
  • Fixed a latent bug in Kanban.delete_card/2: it was logging an activity with card_id: <just-deleted id> which violated the FK. Switched to logging with card_id: nil; meta.title already carries the human-readable string. No existing test triggered this path — the LiveView UI would have crashed on first delete in prod.
  • WaxxWeb.Api.V1.BoardJSON.card_response/1 — single-card envelope so mutation endpoints share the JSON shape with the list endpoint.
  • 11 new controller tests (create / update / move / delete × happy path + auth + role + transition).
  • Android writes: WaxxApi.createCard / updateCard / moveCard / deleteCard (+ matching request body models).
  • BoardScreen gains a FAB + CreateCardDialog (title + optional description); on success refetches cards immediately (the channel push also fires).
  • CardSheet gains three actions (Edit / Move / Delete):
    • Edit swaps into an in-place title/description editor.
    • Move shows a stage picker built from the workflow’s transitions (only valid targets surface; server still authoritative via 422 invalid_transition).
    • Delete prompts a confirm dialog, then dismisses the sheet.

Phase 4b — Labels, fields, assignees, notes ✅ shipped

  • WaxxWeb.Api.V1.CardController gains five actions:

    • GET /api/v1/cards/:id — single card with notes preloaded
    • POST /api/v1/cards/:id/labels/:label_id/toggle — toggle on/off
    • PUT /api/v1/cards/:id/fields/:field_id {value} — set or clear
    • POST /api/v1/cards/:id/assignees {user_id} — must already be a board member
    • DELETE /api/v1/cards/:id/assignees/:user_id
  • New WaxxWeb.Api.V1.NoteController:

    • POST /api/v1/cards/:card_id/notes {body, kind?}
    • PATCH /api/v1/notes/:id {body?, done?}
    • DELETE /api/v1/notes/:id
  • All gated on owner/editor. Member validation on assignees (fetch_board_member/2) prevents assigning users outside the board’s membership list — mirrors the LiveView assignable_users guard.

  • Labels from a different board map to :not_found (no leak). Invalid select field options map to validation_failed.

  • BoardJSON.card_detail_response/1 includes notes (intentionally not on the list payload — would bloat every /cards fetch). BoardJSON.note/1 + note_response/1 for the note JSON envelope.

  • Test fixtures: board_label_fixture/2, board_field_fixture/2 for direct board-scoped seeding.

  • 14 new controller tests; backend now at 122 total.

  • Android: 8 new WaxxApi methods + matching request bodies and the CardDetail/CardNote response models.

  • BoardScreen also surfaces board.memberships via a new onMembersLoaded callback so the sheet can render assignee chips without re-fetching.

  • CardSheet rewritten as a scrollable LazyColumn with sections:

    • Title / Edit / Move / Delete (existing)
    • Labels — FilterChip toggles for every label on the board.
    • Fields — per-field row with kind-aware editor (text input or select option list); Save / Clear / Cancel.
    • Assignees — FilterChip per board member, toggles assignment.
    • Notes — list of notes (with todo-style checkbox when kind == "todo") + inline add form (free-form or todo).
  • Card sheet fetches GET /api/v1/cards/:id on open for the freshest state + notes; mutations re-fetch the detail before re-rendering.

Phase 4c — Drag-and-drop kanban ✅ shipped

  • org.neiam.waxx.app.ui.DragState — shared state container for the kanban DnD:
    • start / update / cancel / dropAt(workflow) lifecycle
    • setStageBounds / clearStageBounds for column hit-test
    • isValidTarget(stageId, workflow) for column highlighting
  • BoardScreen owns one instance and passes it down. The drop handler reads the dragged card before clearing the session, then calls /api/v1/cards/:id/move with the target stage id. Refetches cards on success (channel push fires too).
  • Long-press starts a drag on a card chip (detectDragGesturesAfterLongPress). The chip stays in its original column (no snapshot avatar yet — user sees a blue border on the source chip while dragging).
  • Each StageColumn registers its window-space bounds via Modifier.onGloballyPositioned. During a drag, columns paint a translucent overlay:
    • grey (#33888888) for the source column
    • green (#6680FF80) for valid targets (transition exists)
    • red (#66FF8080) for forbidden targets
  • The server is still authoritative: a drop that would be valid but has stale workflow data still gets a 422 invalid_transition and the chip snaps back via the next refresh.

Phase 4c remainder — still deferred

  • Within-column reorder via DnD — the move endpoint accepts a position argument; the chip doesn’t expose insertion-line UI yet. Wait until users ask.
  • Auto-scroll during drag — boards with more than ~3 columns don’t fit on a phone in landscape; today the user has to release+rescroll. Add when columns commonly exceed the screen width.
  • lock_version optimistic locking — concurrent-edit collisions follow web LiveView’s last-write-wins model today. PubSub fan-out narrows the window; revisit when there’s a real case of clobbering.
  • Subboards (2-D grid) — covered by the API but the kanban view doesn’t group rows yet. Will land alongside Phase 6.

Phase 5 — Board settings, members, invites ✅ shipped

(Template editor + user preferences pulled out into Phase 5b; this turn keeps the daily-driver pieces.)

  • Endpoints (all editor-or-owner enforced as appropriate):

    • PATCH /api/v1/boards/:id — rename, description, archive days (owner-only)
    • DELETE /api/v1/boards/:id — owner-only
    • PUT /api/v1/boards/:board_id/memberships/:user_id {role} (owner-only)
    • DELETE /api/v1/boards/:board_id/memberships/:user_id — owner-only; refuses the last-owner removal
    • GET /api/v1/boards/:board_id/invites — owner-only
    • POST /api/v1/boards/:board_id/invites {role?, note?, expires_in_days?} (owner-only)
    • DELETE /api/v1/boards/:board_id/invites/:id — owner-only
    • GET /api/v1/users/invites
    • POST /api/v1/users/invites {note?, expires_in_days?}
    • DELETE /api/v1/users/invites/:id — caller must own the invite
  • Each invite endpoint returns a precomputed redemption_url built from WaxxWeb.PublicUrl.derive(conn) — board invites point at /b/<token>, app invites at /users/register?invite=<token>. Self-hosted installs get the LAN URL baked in automatically.

  • Fixed Waxx.Accounts.create_invite/2: it was putting :created_by_id (atom) onto an attrs map of string keys, which Ecto rejects as mixed keys. Switched to the same normalize-to-string-keys pattern create_system_invite/1 already uses. The LiveView caller passes no attrs so this wasn’t a regression in prod.

  • Membership deletion guard_last_owner/1 refuses to remove the only remaining owner — the LiveView relies on at-least-one-owner being an invariant elsewhere.

  • 12 new controller tests; backend now at 134 total.

  • Android: 10 new WaxxApi methods + matching request/response models.

  • BoardSettingsScreen — tabbed (Settings / Members / Invites), reachable from the new gear icon in the BoardScreen top bar:

    • Settings — name, description, archive-after-days, Delete board with confirm. Non-owners see disabled fields + an explanatory line.
    • Members — list with role chips and a Remove button; owners can promote/demote between owner / editor / viewer.
    • Invites — owner-only “Generate” panel (role / note / TTL), each existing invite renders with the redemption URL, Copy-to- clipboard, and Revoke buttons; consumed invites show who took them.
  • AppInvitesScreen — reachable from the new overflow menu on BoardsListScreen (“Registration invites”). Same generate / list / copy / revoke flow against /api/v1/users/invites.

  • BoardsListScreen top-right icon changed to a MoreVert overflow menu hosting “Registration invites” + “Sign out”.

Phase 5b — Template editor ✅ shipped

  • WaxxWeb.Api.V1.TemplateController covers template CRUD + nested stage/transition/label/field CRUD. All authenticated users can read/write (matches the LiveView surface at /workflow-templates).
  • POST /api/v1/boards {template_id, name, ...} clones a template the same way the LiveView creator does.
  • Each mutation cascades to existing boards via the existing Waxx.Workflows.* propagation logic.
  • Android: TemplatesListScreen (reached from BoardsListScreen overflow) + TemplateEditorScreen with three tabs (Workflow / Labels / Fields). BoardsListScreen gains a FAB + CreateBoardDialog for spinning up boards from templates without going to the web.
  • 8 new controller tests; backend at 154 total.

Polish shipped alongside Phase 5b

  • Force-disconnect on token revocation — Accounts.delete_api_token/2 broadcasts "disconnect" on the user’s socket topic; the revoked device’s reconnect 401s, other devices reconnect cleanly.
  • Subboard reordering — Kanban.reorder_subboard/2, PATCH /api/v1/subboards/:id {position}; Android Rows tab has up/down arrows.
  • Within-column DnD reorder — DragState.dropAt returns a Drop(target, index?); same-cell drops compute insertion index from chips’ vertical centers. Server’s existing move_card/4 target_index handled it.
  • Auto-scroll during drag — Kanban LaunchedEffect watches the drag pointer; animateScrollBy runs while the pointer sits within 80dp of either horizontal edge.
  • GitLab CI — new android-debug job runs :app:assembleDebug on changes under /android/**, uses cimg/android:2024.07.1, caches the gradle directory, publishes the debug APK as an artifact.

Still deferred

  • User preferences sync — PATCH /api/v1/users/me/preferences for theme + per-board label-text toggle. Theme picker uses local storage today.
  • Default invite role / note suggestions — bare form today.
  • Per-token socket id (so revoking one token only takes down that connection, not all the user’s sockets). Acceptable as-is.

Phase 6 — Subboards (2-D grid) ✅ shipped

  • POST /api/v1/cards/:id/move now accepts an optional subboard_id in the body; the server distinguishes “absent” (leave the row alone) from “null” (clear to default row) via Map.has_key?. After the stage move succeeds, Kanban.set_card_subboard/3 runs in the same request — same serial pattern the LiveView uses.

  • New WaxxWeb.Api.V1.SubboardController:

    • POST /api/v1/boards/:board_id/subboards {name}
    • DELETE /api/v1/subboards/:id Owner-only. Cards previously assigned fall back to the default row via the FK’s on_delete: :nilify_all.
  • BoardJSON.subboard_response/1 returns the {subboard: …} envelope.

  • Foreign-board subboard ids on a move are rejected as 422 invalid_subboard — Kanban.set_card_subboard/3 already enforces this, the API just surfaces it.

  • 7 new controller tests; backend now at 141 total.

  • Android wire-format: MoveCardBody stays for stage-only moves; a parallel moveCardWithSubboard extension uses a sealed SubboardChange (Leave | Clear | Set(id)) plus a free-form JsonObject body to control which fields actually appear on the wire — kotlinx.serialization defaults can’t distinguish “absent field” from “null field” otherwise.

  • DragState reworked to track cells by (stageId, subboardId?) instead of just stageId. Same shape covers 1-D (subboardId = null everywhere) and 2-D layouts.

  • BoardScreen Kanban dispatches:

    • 1-D (no subboards): existing horizontal Row of stage columns.
    • 2-D (subboards present): vertical Column of subboard rows (Default first, then sorted subboards), each a horizontal Row of stage cells. A header strip across the top labels the columns.
  • DnD across the 2-D grid uses moveCardWithSubboard. Dropping in (target_stage, target_subboard) moves stage and re-assigns row in one call; same-row drops omit subboard_id to leave it alone.

  • CardSheet got a new “Row” section above Labels: a row of FilterChips for Default + every subboard. Selecting one calls the same move endpoint (with the current stage_id) to re-assign.

  • BoardSettingsScreen got a fourth tab — Rows — with a Compose form to add a row (name) and a list to delete existing ones. Owner-only.

Phase 6 remainder — deferred (optional polish)

  • Within-column reorder via DnD (still deferred from Phase 4c).
  • Auto-scroll during drag for wide boards.
  • Reorder subboard rows — Kanban context has position but no helper to swap; LiveView doesn’t expose it either. Wait for ask.
  • Empty-state polish / error retry UX / accessibility audit / in-app theme picker / push notifications. Out of scope for parity.

9. Open questions

  1. Domain & TLS for App Links. Need the exact domain Phoenix will be served from in production. Affects assetlinks.json and the intent filter manifest. Less urgent now that QR pairing covers the self-hosted “no SMTP, no public domain” case — App Links can wait until we ship the magic-link path publicly.
  2. Play Store distribution. Internal track only? Closed beta? Public? Affects when we need the privacy policy URL and Data Safety form filled out.
  3. Lock version. Adding lock_version to cards is a migration + touches every mutation. Worth doing for Android (and good hygiene for web), but should it be a separate prerequisite PR before Phase 4?
  4. CORS. REST endpoints are same-origin from web LiveView’s POV (no CORS needed for the web app, which uses LiveView, not the API). But if we ever want JS to call /api/v1 we’ll need CorsPlug. Punt until needed.
  5. Rate limiting. No rate limit on the existing auth endpoints. Add hammer-backed limit at least to /api/v1/sessions/request_magic_link before any public exposure of the API.
  6. mix phx.gen.invite parity. Should app invites flow through the API too, or stay CLI/web only? Probably API-too (so admins can invite from their phone), but it’s not on the critical path — covered in Phase 5.
  7. API token labels. Resolved in Phase 1a: label column added, trimmed/truncated server-side. Per-token TTL still uses the global 90-day window — revisit if users ask for custom expiries.
  8. QR generator deps. Resolved: :eqrcode ~> 0.2.0 compiles cleanly on the current toolchain and is in use via WaxxWeb.QR.
  9. Scanner camera permission UX. ZXing’s embedded scanner activity handles the runtime permission prompt itself; we may want a custom pre-prompt explaining why we need the camera before the OS dialog fires. Decide in Phase 1b.
  10. Token list precision. authenticated_at is bumped at most once per 24h (by maybe_touch_api_token/1), so the “Last seen” timestamp in the device list has up to a day of lag. Fine for a coarse heads-up-display; if we want per-request precision, lift the refresh window or maintain a separate last_seen_at column.

10. Glossary

  • Drift — a board entity (stage/label/field) whose name no longer matches the template it came from. Template edits skip drifted entities.
  • Workflow snapshot — the static structure of a board (stages, transitions, labels, fields, subboards) as returned by GET /api/v1/boards/:id/workflow. Refreshed only on workflow_changed.
  • Card snapshot — the dynamic content of a board (cards + their fields/labels/assignees/positions), returned by /cards. Refreshed on cards_changed.