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.
This commit is contained in:
Julien Calixte
2026-06-26 16:20:52 +01:00
parent e47302218b
commit de98bdeef7
4 changed files with 167 additions and 39 deletions

View File

@@ -7,41 +7,61 @@ mkdirSync(dirname(dbPath), { recursive: true })
export const db = new DatabaseSync(dbPath) export const db = new DatabaseSync(dbPath)
// Cache of email -> Slack avatar URL, so we don't hit Slack on every request. // A snapshot of the Slack workspace directory: one row per Slack user that has
// image_url is nullable: a NULL row means "looked up, no Slack match" (also // an email, plus a single meta row recording when the snapshot was last swept.
// worth caching, to avoid re-querying people who aren't in Slack). // An unmatched Member is simply one whose email isn't in this table (see ADR-0001).
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS slack_avatar_cache ( CREATE TABLE IF NOT EXISTS slack_directory (
email TEXT PRIMARY KEY, email TEXT PRIMARY KEY,
image_url TEXT, 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 export interface DirectoryEntry {
email: string
interface CacheRow { imageUrl: string | null
image_url: string | null slackId: string
cached_at: number
} }
// Returns the cached URL (which may be null = known-no-match), or undefined export function getDirectoryRefreshedAt(): number | null {
// when there is no fresh cache entry and the caller should query Slack.
export function getCachedAvatar(email: string): string | null | undefined {
const row = db const row = db
.prepare("SELECT image_url, cached_at FROM slack_avatar_cache WHERE email = ?") .prepare("SELECT refreshed_at FROM slack_directory_meta WHERE id = 1")
.get(email.toLowerCase()) as CacheRow | undefined .get() as { refreshed_at: number } | undefined
if (!row) return undefined return row ? row.refreshed_at : null
if (Date.now() - row.cached_at > CACHE_TTL_MS) return undefined
return row.image_url
} }
export function setCachedAvatar(email: string, imageUrl: string | null): void { // Atomically replace the whole directory snapshot and stamp refreshed_at.
db.prepare( export function replaceDirectory(entries: DirectoryEntry[]): void {
`INSERT INTO slack_avatar_cache (email, image_url, cached_at) const insert = db.prepare(
VALUES (?, ?, ?) "INSERT OR REPLACE INTO slack_directory (email, image_url, slack_id) VALUES (?, ?, ?)",
ON CONFLICT(email) DO UPDATE SET )
image_url = excluded.image_url, const stamp = db.prepare(
cached_at = excluded.cached_at`, "INSERT OR REPLACE INTO slack_directory_meta (id, refreshed_at) VALUES (1, ?)",
).run(email.toLowerCase(), imageUrl, Date.now()) )
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,10 +1,10 @@
import { serve } from "@hono/node-server" import { serve } from "@hono/node-server"
import { Hono } from "hono" import { Hono } from "hono"
import { logger } from "hono/logger" 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 { fixtureMembers, fixtureProjects } from "./fixtures.ts"
import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.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 import "./db.ts" // initialise the SQLite schema at boot
function errorMessage(err: unknown): string { function errorMessage(err: unknown): string {
@@ -32,17 +32,31 @@ app.get("/api/projects", async (c) => {
app.get("/api/projects/:id/members", async (c) => { app.get("/api/projects/:id/members", async (c) => {
const id = c.req.param("id") const id = c.req.param("id")
if (!hasNaptaCredentials) { if (!hasNaptaCredentials) {
return c.json({ source: "fixture", members: fixtureMembers(id) }) return c.json({ source: "fixture", slack: "unconfigured", members: fixtureMembers(id) })
} }
try { try {
const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id)) const { members, slack } = await enrichWithSlackAvatars(
return c.json({ source: "napta", members }) await fetchNaptaProjectMembers(id),
)
return c.json({ source: "napta", slack, members })
} catch (err) { } catch (err) {
console.error("fetchNaptaProjectMembers failed:", err) console.error("fetchNaptaProjectMembers failed:", err)
return c.json({ error: errorMessage(err) }, 502) 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( console.log(
`photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`, `photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`,
) )

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 const TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
// 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 interface SlackUser {
// is wired end-to-end and members render with initials fallbacks. id: string
export async function enrichWithSlackAvatars(members: Member[]): Promise<Member[]> { deleted?: boolean
return members 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" 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"