From 42edd93f6dbba7b778f4de8ccca30cf2820bec1a Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Wed, 26 Aug 2026 01:35:02 +0000 Subject: [PATCH] feat: add emoji cache purge --- README.md | 2 +- src/__tests__/analytics-helpers.test.ts | 1 + src/__tests__/cache.integration.test.ts | 11 +++++++++++ src/__tests__/handlers.test.ts | 16 ++++++++++++++++ src/__tests__/normalizeEndpoint.test.ts | 4 ++++ src/cache.ts | 14 ++++++++++++++ src/config.ts | 2 +- src/handlers/index.ts | 17 +++++++++++++++++ src/lib/analytics-queries.ts | 11 ++++++----- src/migrations/normalizeEndpoint.ts | 2 ++ src/routes/api-routes.ts | 23 +++++++++++++++++++++++ 11 file(s) changed, 96 insertion(s)(+), 7 deletion(s)(-) diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ The api is pretty simple. You can get a profile picture by calling `GET /users/:id` where `:id` is the slack user id. You can get a redirect to the profile image directly with `GET /users/:id/r`. You can get an emoji by calling `GET /emoji/:name` where `:name` is the name of the emoji. You can also get a list of all emojis by calling `GET /emojis`. On cache miss, `GET /users/:id` returns `202 Accepted` with a placeholder image URL and queues a background fetch. Retry after a few seconds to get the real profile data. -Additionally, you can manually purge a specific user's cache with `POST /users/:user/purge` (requires authentication with a bearer token). +Additionally, you can manually purge a specific user's cache with `POST /users/:user/purge` or purge all cached emojis with `POST /emojis/purge` (both require authentication with a bearer token). The analytics dashboard at `/` shows request counts and latency over time with configurable time ranges. I split the analytics into separate API endpoints (`/api/stats/essential`, `/api/stats/charts`, `/api/stats/useragents`) so the basic stats load immediately while the heavy chart queries run in the background. diff --git a/src/__tests__/analytics-helpers.test.ts b/src/__tests__/analytics-helpers.test.ts --- a/src/__tests__/analytics-helpers.test.ts +++ b/src/__tests__/analytics-helpers.test.ts @@ -68,6 +68,7 @@ }); it("groups cache management", () => { expect(groupEndpoint("/users/U062UG485EE/purge")).toBe("Cache Management"); + expect(groupEndpoint("/emojis/purge")).toBe("Cache Management"); expect(groupEndpoint("/reset")).toBe("Cache Management"); }); diff --git a/src/__tests__/cache.integration.test.ts b/src/__tests__/cache.integration.test.ts --- a/src/__tests__/cache.integration.test.ts +++ b/src/__tests__/cache.integration.test.ts @@ -153,6 +153,17 @@ it("returns null for non-existent emoji", async () => { const emoji = await cache.getEmoji("nonexistent"); expect(emoji).toBeNull(); }); + + it("purges all emojis", async () => { + await cache.insertEmoji("purge1", null, "https://emoji.com/p1.png"); + await cache.insertEmoji("purge2", null, "https://emoji.com/p2.png"); + + const count = await cache.purgeEmojis(); + expect(count).toBeGreaterThanOrEqual(2); + + const emoji = await cache.getEmoji("purge1"); + expect(emoji).toBeNull(); + }); }); describe("purgeAll", () => { diff --git a/src/__tests__/handlers.test.ts b/src/__tests__/handlers.test.ts --- a/src/__tests__/handlers.test.ts +++ b/src/__tests__/handlers.test.ts @@ -11,6 +11,7 @@ insertUser: mock(async () => true), insertEmoji: mock(async () => true), batchInsertEmojis: mock(async () => true), purgeUserCache: mock(async () => true), + purgeEmojis: mock(async () => 0), purgeAll: mock(async () => ({ message: "Cache purged", users: 0, @@ -217,6 +218,21 @@ // The important thing is it doesn't crash expect(response.status).toBeOneOf([200, 401, 500]); if (origToken) process.env.BEARER_TOKEN = origToken; + }); + }); + + describe("handlePurgeEmojis", () => { + it("does not crash regardless of auth configuration", async () => { + const cache = createMockCache(); + const handlers = createHandlers(cache); + const request = new Request("http://localhost/emojis/purge", { + method: "POST", + }); + const response = await handlers.handlePurgeEmojis(request, noopAnalytics); + + // Same as handlePurgeUser: config is frozen at import time, + // so just verify it doesn't crash + expect(response.status).toBeOneOf([200, 401, 500]); }); }); diff --git a/src/__tests__/normalizeEndpoint.test.ts b/src/__tests__/normalizeEndpoint.test.ts --- a/src/__tests__/normalizeEndpoint.test.ts +++ b/src/__tests__/normalizeEndpoint.test.ts @@ -16,6 +16,10 @@ expect(normalizeEndpoint("/users/U062UG485EE/purge")).toBe("/reset"); expect(normalizeEndpoint("/reset")).toBe("/reset"); }); + it("normalizes emoji purge endpoint", () => { + expect(normalizeEndpoint("/emojis/purge")).toBe("/emojis/purge"); + }); + it("normalizes emoji data endpoints", () => { expect(normalizeEndpoint("/emojis/hackshark")).toBe("/emojis/EMOJI_NAME"); }); diff --git a/src/cache.ts b/src/cache.ts --- a/src/cache.ts +++ b/src/cache.ts @@ -360,6 +360,20 @@ return false; } } + async purgeEmojis(): Promise { + try { + const result = this.db.run("DELETE FROM emojis"); + this.emojiCache.clear(); + if (this.onEmojiExpired && result.changes > 0) { + this.onEmojiExpired(); + } + return result.changes; + } catch (error) { + console.error("Error purging emojis:", error); + return 0; + } + } + async purgeAll(): Promise<{ message: string; users: number; diff --git a/src/config.ts b/src/config.ts --- a/src/config.ts +++ b/src/config.ts @@ -54,7 +54,7 @@ const bearerToken = process.env.BEARER_TOKEN || null; if (!bearerToken) { console.warn( - "BEARER_TOKEN is not set. Admin endpoints (/reset, /users/:id/purge) will return 500.", + "BEARER_TOKEN is not set. Admin endpoints (/reset, /users/:id/purge, /emojis/purge) will return 500.", ); } diff --git a/src/handlers/index.ts b/src/handlers/index.ts --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -164,6 +164,22 @@ recordAnalytics(200); return Response.json(emojis); }; + const handlePurgeEmojis: RouteHandlerWithAnalytics = async ( + request, + recordAnalytics, + ) => { + const authError = requireAuth(request, recordAnalytics); + if (authError) return authError; + + const count = await cache.purgeEmojis(); + + recordAnalytics(200); + return Response.json({ + message: "Emojis purged", + emojis: count, + }); + }; + const handleGetEmoji: RouteHandlerWithAnalytics = async ( request, recordAnalytics, @@ -331,6 +347,7 @@ handlePurgeUser, handleListEmojis, handleGetEmoji, handleEmojiRedirect, + handlePurgeEmojis, handleResetCache, handleGetEssentialStats, handleGetChartData, diff --git a/src/lib/analytics-queries.ts b/src/lib/analytics-queries.ts --- a/src/lib/analytics-queries.ts +++ b/src/lib/analytics-queries.ts @@ -40,6 +40,12 @@ return "API Documentation"; } else if (endpoint === "/emojis") { return "Emoji List"; } else if ( + endpoint.match(/^\/users\/[^/]+\/purge$/) || + endpoint === "/emojis/purge" || + endpoint === "/reset" + ) { + return "Cache Management"; + } else if ( endpoint.match(/^\/emojis\/[^/]+$/) || endpoint === "/emojis/EMOJI_NAME" ) { @@ -59,11 +65,6 @@ endpoint.match(/^\/users\/[^/]+\/r$/) || endpoint === "/users/USER_ID/r" ) { return "User Redirects"; - } else if ( - endpoint.match(/^\/users\/[^/]+\/purge$/) || - endpoint === "/reset" - ) { - return "Cache Management"; } else if (endpoint.includes("/users/") && endpoint.includes("/r")) { return "User Redirects"; } else if (endpoint.includes("/users/")) { diff --git a/src/migrations/normalizeEndpoint.ts b/src/migrations/normalizeEndpoint.ts --- a/src/migrations/normalizeEndpoint.ts +++ b/src/migrations/normalizeEndpoint.ts @@ -21,6 +21,8 @@ // Apply grouping rules (order matters: specific patterns before general) if (path.match(/^\/users\/[^/]+\/purge$/) || path === "/reset") { return "/reset"; + } else if (path === "/emojis/purge") { + return "/emojis/purge"; } else if (path.match(/^\/users\/[^/]+\/r$/)) { return "/users/USER_ID/r"; } else if (path.match(/^\/users\/[^/]+$/)) { diff --git a/src/routes/api-routes.ts b/src/routes/api-routes.ts --- a/src/routes/api-routes.ts +++ b/src/routes/api-routes.ts @@ -310,6 +310,29 @@ }, ), }, + "/emojis/purge": { + POST: createRoute( + withAnalytics("/emojis/purge", "POST", handlers.handlePurgeEmojis), + { + summary: "Purge emoji cache", + description: + "Remove all emojis from the cache and trigger a re-fetch from Slack (requires authentication)", + tags: ["Emojis", "Admin"], + requiresAuth: true, + responses: Object.fromEntries([ + apiResponse(200, "Emojis purged successfully", { + type: "object", + properties: { + message: { type: "string", example: "Emojis purged" }, + emojis: { type: "number", example: 1337 }, + }, + }), + apiResponse(401, "Unauthorized"), + ]), + }, + ), + }, + "/reset": { POST: createRoute( withAnalytics("/reset", "POST", handlers.handleResetCache), -- tangled.sh