diff --git a/backend/src/db.ts b/backend/src/db.ts index da68752..98e6591 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -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 { + 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 }) + } + return map } diff --git a/backend/src/index.ts b/backend/src/index.ts index 639cced..2b3f2e5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,10 +1,10 @@ import { serve } from "@hono/node-server" import { Hono } from "hono" import { logger } from "hono/logger" -import { config, hasNaptaCredentials } 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 { @@ -32,17 +32,31 @@ app.get("/api/projects", async (c) => { app.get("/api/projects/:id/members", async (c) => { const id = c.req.param("id") if (!hasNaptaCredentials) { - return c.json({ source: "fixture", members: fixtureMembers(id) }) + return c.json({ source: "fixture", slack: "unconfigured", members: fixtureMembers(id) }) } try { - const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id)) - return c.json({ source: "napta", members }) + 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) + } +}) + console.log( `photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`, ) diff --git a/backend/src/slack.ts b/backend/src/slack.ts index e81c4e3..9be00a2 100644 --- a/backend/src/slack.ts +++ b/backend/src/slack.ts @@ -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 { - 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 { + 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 { + 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 { + 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 { + 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 } } diff --git a/backend/src/types.ts b/backend/src/types.ts index ef4ffa3..b55d397 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -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"