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)
// 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,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})`,
)

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"