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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user