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
}