Compare commits

...

4 Commits

Author SHA1 Message Date
Julien Calixte
de98bdeef7 feat(slack): cached directory matching + manual refresh
Rework cache to a slack_directory snapshot + refreshed_at (ADR-0001). Sweep the
workspace via users.list (skip deleted/bots), match Members by email in memory,
30-day TTL. Members response carries slack status (ok|degraded|unconfigured);
degrade to initials on Slack failure. Add POST /api/slack/refresh.
2026-06-26 16:20:52 +01:00
Julien Calixte
e47302218b feat(napta): resolve project Members (real+active, role, sorted)
Filter user_project by project + simulated=false, include user, drop inactive
people, dedupe per user, map user_position -> Role, sort by last name.
2026-06-26 16:20:51 +01:00
Julien Calixte
9ded10f68e feat(napta): client-credentials auth + real project listing
Napta uses Auth0 M2M, not a static token: exchange client_id/secret for a cached
JWT, then list non-archived projects via JSON:API (include client name). Falls back
to fixtures when NAPTA_CLIENT_ID/SECRET are unset. Member resolution still stubbed.
2026-06-26 16:16:06 +01:00
Julien Calixte
fc039ac0fc docs: capture design from walk-with-me (CONTEXT, DESIGN, ADR-0001) 2026-06-26 16:16:05 +01:00
11 changed files with 611 additions and 65 deletions

67
CONTEXT.md Normal file
View File

@@ -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**.

110
DESIGN.md Normal file
View File

@@ -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 `<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`
## 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.

View File

@@ -3,14 +3,19 @@
PORT=8000
# Napta API — required for live data. Without it, the API serves demo fixtures.
# Base URL + token: see https://app.napta.io (Settings → API).
NAPTA_API_TOKEN=
# Napta API (https://app.napta.io). Auth is Auth0 Machine-to-Machine: the backend
# exchanges client_id + client_secret for a JWT. Without BOTH, the API serves demo
# fixtures. Get an M2M credential from Napta (Settings -> API / your Napta admin).
NAPTA_CLIENT_ID=
NAPTA_CLIENT_SECRET=
NAPTA_BASE_URL=https://app.napta.io/api/v1
# Override only if Napta tells you to:
# NAPTA_AUTH_URL=https://auth.napta.io/oauth/token
# NAPTA_AUDIENCE=backend
# Slack bot token (xoxb-...) with scopes: users:read, users:read.email
# Used to resolve each person's profile picture by email.
# Optional — without it, Members show initials Avatars instead of Slack photos.
SLACK_BOT_TOKEN=
# SQLite location (avatar cache). Matches the Coolify persistent volume.
# SQLite location (Slack directory cache). Matches the Coolify persistent volume.
DB_PATH=data/app.db

View File

@@ -1,19 +1,29 @@
export interface Config {
port: number
naptaApiToken: string | null
// Napta uses Auth0 Machine-to-Machine (client-credentials), not a static token:
// client_id + client_secret are exchanged for a short-lived JWT (see napta.ts).
naptaClientId: string | null
naptaClientSecret: string | null
naptaBaseUrl: string
naptaAuthUrl: string
naptaAudience: string
slackBotToken: string | null
}
export const config: Config = {
port: Number(process.env.PORT ?? 8000),
naptaApiToken: process.env.NAPTA_API_TOKEN || null,
naptaClientId: process.env.NAPTA_CLIENT_ID || null,
naptaClientSecret: process.env.NAPTA_CLIENT_SECRET || null,
naptaBaseUrl: process.env.NAPTA_BASE_URL || "https://app.napta.io/api/v1",
naptaAuthUrl: process.env.NAPTA_AUTH_URL || "https://auth.napta.io/oauth/token",
naptaAudience: process.env.NAPTA_AUDIENCE || "backend",
slackBotToken: process.env.SLACK_BOT_TOKEN || null,
}
// Live data needs BOTH a Napta token (to list projects/people) and a Slack
// token (to resolve avatars). Without both, the API serves demo fixtures.
export const hasLiveCredentials: boolean = Boolean(
config.naptaApiToken && config.slackBotToken,
// Listing Projects and Members needs Napta. Without it, the API serves fixtures.
export const hasNaptaCredentials: boolean = Boolean(
config.naptaClientId && config.naptaClientSecret,
)
// Slack only resolves Avatars; it's optional and degrades to initials when absent.
export const hasSlackCredentials: boolean = Boolean(config.slackBotToken)

View File

@@ -7,41 +7,61 @@ mkdirSync(dirname(dbPath), { recursive: true })
export const db = new DatabaseSync(dbPath)
// Cache of email -> Slack avatar URL, so we don't hit Slack on every request.
// image_url is nullable: a NULL row means "looked up, no Slack match" (also
// worth caching, to avoid re-querying people who aren't in Slack).
// A snapshot of the Slack workspace directory: one row per Slack user that has
// an email, plus a single meta row recording when the snapshot was last swept.
// An unmatched Member is simply one whose email isn't in this table (see ADR-0001).
db.exec(`
CREATE TABLE IF NOT EXISTS slack_avatar_cache (
CREATE TABLE IF NOT EXISTS slack_directory (
email TEXT PRIMARY KEY,
image_url TEXT,
cached_at INTEGER NOT NULL
)
slack_id TEXT
);
CREATE TABLE IF NOT EXISTS slack_directory_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
refreshed_at INTEGER NOT NULL
);
`)
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
interface CacheRow {
image_url: string | null
cached_at: number
export interface DirectoryEntry {
email: string
imageUrl: string | null
slackId: string
}
// Returns the cached URL (which may be null = known-no-match), or undefined
// when there is no fresh cache entry and the caller should query Slack.
export function getCachedAvatar(email: string): string | null | undefined {
export function getDirectoryRefreshedAt(): number | null {
const row = db
.prepare("SELECT image_url, cached_at FROM slack_avatar_cache WHERE email = ?")
.get(email.toLowerCase()) as CacheRow | undefined
if (!row) return undefined
if (Date.now() - row.cached_at > CACHE_TTL_MS) return undefined
return row.image_url
.prepare("SELECT refreshed_at FROM slack_directory_meta WHERE id = 1")
.get() as { refreshed_at: number } | undefined
return row ? row.refreshed_at : null
}
export function setCachedAvatar(email: string, imageUrl: string | null): void {
db.prepare(
`INSERT INTO slack_avatar_cache (email, image_url, cached_at)
VALUES (?, ?, ?)
ON CONFLICT(email) DO UPDATE SET
image_url = excluded.image_url,
cached_at = excluded.cached_at`,
).run(email.toLowerCase(), imageUrl, Date.now())
// Atomically replace the whole directory snapshot and stamp refreshed_at.
export function replaceDirectory(entries: DirectoryEntry[]): void {
const insert = db.prepare(
"INSERT OR REPLACE INTO slack_directory (email, image_url, slack_id) VALUES (?, ?, ?)",
)
const stamp = db.prepare(
"INSERT OR REPLACE INTO slack_directory_meta (id, refreshed_at) VALUES (1, ?)",
)
db.exec("BEGIN")
try {
db.exec("DELETE FROM slack_directory")
for (const e of entries) insert.run(e.email.toLowerCase(), e.imageUrl, e.slackId)
stamp.run(Date.now())
db.exec("COMMIT")
} catch (err) {
db.exec("ROLLBACK")
throw err
}
}
export function getDirectoryMap(): Map<string, DirectoryEntry> {
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<string, DirectoryEntry>()
for (const r of rows) {
map.set(r.email, { email: r.email, imageUrl: r.image_url, slackId: r.slack_id })
}
return map
}

View File

@@ -1,35 +1,63 @@
import { serve } from "@hono/node-server"
import { Hono } from "hono"
import { logger } from "hono/logger"
import { config, hasLiveCredentials } from "./config.ts"
import { config, hasNaptaCredentials, hasSlackCredentials } from "./config.ts"
import { fixtureMembers, fixtureProjects } from "./fixtures.ts"
import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts"
import { enrichWithSlackAvatars } from "./slack.ts"
import { enrichWithSlackAvatars, refreshDirectory } from "./slack.ts"
import "./db.ts" // initialise the SQLite schema at boot
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
const app = new Hono()
app.use("*", logger())
app.get("/api/health", (c) => c.json({ status: "ok" }))
app.get("/api/projects", async (c) => {
if (!hasLiveCredentials) {
if (!hasNaptaCredentials) {
return c.json({ source: "fixture", projects: fixtureProjects })
}
const projects = await fetchNaptaProjects()
return c.json({ source: "napta", projects })
try {
const projects = await fetchNaptaProjects()
return c.json({ source: "napta", projects })
} catch (err) {
console.error("fetchNaptaProjects failed:", err)
return c.json({ error: errorMessage(err) }, 502)
}
})
app.get("/api/projects/:id/members", async (c) => {
const id = c.req.param("id")
if (!hasLiveCredentials) {
return c.json({ source: "fixture", members: fixtureMembers(id) })
if (!hasNaptaCredentials) {
return c.json({ source: "fixture", slack: "unconfigured", members: fixtureMembers(id) })
}
try {
const { members, slack } = await enrichWithSlackAvatars(
await fetchNaptaProjectMembers(id),
)
return c.json({ source: "napta", slack, members })
} catch (err) {
console.error("fetchNaptaProjectMembers failed:", err)
return c.json({ error: errorMessage(err) }, 502)
}
})
// Force a re-sweep of the Slack directory (the manual cache invalidation).
app.post("/api/slack/refresh", async (c) => {
if (!hasSlackCredentials) return c.json({ error: "Slack is not configured" }, 400)
try {
const refreshed = await refreshDirectory()
return c.json({ refreshed })
} catch (err) {
console.error("slack refresh failed:", err)
return c.json({ error: errorMessage(err) }, 502)
}
const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id))
return c.json({ source: "napta", members })
})
console.log(
`photofetch backend listening on :${config.port} (live credentials: ${hasLiveCredentials})`,
`photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`,
)
serve({ fetch: app.fetch, port: config.port })

View File

@@ -1,15 +1,204 @@
import { config } from "./config.ts"
import type { Member, Project } from "./types.ts"
// Real Napta integration. Implemented in Step 11 (build-out) once the exact
// Napta API endpoints, auth header, and response shapes are confirmed in the
// walk-with-me design session. Until then these throw, and the route layer
// only calls them when live credentials are present — so the default
// experience (no tokens) serves fixtures instead of erroring.
// --- Auth: Auth0 client-credentials, with an in-memory JWT cache ---
interface CachedToken {
token: string
expiresAt: number
}
let tokenCache: CachedToken | null = null
async function getAccessToken(): Promise<string> {
// Reuse the cached token until ~1 min before it expires.
if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) {
return tokenCache.token
}
const res = await fetch(config.naptaAuthUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: config.naptaClientId,
client_secret: config.naptaClientSecret,
audience: config.naptaAudience,
}),
})
if (!res.ok) {
throw new Error(`Napta auth failed: ${res.status} ${await res.text()}`)
}
const body = (await res.json()) as { access_token: string; expires_in: number }
tokenCache = {
token: body.access_token,
expiresAt: Date.now() + body.expires_in * 1000,
}
return body.access_token
}
// --- JSON:API helpers (Napta is JSON:API 1.0 over flask-rest-jsonapi) ---
interface JsonApiResource {
id: string
type: string
attributes: Record<string, unknown>
relationships?: Record<
string,
{ data: { id: string; type: string } | { id: string; type: string }[] | null }
>
}
interface JsonApiResponse {
data: JsonApiResource[]
included?: JsonApiResource[]
}
interface Filter {
name: string
op: string
val: unknown
}
interface QueryOpts {
filters?: Filter[]
include?: string[]
pageSize?: number
}
async function naptaGetPage(
path: string,
opts: QueryOpts,
page: number,
): Promise<JsonApiResponse> {
const token = await getAccessToken()
const url = new URL(`${config.naptaBaseUrl}${path}`)
// flask-rest-jsonapi: ?filter=[{"name","op","val"}], page[size], page[number], include.
if (opts.filters?.length) url.searchParams.set("filter", JSON.stringify(opts.filters))
if (opts.include?.length) url.searchParams.set("include", opts.include.join(","))
url.searchParams.set("page[size]", String(opts.pageSize ?? 100))
url.searchParams.set("page[number]", String(page))
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) {
// Surface the exact request + body so a live mismatch (filter syntax, etc.) is obvious.
throw new Error(`Napta GET ${url.pathname}${url.search} -> ${res.status} ${await res.text()}`)
}
return (await res.json()) as JsonApiResponse
}
// Page through a collection, accumulating `data` and sideloaded `included`.
async function naptaGetAll(
path: string,
opts: QueryOpts,
): Promise<{ data: JsonApiResource[]; included: JsonApiResource[] }> {
const data: JsonApiResource[] = []
const included: JsonApiResource[] = []
const pageSize = opts.pageSize ?? 100
for (let page = 1; page <= 1000; page++) {
const res = await naptaGetPage(path, opts, page)
data.push(...res.data)
if (res.included) included.push(...res.included)
if (res.data.length < pageSize) break // short page => last page
}
return { data, included }
}
function attr(resource: JsonApiResource, key: string): string {
const v = resource.attributes[key]
return v == null ? "" : String(v)
}
function relId(resource: JsonApiResource, name: string): string | null {
const rel = resource.relationships?.[name]?.data
if (!rel || Array.isArray(rel)) return null
return rel.id
}
// --- Projects ---
export async function fetchNaptaProjects(): Promise<Project[]> {
throw new Error("Napta integration not implemented yet (Step 11)")
const { data, included } = await naptaGetAll("/project", {
filters: [{ name: "is_archived", op: "eq", val: false }],
include: ["client"],
})
const clientNameById = new Map<string, string>()
for (const inc of included) {
if (inc.type === "client") clientNameById.set(inc.id, attr(inc, "name"))
}
return data
.map((p) => {
const clientId = relId(p, "client")
const clientName = clientId ? clientNameById.get(clientId) : undefined
return {
id: p.id,
name: attr(p, "name") || "Untitled project",
clientName: clientName || undefined,
}
})
.sort((a, b) => a.name.localeCompare(b.name))
}
export async function fetchNaptaProjectMembers(_projectId: string): Promise<Member[]> {
throw new Error("Napta integration not implemented yet (Step 11)")
// Prefer a flat `<name>_id` attribute, else the relationship's resource id.
function relatedId(resource: JsonApiResource, name: string): string | null {
const flat = resource.attributes[`${name}_id`]
if (flat != null) return String(flat)
return relId(resource, name)
}
// --- Positions (Role lookup), cached for the process lifetime ---
let positionMapCache: Map<string, string> | null = null
async function getPositionMap(): Promise<Map<string, string>> {
if (positionMapCache) return positionMapCache
const { data } = await naptaGetAll("/user_position", {})
const map = new Map<string, string>()
for (const p of data) map.set(p.id, attr(p, "name"))
positionMapCache = map
return map
}
// --- Members ---
export async function fetchNaptaProjectMembers(projectId: string): Promise<Member[]> {
const [{ data, included }, positions] = await Promise.all([
naptaGetAll("/user_project", {
// Real staffing on this Project only (drop simulated/forecast scenarios).
filters: [
{ name: "project_id", op: "eq", val: projectId },
{ name: "simulated", op: "eq", val: false },
],
include: ["user"],
}),
getPositionMap(),
])
const userById = new Map<string, JsonApiResource>()
for (const inc of included) {
if (inc.type === "user") userById.set(inc.id, inc)
}
// One Member per user, even if they have several assignments on the Project.
const byUserId = new Map<string, Member>()
for (const up of data) {
const userId = relatedId(up, "user")
if (!userId || byUserId.has(userId)) continue
const user = userById.get(userId)
if (!user) continue
if (user.attributes.active === false) continue // drop departed people
const positionId = relatedId(user, "user_position")
byUserId.set(userId, {
id: userId,
firstName: attr(user, "first_name"),
lastName: attr(user, "last_name"),
role: (positionId && positions.get(positionId)) || "",
email: attr(user, "email"),
imageUrl: null,
slackMatched: false,
})
}
return [...byUserId.values()].sort(
(a, b) =>
a.lastName.localeCompare(b.lastName) || a.firstName.localeCompare(b.firstName),
)
}

View File

@@ -1,9 +1,99 @@
import type { Member } from "./types.ts"
import { config, hasSlackCredentials } from "./config.ts"
import type { Member, SlackStatus } from "./types.ts"
import {
type DirectoryEntry,
getDirectoryMap,
getDirectoryRefreshedAt,
replaceDirectory,
} from "./db.ts"
// Given Napta members (which carry emails), fill in imageUrl from Slack by
// matching on email — caching results in slack_avatar_cache (see db.ts).
// Implemented in Step 11; for now it's a no-op pass-through so the pipeline
// is wired end-to-end and members render with initials fallbacks.
export async function enrichWithSlackAvatars(members: Member[]): Promise<Member[]> {
return members
const TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
interface SlackUser {
id: string
deleted?: boolean
is_bot?: boolean
profile?: {
email?: string
image_512?: string
image_192?: string
image_72?: string
}
}
interface UsersListResponse {
ok: boolean
error?: string
members?: SlackUser[]
response_metadata?: { next_cursor?: string }
}
async function slackUsersList(cursor?: string): Promise<UsersListResponse> {
const url = new URL("https://slack.com/api/users.list")
url.searchParams.set("limit", "200")
if (cursor) url.searchParams.set("cursor", cursor)
const res = await fetch(url, {
headers: { Authorization: `Bearer ${config.slackBotToken}` },
})
if (!res.ok) throw new Error(`Slack users.list HTTP ${res.status}`)
const body = (await res.json()) as UsersListResponse
if (!body.ok) throw new Error(`Slack users.list error: ${body.error}`)
return body
}
// Sweep the whole workspace (paginated) and replace the cached directory.
// Skips deleted users and bots. Returns the number of entries stored.
export async function refreshDirectory(): Promise<number> {
const entries: DirectoryEntry[] = []
let cursor: string | undefined
do {
const page = await slackUsersList(cursor)
for (const u of page.members ?? []) {
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
entries.push({ email, imageUrl, slackId: u.id })
}
cursor = page.response_metadata?.next_cursor || undefined
} while (cursor)
replaceDirectory(entries)
return entries.length
}
async function ensureDirectoryFresh(): Promise<void> {
const refreshedAt = getDirectoryRefreshedAt()
if (refreshedAt == null || Date.now() - refreshedAt > TTL_MS) {
await refreshDirectory()
}
}
export interface EnrichResult {
members: Member[]
slack: SlackStatus
}
// Fill each Member's Avatar from the cached Slack directory, matched by email.
// Degrades gracefully: a Slack failure never hides the roster.
export async function enrichWithSlackAvatars(members: Member[]): Promise<EnrichResult> {
if (!hasSlackCredentials) {
return { members, slack: "unconfigured" }
}
let slack: SlackStatus = "ok"
try {
await ensureDirectoryFresh()
} catch (err) {
console.error("Slack directory refresh failed:", err)
slack = "degraded" // fall through and match against whatever snapshot exists
}
const dir = getDirectoryMap()
if (dir.size === 0) {
return { members, slack: "degraded" }
}
const matched = members.map((m) => {
const hit = dir.get(m.email.toLowerCase())
return hit?.imageUrl ? { ...m, imageUrl: hit.imageUrl, slackMatched: true } : m
})
return { members: matched, slack }
}

View File

@@ -15,3 +15,7 @@ export interface Member {
}
export type DataSource = "napta" | "fixture"
// ok = matched against a fresh directory; degraded = Slack unreachable, matched
// against a stale/empty snapshot; unconfigured = no Slack token set.
export type SlackStatus = "ok" | "degraded" | "unconfigured"

View File

@@ -15,8 +15,9 @@ services:
- PORT=8000
- DB_PATH=/app/data/app.db
# Set these as env vars on the Coolify app to switch from demo fixtures
# to live data. Empty -> the backend serves fixtures.
- NAPTA_API_TOKEN=${NAPTA_API_TOKEN:-}
# to live data. Empty Napta creds -> the backend serves fixtures.
- NAPTA_CLIENT_ID=${NAPTA_CLIENT_ID:-}
- NAPTA_CLIENT_SECRET=${NAPTA_CLIENT_SECRET:-}
- NAPTA_BASE_URL=${NAPTA_BASE_URL:-https://app.napta.io/api/v1}
- SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN:-}
volumes:

View File

@@ -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.