From fc039ac0fcf2092e95107bd6cf2e483c72ae68da Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Fri, 26 Jun 2026 16:16:05 +0100 Subject: [PATCH] docs: capture design from walk-with-me (CONTEXT, DESIGN, ADR-0001) --- CONTEXT.md | 67 +++++++++++++++ DESIGN.md | 110 +++++++++++++++++++++++++ docs/adr/0001-slack-directory-cache.md | 22 +++++ 3 files changed, 199 insertions(+) create mode 100644 CONTEXT.md create mode 100644 DESIGN.md create mode 100644 docs/adr/0001-slack-directory-cache.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..2d793a4 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,67 @@ +# photofetch + +Pick a Napta **Project** and see a grid of its **Members** — each shown with their +Slack **Avatar**, name, and **Role**. The shared vocabulary below is the ubiquitous +language: it must appear verbatim in conversation, code, tests, commits, and docs. + +## Language + +**Project**: +A Napta project that people are staffed on; the unit you select to see its team. +_Avoid_: mission, engagement. + +**Person**: +An employee in Napta (a Napta `user`), carrying an email, first/last name, and Position. +_Avoid_: collaborator, resource, user (ambiguous — there are no app accounts). + +**Staffing Assignment**: +A Napta `user_project` row linking one Person to one Project; may be real or `simulated`. +_Avoid_: allocation, booking. + +**Member**: +A Person with a real (non-`simulated`) Staffing Assignment on the selected Project who is still an active employee — one cell in the grid. +_Avoid_: teammate, staff. + +**Role**: +A Member's Napta job Position (`user_position`), shown under their name. +_Avoid_: title, grade, seniority. + +**Match**: +The link from a Member to their Slack profile, made by equal email. A Member is _matched_ or _unmatched_. +_Avoid_: lookup, mapping. + +**Avatar**: +A matched Member's Slack profile picture. Unmatched Members fall back to an initials Avatar. +_Avoid_: photo, picture, image. + +**Selection**: +The set of one or more Projects currently chosen, whose teams are shown together. +_Avoid_: filter. + +**Project Group**: +One selected Project plus its Members — a section in the grid and a folder in an Export. +_Avoid_: section, bucket. + +**Export**: +A downloadable zip of Member Avatars, organized as one folder per Project Group (folder named after the Project). +_Avoid_: download, dump. + +## Relationships + +- A **Project** has many **Staffing Assignments**; each Assignment links one **Person**. +- A **Member** is a **Person** with a qualifying **Staffing Assignment** on a selected **Project**. +- A **Member** has exactly one **Role** and at most one **Avatar** (via an email **Match**). +- A **Selection** contains one or more **Projects**; each renders as a **Project Group**. +- A Person staffed on several selected **Projects** appears in each of those **Project Groups**. +- An **Export** writes one folder per **Project Group**, holding its Members' **Avatar** files. + +## Example dialogue + +> **Dev:** "When I pick a **Project**, do I show every **Person** who has a **Staffing Assignment** on it?" +> **Domain expert:** "Only real ones — drop the `simulated` assignments, and drop people who've left." +> **Dev:** "And if a **Member**'s email has no **Match** in Slack?" +> **Domain expert:** "Still show them — initials **Avatar**, real name and **Role**. A missing photo isn't a missing person." + +## Flagged ambiguities + +- "user" is avoided: Napta calls a Person a `user`, but photofetch has no login/accounts, so the word would mislead. A Person on the selected Project is a **Member**. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..43c4604 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,110 @@ +# 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](./CONTEXT.md); +the Slack-matching decision is recorded in [ADR-0001](./docs/adr/0001-slack-directory-cache.md). +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]` (drop `simulated`) → resolve `user_id`s → `GET /user` (drop inactive) → resolve `user_position_id` → Role + - **Component**: `backend/src/napta.ts` (client + token + queries) + - **F4** Authenticate to Napta + - **How**: client-credentials → JWT at `auth.napta.io/oauth/token` (aud `backend`), cache until expiry, refresh on 401 + - **Component**: `backend/src/napta.ts` (token cache) + - **F2** Render the grid + - **How**: one backend call `GET /api/projects/:id/members` returns ready-to-render Members; SPA shows loading skeleton → grid + - **Component**: `src/App.vue`, `src/components/MemberGrid.vue`, `MemberCard.vue` +- **G2** Correct name + Role _(W8)_ + - **F1** (Role = Napta Position) — see above + - **F3** Match Members → Avatars + - **How**: `users.list` sweep → `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) +- **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` +- **G4** Useful when Slack is down / no match _(W6)_ + - **F7→degrade**: if the directory sweep fails, return Members anyway with `slackMatched:false` + a `slackError` flag; UI shows initials + dismissible warning + - **Component**: `backend/src/slack.ts`, `MemberGrid.vue` +- **G5** Private to the team _(W7)_ + - **F7** Basic Auth at the edge + - **How**: nginx `auth_basic` over the whole site incl. `/api`; htpasswd generated at container start from `BASIC_AUTH_USER`/`BASIC_AUTH_PASSWORD`; no-op when unset (local dev) + - **Component**: `nginx.conf`, web image entrypoint +- **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` + - **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 `/.`; 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` + +## 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](./docs/adr/0001-slack-directory-cache.md) | +| 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 becomes `NAPTA_CLIENT_ID` + `NAPTA_CLIENT_SECRET` with a token-exchange step. +- **Wrong cache shape.** Scaffold `db.ts` modeled a per-email avatar cache (`email → url`, null = no-match), which fits `lookupByEmail`, not the chosen `users.list` sweep. → Reworked to a directory snapshot table + a `refreshed_at` timestamp. +- **Host port publishing.** `docker-compose.yml` published `80`/`8000`, which collided on the Coolify host and failed the first deploy. → Switched to `expose:` (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. diff --git a/docs/adr/0001-slack-directory-cache.md b/docs/adr/0001-slack-directory-cache.md new file mode 100644 index 0000000..f62502e --- /dev/null +++ b/docs/adr/0001-slack-directory-cache.md @@ -0,0 +1,22 @@ +# Match Members to Slack via a cached whole-workspace directory + +We resolve Slack **Avatars** by sweeping the entire workspace once with `users.list` +and caching an `email → {imageUrl, slackId}` directory in SQLite, rather than calling +`users.lookupByEmail` per Member. A **Match** is then an in-memory lookup, and an +unmatched Member is simply one whose email isn't in the directory. + +## Considered Options + +- **`users.lookupByEmail` per Member** — only fetches who you view, but makes N calls + per uncached Project view and is more exposed to per-method rate limits. +- **`users.list` directory sweep (chosen)** — one paginated sweep covers everyone; + repeated Project views cost zero Slack calls; matching is in-memory. + +## Consequences + +- Cache is a directory snapshot + a single `refreshed_at`, with a **30-day TTL** and a + manual **Refresh** (`POST /api/slack/refresh`) — Avatars change rarely, so staleness is + cheap and the button covers the exceptions. +- If the sweep fails, we still return Members (unmatched) and the UI degrades to initials + Avatars + a warning — a Slack outage never hides the roster. +- Deleted/bot Slack users are filtered out of the directory.