diff --git a/public/index.html b/public/index.html index 00ac223..5fb6f3e 100644 --- a/public/index.html +++ b/public/index.html @@ -125,13 +125,13 @@ el.channelSelect.value = state.selectedVoiceChannel; el.channelFilter.value = state.selectedTextChannel; applyActiveTab(state.activeTab); + if ((textChanged || textGuildChanged) && state.selectedTextChannel && state.selectedTextGuild) { + await apiRequest('/api/backlog-sync', { + method: 'POST', + body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }), + }).catch((error) => showError(`Backlog sync failed: ${error.message}`)); + } if (textChanged || textGuildChanged || state.activeTab === 'text') { - if (state.selectedTextChannel && state.selectedTextGuild) { - await apiRequest('/api/backlog-sync', { - method: 'POST', - body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }), - }).catch((error) => showError(`Backlog sync failed: ${error.message}`)); - } await fetchText().catch((error) => showError(error.message)); } await reconcileListenState(); diff --git a/src/routes/syncRoutes.ts b/src/routes/syncRoutes.ts index 981df0b..1a80597 100644 --- a/src/routes/syncRoutes.ts +++ b/src/routes/syncRoutes.ts @@ -6,6 +6,24 @@ import { createChildLogger } from "../logger"; import { syncSelectedChannelBacklog } from "../moderation/backlogSync"; const logger = createChildLogger("sync-routes"); +const BACKLOG_SYNC_COOLDOWN_MS = 5 * 60 * 1000; +const recentBacklogSyncs = new Map(); + +export function shouldSkipRecentBacklogSync( + guildId: string, + channelId: string, + now = Date.now(), +): boolean { + const key = `${guildId}:${channelId}`; + const lastSync = recentBacklogSyncs.get(key); + if (lastSync && now - lastSync < BACKLOG_SYNC_COOLDOWN_MS) return true; + recentBacklogSyncs.set(key, now); + return false; +} + +export function clearRecentBacklogSyncs(): void { + recentBacklogSyncs.clear(); +} export function createSyncRoutes(client: Client): Router { const router = express.Router(); @@ -26,6 +44,17 @@ export function createSyncRoutes(client: Client): Router { ); } + if (shouldSkipRecentBacklogSync(guildId, channelId)) { + logger.debug({ guildId, channelId }, "Skipping recent backlog sync"); + res.json({ + success: true, + channelId, + messagesSync: 0, + skipped: true, + }); + return; + } + logger.info({ guildId, channelId }, "Starting backlog sync"); const count = await syncSelectedChannelBacklog( @@ -43,6 +72,7 @@ export function createSyncRoutes(client: Client): Router { success: true, channelId, messagesSync: count, + skipped: false, }); } catch (error) { next(error); diff --git a/tests/routes/syncRoutes.test.ts b/tests/routes/syncRoutes.test.ts index 5664017..2dc9eda 100644 --- a/tests/routes/syncRoutes.test.ts +++ b/tests/routes/syncRoutes.test.ts @@ -1,6 +1,10 @@ import type { Request, Response } from "express"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createSyncRoutes } from "../../src/routes/syncRoutes"; +import { + clearRecentBacklogSyncs, + createSyncRoutes, + shouldSkipRecentBacklogSync, +} from "../../src/routes/syncRoutes"; const syncSelectedChannelBacklog = vi.hoisted(() => vi.fn()); @@ -11,6 +15,7 @@ vi.mock("../../src/moderation/backlogSync", () => ({ describe("createSyncRoutes", () => { beforeEach(() => { syncSelectedChannelBacklog.mockReset(); + clearRecentBacklogSyncs(); }); it("syncs the selected guild and channel from the request", async () => { @@ -41,7 +46,52 @@ describe("createSyncRoutes", () => { success: true, channelId: "selected-channel", messagesSync: 3, + skipped: false, }); expect(next).not.toHaveBeenCalled(); }); + + it("skips repeated sync requests during the cooldown window", async () => { + expect(shouldSkipRecentBacklogSync("guild", "channel", 1000)).toBe(false); + expect(shouldSkipRecentBacklogSync("guild", "channel", 1001)).toBe(true); + }); + + it("allows repeated sync requests after the cooldown window", async () => { + expect(shouldSkipRecentBacklogSync("guild", "channel", 1000)).toBe(false); + expect(shouldSkipRecentBacklogSync("guild", "channel", 301001)).toBe(false); + }); + + it("does not call Discord backlog sync for repeated requests", async () => { + syncSelectedChannelBacklog.mockResolvedValue(3); + const router = createSyncRoutes({} as never); + const route = router.stack.find( + (layer) => layer.route?.path === "/backlog-sync", + ); + const handler = route?.route?.stack[0]?.handle; + + await handler?.( + { + body: { guildId: "selected-guild", channelId: "selected-channel" }, + } as Request, + { json: vi.fn() } as unknown as Response, + vi.fn(), + ); + + const json = vi.fn(); + await handler?.( + { + body: { guildId: "selected-guild", channelId: "selected-channel" }, + } as Request, + { json } as unknown as Response, + vi.fn(), + ); + + expect(syncSelectedChannelBacklog).toHaveBeenCalledTimes(1); + expect(json).toHaveBeenCalledWith({ + success: true, + channelId: "selected-channel", + messagesSync: 0, + skipped: true, + }); + }); });