10 KiB
10 KiB
photofetch — Design (QFD)
How photofetch turns "pick a Project, see its team as faces" into engineering functions and components. Vocabulary is defined in CONTEXT.md; the Slack-matching decision is recorded in ADR-0001. This is the tree-only QFD variant (cascade + budget + tradeoffs) — the goal/function counts don't justify the upkeep of full QFD matrices.
Strength weights used below: 9 strong, 3 medium, 1 weak, blank none.
1. Goals — the WHATs
| ID | Goal | Weight | Source |
|---|---|---|---|
| G1 | Pick a Project and immediately see who's on it, as faces | 10 | user request |
| G2 | Each Member shows the correct name + Role | 8 | user request |
| G3 | Find a specific Project fast, even among hundreds | 6 | [Q3] |
| G4 | Stay useful when Slack is down or a Member has no Slack | 6 | [Q6] |
| G5 | Keep the roster private to the team | 7 | [Q7] |
| G6 | Select several Projects, see their teams grouped, and export the photos | 8 | user request |
2. Functions — the HOWs
| ID | Function | Dir | Target (now) | Target (future) |
|---|---|---|---|---|
| F1 | Resolve a Project's Members from Napta (real + active only) | → | correct set | — |
| F2 | Render a Project's grid end-to-end | ↓ | ≤ 2 s p95 (warm cache) | ≤ 1 s |
| F3 | Match Members → Slack Avatars | ↑ | in-memory vs cached directory | — |
| F4 | Authenticate to Napta (Auth0 M2M) | → | cached JWT, refresh on 401/expiry | — |
| F5 | Keep the Slack directory cache fresh | → | 30-day TTL + manual Refresh | — |
| F6 | Filter/search the Project picker (multi-select) | ↓ | instant on ≤ few hundred (client-side) | server-side if thousands |
| F7 | Gate access to the whole site | → | Basic Auth at nginx | SSO |
| F8 | Build the Export zip (folder per Project Group) | ↓ | ≤ ~15 s for a few hundred photos | stream + progress |
3. Cascade — Goals → Functions → How → Components
- G1 Pick a Project, see its team (W10)
- F1 Resolve Members from Napta
- How:
GET /user_project?filter[project_id](dropsimulated) → resolveuser_ids →GET /user(drop inactive) → resolveuser_position_id→ Role- Component:
backend/src/napta.ts(client + token + queries)
- Component:
- How:
- F4 Authenticate to Napta
- How: client-credentials → JWT at
auth.napta.io/oauth/token(audbackend), cache until expiry, refresh on 401- Component:
backend/src/napta.ts(token cache)
- Component:
- How: client-credentials → JWT at
- F2 Render the grid
- How: one backend call
GET /api/projects/:id/membersreturns ready-to-render Members; SPA shows loading skeleton → grid- Component:
src/App.vue,src/components/MemberGrid.vue,MemberCard.vue
- Component:
- How: one backend call
- F1 Resolve Members from Napta
- G2 Correct name + Role (W8)
- F1 (Role = Napta Position) — see above
- F3 Match Members → Avatars
- How:
users.listsweep →email → {imageUrl, slackId}directory in SQLite; match each Member's email in memory; unmatched → initials Avatar- Component:
backend/src/slack.ts,backend/src/db.ts(directory table)
- Component:
- How:
- G3 Find a Project fast (W6)
- F6 Client-side searchable picker
- How: fetch all non-archived Projects once; typeahead filters by name + client
- Component:
src/components/ProjectPicker.vue
- Component:
- How: fetch all non-archived Projects once; typeahead filters by name + client
- F6 Client-side searchable picker
- G4 Useful when Slack is down / no match (W6)
- F7→degrade: if the directory sweep fails, return Members anyway with
slackMatched:false+ aslackErrorflag; UI shows initials + dismissible warning- Component:
backend/src/slack.ts,MemberGrid.vue
- Component:
- F7→degrade: if the directory sweep fails, return Members anyway with
- G5 Private to the team (W7)
- F7 Basic Auth at the edge
- How: nginx
auth_basicover the whole site incl./api; htpasswd generated at container start fromBASIC_AUTH_USER/BASIC_AUTH_PASSWORD; no-op when unset (local dev)- Component:
nginx.conf, web image entrypoint
- Component:
- How: nginx
- F7 Basic Auth at the edge
- G6 Multi-select + grouped view + photo Export (W8)
- F6 Multi-select searchable picker; chosen Projects render as Project Groups (a Person on several appears in each)
- Component:
src/components/ProjectPicker.vue,src/App.vue,ProjectGroup.vue
- Component:
- F8 Export zip
- How:
POST /api/export {projectIds[]}→ per Project Group fetch Members, fetch each Avatar's bytes (dedup within the export, bounded concurrency), write<project>/<lastname-firstname>.<ext>; unmatched Members get a generated initials SVG; stream the zip (fflate)- Component:
backend/src/export.ts,backend/src/avatar.ts(initials SVG),backend/src/index.ts
- Component:
- How:
- F6 Multi-select searchable picker; chosen Projects render as Project Groups (a Person on several appears in each)
7. Critical performance budget
| Rank | Function | Target | Watched on | If we miss it |
|---|---|---|---|---|
| 1 | F2 grid render | ≤ 2 s p95 (warm) | backend request logs | parallelize Napta calls; cache Positions table; batch user fetch by id |
| 2 | F5 directory sweep | ≤ ~10 s for full workspace | sweep duration log | paginate + serve stale snapshot while refreshing in background |
| 3 | F1 Member resolution | correct set, not slow path | spot-check vs Napta UI | add staffed_days > 0 filter if "assigned but never staffed" noise appears |
| 4 | F8 Export build | ≤ ~15 s, a few hundred photos | export duration log | bounded-concurrency avatar fetch; dedup repeated avatars; cap selection size with a warning |
8. Tradeoffs — Got / Paid / ADR
| ID | Tradeoff | Got | Paid | ADR |
|---|---|---|---|---|
| T1 | users.list whole-workspace sweep over per-email lookup |
few Slack calls, rate-limit-safe, in-memory matching | fetch entire directory; up to 30-day staleness (mitigated by Refresh) | ADR-0001 |
| T2 | Napta Auth0 M2M cached JWT over a static token | matches Napta's real auth; survives token expiry | token-exchange code + refresh-on-401 | — |
| T3 | HTTP Basic Auth over SSO | strangers kept out with ~zero build | one shared credential; no per-user identity/audit; easily swapped later | — |
| T4 | Email as the only Napta↔Slack join key | one reliable key, no fuzzy matching | Members whose Slack email differs go unmatched (shown with initials) | — |
| T5 | Display-only grid for v1 | ships the core ask fastest | no click-to-Slack / copy-email yet | — |
| T6 | Initials Avatars exported as SVG, not rasterized PNG | zero native deps, clean Alpine image, scalable | mixed extensions in folders; SVG unsuitable where only raster embeds | — |
| T7 | Server-side zip (fflate), built in memory |
no Slack-CDN CORS, has avatar bytes; simple | whole zip held in memory; large Selections need streaming (F8 future) | — |
Tensions being watched (unresolved by design)
- Project count. Client-side search assumes ≤ a few hundred active Projects. Trigger to revisit: the all-Projects fetch gets slow or Napta paginates it past one page → move search server-side (F6 future).
9. Inconsistencies spotted and fixed
- Static token assumption. Scaffold used
NAPTA_API_TOKEN; Napta actually uses Auth0 M2M client-credentials. → Config becomesNAPTA_CLIENT_ID+NAPTA_CLIENT_SECRETwith a token-exchange step. - Wrong cache shape. Scaffold
db.tsmodeled a per-email avatar cache (email → url, null = no-match), which fitslookupByEmail, not the chosenusers.listsweep. → Reworked to a directory snapshot table + arefreshed_attimestamp. - Host port publishing.
docker-compose.ymlpublished80/8000, which collided on the Coolify host and failed the first deploy. → Switched toexpose:(already fixed and deployed).
How to keep this honest
- New ADR lands → reference it from §8 and (if matrices ever added) §6.
- Spike/measurement returns numbers → update §7
Target/Watched on. - WHATs (§1) change rarely; HOWs (§2) change per release; revisit §3 cascade when either side moves.
- Delete any section that becomes empty — empty sections lie.