wahh'

1405e6a · gmorell · 2026-05-24 22:24

21 files +1502 -3

Files changed

modified config/runtime.exs
+8 −2
@@ -106,9 +106,14 @@ if config_env() == :prod do
106 106 # rendered email at :info — but it gets us off Swoosh.Adapters.Local
107 107 # (the dev mailbox), so the magic-link flow at least leaves a trail
108 108 # in the pod log when SMTP isn't configured.
109 + # Map env-string to a literal atom. `String.to_existing_atom/1` doesn't
110 + # work here in a release: runtime.exs runs before the application that
111 + # owns these atoms has loaded, so the atoms aren't in the table yet.
109 112 smtp_tri = fn var, default ->
110 113 case System.get_env(var, default) do
111 − v when v in ~w(always if_available never) -> String.to_existing_atom(v)
114 + "always" -> :always
115 + "if_available" -> :if_available
116 + "never" -> :never
112 117 other -> raise "#{var} must be one of always|if_available|never, got: #{inspect(other)}"
113 118 end
114 119 end
@@ -137,7 +142,8 @@ if config_env() == :prod do
137 142
138 143 smtp_verify =
139 144 case System.get_env("SMTP_TLS_VERIFY", "verify_peer") do
140 − v when v in ~w(verify_peer verify_none) -> String.to_existing_atom(v)
145 + "verify_peer" -> :verify_peer
146 + "verify_none" -> :verify_none
141 147 other -> raise "SMTP_TLS_VERIFY must be verify_peer or verify_none, got: #{inspect(other)}"
142 148 end
143 149
added docs/android.md
+950 −0
@@ -0,0 +1,950 @@
1 +# Waxx Android client — design doc
2 +
3 +Status: **draft**, pre-implementation.
4 +Owner: gmp@h4xmb.org.
5 +Goal: native Android client with full feature parity to the web LiveView,
6 +sharing the existing Phoenix backend.
7 +
8 +This doc is the contract for the work. Implementation lands in phases (see
9 +[Phase plan](#phase-plan)); each phase has its own PR(s) and updates this
10 +doc with what shipped.
11 +
12 +---
13 +
14 +## 1. Goals & non-goals
15 +
16 +### Goals
17 +- Native Kotlin/Compose Android app, distributed via Play Store eventually.
18 +- Full parity with the web UI: boards, kanban DnD, templates, settings,
19 + invites, history, subboards, themes, label/field/assignee management.
20 +- Real-time multi-device updates — a drag on web is visible on Android
21 + within the same second a drag on web is visible to another web tab.
22 +- Two auth paths into the app: tap-an-email-link (magic-link via Android
23 + App Links) **or** scan-a-QR generated by the logged-in web user (mirrors
24 + the pairing flow in `../dms`). Either alone is sufficient — most users
25 + will use the QR path on self-hosted installs where SMTP isn't wired up.
26 +- Server stays the source of truth for transition enforcement, drift
27 + rules, and propagation logic — none of those rules get reimplemented
28 + on the client.
29 +
30 +### Non-goals (v1)
31 +- iOS. The architecture should not preclude it, but no iOS work in v1.
32 +- Offline-first writes. Read caching is fine; mutations require a live
33 + connection. Adding queued offline writes is post-v1.
34 +- Push notifications. Possible later via FCM, not in this scope.
35 +- A public/third-party API. The endpoints exist for the first-party
36 + Android client only — stability guarantees are weak until v2.
37 +
38 +---
39 +
40 +## 2. Architecture overview
41 +
42 +```
43 ++-------------------+ HTTPS/JSON +-----------------------------+
44 +| Android client | <---------------> | WaxxWeb.Api.* (Phase 0) |
45 +| (Kotlin/Compose) | | - Plug pipeline :api |
46 +| | WSS/Channels | - bearer-token auth |
47 +| | <---------------> | WaxxWeb.BoardChannel |
48 ++-------------------+ | (Phase 3) |
49 + +--------------+--------------+
50 + |
51 + v
52 + +-----------------------------+
53 + | Waxx.{Accounts,Kanban, |
54 + | Workflows} contexts |
55 + | (UNCHANGED business logic)|
56 + +-----------------------------+
57 +```
58 +
59 +The server gains two new surfaces — an `/api/v1` HTTP scope and a
60 +`/socket` user socket with a `BoardChannel`. Both call into the existing
61 +contexts. No business logic moves; only thin presentation/transport
62 +layers are added.
63 +
64 +The Android app is a single Gradle project under `/android`, structured
65 +as a few feature modules around a shared `:core` module that owns the
66 +HTTP/socket client, models, and auth state.
67 +
68 +---
69 +
70 +## 3. HTTP API design
71 +
72 +### Conventions
73 +- Scope: `/api/v1/...`. Major version bumps for breaking changes; additive
74 + fields are not breaking.
75 +- Auth: `Authorization: Bearer <token>` on every request except the
76 + session-redemption endpoints. Tokens are minted in the
77 + `Waxx.Accounts.UserToken` table with a new context `"api"`.
78 +- Content type: `application/json` on requests and responses. snake_case
79 + field names (matches the existing schemas).
80 +- Timestamps: ISO-8601 UTC strings (`"2026-05-24T10:30:00Z"`).
81 +- IDs: UUIDv7 strings as-is — the client treats them as opaque.
82 +- Errors: a uniform shape, never a bare 500 with HTML.
83 +
84 + ```json
85 + { "error": { "code": "invalid_transition",
86 + "message": "Cards in 'Done' cannot move to 'Backlog'",
87 + "details": { "from_stage_id": "...", "to_stage_id": "..." } } }
88 + ```
89 +
90 + Standard codes: `unauthenticated`, `forbidden`, `not_found`,
91 + `validation_failed` (details = changeset errors), `invalid_transition`,
92 + `conflict` (e.g. stale-write), `rate_limited`.
93 +- Pagination: cursor-based (`?cursor=<opaque>&limit=50`); response
94 + includes `next_cursor` (null when done). UUIDv7 IDs make `id > cursor`
95 + pagination trivially time-ordered.
96 +- Concurrency: writes that mutate a card include `If-Match: <card_id>:<lock_version>`
97 + once we add a `lock_version` column (Phase 4). Phase 2 read-only is fine
98 + without it.
99 +
100 +### Endpoints (v1 surface)
101 +
102 +Auth
103 +- `POST /api/v1/sessions/request_magic_link` `{email}` → `204`
104 +- `POST /api/v1/sessions/redeem` `{token}` → `200 {api_token, user}`
105 +- `DELETE /api/v1/sessions/current` → `204`
106 +- `GET /api/v1/users/me` → `200 {id, email, preferences}`
107 +- `PATCH /api/v1/users/me/preferences` `{key, value}` → `200`
108 +
109 +Boards
110 +- `GET /api/v1/boards` → list user's boards (with role, last activity)
111 +- `POST /api/v1/boards` `{template_id, name}` → `201 board`
112 +- `GET /api/v1/boards/:id` → board metadata + memberships + settings
113 +- `GET /api/v1/boards/:id/workflow` → stages + transitions + labels +
114 + fields + subboards (the static structure)
115 +- `GET /api/v1/boards/:id/cards` → cards in the board (with assignees,
116 + labels, field values, stage_id, subboard_id, position)
117 +- `PATCH /api/v1/boards/:id` → settings (name, archive days, etc.)
118 +- `DELETE /api/v1/boards/:id`
119 +- `GET /api/v1/boards/:id/history?cursor=...`
120 +- `GET /api/v1/boards/:id/memberships`
121 +- `POST /api/v1/boards/:id/invites` `{role}` → returns invite URL
122 +- `DELETE /api/v1/boards/:id/memberships/:user_id`
123 +
124 +Cards
125 +- `POST /api/v1/boards/:id/cards` → create
126 +- `PATCH /api/v1/cards/:id` → title/description/field values/assignees/labels
127 +- `POST /api/v1/cards/:id/move` `{stage_id, subboard_id, position}` → calls
128 + `Kanban.move_card/3`; returns `422 invalid_transition` if illegal
129 +- `DELETE /api/v1/cards/:id`
130 +- `POST /api/v1/cards/:id/notes` `{body}` → card notes (chat thread)
131 +- `GET /api/v1/cards/:id` → full card with notes, history hints
132 +
133 +Templates
134 +- `GET /api/v1/workflow_templates`
135 +- `POST /api/v1/workflow_templates` `{name}`
136 +- `GET /api/v1/workflow_templates/:id` → stages, transitions, labels,
137 + fields
138 +- `PATCH /api/v1/workflow_templates/:id`
139 +- `DELETE /api/v1/workflow_templates/:id`
140 +- `POST /api/v1/workflow_templates/:id/stages`
141 +- `PATCH /api/v1/workflow_templates/:id/stages/:stage_id`
142 +- `DELETE /api/v1/workflow_templates/:id/stages/:stage_id`
143 +- (same shape for transitions, labels, fields)
144 +
145 +Invites / accounts admin
146 +- `GET /api/v1/users/invites`
147 +- `POST /api/v1/users/invites` `{email}` → `201 {invite_url}`
148 +- `DELETE /api/v1/users/invites/:id`
149 +
150 +### View modules
151 +Phoenix views are JSON-only here. Layout in code:
152 +
153 +```
154 +lib/waxx_web/
155 + api/
156 + auth.ex # bearer-token plug + scope assign
157 + error_handler.ex # falls back FallbackController for {:error, _}
158 + fallback_controller.ex
159 + json/ # render helpers; one module per resource
160 + board_json.ex
161 + card_json.ex
162 + workflow_json.ex
163 + ...
164 + v1/
165 + session_controller.ex
166 + board_controller.ex
167 + card_controller.ex
168 + template_controller.ex
169 + ...
170 +```
171 +
172 +Router additions (sketch):
173 +
174 +```elixir
175 +pipeline :api do
176 + plug :accepts, ["json"]
177 + plug WaxxWeb.Api.Auth # parses Bearer, assigns :current_scope
178 +end
179 +
180 +pipeline :api_public do
181 + plug :accepts, ["json"]
182 +end
183 +
184 +scope "/api/v1", WaxxWeb.Api.V1, as: :api_v1 do
185 + pipe_through :api_public
186 + post "/sessions/request_magic_link", SessionController, :request_magic_link
187 + post "/sessions/redeem", SessionController, :redeem
188 +end
189 +
190 +scope "/api/v1", WaxxWeb.Api.V1, as: :api_v1 do
191 + pipe_through :api
192 + delete "/sessions/current", SessionController, :delete
193 + resources "/boards", BoardController, except: [:new, :edit] do
194 + get "/workflow", BoardController, :workflow
195 + get "/cards", BoardController, :cards
196 + get "/history", BoardController, :history
197 + resources "/memberships", MembershipController, only: [:index, :delete]
198 + resources "/invites", BoardInviteController, only: [:create]
199 + post "/cards", CardController, :create, as: :card
200 + end
201 + patch "/cards/:id", CardController, :update
202 + delete "/cards/:id", CardController, :delete
203 + post "/cards/:id/move", CardController, :move
204 + # ...templates, invites, users
205 +end
206 +```
207 +
208 +---
209 +
210 +## 4. Auth flows
211 +
212 +Two independent paths get an `api`-context bearer token onto the device.
213 +Both produce the same kind of token — once the app holds it, every
214 +subsequent request looks identical.
215 +
216 +### 4a. Magic link with Android App Links
217 +
218 +```
219 +[Android] [Phoenix server] [Mail / browser]
220 + | | |
221 + |-- POST /api/v1/sessions/ | |
222 + | request_magic_link (email) | |
223 + |----------------------------->|--- send mail w/ link ------->|
224 + | 204 | |
225 + |<-----------------------------| |
226 + | | |
227 + | | user taps link in mail |
228 + | | | |
229 + | Android opens via App Link (https://waxx.example/m/<tok>) |
230 + |<--- intent (data URI) ---------------------------------------|
231 + | | |
232 + |-- POST /api/v1/sessions/ | |
233 + | redeem {token} | |
234 + |----------------------------->| |
235 + | 200 {api_token, user} | |
236 + |<-----------------------------| |
237 + | | |
238 + |-- everything after carries | |
239 + | Authorization: Bearer ... | |
240 +```
241 +
242 +Key details:
243 +- The magic-link URL becomes `https://waxx.example/m/<token>` (new route,
244 + not the existing `/users/log-in/<token>` web flow — the web flow logs
245 + the user in directly to a browser session, we want a redirect that's
246 + *also* claimable by the Android intent filter).
247 +- Server-side, `GET /m/:token` does an HTML redirect to the web login
248 + flow if the request comes from a browser (no Android handoff
249 + happened). For the Android path, the intent filter on the app
250 + intercepts before the browser ever loads anything, and the app
251 + immediately calls `POST /api/v1/sessions/redeem`.
252 +- `redeem` invokes the existing `Waxx.Accounts.login_user_by_magic_link/1`
253 + to consume the magic-link token, then mints a new `UserToken` with
254 + context `"api"` (long-lived; 90-day rolling validity, renewed on use).
255 +- API tokens are revocable individually from `/users/settings`
256 + ("Connected devices" section, new in Phase 1) and bulk-revoked when
257 + the user changes email/password.
258 +
259 +### Why not the web `/users/log-in/<token>` route?
260 +That route creates a Plug session cookie and redirects to a HEEx page.
261 +It assumes a browser. Reusing it for Android would mean parsing
262 +HTML or hijacking the redirect. A purpose-built `/m/:token` + `POST /redeem`
263 +pair is cleaner and lets the web fallback continue working untouched.
264 +
265 +### App Links setup
266 +- Domain: TBD (user has a domain; needs to be the same one Phoenix
267 + serves the API from, e.g. `waxx.example.com`).
268 +- File: `https://waxx.example.com/.well-known/assetlinks.json`, served by
269 + Phoenix as a static asset (add to `priv/static/.well-known/` or serve
270 + via a controller).
271 +- Contents:
272 + ```json
273 + [{
274 + "relation": ["delegate_permission/common.handle_all_urls"],
275 + "target": {
276 + "namespace": "android_app",
277 + "package_name": "com.waxx.app",
278 + "sha256_cert_fingerprints": ["AB:CD:..."]
279 + }
280 + }]
281 + ```
282 +- Two fingerprints listed: the debug keystore and the release keystore.
283 +- Keystore generation (Phase 0): we generate both, store the release
284 + keystore + password in a secret manager (1Password / Bitwarden), and
285 + commit only the debug one to `android/keys/`.
286 +
287 +### Token lifecycle
288 +- `api` tokens: 90 day inactivity expiry, refreshed (`inserted_at`
289 + bumped) on every successful authenticated request.
290 +- Revocation: server-side delete from `users_tokens`; any in-flight
291 + request gets `401 unauthenticated`.
292 +- The Android app stores the token in EncryptedSharedPreferences (AES
293 + via the Android Keystore).
294 +
295 +### 4b. Pair via QR (mirrors `../dms`)
296 +
297 +The web user — already authenticated in a browser session — generates an
298 +API token from `/users/settings` ("Connected devices" panel). The
299 +server renders a QR encoding everything the app needs (server URL +
300 +token), the phone scans it, and the phone is paired. No email, no SMTP,
301 +no App Links required.
302 +
303 +```
304 +[Browser, logged in] [Phoenix server] [Phone]
305 + | | |
306 + |-- POST /users/settings/api_tokens | |
307 + | (LiveView form: label, ttl) | |
308 + |-------------------------------------->| |
309 + | |-- Accounts.create_api_ |
310 + | | token/2 |
311 + | | |
312 + | 200 (LiveView re-renders with the | |
313 + | "token created" panel) | |
314 + |<--------------------------------------| |
315 + | | |
316 + | panel shows QR + URI + raw token | |
317 + | (only time the raw token is shown) | |
318 + | | |
319 + | user opens app on phone, | |
320 + | taps "Scan QR" on Pair screen ----->|------------------------->|
321 + | | |
322 + | phone parses payload, calls | |
323 + | GET /api/v1/users/me with bearer | |
324 + | ------------------------------------|<-------------------------|
325 + | | 200 {user} |
326 + | |------------------------->|
327 + | | |
328 + | | phone stores (base, |
329 + | | token), lands on app |
330 +```
331 +
332 +#### QR payload format
333 +
334 +```
335 +waxx://pair?base=<url-encoded server URL>&token=<url-encoded token>
336 +```
337 +
338 +- `base` is the full origin the app should talk to, e.g.
339 + `https://waxx.example.com` or `http://192.168.1.50:4000` for LAN.
340 +- `token` is the raw bearer token (same string the user would paste into
341 + `Authorization: Bearer …`).
342 +- The custom `waxx://` scheme also lets the user tap the URI from a note
343 + or chat to deep-link straight into the Pair screen with both fields
344 + prefilled (intent filter on `waxx://pair` in the manifest).
345 +
346 +#### Server-side base URL derivation
347 +
348 +Mirrors `derive_base_url/2` in `dms/src/handlers/api_tokens.rs` so
349 +self-hosted installs behind a reverse proxy don't bake a bind address
350 +into the QR. Priority:
351 +
352 +1. `WAXX_PUBLIC_URL` env (explicit override).
353 +2. `X-Forwarded-Proto` + `X-Forwarded-Host` (standard proxy headers).
354 +3. The request's `Host` header (covers LAN access on `0.0.0.0`).
355 +4. The endpoint config as a last resort.
356 +
357 +#### Web "Connected devices" panel
358 +
359 +New LiveView surface inside the existing `/users/settings` page (mounts
360 +into `UserLive.Settings` as another section). Lists active API tokens
361 +with `sent_to`, `authenticated_at`, `inserted_at`, and a Revoke action.
362 +The Generate form sits at the top; submitting it inserts the token row
363 +and re-renders with the QR panel inline — the same idiom as dms's
364 +`api_tokens/created.html` template.
365 +
366 +#### Server dependencies
367 +
368 +- **QR rendering**: add `:eqrcode` to `mix.exs` (pure-Elixir, SVG output).
369 + Roughly: `{:eqrcode, "~> 0.2.1"}`.
370 +- No other new deps. The token-minting path (`Accounts.create_api_token/1`)
371 + already exists from Phase 0.
372 +
373 +#### Android dependencies and screen
374 +
375 +- **Scanner**: `journeyapps:zxing-android-embedded` (same lib as dms),
376 + via the `ScanContract` activity-result API.
377 +- **Pair screen**: `feature:auth/PairScreen.kt` — three states (Idle,
378 + Verifying, Success/Error). Scan button launches the scanner; manual
379 + base+token fields below it for paste/typing fallbacks. On submit,
380 + calls `GET /api/v1/users/me` with the candidate bearer; on success,
381 + writes to `TokenStore` (EncryptedSharedPreferences) and navigates to
382 + the Boards list.
383 +
384 +#### Why both 4a and 4b?
385 +
386 +- **QR (4b)** is the easier UX when the user already has a logged-in web
387 + session and physical access to the screen, especially for self-hosted
388 + Waxx without SMTP wired up. No App Links setup needed.
389 +- **Magic link (4a)** is the answer for "I'm only on my phone, I've
390 + never logged in on a desktop." Requires the SMTP path and App Links
391 + for the nicest UX (or falls back to the custom scheme).
392 +
393 +The Android Login screen offers both as top-level buttons; users pick
394 +whichever fits their situation. The backend treats both identically once
395 +a bearer token is in hand.
396 +
397 +---
398 +
399 +## 5. Real-time channel
400 +
401 +### Server: `WaxxWeb.UserSocket` + `BoardChannel`
402 +
403 +```
404 +WaxxWeb.UserSocket
405 + /socket connect: ?token=<api_token> → verifies via UserToken (context "api")
406 + channel "board:*", BoardChannel
407 +
408 +WaxxWeb.BoardChannel
409 + join("board:<id>"):
410 + - assert membership via Kanban.member?/2
411 + - Kanban.subscribe(id) # joins the existing PubSub topic
412 + - push("snapshot", workflow + cards)
413 + handle_info({:cards_changed, _}, socket):
414 + push("cards_changed", %{board_id: ...}) # client refetches /cards
415 + handle_info({:workflow_changed, _}, socket):
416 + push("workflow_changed", %{board_id: ...}) # client refetches /workflow
417 +```
418 +
419 +Channel pushes are intentionally **lightweight notifications, not
420 +deltas** — the client re-fetches the affected resource. Rationale:
421 +- The existing PubSub events (`:cards_changed`, `:workflow_changed`)
422 + already match this contract.
423 +- Avoids re-serialising state into channel payloads with their own
424 + schema drift.
425 +- Re-fetch latency is small (one HTTP round-trip) and we can move to
426 + delta payloads later without changing the channel API surface.
427 +
428 +The Android client uses [phoenix-channels-client (kotlin)] or rolls a
429 +small wrapper around OkHttp WebSocket; decision deferred to Phase 3.
430 +
431 +### Channel auth
432 +`UserSocket.connect/3` takes `?token=...`, verifies it through
433 +`Waxx.Accounts.UserToken` with context `"api"`, assigns the user_id to
434 +the socket. The board channel re-checks membership on every join.
435 +
436 +---
437 +
438 +## 6. Android app architecture
439 +
440 +### Stack
441 +- **Language**: Kotlin 2.x
442 +- **UI**: Jetpack Compose + Material 3
443 +- **DI**: Hilt
444 +- **HTTP**: Retrofit + OkHttp + Moshi (codegen)
445 +- **QR scanner**: `com.journeyapps:zxing-android-embedded` (matches dms;
446 + `ScanContract` activity-result API)
447 +- **WebSocket / Channels**: phoenix-channels-client (Kotlin) — if mature
448 + enough; otherwise a thin wrapper over OkHttp's `WebSocket` (decided
449 + Phase 3).
450 +- **Navigation**: Compose Navigation with type-safe routes
451 +- **State**: ViewModel + StateFlow; no Redux/MVI framework
452 +- **Persistence**: Room for caching boards/cards/workflow; auth token in
453 + EncryptedSharedPreferences
454 +- **Image loading**: Coil (for user avatars if/when added)
455 +- **Build**: Gradle 8.x with Kotlin DSL, version catalog
456 + (`gradle/libs.versions.toml`)
457 +- **Min SDK**: 26 (Android 8). Target SDK: current.
458 +
459 +### Module layout
460 +
461 +```
462 +android/
463 + app/ # Entry point, Application, navigation host
464 + core/
465 + network/ # Retrofit interfaces, OkHttp interceptors,
466 + # auth header injection, channel client
467 + auth/ # Token storage (EncryptedSharedPreferences),
468 + # magic-link redemption + QR pairing flows,
469 + # ApplinkActivity (https://host/m/:token) +
470 + # PairActivity (waxx://pair?...)
471 + model/ # Data classes mirroring API JSON
472 + db/ # Room database, DAOs, mappers
473 + ui/ # Shared composables, theme (mirrors daisyUI
474 + # palettes), error surfaces
475 + feature/
476 + boards/ # /boards list + create
477 + board/ # /boards/:id kanban view + DnD + card sheet
478 + template/ # Template editor (tabs: workflow/labels/fields)
479 + settings/ # Board settings, user settings, invites
480 + history/ # Per-board history
481 + build-logic/ # Convention plugins for shared Gradle config
482 + keys/ # Debug keystore (committed). Release keystore
483 + # NOT committed.
484 +```
485 +
486 +### Navigation graph
487 +
488 +```
489 +Login
490 + ├─ Email me a link (magic-link, App Link redemption)
491 + └─ Scan QR / paste (QR pairing, opens PairScreen)
492 + │
493 + v
494 +Boards list (root after auth)
495 + ├─ Board detail (kanban grid)
496 + │ ├─ Card detail sheet
497 + │ ├─ Board settings
498 + │ ├─ Board history
499 + │ └─ Board members & invites
500 + ├─ Workflow templates list
501 + │ └─ Template editor (workflow / labels / fields tabs)
502 + └─ User settings
503 + ├─ Email change
504 + ├─ Connected devices (list active API tokens, revoke)
505 + └─ App invites
506 +```
507 +
508 +(The "Connected devices" panel also exists on the **web** under
509 +`/users/settings` — that's where the QR generator lives, since the
510 +phone is the consumer side of the pairing.)
511 +
512 +### Drag and drop
513 +
514 +Compose has no first-class kanban DnD. Realistic options:
515 +1. `reorderable` library (sh-no-rebola) — built for kanban-style
516 + reordering, supports cross-list drops.
517 +2. Custom drag detection with `pointerInput` + a shared overlay layer
518 + for the drag avatar.
519 +
520 +Plan: try (1) first; fall back to (2) if the constraints don't fit
521 +(green/red drop highlighting tied to workflow edges, drag avatars that
522 +respect the card-chip layout). Decision in Phase 4.
523 +
524 +The transition rules come from the workflow snapshot fetched at board
525 +load and refreshed on `workflow_changed`. The client greys out illegal
526 +drop targets, but the server is still authoritative — `POST
527 +/cards/:id/move` will return `422 invalid_transition` if the client is
528 +wrong (stale workflow, bug), and the UI rolls back optimistically.
529 +
530 +### Theming
531 +Port the eight daisyUI themes (six neiam-co + blueprint + default) to
532 +Material 3 ColorSchemes. Lives in `core/ui/Themes.kt`. Same persistence
533 +model as web — preferences blob in `users.preferences` under the
534 +`theme_default` key, synced on login.
535 +
536 +### Error handling
537 +- Network errors surface as Compose snackbars with a Retry action.
538 +- `401`: drop the stored token, navigate to Login.
539 +- `403`: snackbar "you don't have access".
540 +- `422 invalid_transition`: silent (DnD already blocked it client-side;
541 + if we see this, log to Sentry — it's a workflow-snapshot drift bug).
542 +- `5xx`: snackbar "server error" + Sentry breadcrumb.
543 +
544 +---
545 +
546 +## 7. Build & release
547 +
548 +### Local dev
549 +- `./gradlew :app:installDebug` from `/android`.
550 +- API base URL is a `BuildConfig` field, defaulting to
551 + `http://10.0.2.2:4000` for the emulator pointing at the host's
552 + `mix phx.server`.
553 +
554 +### CI
555 +- Add an `android` job to `.gitlab-ci.yml` that runs on changes under
556 + `/android/**`: `./gradlew :app:assembleDebug :app:testDebugUnitTest`.
557 +- Release builds (Play Store) come later — out of scope for the design
558 + doc, but the keystore + signing-config story needs to be settled
559 + before we ship publicly.
560 +
561 +### Versioning
562 +- Android `versionCode`/`versionName` decoupled from the Phoenix release
563 + tag. The app advertises `X-Waxx-Client: android/<versionName>` so the
564 + server can log/refuse old clients if needed.
565 +
566 +---
567 +
568 +## 8. Phase plan
569 +
570 +Each phase is a separate PR (or small stack of PRs). The doc gets
571 +updated with what shipped before moving on.
572 +
573 +### Phase 0 — Backend foundation (no Android code) ✅ shipped
574 +- `WaxxWeb.Api.Auth` plug + `:api_authenticated` pipeline.
575 +- `UserToken` gains `"api"` context with mint/verify/refresh/revoke.
576 +- `WaxxWeb.Api.FallbackController` + `ErrorJSON` envelope.
577 +- `WaxxWeb.Api.V1.SessionController` with `request_magic_link`, `redeem`,
578 + `delete`.
579 +- `/m/:token` route handing off to the web login flow (Android intent
580 + filter intercepts before this fires).
581 +- 55 tests passing.
582 +
583 +### Phase 1a — Web "Connected devices" + QR generation ✅ shipped
584 +- `:eqrcode ~> 0.2.0` added to `mix.exs`.
585 +- Migration `20260524120000_add_label_to_users_tokens` adds nullable
586 + `label :string` to `users_tokens`.
587 +- `Waxx.Accounts.UserToken.build_api_token/2` and
588 + `Accounts.create_api_token/2` accept an optional label (trimmed,
589 + empty→nil, truncated to 80 chars). `Accounts.list_api_tokens/1`
590 + returns it.
591 +- `WaxxWeb.PublicUrl.derive/1` implements the four-tier precedence
592 + (`WAXX_PUBLIC_URL` → `X-Forwarded-*` → conn host/scheme/port →
593 + endpoint URL). Two-arity dispatch: takes a `Plug.Conn` or a
594 + `LiveView.Socket`. The socket path requires `:uri` + `:x_headers`
595 + in the endpoint `connect_info`, and must be called from `mount/3`
596 + (LiveView destroys connect_info after mount), so callers stash the
597 + result in assigns.
598 +- `WaxxWeb.QR.svg/2` wraps `EQRCode` and returns an SVG string suitable
599 + for inline `raw/1` rendering.
600 +- `UserLive.Settings` grew a "Connected devices" section: label form →
601 + Generate → one-time QR panel (SVG + base URL + raw token + the
602 + `waxx://pair?...` URI) → list of active tokens with Revoke buttons.
603 + Sudo mode is required (inherited from the page's on_mount).
604 +- 22 new tests covering: label handling on Accounts, derive precedence
605 + on `Plug.Conn`, QR output shape, LiveView interactions (empty state,
606 + generate, revoke, dismiss).
607 +- `ConnCase.log_in_user/2` helper added for future LiveView tests.
608 +
609 +### Phase 1b — Android scaffold + both login paths ✅ shipped
610 +- `/android` Gradle project under `org.neiam.waxx.app` (single-module
611 + to match `../dms/android`'s shape; multi-module split deferred until
612 + the screen count justifies it). Kotlin 2.0 · Compose · AGP 8.5.2 ·
613 + Gradle 9.0 · minSdk 26 · targetSdk 34.
614 +- `:app:assembleDebug` produces an 18 MB APK with no warnings.
615 +- `GET /api/v1/users/me` endpoint (`WaxxWeb.Api.V1.UserController`)
616 + pulled forward from Phase 2 as the Pair-screen / Boards-stub probe.
617 +- `.well-known/assetlinks.json` served from Phoenix (`priv/static/.well-known/`,
618 + added to `WaxxWeb.static_paths`). Placeholders for debug + release
619 + SHA-256 fingerprints; instructions in `android/README.md`.
620 +- Manifest registers two intent filters:
621 + - `waxx://pair?base=…&token=…` (custom scheme, no `autoVerify` —
622 + works in any debug build immediately).
623 + - `https://${appLinksHost}/m/<token>` (`autoVerify=true` — needs a
624 + real domain + matching `assetlinks.json` to take effect; falls
625 + back to the OS chooser otherwise).
626 +- Three screens: `LoginScreen` (two-button picker), `PairScreen` (QR
627 + scan + paste fallback, ports the dms equivalent), `MagicLinkScreen`
628 + (send + paste-link / wait-for-deep-link).
629 +- `BoardsScreen` is a stub that calls `/api/v1/users/me` to confirm the
630 + token is good end-to-end; Phase 2 replaces it with the boards list.
631 +- `TokenStore` uses EncryptedSharedPreferences (AES via Android
632 + Keystore); `data_extraction_rules.xml` excludes the secure-prefs
633 + file from cloud backup + device transfer.
634 +- README documents the local dev workflow (emulator → `10.0.2.2:4000`,
635 + `WAXX_PUBLIC_URL` override for QR), keystore generation, and the
636 + App Links handover process.
637 +- Backend now at 81 tests (+4 for `/users/me` and assetlinks serving).
638 +
639 +Deferred from Phase 1b:
640 +- Release keystore (documented; not generated — needs an out-of-repo
641 + secret store).
642 +- GitLab CI job for `:app:assembleDebug` + tests (needs an Android-SDK
643 + image; deferred to a dedicated CI PR).
644 +- Hilt / multi-module split (revisit when screen count grows).
645 +
646 +### Phase 2 — Read-only boards & kanban ✅ shipped
647 +- `WaxxWeb.Api.V1.BoardController` with five actions backed by the
648 + existing `Kanban` context — every action runs `Kanban.get_board_for_user/2`
649 + first, which 404s for non-members (no leak of board existence):
650 + - `GET /api/v1/boards` → list with role + timestamps
651 + - `GET /api/v1/boards/:id` → board metadata + members
652 + - `GET /api/v1/boards/:id/workflow` → stages, transitions, labels,
653 + fields, subboards (the static structure)
654 + - `GET /api/v1/boards/:id/cards` → cards with assignee_ids, label_ids,
655 + field_values (already runs the archive-terminal filter)
656 + - `GET /api/v1/boards/:id/history?limit=N` → activities newest-first,
657 + limit clamped to [1, 500], default 200
658 +- `WaxxWeb.Api.V1.BoardJSON` — pure serialization, takes Ecto structs
659 + and returns plain maps. Handles `Ecto.Association.NotLoaded` so a
660 + missing preload doesn't crash.
661 +- `Waxx.KanbanFixtures` (test/support) — `template_fixture`,
662 + `board_fixture`, `card_fixture` so controller tests don't have to
663 + rebuild graphs inline.
664 +- Android replaces the v1b stub with a three-screen flow:
665 + - `BoardsListScreen` — calls `/boards`, taps open `BoardScreen`.
666 + - `BoardScreen` — calls `/board/:id`, `/workflow`, `/cards` in
667 + parallel-fetch fashion; renders a horizontally-scrolling kanban
668 + of `StageColumn`s with card chips. Top-right History button opens
669 + `HistoryScreen`. Card taps surface a `CardSheet` (ModalBottomSheet).
670 + - `HistoryScreen` — calls `/history`, renders newest-first.
671 +- Activity `meta` (jsonb) is intentionally dropped on the Android side
672 + for now — `ignoreUnknownKeys = true` on the kotlinx.serialization
673 + config makes this safe. The web LiveView still renders it per-action;
674 + porting that formatter is deferred.
675 +- Backend: 10 new controller tests, 91 tests total green.
676 +- Android: `:app:assembleDebug` clean (no warnings).
677 +
678 +Deferred from Phase 2 (tracked):
679 +- **Room caching with stale-while-revalidate**: every screen currently
680 + re-fetches on entry. The data model is small enough (single board ≤
681 + ~1 MB JSON) that the perceived latency is mostly the request itself,
682 + not parsing. Add Room when there's evidence — e.g., users tapping
683 + back/forward across boards in poor connectivity.
684 +- **Subboards in the kanban view**: the API returns `subboard_id` on
685 + cards and the workflow's `subboards` list, but `BoardScreen` doesn't
686 + group rows yet. Add when Phase 6 lands.
687 +- **Activity `meta` rendering**: needs a per-action formatter ported
688 + from `WaxxWeb.BoardLive.History`. Deferred to a polish PR.
689 +
690 +### Phase 3 — Real-time ✅ shipped
691 +- `WaxxWeb.UserSocket` — token-authenticated Phoenix socket; the same
692 + `api`-context bearer used by HTTP. Connect rejects bad/missing
693 + tokens; `id/1` returns `"user_socket:#{user_id}"` so future server-
694 + side `Endpoint.broadcast("user_socket:<id>", "disconnect", %{})`
695 + calls can force-evict a logged-out device.
696 +- `WaxxWeb.BoardChannel` — topic `"board:<id>"`. Join asserts the
697 + caller has a `Membership` row on the board; non-members get
698 + `{:error, "forbidden"}`. On join the channel subscribes to the
699 + existing `Kanban` PubSub topic and forwards `:cards_changed` /
700 + `:workflow_changed` as lightweight channel pushes (no payload
701 + deltas — the client refetches the affected resource via HTTP).
702 +- Endpoint registers `socket "/socket", WaxxWeb.UserSocket` alongside
703 + the existing LiveView socket.
704 +- 6 channel tests: connect (good/bad/missing token), join (member /
705 + non-member), end-to-end push forwarding via `card_fixture/3`.
706 +- Android adds `com.github.dsrees:JavaPhoenixClient:0.3.4` (jitpack;
707 + newer 1.x versions all show "Error" on jitpack right now).
708 +- `WaxxSocket.subscribeBoard(creds, boardId)` returns a `Flow<BoardEvent>`:
709 + `Connected | Joined | Disconnected | CardsChanged | WorkflowChanged |
710 + Error`. One socket per Flow keeps lifecycle simple (the lib's 0.3.x
711 + API doesn't expose `off()` on socket-level listeners).
712 +- `BoardScreen` runs a `DisposableEffect` that launches the
713 + subscription; refetches on cards_changed / workflow_changed; tracks
714 + connection state for the new top banner ("Connecting…" / "Offline —
715 + changes won't sync until reconnect.") shown when not Live.
716 +
717 +Deferred:
718 +- Force-disconnect on token revocation. Hook
719 + `Endpoint.broadcast("user_socket:#{user_id}", "disconnect", %{})` into
720 + `Accounts.delete_api_token/2` so revoking a device from
721 + /users/settings kicks the live socket too. Currently the revoked
722 + bearer becomes 401 on next HTTP call but the existing channel stays
723 + open until reconnect.
724 +- Per-channel resubscribe metrics / Sentry breadcrumbs. Worth doing
725 + when we have telemetry plumbed but not load-bearing now.
726 +
727 +### Phase 4 — Card writes (core) ✅ shipped
728 +*(Scope trimmed — see Phase 4b for the rest.)*
729 +
730 +- `WaxxWeb.Api.V1.CardController` with four actions, all gated on
731 + owner/editor role on the card's board:
732 + - `POST /api/v1/boards/:board_id/cards` {title, description?,
733 + board_stage_id?, subboard_id?, position?}
734 + - `PATCH /api/v1/cards/:id` {title?, description?}
735 + - `POST /api/v1/cards/:id/move` {board_stage_id, position?}
736 + - `DELETE /api/v1/cards/:id`
737 +- 404 for non-members, 403 for members without edit permission, 422 for
738 + validation / invalid_transition / invalid_stage.
739 +- Fixed a latent bug in `Kanban.delete_card/2`: it was logging an
740 + activity with `card_id: <just-deleted id>` which violated the FK.
741 + Switched to logging with `card_id: nil`; `meta.title` already carries
742 + the human-readable string. No existing test triggered this path —
743 + the LiveView UI would have crashed on first delete in prod.
744 +- `WaxxWeb.Api.V1.BoardJSON.card_response/1` — single-card envelope so
745 + mutation endpoints share the JSON shape with the list endpoint.
746 +- 11 new controller tests (create / update / move / delete × happy
747 + path + auth + role + transition).
748 +- Android writes: `WaxxApi.createCard / updateCard / moveCard /
749 + deleteCard` (+ matching request body models).
750 +- `BoardScreen` gains a FAB + `CreateCardDialog` (title + optional
751 + description); on success refetches cards immediately (the channel
752 + push also fires).
753 +- `CardSheet` gains three actions (Edit / Move / Delete):
754 + - Edit swaps into an in-place title/description editor.
755 + - Move shows a stage picker built from the workflow's `transitions`
756 + (only valid targets surface; server still authoritative via 422
757 + `invalid_transition`).
758 + - Delete prompts a confirm dialog, then dismisses the sheet.
759 +
760 +### Phase 4b — Labels, fields, assignees, notes ✅ shipped
761 +- `WaxxWeb.Api.V1.CardController` gains five actions:
762 + - `GET /api/v1/cards/:id` — single card with `notes` preloaded
763 + - `POST /api/v1/cards/:id/labels/:label_id/toggle` — toggle on/off
764 + - `PUT /api/v1/cards/:id/fields/:field_id` `{value}` — set or clear
765 + - `POST /api/v1/cards/:id/assignees` `{user_id}` — must already be a board member
766 + - `DELETE /api/v1/cards/:id/assignees/:user_id`
767 +- New `WaxxWeb.Api.V1.NoteController`:
768 + - `POST /api/v1/cards/:card_id/notes` `{body, kind?}`
769 + - `PATCH /api/v1/notes/:id` `{body?, done?}`
770 + - `DELETE /api/v1/notes/:id`
771 +- All gated on owner/editor. Member validation on assignees (`fetch_board_member/2`)
772 + prevents assigning users outside the board's membership list — mirrors
773 + the LiveView `assignable_users` guard.
774 +- Labels from a different board map to `:not_found` (no leak).
775 + Invalid `select` field options map to `validation_failed`.
776 +- `BoardJSON.card_detail_response/1` includes `notes` (intentionally
777 + not on the list payload — would bloat every `/cards` fetch).
778 + `BoardJSON.note/1` + `note_response/1` for the note JSON envelope.
779 +- Test fixtures: `board_label_fixture/2`, `board_field_fixture/2`
780 + for direct board-scoped seeding.
781 +- 14 new controller tests; backend now at **122** total.
782 +
783 +- Android: 8 new `WaxxApi` methods + matching request bodies and the
784 + `CardDetail`/`CardNote` response models.
785 +- `BoardScreen` also surfaces `board.memberships` via a new
786 + `onMembersLoaded` callback so the sheet can render assignee chips
787 + without re-fetching.
788 +- `CardSheet` rewritten as a scrollable `LazyColumn` with sections:
789 + - Title / Edit / Move / Delete (existing)
790 + - **Labels** — `FilterChip` toggles for every label on the board.
791 + - **Fields** — per-field row with kind-aware editor (text input or
792 + select option list); Save / Clear / Cancel.
793 + - **Assignees** — `FilterChip` per board member, toggles assignment.
794 + - **Notes** — list of notes (with todo-style checkbox when
795 + `kind == "todo"`) + inline add form (free-form or todo).
796 +- Card sheet fetches `GET /api/v1/cards/:id` on open for the freshest
797 + state + notes; mutations re-fetch the detail before re-rendering.
798 +
799 +### Phase 4c — Drag-and-drop kanban ✅ shipped
800 +- `org.neiam.waxx.app.ui.DragState` — shared state container for the
801 + kanban DnD:
802 + - `start / update / cancel / dropAt(workflow)` lifecycle
803 + - `setStageBounds / clearStageBounds` for column hit-test
804 + - `isValidTarget(stageId, workflow)` for column highlighting
805 +- `BoardScreen` owns one instance and passes it down. The drop handler
806 + reads the dragged card before clearing the session, then calls
807 + `/api/v1/cards/:id/move` with the target stage id. Refetches cards
808 + on success (channel push fires too).
809 +- Long-press starts a drag on a card chip (`detectDragGesturesAfterLongPress`).
810 + The chip stays in its original column (no snapshot avatar yet —
811 + user sees a blue border on the source chip while dragging).
812 +- Each `StageColumn` registers its window-space bounds via
813 + `Modifier.onGloballyPositioned`. During a drag, columns paint a
814 + translucent overlay:
815 + - grey (`#33888888`) for the source column
816 + - green (`#6680FF80`) for valid targets (transition exists)
817 + - red (`#66FF8080`) for forbidden targets
818 +- The server is still authoritative: a drop that *would* be valid but
819 + has stale workflow data still gets a `422 invalid_transition` and
820 + the chip snaps back via the next refresh.
821 +
822 +### Phase 4c remainder — still deferred
823 +- **Within-column reorder via DnD** — the move endpoint accepts a
824 + `position` argument; the chip doesn't expose insertion-line UI yet.
825 + Wait until users ask.
826 +- **Auto-scroll during drag** — boards with more than ~3 columns don't
827 + fit on a phone in landscape; today the user has to release+rescroll.
828 + Add when columns commonly exceed the screen width.
829 +- **`lock_version` optimistic locking** — concurrent-edit collisions
830 + follow web LiveView's last-write-wins model today. PubSub fan-out
831 + narrows the window; revisit when there's a real case of clobbering.
832 +- **Subboards (2-D grid)** — covered by the API but the kanban view
833 + doesn't group rows yet. Will land alongside Phase 6.
834 +
835 +### Phase 5 — Board settings, members, invites ✅ shipped
836 +*(Template editor + user preferences pulled out into Phase 5b; this turn
837 +keeps the daily-driver pieces.)*
838 +
839 +- Endpoints (all editor-or-owner enforced as appropriate):
840 + - `PATCH /api/v1/boards/:id` — rename, description, archive days (owner-only)
841 + - `DELETE /api/v1/boards/:id` — owner-only
842 + - `PUT /api/v1/boards/:board_id/memberships/:user_id` `{role}` (owner-only)
843 + - `DELETE /api/v1/boards/:board_id/memberships/:user_id` — owner-only; refuses the last-owner removal
844 + - `GET /api/v1/boards/:board_id/invites` — owner-only
845 + - `POST /api/v1/boards/:board_id/invites` `{role?, note?, expires_in_days?}` (owner-only)
846 + - `DELETE /api/v1/boards/:board_id/invites/:id` — owner-only
847 + - `GET /api/v1/users/invites`
848 + - `POST /api/v1/users/invites` `{note?, expires_in_days?}`
849 + - `DELETE /api/v1/users/invites/:id` — caller must own the invite
850 +- Each invite endpoint returns a precomputed `redemption_url` built from
851 + `WaxxWeb.PublicUrl.derive(conn)` — board invites point at `/b/<token>`,
852 + app invites at `/users/register?invite=<token>`. Self-hosted installs
853 + get the LAN URL baked in automatically.
854 +- Fixed `Waxx.Accounts.create_invite/2`: it was putting `:created_by_id`
855 + (atom) onto an attrs map of string keys, which Ecto rejects as mixed
856 + keys. Switched to the same normalize-to-string-keys pattern
857 + `create_system_invite/1` already uses. The LiveView caller passes no
858 + attrs so this wasn't a regression in prod.
859 +- Membership deletion `guard_last_owner/1` refuses to remove the only
860 + remaining owner — the LiveView relies on at-least-one-owner being an
861 + invariant elsewhere.
862 +- 12 new controller tests; backend now at **134** total.
863 +
864 +- Android: 10 new `WaxxApi` methods + matching request/response models.
865 +- `BoardSettingsScreen` — tabbed (Settings / Members / Invites),
866 + reachable from the new gear icon in the `BoardScreen` top bar:
867 + - **Settings** — name, description, archive-after-days, Delete board
868 + with confirm. Non-owners see disabled fields + an explanatory line.
869 + - **Members** — list with role chips and a Remove button; owners can
870 + promote/demote between owner / editor / viewer.
871 + - **Invites** — owner-only "Generate" panel (role / note / TTL),
872 + each existing invite renders with the redemption URL, Copy-to-
873 + clipboard, and Revoke buttons; consumed invites show who took them.
874 +- `AppInvitesScreen` — reachable from the new overflow menu on
875 + `BoardsListScreen` ("Registration invites"). Same generate / list /
876 + copy / revoke flow against `/api/v1/users/invites`.
877 +- `BoardsListScreen` top-right icon changed to a `MoreVert` overflow
878 + menu hosting "Registration invites" + "Sign out".
879 +
880 +### Phase 5b — Deferred
881 +- **Template editor** — three-tab editor (workflow / labels / fields)
882 + is a substantial chunk; mobile usage is rare (most users set up once
883 + on the web). Endpoints still needed: template CRUD + stages /
884 + transitions / labels / fields CRUD.
885 +- **User preferences sync** — `PATCH /api/v1/users/me/preferences`
886 + for theme + per-board label-text toggle. Low priority until the
887 + Android client itself has a theme picker.
888 +- **Default invite role / note suggestions** — sensible defaults
889 + when generating invites (currently the form is bare).
890 +
891 +### Phase 6 — Subboards & polish
892 +- Move endpoint already covers `subboard_id`; add UI for the 2-D grid
893 + view.
894 +- Polish: empty states, error retry UX, accessibility audit, in-app
895 + theme picker, push-notification scaffolding (deferred but stubbed).
896 +
897 +---
898 +
899 +## 9. Open questions
900 +
901 +1. **Domain & TLS for App Links.** Need the exact domain Phoenix will be
902 + served from in production. Affects `assetlinks.json` and the intent
903 + filter manifest. Less urgent now that QR pairing covers the
904 + self-hosted "no SMTP, no public domain" case — App Links can wait
905 + until we ship the magic-link path publicly.
906 +2. **Play Store distribution.** Internal track only? Closed beta? Public?
907 + Affects when we need the privacy policy URL and Data Safety form
908 + filled out.
909 +3. **Lock version.** Adding `lock_version` to `cards` is a migration +
910 + touches every mutation. Worth doing for Android (and good hygiene for
911 + web), but should it be a separate prerequisite PR before Phase 4?
912 +4. **CORS.** REST endpoints are same-origin from web LiveView's POV (no
913 + CORS needed for the web app, which uses LiveView, not the API). But
914 + if we ever want JS to call `/api/v1` we'll need CorsPlug. Punt until
915 + needed.
916 +5. **Rate limiting.** No rate limit on the existing auth endpoints. Add
917 + `hammer`-backed limit at least to `/api/v1/sessions/request_magic_link`
918 + before any public exposure of the API.
919 +6. **`mix phx.gen.invite` parity.** Should app invites flow through the
920 + API too, or stay CLI/web only? Probably API-too (so admins can invite
921 + from their phone), but it's not on the critical path — covered in
922 + Phase 5.
923 +7. ~~**API token labels.**~~ Resolved in Phase 1a: `label` column added,
924 + trimmed/truncated server-side. Per-token TTL still uses the global
925 + 90-day window — revisit if users ask for custom expiries.
926 +8. ~~**QR generator deps.**~~ Resolved: `:eqrcode ~> 0.2.0` compiles
927 + cleanly on the current toolchain and is in use via `WaxxWeb.QR`.
928 +9. **Scanner camera permission UX.** ZXing's embedded scanner activity
929 + handles the runtime permission prompt itself; we may want a custom
930 + pre-prompt explaining *why* we need the camera before the OS dialog
931 + fires. Decide in Phase 1b.
932 +10. **Token list precision.** `authenticated_at` is bumped at most once
933 + per 24h (by `maybe_touch_api_token/1`), so the "Last seen" timestamp
934 + in the device list has up to a day of lag. Fine for a coarse
935 + heads-up-display; if we want per-request precision, lift the
936 + refresh window or maintain a separate `last_seen_at` column.
937 +
938 +---
939 +
940 +## 10. Glossary
941 +
942 +- **Drift** — a board entity (stage/label/field) whose name no longer
943 + matches the template it came from. Template edits skip drifted
944 + entities.
945 +- **Workflow snapshot** — the static structure of a board (stages,
946 + transitions, labels, fields, subboards) as returned by
947 + `GET /api/v1/boards/:id/workflow`. Refreshed only on `workflow_changed`.
948 +- **Card snapshot** — the dynamic content of a board (cards + their
949 + fields/labels/assignees/positions), returned by `/cards`. Refreshed on
950 + `cards_changed`.
modified lib/waxx/accounts.ex
+4 −1
@@ -68,7 +68,10 @@ defmodule Waxx.Accounts do
68 68
69 69 @doc "Creates an invite token, owned by the given user."
70 70 def create_invite(%User{id: id}, attrs \\ %{}) do
71 − attrs = Map.put(attrs, :created_by_id, id)
71 + attrs =
72 + attrs
73 + |> Map.new(fn {k, v} -> {to_string(k), v} end)
74 + |> Map.put("created_by_id", id)
72 75
73 76 %Invite{}
74 77 |> Invite.create_changeset(attrs)
added lib/waxx_web/api/v1/app_invite_controller.ex
+73 −0
@@ -0,0 +1,73 @@
1 +defmodule WaxxWeb.Api.V1.AppInviteController do
2 + @moduledoc """
3 + App-level registration invites. Any authenticated user can mint one
4 + (matches the LiveView `/users/invites` UI which any logged-in user
5 + can reach).
6 +
7 + GET /api/v1/users/invites
8 + POST /api/v1/users/invites {note?, expires_in_days?}
9 + DELETE /api/v1/users/invites/:id
10 + """
11 +
12 + use WaxxWeb, :controller
13 +
14 + alias Waxx.Accounts
15 + alias WaxxWeb.Api.V1.BoardJSON
16 + alias WaxxWeb.PublicUrl
17 +
18 + action_fallback WaxxWeb.Api.FallbackController
19 +
20 + def index(conn, _params) do
21 + user = conn.assigns.current_scope.user
22 + invites = Accounts.list_invites(user)
23 + json(conn, BoardJSON.app_invites_list(invites, PublicUrl.derive(conn)))
24 + end
25 +
26 + def create(conn, params) do
27 + user = conn.assigns.current_scope.user
28 + attrs = build_create_attrs(params)
29 +
30 + case Accounts.create_invite(user, attrs) do
31 + {:ok, invite} ->
32 + conn
33 + |> put_status(:created)
34 + |> json(BoardJSON.app_invite_response(invite, PublicUrl.derive(conn)))
35 +
36 + {:error, changeset} ->
37 + {:error, changeset}
38 + end
39 + end
40 +
41 + def delete(conn, %{"id" => id}) do
42 + user = conn.assigns.current_scope.user
43 +
44 + case find_owned_invite(user, id) do
45 + nil -> {:error, :not_found}
46 + invite -> handle_revoke(conn, user, invite)
47 + end
48 + end
49 +
50 + defp handle_revoke(conn, user, invite) do
51 + case Accounts.revoke_invite(user, invite) do
52 + {:ok, _} -> send_resp(conn, :no_content, "")
53 + {:error, :forbidden} -> {:error, :forbidden}
54 + {:error, changeset} -> {:error, changeset}
55 + end
56 + end
57 +
58 + defp find_owned_invite(user, id) do
59 + Enum.find(Accounts.list_invites(user), &(&1.id == id))
60 + end
61 +
62 + defp build_create_attrs(params) do
63 + base = params |> Map.take(["note"])
64 +
65 + case params["expires_in_days"] do
66 + n when is_integer(n) and n > 0 ->
67 + Map.put(base, "expires_at", DateTime.utc_now() |> DateTime.add(n * 86_400, :second))
68 +
69 + _ ->
70 + base
71 + end
72 + end
73 +end
modified lib/waxx_web/api/v1/board_controller.ex
+30 −0
@@ -77,6 +77,28 @@ defmodule WaxxWeb.Api.V1.BoardController do
77 77 end
78 78 end
79 79
80 + def update(conn, %{"id" => id} = params) do
81 + user = conn.assigns.current_scope.user
82 +
83 + with {:ok, board} <- fetch_owner_board(id, user),
84 + attrs <-
85 + Map.take(params, ["name", "description", "archive_terminal_after_days"]),
86 + {:ok, updated} <- Kanban.update_board(board, attrs) do
87 + role = Kanban.role_for(updated.id, user)
88 + members = Kanban.list_members(updated.id)
89 + json(conn, BoardJSON.board(updated, role, members))
90 + end
91 + end
92 +
93 + def delete(conn, %{"id" => id}) do
94 + user = conn.assigns.current_scope.user
95 +
96 + with {:ok, board} <- fetch_owner_board(id, user),
97 + {:ok, _} <- Kanban.delete_board(board) do
98 + send_resp(conn, :no_content, "")
99 + end
100 + end
101 +
80 102 defp fetch_board(id, user) do
81 103 case Kanban.get_board_for_user(id, user) do
82 104 nil -> {:error, :not_found}
@@ -84,6 +106,14 @@ defmodule WaxxWeb.Api.V1.BoardController do
84 106 end
85 107 end
86 108
109 + defp fetch_owner_board(id, user) do
110 + with {:ok, board} <- fetch_board(id, user) do
111 + if Kanban.can_manage?(Kanban.role_for(board.id, user)),
112 + do: {:ok, board},
113 + else: {:error, :forbidden}
114 + end
115 + end
116 +
87 117 defp parse_limit(nil), do: 200
88 118
89 119 defp parse_limit(s) when is_binary(s) do
added lib/waxx_web/api/v1/board_invite_controller.ex
+79 −0
@@ -0,0 +1,79 @@
1 +defmodule WaxxWeb.Api.V1.BoardInviteController do
2 + @moduledoc """
3 + Board-scoped invite tokens. Owners can mint and revoke; the redemption
4 + URL is built using `WaxxWeb.PublicUrl` so self-hosted installs get a
5 + link that works on whatever origin the client used to reach us.
6 +
7 + GET /api/v1/boards/:board_id/invites
8 + POST /api/v1/boards/:board_id/invites {role?, note?, expires_in_days?}
9 + DELETE /api/v1/boards/:board_id/invites/:id
10 + """
11 +
12 + use WaxxWeb, :controller
13 +
14 + alias Waxx.{Kanban, Repo}
15 + alias Waxx.Kanban.BoardInvite
16 + alias WaxxWeb.Api.V1.BoardJSON
17 + alias WaxxWeb.PublicUrl
18 +
19 + action_fallback WaxxWeb.Api.FallbackController
20 +
21 + def index(conn, %{"board_id" => board_id}) do
22 + user = conn.assigns.current_scope.user
23 +
24 + with {:ok, board} <- fetch_owner_board(board_id, user) do
25 + invites = Kanban.list_board_invites(board)
26 + json(conn, BoardJSON.board_invites_list(invites, PublicUrl.derive(conn)))
27 + end
28 + end
29 +
30 + def create(conn, %{"board_id" => board_id} = params) do
31 + user = conn.assigns.current_scope.user
32 +
33 + with {:ok, board} <- fetch_owner_board(board_id, user),
34 + attrs <- build_create_attrs(params),
35 + {:ok, invite} <- Kanban.create_board_invite(board, user, attrs) do
36 + conn
37 + |> put_status(:created)
38 + |> json(BoardJSON.board_invite_response(invite, PublicUrl.derive(conn)))
39 + end
40 + end
41 +
42 + def delete(conn, %{"board_id" => board_id, "id" => invite_id}) do
43 + user = conn.assigns.current_scope.user
44 +
45 + with {:ok, _board} <- fetch_owner_board(board_id, user),
46 + %BoardInvite{board_id: ^board_id} = invite <-
47 + Repo.get(BoardInvite, invite_id) || {:error, :not_found},
48 + {:ok, _} <- Kanban.revoke_board_invite(invite) do
49 + send_resp(conn, :no_content, "")
50 + else
51 + %BoardInvite{} -> {:error, :not_found}
52 + err -> err
53 + end
54 + end
55 +
56 + defp build_create_attrs(params) do
57 + base = params |> Map.take(["role", "note"])
58 +
59 + case params["expires_in_days"] do
60 + n when is_integer(n) and n > 0 ->
61 + Map.put(base, "expires_at", DateTime.utc_now() |> DateTime.add(n * 86_400, :second))
62 +
63 + _ ->
64 + base
65 + end
66 + end
67 +
68 + defp fetch_owner_board(board_id, user) do
69 + case Kanban.get_board_for_user(board_id, user) do
70 + nil ->
71 + {:error, :not_found}
72 +
73 + board ->
74 + if Kanban.can_manage?(Kanban.role_for(board.id, user)),
75 + do: {:ok, board},
76 + else: {:error, :forbidden}
77 + end
78 + end
79 +end
modified lib/waxx_web/api/v1/board_json.ex
+59 −0
@@ -155,6 +155,65 @@ defmodule WaxxWeb.Api.V1.BoardJSON do
155 155
156 156 def note_response(%CardNote{} = n), do: %{note: note(n)}
157 157
158 + ## Members + invites --------------------------------------------------
159 +
160 + def members_list(memberships) when is_list(memberships) do
161 + %{memberships: Enum.map(memberships, &membership/1)}
162 + end
163 +
164 + def membership_response(%Membership{} = m), do: %{membership: membership(m)}
165 +
166 + def board_invites_list(invites, base_url) when is_list(invites) do
167 + %{invites: Enum.map(invites, &board_invite(&1, base_url))}
168 + end
169 +
170 + def board_invite_response(invite, base_url) do
171 + %{invite: board_invite(invite, base_url)}
172 + end
173 +
174 + defp board_invite(invite, base_url) do
175 + %{
176 + id: invite.id,
177 + token: invite.token,
178 + role: invite.role,
179 + note: invite.note,
180 + expires_at: invite.expires_at,
181 + consumed_at: invite.consumed_at,
182 + inserted_at: invite.inserted_at,
183 + redemption_url: "#{base_url}/b/#{invite.token}",
184 + consumed_by_email:
185 + case Map.get(invite, :consumed_by) do
186 + %{email: email} -> email
187 + _ -> nil
188 + end
189 + }
190 + end
191 +
192 + def app_invites_list(invites, base_url) when is_list(invites) do
193 + %{invites: Enum.map(invites, &app_invite(&1, base_url))}
194 + end
195 +
196 + def app_invite_response(invite, base_url) do
197 + %{invite: app_invite(invite, base_url)}
198 + end
199 +
200 + defp app_invite(invite, base_url) do
201 + %{
202 + id: invite.id,
203 + token: invite.token,
204 + note: invite.note,
205 + expires_at: invite.expires_at,
206 + consumed_at: invite.consumed_at,
207 + inserted_at: invite.inserted_at,
208 + redemption_url: "#{base_url}/users/register?invite=#{invite.token}",
209 + consumed_by_email:
210 + case Map.get(invite, :consumed_by) do
211 + %{email: email} -> email
212 + _ -> nil
213 + end
214 + }
215 + end
216 +
158 217 def card(%Card{} = c) do
159 218 %{
160 219 id: c.id,
added lib/waxx_web/api/v1/membership_controller.ex
+83 −0
@@ -0,0 +1,83 @@
1 +defmodule WaxxWeb.Api.V1.MembershipController do
2 + @moduledoc """
3 + Board membership management. Owners only.
4 +
5 + PUT /api/v1/boards/:board_id/memberships/:user_id {role}
6 + DELETE /api/v1/boards/:board_id/memberships/:user_id
7 +
8 + We expose memberships by `user_id` rather than the membership row id —
9 + user_id is what the caller already has from `board.memberships` on the
10 + board show endpoint, and it makes the URL idempotent (one membership
11 + per (board, user) by table uniqueness).
12 + """
13 +
14 + use WaxxWeb, :controller
15 +
16 + import Ecto.Query
17 +
18 + alias Waxx.{Kanban, Repo}
19 + alias Waxx.Kanban.Membership
20 + alias WaxxWeb.Api.V1.BoardJSON
21 +
22 + action_fallback WaxxWeb.Api.FallbackController
23 +
24 + def update(conn, %{"board_id" => board_id, "user_id" => user_id, "role" => role}) do
25 + actor = conn.assigns.current_scope.user
26 +
27 + with {:ok, _board} <- fetch_owner_board(board_id, actor),
28 + {:ok, membership} <- fetch_membership(board_id, user_id),
29 + {:ok, updated} <- Kanban.update_member_role(membership, role) do
30 + json(conn, BoardJSON.membership_response(Repo.preload(updated, :user)))
31 + end
32 + end
33 +
34 + def update(_conn, _params), do: {:error, :validation_failed}
35 +
36 + def delete(conn, %{"board_id" => board_id, "user_id" => user_id}) do
37 + actor = conn.assigns.current_scope.user
38 +
39 + with {:ok, _board} <- fetch_owner_board(board_id, actor),
40 + {:ok, membership} <- fetch_membership(board_id, user_id),
41 + _ <- guard_last_owner(membership) || throw_forbidden(),
42 + {:ok, _} <- Kanban.remove_member(membership) do
43 + send_resp(conn, :no_content, "")
44 + end
45 + catch
46 + :forbidden -> {:error, :forbidden}
47 + end
48 +
49 + defp guard_last_owner(%Membership{role: "owner", board_id: board_id} = m) do
50 + other_owners =
51 + Repo.aggregate(
52 + from(o in Membership,
53 + where: o.board_id == ^board_id and o.role == "owner" and o.id != ^m.id
54 + ),
55 + :count
56 + )
57 +
58 + other_owners > 0
59 + end
60 +
61 + defp guard_last_owner(_), do: true
62 +
63 + defp throw_forbidden, do: throw(:forbidden)
64 +
65 + defp fetch_owner_board(board_id, user) do
66 + case Kanban.get_board_for_user(board_id, user) do
67 + nil ->
68 + {:error, :not_found}
69 +
70 + board ->
71 + if Kanban.can_manage?(Kanban.role_for(board.id, user)),
72 + do: {:ok, board},
73 + else: {:error, :forbidden}
74 + end
75 + end
76 +
77 + defp fetch_membership(board_id, user_id) do
78 + case Repo.get_by(Membership, board_id: board_id, user_id: user_id) do
79 + nil -> {:error, :not_found}
80 + m -> {:ok, m}
81 + end
82 + end
83 +end
modified lib/waxx_web/router.ex
+14 −0
@@ -134,6 +134,20 @@ defmodule WaxxWeb.Router do
134 134 post "/cards/:card_id/notes", NoteController, :create
135 135 patch "/notes/:id", NoteController, :update
136 136 delete "/notes/:id", NoteController, :delete
137 +
138 + patch "/boards/:id", BoardController, :update
139 + delete "/boards/:id", BoardController, :delete
140 +
141 + put "/boards/:board_id/memberships/:user_id", MembershipController, :update
142 + delete "/boards/:board_id/memberships/:user_id", MembershipController, :delete
143 +
144 + get "/boards/:board_id/invites", BoardInviteController, :index
145 + post "/boards/:board_id/invites", BoardInviteController, :create
146 + delete "/boards/:board_id/invites/:id", BoardInviteController, :delete
147 +
148 + get "/users/invites", AppInviteController, :index
149 + post "/users/invites", AppInviteController, :create
150 + delete "/users/invites/:id", AppInviteController, :delete
137 151 end
138 152 end
139 153 end
added test/waxx_web/api/v1/board_settings_test.exs
+202 −0
@@ -0,0 +1,202 @@
1 +defmodule WaxxWeb.Api.V1.BoardSettingsTest do
2 + use WaxxWeb.ConnCase, async: true
3 +
4 + import Waxx.AccountsFixtures
5 + import Waxx.KanbanFixtures
6 +
7 + alias Waxx.Kanban
8 +
9 + defp setup_owner do
10 + user = confirmed_user_fixture()
11 + board = board_fixture(user, name: "B")
12 + %{user: user, board: board, token: api_token_fixture(user)}
13 + end
14 +
15 + defp auth(conn, token), do: put_req_header(conn, "authorization", "Bearer " <> token)
16 +
17 + describe "PATCH /api/v1/boards/:id" do
18 + test "owner can rename + change archive days", %{conn: conn} do
19 + %{token: token, board: board} = setup_owner()
20 +
21 + conn =
22 + conn
23 + |> auth(token)
24 + |> patch(~p"/api/v1/boards/#{board.id}", %{
25 + name: "Renamed",
26 + archive_terminal_after_days: 30
27 + })
28 +
29 + assert %{"board" => b} = json_response(conn, 200)
30 + assert b["name"] == "Renamed"
31 + assert b["archive_terminal_after_days"] == 30
32 + end
33 +
34 + test "editor (non-owner) gets 403", %{conn: conn} do
35 + owner = confirmed_user_fixture()
36 + editor = confirmed_user_fixture()
37 + board = board_fixture(owner)
38 + {:ok, _} = Kanban.add_member(board, editor, "editor")
39 + token = api_token_fixture(editor)
40 +
41 + conn = conn |> auth(token) |> patch(~p"/api/v1/boards/#{board.id}", %{name: "nope"})
42 + assert json_response(conn, 403)
43 + end
44 + end
45 +
46 + describe "DELETE /api/v1/boards/:id" do
47 + test "owner can delete the board", %{conn: conn} do
48 + %{token: token, board: board} = setup_owner()
49 +
50 + conn = conn |> auth(token) |> delete(~p"/api/v1/boards/#{board.id}")
51 + assert response(conn, 204) == ""
52 + refute Waxx.Repo.get(Waxx.Kanban.Board, board.id)
53 + end
54 +
55 + test "editor gets 403", %{conn: conn} do
56 + owner = confirmed_user_fixture()
57 + editor = confirmed_user_fixture()
58 + board = board_fixture(owner)
59 + {:ok, _} = Kanban.add_member(board, editor, "editor")
60 + token = api_token_fixture(editor)
61 +
62 + conn = conn |> auth(token) |> delete(~p"/api/v1/boards/#{board.id}")
63 + assert json_response(conn, 403)
64 + end
65 + end
66 +
67 + describe "PUT /api/v1/boards/:board_id/memberships/:user_id" do
68 + test "owner can promote an editor to owner", %{conn: conn} do
69 + %{token: token, board: board} = setup_owner()
70 + teammate = confirmed_user_fixture()
71 + {:ok, _} = Kanban.add_member(board, teammate, "editor")
72 +
73 + conn =
74 + conn
75 + |> auth(token)
76 + |> put(~p"/api/v1/boards/#{board.id}/memberships/#{teammate.id}", %{role: "owner"})
77 +
78 + assert %{"membership" => m} = json_response(conn, 200)
79 + assert m["role"] == "owner"
80 + end
81 +
82 + test "non-owner gets 403", %{conn: conn} do
83 + owner = confirmed_user_fixture()
84 + editor = confirmed_user_fixture()
85 + target = confirmed_user_fixture()
86 + board = board_fixture(owner)
87 + {:ok, _} = Kanban.add_member(board, editor, "editor")
88 + {:ok, _} = Kanban.add_member(board, target, "viewer")
89 + token = api_token_fixture(editor)
90 +
91 + conn =
92 + conn
93 + |> auth(token)
94 + |> put(~p"/api/v1/boards/#{board.id}/memberships/#{target.id}", %{role: "editor"})
95 +
96 + assert json_response(conn, 403)
97 + end
98 + end
99 +
100 + describe "DELETE /api/v1/boards/:board_id/memberships/:user_id" do
101 + test "owner can remove a teammate", %{conn: conn} do
102 + %{token: token, board: board} = setup_owner()
103 + teammate = confirmed_user_fixture()
104 + {:ok, _} = Kanban.add_member(board, teammate, "editor")
105 +
106 + conn =
107 + conn
108 + |> auth(token)
109 + |> delete(~p"/api/v1/boards/#{board.id}/memberships/#{teammate.id}")
110 +
111 + assert response(conn, 204) == ""
112 + refute Kanban.role_for(board.id, teammate)
113 + end
114 +
115 + test "refuses to remove the last owner", %{conn: conn} do
116 + %{token: token, board: board, user: owner} = setup_owner()
117 +
118 + conn =
119 + conn
120 + |> auth(token)
121 + |> delete(~p"/api/v1/boards/#{board.id}/memberships/#{owner.id}")
122 +
123 + assert json_response(conn, 403)
124 + assert Kanban.role_for(board.id, owner) == "owner"
125 + end
126 + end
127 +
128 + describe "Board invites" do
129 + test "owner can list + create + revoke", %{conn: conn} do
130 + %{token: token, board: board} = setup_owner()
131 +
132 + list1 = conn |> auth(token) |> get(~p"/api/v1/boards/#{board.id}/invites")
133 + assert %{"invites" => []} = json_response(list1, 200)
134 +
135 + created =
136 + build_conn()
137 + |> auth(token)
138 + |> post(~p"/api/v1/boards/#{board.id}/invites", %{role: "viewer", note: "QA folks"})
139 +
140 + assert %{"invite" => inv} = json_response(created, 201)
141 + assert inv["role"] == "viewer"
142 + assert inv["note"] == "QA folks"
143 + assert is_binary(inv["token"])
144 + assert String.contains?(inv["redemption_url"], "/b/" <> inv["token"])
145 +
146 + list2 = build_conn() |> auth(token) |> get(~p"/api/v1/boards/#{board.id}/invites")
147 + assert %{"invites" => [_]} = json_response(list2, 200)
148 +
149 + revoked =
150 + build_conn()
151 + |> auth(token)
152 + |> delete(~p"/api/v1/boards/#{board.id}/invites/#{inv["id"]}")
153 +
154 + assert response(revoked, 204) == ""
155 + end
156 +
157 + test "non-owner gets 403 on create", %{conn: conn} do
158 + owner = confirmed_user_fixture()
159 + editor = confirmed_user_fixture()
160 + board = board_fixture(owner)
161 + {:ok, _} = Kanban.add_member(board, editor, "editor")
162 + token = api_token_fixture(editor)
163 +
164 + conn = conn |> auth(token) |> post(~p"/api/v1/boards/#{board.id}/invites", %{})
165 + assert json_response(conn, 403)
166 + end
167 + end
168 +
169 + describe "App invites" do
170 + test "any user can mint + revoke their own", %{conn: conn} do
171 + user = confirmed_user_fixture()
172 + token = api_token_fixture(user)
173 +
174 + created =
175 + conn
176 + |> auth(token)
177 + |> post(~p"/api/v1/users/invites", %{note: "onboarding"})
178 +
179 + assert %{"invite" => inv} = json_response(created, 201)
180 + assert inv["note"] == "onboarding"
181 + assert String.contains?(inv["redemption_url"], "/users/register?invite=")
182 +
183 + listing = build_conn() |> auth(token) |> get(~p"/api/v1/users/invites")
184 + assert %{"invites" => [_]} = json_response(listing, 200)
185 +
186 + revoked =
187 + build_conn() |> auth(token) |> delete(~p"/api/v1/users/invites/#{inv["id"]}")
188 +
189 + assert response(revoked, 204) == ""
190 + end
191 +
192 + test "can't revoke someone else's invite", %{conn: conn} do
193 + alice = confirmed_user_fixture()
194 + bob = confirmed_user_fixture()
195 + {:ok, invite} = Waxx.Accounts.create_invite(alice)
196 + bob_token = api_token_fixture(bob)
197 +
198 + conn = conn |> auth(bob_token) |> delete(~p"/api/v1/users/invites/#{invite.id}")
199 + assert json_response(conn, 404)
200 + end
201 + end
202 +end

Parents: c5a2f92