From 09238df7e38f820e74d4a5cf88157f6dd9214c44 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Fri, 26 Jun 2026 16:25:08 +0100 Subject: [PATCH] style: apply oxfmt to backend + docs --- DESIGN.md | 70 ++++++++++++++++++++--------------------- README.md | 8 ++--- backend/src/db.ts | 14 +++++---- backend/src/fixtures.ts | 7 +---- backend/src/index.ts | 8 ++--- backend/src/napta.ts | 9 ++---- backend/src/slack.ts | 3 +- 7 files changed, 53 insertions(+), 66 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 43c4604..4d5a804 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,27 +12,27 @@ 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 | +| 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 | +| 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 @@ -57,38 +57,38 @@ Strength weights used below: **9** strong, **3** medium, **1** weak, blank none. - **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` + - **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` + - **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 | +| 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) | — | +| 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) diff --git a/README.md b/README.md index 56400ef..60874cb 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,10 @@ cd backend && pnpm typecheck Set on the Coolify app (or in `backend/.env` locally — see `backend/.env.example`): -| Var | Purpose | -|---|---| -| `NAPTA_API_TOKEN` | Napta API token (lists projects + their people) | -| `NAPTA_BASE_URL` | Napta API base (default `https://app.napta.io/api/v1`) | +| Var | Purpose | +| ----------------- | --------------------------------------------------------- | +| `NAPTA_API_TOKEN` | Napta API token (lists projects + their people) | +| `NAPTA_BASE_URL` | Napta API base (default `https://app.napta.io/api/v1`) | | `SLACK_BOT_TOKEN` | Slack bot token, scopes `users:read` + `users:read.email` | Without **both** tokens the API serves demo fixtures and the UI shows a diff --git a/backend/src/db.ts b/backend/src/db.ts index 98e6591..4a4f41d 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -29,9 +29,9 @@ export interface DirectoryEntry { } export function getDirectoryRefreshedAt(): number | null { - const row = db - .prepare("SELECT refreshed_at FROM slack_directory_meta WHERE id = 1") - .get() as { refreshed_at: number } | undefined + const row = db.prepare("SELECT refreshed_at FROM slack_directory_meta WHERE id = 1").get() as + | { refreshed_at: number } + | undefined return row ? row.refreshed_at : null } @@ -56,9 +56,11 @@ export function replaceDirectory(entries: DirectoryEntry[]): void { } export function getDirectoryMap(): Map { - const rows = db - .prepare("SELECT email, image_url, slack_id FROM slack_directory") - .all() as { email: string; image_url: string | null; slack_id: string }[] + const rows = db.prepare("SELECT email, image_url, slack_id FROM slack_directory").all() as { + email: string + image_url: string | null + slack_id: string + }[] const map = new Map() for (const r of rows) { map.set(r.email, { email: r.email, imageUrl: r.image_url, slackId: r.slack_id }) diff --git a/backend/src/fixtures.ts b/backend/src/fixtures.ts index 55efbe1..48c1a00 100644 --- a/backend/src/fixtures.ts +++ b/backend/src/fixtures.ts @@ -10,12 +10,7 @@ export const fixtureProjects: Project[] = [ { id: "demo-zephyr", name: "Zephyr Mobile App", clientName: "Globex" }, ] -function member( - id: string, - firstName: string, - lastName: string, - role: string, -): Member { +function member(id: string, firstName: string, lastName: string, role: string): Member { return { id, firstName, diff --git a/backend/src/index.ts b/backend/src/index.ts index 2b3f2e5..396a9cc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -35,9 +35,7 @@ app.get("/api/projects/:id/members", async (c) => { return c.json({ source: "fixture", slack: "unconfigured", members: fixtureMembers(id) }) } try { - const { members, slack } = await enrichWithSlackAvatars( - await fetchNaptaProjectMembers(id), - ) + const { members, slack } = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id)) return c.json({ source: "napta", slack, members }) } catch (err) { console.error("fetchNaptaProjectMembers failed:", err) @@ -57,7 +55,5 @@ app.post("/api/slack/refresh", async (c) => { } }) -console.log( - `photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`, -) +console.log(`photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`) serve({ fetch: app.fetch, port: config.port }) diff --git a/backend/src/napta.ts b/backend/src/napta.ts index cedda8e..8a335f7 100644 --- a/backend/src/napta.ts +++ b/backend/src/napta.ts @@ -65,11 +65,7 @@ interface QueryOpts { pageSize?: number } -async function naptaGetPage( - path: string, - opts: QueryOpts, - page: number, -): Promise { +async function naptaGetPage(path: string, opts: QueryOpts, page: number): Promise { const token = await getAccessToken() const url = new URL(`${config.naptaBaseUrl}${path}`) // flask-rest-jsonapi: ?filter=[{"name","op","val"}], page[size], page[number], include. @@ -198,7 +194,6 @@ export async function fetchNaptaProjectMembers(projectId: string): Promise - a.lastName.localeCompare(b.lastName) || a.firstName.localeCompare(b.firstName), + (a, b) => a.lastName.localeCompare(b.lastName) || a.firstName.localeCompare(b.firstName), ) } diff --git a/backend/src/slack.ts b/backend/src/slack.ts index 9be00a2..4f1cf03 100644 --- a/backend/src/slack.ts +++ b/backend/src/slack.ts @@ -52,8 +52,7 @@ export async function refreshDirectory(): Promise { if (u.deleted || u.is_bot || u.id === "USLACKBOT") continue const email = u.profile?.email if (!email) continue - const imageUrl = - u.profile?.image_512 || u.profile?.image_192 || u.profile?.image_72 || null + const imageUrl = u.profile?.image_512 || u.profile?.image_192 || u.profile?.image_72 || null entries.push({ email, imageUrl, slackId: u.id }) } cursor = page.response_metadata?.next_cursor || undefined