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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user