From 8bee791a7d6272161d848923a56b7fb362debdc5 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 15 May 2026 19:58:05 -0300 Subject: [PATCH] feat(bot): track guild join/leave history Adds first-class persistence for which Discord servers the bot is in and when it was added to each, replacing the implicit (and inaccurate) reliance on `Guild.createdAt` for join time. Schema - guilds: add nullable `joinedAt` + `leftAt` for cheap current-state queries - new `guild_membership_events` table: immutable JOIN/LEAVE audit log keyed by Discord snowflake so history survives Guild row deletion - migration: 20260515000000_add_guild_membership_tracking Bot - new `guildMembershipService` with `recordGuildJoin`, `recordGuildLeave`, and `syncGuildsOnReady`; all writes go through a single transaction - `handleGuildCreate` event handler upserts the Guild and writes a JOIN event (using Discord-provided `guild.joinedTimestamp`) - `handleGuildDelete` now stamps `leftAt` and writes a LEAVE event before the existing cache cleanup - on ClientReady, idempotent backfill for any cached guild missing `joinedAt` (recovers history for guilds joined before this feature shipped or during downtime) Tests - guildMembershipService.spec: covers JOIN/LEAVE writes, fallback when `joinedTimestamp` is null, error swallowing, and on-ready idempotency - eventHandler.spec: stubs out the new service so existing tests stay green Foundation for upcoming PR B (bot observability) which will expose a `lucky_bot_guilds_total` gauge backed by this same data. --- .../bot/src/handlers/eventHandler.spec.ts | 11 ++ packages/bot/src/handlers/eventHandler.ts | 37 ++++ .../services/guildMembershipService.spec.ts | 169 ++++++++++++++++++ .../src/services/guildMembershipService.ts | 135 ++++++++++++++ .../migration.sql | 24 +++ prisma/schema.prisma | 30 +++- 6 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 packages/bot/src/services/guildMembershipService.spec.ts create mode 100644 packages/bot/src/services/guildMembershipService.ts create mode 100644 prisma/migrations/20260515000000_add_guild_membership_tracking/migration.sql diff --git a/packages/bot/src/handlers/eventHandler.spec.ts b/packages/bot/src/handlers/eventHandler.spec.ts index 69ef598b8..b09af5129 100644 --- a/packages/bot/src/handlers/eventHandler.spec.ts +++ b/packages/bot/src/handlers/eventHandler.spec.ts @@ -23,6 +23,9 @@ const namedSessionListMock = jest.fn() const cleanupGuildStateMock = jest.fn() const aiDevToolkitStartMock = jest.fn() const handleReactionRolesMock = jest.fn() +const recordGuildJoinMock = jest.fn(async () => undefined) +const recordGuildLeaveMock = jest.fn(async () => undefined) +const syncGuildsOnReadyMock = jest.fn(async () => undefined) jest.mock('../utils/general/interactionReply', () => ({ interactionReply: (...args: unknown[]) => interactionReplyMock(...args), @@ -91,6 +94,14 @@ jest.mock('../services/AiDevToolkitService', () => ({ }, })) +jest.mock('../services/guildMembershipService', () => ({ + recordGuildJoin: async (...args: unknown[]) => recordGuildJoinMock(...args), + recordGuildLeave: async (...args: unknown[]) => + recordGuildLeaveMock(...args), + syncGuildsOnReady: async (...args: unknown[]) => + syncGuildsOnReadyMock(...args), +})) + function createMockClient() { const onMock = jest.fn() const onceMock = jest.fn() diff --git a/packages/bot/src/handlers/eventHandler.ts b/packages/bot/src/handlers/eventHandler.ts index 4b9146a83..3d3027f73 100644 --- a/packages/bot/src/handlers/eventHandler.ts +++ b/packages/bot/src/handlers/eventHandler.ts @@ -23,6 +23,11 @@ import { reactionRolesService } from '@lucky/shared/services' import { aiDevToolkitService } from '../services/AiDevToolkitService' import { namedSessionService } from '../utils/music/namedSessions' import { cleanupGuildState } from './player/trackNowPlaying' +import { + recordGuildJoin, + recordGuildLeave, + syncGuildsOnReady, +} from '../services/guildMembershipService' function handleClientReady(client: Client): void { client.once('clientReady', () => { @@ -30,6 +35,12 @@ function handleClientReady(client: Client): void { debugLog({ message: `Bot is ready with ${(client as CustomClient).commands.size} commands loaded`, }) + syncGuildsOnReady(client).catch((error) => { + errorLog({ + message: 'guildMembershipService: on-ready sync failed', + error, + }) + }) if (process.env.AI_DEV_TOOLKIT_BOARD_ENABLED === 'true') { aiDevToolkitService.start(client).catch((error) => { errorLog({ @@ -41,6 +52,21 @@ function handleClientReady(client: Client): void { }) } +function handleGuildCreate(client: Client): void { + client.on(Events.GuildCreate, (guild) => { + infoLog({ + message: 'Joined guild', + data: { guildId: guild.id, name: guild.name }, + }) + recordGuildJoin(guild).catch((error) => { + errorLog({ + message: 'Error recording guild join', + error, + }) + }) + }) +} + async function handleCommandNotFound( interaction: ChatInputCommandInteraction, ): Promise { @@ -196,6 +222,16 @@ function handleDebug(client: Client): void { function handleGuildDelete(client: Client): void { client.on(Events.GuildDelete, async (guild) => { + infoLog({ + message: 'Left guild', + data: { guildId: guild.id, name: guild.name }, + }) + await recordGuildLeave(guild.id, guild.name).catch((error) => { + errorLog({ + message: 'Error recording guild leave', + error, + }) + }) try { const duplicateDetection = (await import('../utils/music/duplicateDetection/index.js')) as { @@ -243,6 +279,7 @@ export default function handleEvents(client: Client) { handleError(client) handleWarn(client) handleDebug(client) + handleGuildCreate(client) handleGuildDelete(client) handleChannelDelete(client) } diff --git a/packages/bot/src/services/guildMembershipService.spec.ts b/packages/bot/src/services/guildMembershipService.spec.ts new file mode 100644 index 000000000..1aae5dcf3 --- /dev/null +++ b/packages/bot/src/services/guildMembershipService.spec.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const upsertMock = jest.fn(async () => ({})) +const updateManyMock = jest.fn(async () => ({ count: 1 })) +const createMock = jest.fn(async () => ({})) +const findUniqueMock = jest.fn< + () => Promise<{ joinedAt: Date | null } | null> +>(async () => null) +const transactionMock = jest.fn(async (ops: unknown) => { + // Prisma's $transaction([...]) accepts an array of pending queries; + // resolve them so call assertions on the inner mocks run. + if (Array.isArray(ops)) { + return Promise.all(ops) + } + return ops +}) +const errorLogMock = jest.fn() +const infoLogMock = jest.fn() + +jest.mock('@lucky/shared/utils', () => ({ + getPrismaClient: () => ({ + guild: { + upsert: (...args: unknown[]) => upsertMock(...args), + updateMany: (...args: unknown[]) => updateManyMock(...args), + findUnique: (...args: unknown[]) => findUniqueMock(...args), + }, + guildMembershipEvent: { + create: (...args: unknown[]) => createMock(...args), + }, + $transaction: (...args: unknown[]) => transactionMock(...args), + }), + errorLog: (...args: unknown[]) => errorLogMock(...args), + infoLog: (...args: unknown[]) => infoLogMock(...args), +})) + +import { + recordGuildJoin, + recordGuildLeave, + syncGuildsOnReady, +} from './guildMembershipService' + +type FakeGuild = { + id: string + name: string + icon: string | null + ownerId: string + joinedTimestamp: number | null +} + +function fakeGuild(overrides: Partial = {}): FakeGuild { + return { + id: '111', + name: 'Test Guild', + icon: null, + ownerId: 'owner-1', + joinedTimestamp: 1747200000000, + ...overrides, + } +} + +describe('guildMembershipService', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('recordGuildJoin', () => { + it('upserts guild and writes JOIN event in a transaction', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await recordGuildJoin(fakeGuild() as any) + + expect(transactionMock).toHaveBeenCalledTimes(1) + expect(upsertMock).toHaveBeenCalledTimes(1) + const call = upsertMock.mock.calls[0]?.[0] as Record< + string, + unknown + > + expect(call.where).toEqual({ discordId: '111' }) + const create = call.create as Record + expect(create.discordId).toBe('111') + expect(create.joinedAt).toBeInstanceOf(Date) + expect(create.leftAt).toBeNull() + + expect(createMock).toHaveBeenCalledTimes(1) + const eventArgs = createMock.mock.calls[0]?.[0] as { + data: Record + } + expect(eventArgs.data.kind).toBe('JOIN') + expect(eventArgs.data.guildDiscordId).toBe('111') + expect(eventArgs.data.guildName).toBe('Test Guild') + }) + + it('falls back to now() when guild.joinedTimestamp is null', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await recordGuildJoin(fakeGuild({ joinedTimestamp: null }) as any) + + const call = upsertMock.mock.calls[0]?.[0] as Record< + string, + unknown + > + const update = call.update as Record + expect(update.joinedAt).toBeInstanceOf(Date) + }) + + it('logs an error and does not throw when the transaction fails', async () => { + transactionMock.mockRejectedValueOnce(new Error('db down')) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recordGuildJoin(fakeGuild() as any), + ).resolves.toBeUndefined() + expect(errorLogMock).toHaveBeenCalledTimes(1) + }) + }) + + describe('recordGuildLeave', () => { + it('stamps leftAt on Guild and writes LEAVE event', async () => { + await recordGuildLeave('222', 'Departing Guild') + + expect(transactionMock).toHaveBeenCalledTimes(1) + expect(updateManyMock).toHaveBeenCalledTimes(1) + const updateCall = updateManyMock.mock.calls[0]?.[0] as Record< + string, + unknown + > + expect(updateCall.where).toEqual({ discordId: '222' }) + const data = updateCall.data as Record + expect(data.leftAt).toBeInstanceOf(Date) + + const eventArgs = createMock.mock.calls[0]?.[0] as { + data: Record + } + expect(eventArgs.data.kind).toBe('LEAVE') + expect(eventArgs.data.guildDiscordId).toBe('222') + }) + }) + + describe('syncGuildsOnReady', () => { + it('skips guilds that already have joinedAt and upserts the rest', async () => { + findUniqueMock.mockResolvedValueOnce({ + joinedAt: new Date('2026-01-01'), + }) + findUniqueMock.mockResolvedValueOnce(null) + + const guildA = fakeGuild({ id: 'a' }) + const guildB = fakeGuild({ id: 'b', joinedTimestamp: null }) + const client = { + guilds: { + cache: new Map([ + ['a', guildA], + ['b', guildB], + ]), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + + await syncGuildsOnReady(client) + + expect(findUniqueMock).toHaveBeenCalledTimes(2) + // Only guildB should be upserted (guildA already has joinedAt). + expect(upsertMock).toHaveBeenCalledTimes(1) + const call = upsertMock.mock.calls[0]?.[0] as Record< + string, + unknown + > + expect(call.where).toEqual({ discordId: 'b' }) + expect(infoLogMock).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/bot/src/services/guildMembershipService.ts b/packages/bot/src/services/guildMembershipService.ts new file mode 100644 index 000000000..42be35b11 --- /dev/null +++ b/packages/bot/src/services/guildMembershipService.ts @@ -0,0 +1,135 @@ +import type { Client, Guild } from 'discord.js' +import { getPrismaClient, errorLog, infoLog } from '@lucky/shared/utils' + +/** + * Records a guild join: upserts the Guild row with joinedAt (clearing any + * leftAt) and appends an immutable JOIN event to the membership log. + */ +export async function recordGuildJoin(guild: Guild): Promise { + const prisma = getPrismaClient() + const joinedAt = guild.joinedTimestamp + ? new Date(guild.joinedTimestamp) + : new Date() + try { + await prisma.$transaction([ + prisma.guild.upsert({ + where: { discordId: guild.id }, + create: { + discordId: guild.id, + name: guild.name, + icon: guild.icon ?? null, + ownerId: guild.ownerId, + joinedAt, + leftAt: null, + }, + update: { + name: guild.name, + icon: guild.icon ?? null, + ownerId: guild.ownerId, + joinedAt, + leftAt: null, + }, + }), + prisma.guildMembershipEvent.create({ + data: { + guildDiscordId: guild.id, + guildName: guild.name, + kind: 'JOIN', + occurredAt: joinedAt, + }, + }), + ]) + } catch (error) { + errorLog({ + message: 'guildMembershipService: failed to record JOIN', + data: { guildId: guild.id, name: guild.name }, + error, + }) + } +} + +/** + * Records a guild departure: stamps leftAt on the Guild row (if it exists) + * and appends a LEAVE event. + */ +export async function recordGuildLeave( + guildDiscordId: string, + guildName: string, +): Promise { + const prisma = getPrismaClient() + const leftAt = new Date() + try { + await prisma.$transaction([ + prisma.guild.updateMany({ + where: { discordId: guildDiscordId }, + data: { leftAt }, + }), + prisma.guildMembershipEvent.create({ + data: { + guildDiscordId, + guildName, + kind: 'LEAVE', + occurredAt: leftAt, + }, + }), + ]) + } catch (error) { + errorLog({ + message: 'guildMembershipService: failed to record LEAVE', + data: { guildId: guildDiscordId, name: guildName }, + error, + }) + } +} + +/** + * On startup, backfill joinedAt for any guild the bot is currently in but + * which has no recorded join (older row from before this feature, or join + * event missed during downtime). Idempotent: rows that already have + * joinedAt are left alone. + */ +export async function syncGuildsOnReady(client: Client): Promise { + const prisma = getPrismaClient() + const cache = client.guilds.cache + let synced = 0 + for (const guild of cache.values()) { + try { + const existing = await prisma.guild.findUnique({ + where: { discordId: guild.id }, + select: { joinedAt: true }, + }) + if (existing?.joinedAt) continue + const joinedAt = guild.joinedTimestamp + ? new Date(guild.joinedTimestamp) + : new Date() + await prisma.guild.upsert({ + where: { discordId: guild.id }, + create: { + discordId: guild.id, + name: guild.name, + icon: guild.icon ?? null, + ownerId: guild.ownerId, + joinedAt, + leftAt: null, + }, + update: { + joinedAt, + leftAt: null, + name: guild.name, + icon: guild.icon ?? null, + ownerId: guild.ownerId, + }, + }) + synced += 1 + } catch (error) { + errorLog({ + message: 'guildMembershipService: failed to backfill guild', + data: { guildId: guild.id, name: guild.name }, + error, + }) + } + } + infoLog({ + message: `guildMembershipService: backfilled joinedAt for ${synced} of ${cache.size} cached guilds`, + }) +} diff --git a/prisma/migrations/20260515000000_add_guild_membership_tracking/migration.sql b/prisma/migrations/20260515000000_add_guild_membership_tracking/migration.sql new file mode 100644 index 000000000..f15f13dc8 --- /dev/null +++ b/prisma/migrations/20260515000000_add_guild_membership_tracking/migration.sql @@ -0,0 +1,24 @@ +-- Add bot join/leave timestamps to the existing guild row for cheap +-- current-state queries. +ALTER TABLE "guilds" + ADD COLUMN "joinedAt" TIMESTAMP(3), + ADD COLUMN "leftAt" TIMESTAMP(3); + +-- Immutable audit trail of bot join/leave events per guild. +CREATE TYPE "GuildMembershipEventKind" AS ENUM ('JOIN', 'LEAVE'); + +CREATE TABLE "guild_membership_events" ( + "id" TEXT NOT NULL, + "guildDiscordId" TEXT NOT NULL, + "guildName" TEXT NOT NULL, + "kind" "GuildMembershipEventKind" NOT NULL, + "occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "guild_membership_events_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "guild_membership_events_guildDiscordId_occurredAt_idx" + ON "guild_membership_events" ("guildDiscordId", "occurredAt"); + +CREATE INDEX "guild_membership_events_occurredAt_idx" + ON "guild_membership_events" ("occurredAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f97f33b0b..d7e629056 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,13 +51,15 @@ model UserPreferences { // Guild Management model Guild { - id String @id @default(cuid()) - discordId String @unique + id String @id @default(cuid()) + discordId String @unique name String icon String? ownerId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + joinedAt DateTime? + leftAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations settings GuildSettings? @@ -70,6 +72,26 @@ model Guild { @@map("guilds") } +enum GuildMembershipEventKind { + JOIN + LEAVE +} + +/// Immutable audit trail of bot join/leave events per guild. +/// Keyed by Discord snowflake so the log survives Guild row deletion; +/// useful for "servers over time" historical analytics and churn detection. +model GuildMembershipEvent { + id String @id @default(cuid()) + guildDiscordId String + guildName String + kind GuildMembershipEventKind + occurredAt DateTime @default(now()) + + @@index([guildDiscordId, occurredAt]) + @@index([occurredAt]) + @@map("guild_membership_events") +} + model GuildFeatureToggle { id String @id @default(cuid()) guildId String