From 9f5f8a3090d7d851c1bfd8d35b0fa1286778f8c8 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 14 May 2026 18:48:02 +0700 Subject: [PATCH] feat: add cursor-based message queries --- src/moderation/messageStore.ts | 136 ++++++++++++++++++- tests/moderation/messageStoreQueries.test.ts | 13 ++ 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 tests/moderation/messageStoreQueries.test.ts diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts index 4989d95..91a5102 100644 --- a/src/moderation/messageStore.ts +++ b/src/moderation/messageStore.ts @@ -1,11 +1,43 @@ -import { and, asc, desc, eq, isNull, or } from "drizzle-orm"; +import { and, asc, desc, eq, isNull, or, sql } from "drizzle-orm"; import { getDatabase } from "../database/drizzle"; import { attachmentsTable, messagesTable } from "../database/schema"; import { createChildLogger } from "../logger"; -import type { AttachmentRecord, MessageRecord } from "./types"; +import type { + AIStatus, + AttachmentRecord, + MessageQuery, + MessageRecord, + PageResult, +} from "./types"; const logger = createChildLogger("message-store"); +// Cursor helpers for pagination +interface CursorData { + created_at: number; + id: string; +} + +export function encodeCursor(data: CursorData): string { + return Buffer.from(JSON.stringify(data)).toString("base64"); +} + +export function decodeCursor(cursor?: string): CursorData | null { + if (!cursor) return null; + try { + const data = JSON.parse(Buffer.from(cursor, "base64").toString("utf-8")); + if ( + typeof data.created_at === "number" && + typeof data.id === "string" + ) { + return data; + } + return null; + } catch { + return null; + } +} + export async function insertMessage(message: MessageRecord): Promise { try { const db = getDatabase() as any; @@ -327,3 +359,103 @@ export async function getMessageById( throw error; } } + +export async function listMessages( + query: MessageQuery, +): Promise> { + try { + const db = getDatabase() as any; + const conditions: any[] = []; + + // Apply filters + if (query.guildId) { + conditions.push(eq(messagesTable.guild_id, query.guildId)); + } + + if (query.channelId) { + conditions.push( + or( + eq(messagesTable.channel_id, query.channelId), + eq(messagesTable.thread_id, query.channelId), + ), + ); + } + + if (query.threadId) { + conditions.push(eq(messagesTable.thread_id, query.threadId)); + } + + if (query.userId) { + conditions.push(eq(messagesTable.user_id, query.userId)); + } + + if (query.status && query.status.length > 0) { + conditions.push( + or(...query.status.map((status) => eq(messagesTable.ai_status, status))), + ); + } + + // Text search + if (query.q) { + const pattern = `%${query.q.toLowerCase()}%`; + conditions.push(sql`lower(${messagesTable.content}) like ${pattern}`); + } + + // Cursor-based pagination (newest first) + if (query.cursor) { + const cursorData = decodeCursor(query.cursor); + if (cursorData) { + conditions.push( + or( + sql`${messagesTable.created_at} < ${cursorData.created_at}`, + and( + eq(messagesTable.created_at, cursorData.created_at), + sql`${messagesTable.id} < ${cursorData.id}`, + ), + ), + ); + } + } + + // Fetch limit + 1 to determine if there's a next page + const fetchLimit = query.limit + 1; + const rows = await db + .select() + .from(messagesTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(messagesTable.created_at), desc(messagesTable.id)) + .limit(fetchLimit); + + const hasMore = rows.length > query.limit; + const data = (rows.slice(0, query.limit) as MessageRecord[]); + + let nextCursor: string | null = null; + if (hasMore && data.length > 0) { + const lastItem = data[data.length - 1]; + nextCursor = encodeCursor({ + created_at: lastItem.created_at, + id: lastItem.id, + }); + } + + return { data, nextCursor }; + } catch (error) { + logger.error( + { + query, + error: error instanceof Error ? error.message : String(error), + }, + "Failed to list messages", + ); + throw error; + } +} + +export async function listReviewMessages( + query: Omit, +): Promise> { + return listMessages({ + ...query, + status: ["warn", "flagged", "error"], + }); +} diff --git a/tests/moderation/messageStoreQueries.test.ts b/tests/moderation/messageStoreQueries.test.ts new file mode 100644 index 0000000..eb6e718 --- /dev/null +++ b/tests/moderation/messageStoreQueries.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { decodeCursor, encodeCursor } from "../../src/moderation/messageStore"; + +describe("message cursor helpers", () => { + it("round-trips created_at and id", () => { + const cursor = encodeCursor({ created_at: 1710000000000, id: "abc" }); + expect(decodeCursor(cursor)).toEqual({ created_at: 1710000000000, id: "abc" }); + }); + + it("returns null for invalid cursor", () => { + expect(decodeCursor("not-base64-json")).toBeNull(); + }); +});