neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
wahh'
1405e6a · gmorell · 2026-05-24 22:24
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