2026-05-15 07:13:37 +07:00
|
|
|
import { and, asc, desc, eq, isNull, or, type SQL, sql } from "drizzle-orm";
|
2026-05-14 15:41:11 +07:00
|
|
|
import { getDatabase } from "../database/drizzle";
|
2026-05-14 15:47:03 +07:00
|
|
|
import { attachmentsTable, messagesTable } from "../database/schema";
|
2026-05-14 15:02:23 +07:00
|
|
|
import { createChildLogger } from "../logger";
|
2026-05-14 18:48:02 +07:00
|
|
|
import type {
|
|
|
|
|
AttachmentRecord,
|
|
|
|
|
MessageQuery,
|
|
|
|
|
MessageRecord,
|
|
|
|
|
PageResult,
|
|
|
|
|
} from "./types";
|
2026-05-13 19:34:14 +07:00
|
|
|
|
|
|
|
|
const logger = createChildLogger("message-store");
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
interface QueryBuilder<T = unknown> extends PromiseLike<T> {
|
|
|
|
|
from(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
where(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
orderBy(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
limit(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
offset(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
values(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
onConflictDoNothing(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
returning(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
set(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MessageDatabase {
|
|
|
|
|
select<T = unknown[]>(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
selectDistinct<T = unknown[]>(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
insert<T = unknown>(...args: unknown[]): QueryBuilder<T>;
|
|
|
|
|
update(...args: unknown[]): QueryBuilder<unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function db(): MessageDatabase {
|
|
|
|
|
return getDatabase() as unknown as MessageDatabase;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 18:48:02 +07:00
|
|
|
// 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"));
|
2026-05-14 19:06:17 +07:00
|
|
|
if (typeof data.created_at === "number" && typeof data.id === "string") {
|
2026-05-14 18:48:02 +07:00
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:47:03 +07:00
|
|
|
export async function insertMessage(message: MessageRecord): Promise<void> {
|
2026-05-13 19:34:14 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
await database.insert(messagesTable).values(message).onConflictDoNothing();
|
2026-05-13 19:34:14 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
2026-05-14 15:02:23 +07:00
|
|
|
{
|
|
|
|
|
messageId: message.id,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
2026-05-13 19:34:14 +07:00
|
|
|
"Failed to insert message",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 20:03:02 +07:00
|
|
|
export async function upsertMessageForCapture(
|
|
|
|
|
message: MessageRecord,
|
2026-05-15 06:52:20 +07:00
|
|
|
): Promise<boolean> {
|
2026-05-14 20:03:02 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
2026-05-14 20:03:02 +07:00
|
|
|
const messageWithAIStatus = {
|
|
|
|
|
...message,
|
|
|
|
|
ai_status: "pending" as const,
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
const rows = await database
|
|
|
|
|
.insert<Array<{ id: string }>>(messagesTable)
|
2026-05-14 20:03:02 +07:00
|
|
|
.values(messageWithAIStatus)
|
2026-05-15 06:52:20 +07:00
|
|
|
.onConflictDoNothing()
|
|
|
|
|
.returning({ id: messagesTable.id });
|
2026-05-14 20:03:02 +07:00
|
|
|
|
2026-05-15 22:23:29 +07:00
|
|
|
return rows.length > 0;
|
2026-05-14 20:03:02 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{
|
|
|
|
|
messageId: message.id,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
|
|
|
|
"Failed to upsert message for capture",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
export async function updateMessageAsEdited(
|
2026-05-13 19:34:14 +07:00
|
|
|
messageId: string,
|
|
|
|
|
editedContent: string,
|
|
|
|
|
editedAt: number,
|
2026-05-14 15:41:11 +07:00
|
|
|
): Promise<void> {
|
2026-05-13 19:34:14 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
await database
|
2026-05-14 15:41:11 +07:00
|
|
|
.update(messagesTable)
|
|
|
|
|
.set({
|
|
|
|
|
edited_content: editedContent,
|
|
|
|
|
edited_at: editedAt,
|
|
|
|
|
type: "edited",
|
2026-05-14 20:03:02 +07:00
|
|
|
ai_status: "pending",
|
|
|
|
|
ai_moderation_flags: null,
|
|
|
|
|
ai_moderation_score: null,
|
|
|
|
|
ai_moderation_raw: null,
|
|
|
|
|
ai_analysis: null,
|
|
|
|
|
ai_analyzed_at: null,
|
|
|
|
|
ai_error: null,
|
2026-05-14 15:41:11 +07:00
|
|
|
})
|
|
|
|
|
.where(eq(messagesTable.id, messageId));
|
2026-05-13 19:34:14 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
2026-05-14 15:02:23 +07:00
|
|
|
{
|
|
|
|
|
messageId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
2026-05-13 19:34:14 +07:00
|
|
|
"Failed to update message as edited",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
export async function updateMessageAsDeleted(
|
2026-05-13 19:34:14 +07:00
|
|
|
messageId: string,
|
|
|
|
|
deletedAt: number,
|
2026-05-14 15:41:11 +07:00
|
|
|
): Promise<void> {
|
2026-05-13 19:34:14 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
await database
|
2026-05-14 15:41:11 +07:00
|
|
|
.update(messagesTable)
|
|
|
|
|
.set({
|
|
|
|
|
deleted_at: deletedAt,
|
|
|
|
|
type: "deleted",
|
|
|
|
|
})
|
|
|
|
|
.where(eq(messagesTable.id, messageId));
|
2026-05-13 19:34:14 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
2026-05-14 15:02:23 +07:00
|
|
|
{
|
|
|
|
|
messageId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
2026-05-13 19:34:14 +07:00
|
|
|
"Failed to update message as deleted",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
export async function getMessagesByChannel(
|
2026-05-13 19:34:14 +07:00
|
|
|
channelId: string,
|
|
|
|
|
limit: number = 50,
|
|
|
|
|
offset: number = 0,
|
2026-05-14 15:41:11 +07:00
|
|
|
): Promise<MessageRecord[]> {
|
2026-05-13 19:34:14 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
const rows = await database
|
2026-05-14 15:41:11 +07:00
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(
|
|
|
|
|
or(
|
|
|
|
|
eq(messagesTable.channel_id, channelId),
|
|
|
|
|
eq(messagesTable.thread_id, channelId),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.orderBy(desc(messagesTable.created_at))
|
|
|
|
|
.limit(limit)
|
|
|
|
|
.offset(offset);
|
2026-05-13 19:34:14 +07:00
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
return rows as MessageRecord[];
|
2026-05-13 19:34:14 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
2026-05-14 15:02:23 +07:00
|
|
|
{
|
|
|
|
|
channelId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
2026-05-13 19:34:14 +07:00
|
|
|
"Failed to get messages by channel",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
export async function insertAttachment(
|
2026-05-14 15:02:23 +07:00
|
|
|
attachment: AttachmentRecord,
|
2026-05-14 15:41:11 +07:00
|
|
|
): Promise<void> {
|
2026-05-13 19:34:14 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
await database
|
|
|
|
|
.insert(attachmentsTable)
|
|
|
|
|
.values(attachment)
|
|
|
|
|
.onConflictDoNothing();
|
2026-05-13 19:34:14 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
2026-05-14 15:02:23 +07:00
|
|
|
{
|
|
|
|
|
attachmentId: attachment.id,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
2026-05-13 19:34:14 +07:00
|
|
|
"Failed to insert attachment",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
export async function getAttachmentsByChannel(
|
2026-05-13 19:34:14 +07:00
|
|
|
channelId: string,
|
|
|
|
|
limit: number = 50,
|
|
|
|
|
offset: number = 0,
|
2026-05-14 15:41:11 +07:00
|
|
|
): Promise<AttachmentRecord[]> {
|
2026-05-13 19:34:14 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
const rows = await database
|
2026-05-14 15:41:11 +07:00
|
|
|
.select()
|
|
|
|
|
.from(attachmentsTable)
|
|
|
|
|
.where(
|
|
|
|
|
or(
|
|
|
|
|
eq(attachmentsTable.channel_id, channelId),
|
|
|
|
|
eq(attachmentsTable.thread_id, channelId),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.orderBy(desc(attachmentsTable.created_at))
|
|
|
|
|
.limit(limit)
|
|
|
|
|
.offset(offset);
|
2026-05-13 19:34:14 +07:00
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
return rows as AttachmentRecord[];
|
2026-05-13 19:34:14 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
2026-05-14 15:02:23 +07:00
|
|
|
{
|
|
|
|
|
channelId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
2026-05-13 19:34:14 +07:00
|
|
|
"Failed to get attachments by channel",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
export async function updateAttachmentAsUploaded(
|
2026-05-13 19:34:14 +07:00
|
|
|
attachmentId: string,
|
|
|
|
|
uploadedUrl: string,
|
|
|
|
|
uploadedAt: number,
|
2026-05-14 15:41:11 +07:00
|
|
|
): Promise<void> {
|
2026-05-13 19:34:14 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
await database
|
2026-05-14 15:41:11 +07:00
|
|
|
.update(attachmentsTable)
|
|
|
|
|
.set({
|
|
|
|
|
uploaded_url: uploadedUrl,
|
|
|
|
|
upload_status: "uploaded",
|
|
|
|
|
uploaded_at: uploadedAt,
|
|
|
|
|
})
|
|
|
|
|
.where(eq(attachmentsTable.id, attachmentId));
|
2026-05-13 19:34:14 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
2026-05-14 15:02:23 +07:00
|
|
|
{
|
|
|
|
|
attachmentId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
2026-05-13 19:34:14 +07:00
|
|
|
"Failed to update attachment as uploaded",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
export async function updateAttachmentAsFailedUpload(
|
2026-05-13 19:34:14 +07:00
|
|
|
attachmentId: string,
|
|
|
|
|
error: string,
|
2026-05-14 15:41:11 +07:00
|
|
|
): Promise<void> {
|
2026-05-13 19:34:14 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
await database
|
2026-05-14 15:41:11 +07:00
|
|
|
.update(attachmentsTable)
|
|
|
|
|
.set({
|
|
|
|
|
upload_status: "failed",
|
|
|
|
|
upload_error: error,
|
|
|
|
|
})
|
|
|
|
|
.where(eq(attachmentsTable.id, attachmentId));
|
2026-05-13 19:34:14 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
2026-05-14 15:02:23 +07:00
|
|
|
{
|
|
|
|
|
attachmentId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
2026-05-13 19:34:14 +07:00
|
|
|
"Failed to update attachment as failed",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 02:31:16 +07:00
|
|
|
|
|
|
|
|
interface AIAnalysisUpdate {
|
feat: add warn category for minor rule violations
- Add "warn" status between "clean" and "flagged" for minor violations
- Update AI analyzer system prompt with community rules and warn category
- Warn: profanity, OOT, tone issues - requires warning but not deletion
- Flagged: NSFW, illegal, hacking, scam, harassment, violence, SARA - requires review/deletion
- Update types to support warn status in MessageRecord and AIAnalysisUpdate
- Update client UI to show three panels: All Messages, Warned, Flagged
- Warned messages show in right-top panel for quick review
- Flagged messages show in right-bottom panel for moderation action
This resolves:
- Need to distinguish between minor and severe violations
- Moderators can now warn users before taking action
- Better moderation workflow with three-tier system
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:23:11 +07:00
|
|
|
status: "pending" | "clean" | "warn" | "flagged" | "error";
|
2026-05-14 02:31:16 +07:00
|
|
|
flags?: string | null;
|
|
|
|
|
score?: number | null;
|
|
|
|
|
raw?: string | null;
|
|
|
|
|
analysis?: string | null;
|
|
|
|
|
analyzedAt?: number | null;
|
|
|
|
|
error?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
export async function updateMessageAIAnalysis(
|
2026-05-14 02:31:16 +07:00
|
|
|
messageId: string,
|
|
|
|
|
result: AIAnalysisUpdate,
|
2026-05-14 15:41:11 +07:00
|
|
|
): Promise<MessageRecord | null> {
|
2026-05-14 02:31:16 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
await database
|
2026-05-14 15:41:11 +07:00
|
|
|
.update(messagesTable)
|
|
|
|
|
.set({
|
|
|
|
|
ai_status: result.status,
|
|
|
|
|
ai_moderation_flags: result.flags ?? null,
|
|
|
|
|
ai_moderation_score: result.score ?? null,
|
|
|
|
|
ai_moderation_raw: result.raw ?? null,
|
|
|
|
|
ai_analysis: result.analysis ?? null,
|
|
|
|
|
ai_analyzed_at: result.analyzedAt ?? Date.now(),
|
|
|
|
|
ai_error: result.error ?? null,
|
|
|
|
|
})
|
|
|
|
|
.where(eq(messagesTable.id, messageId));
|
2026-05-14 02:31:16 +07:00
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
const rows = await database
|
2026-05-14 15:41:11 +07:00
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(eq(messagesTable.id, messageId));
|
2026-05-14 02:31:16 +07:00
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
return (rows[0] as MessageRecord) ?? null;
|
2026-05-14 02:31:16 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
2026-05-14 15:02:23 +07:00
|
|
|
{
|
|
|
|
|
messageId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
2026-05-14 02:31:16 +07:00
|
|
|
"Failed to update message AI analysis",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
export async function getPendingAIAnalysisMessages(
|
2026-05-14 02:31:16 +07:00
|
|
|
limit: number = 25,
|
2026-05-14 15:41:11 +07:00
|
|
|
): Promise<MessageRecord[]> {
|
2026-05-14 02:31:16 +07:00
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
const rows = await database
|
2026-05-14 15:41:11 +07:00
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(messagesTable.ai_status, "pending"),
|
|
|
|
|
isNull(messagesTable.deleted_at),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.orderBy(asc(messagesTable.created_at))
|
|
|
|
|
.limit(limit);
|
|
|
|
|
|
|
|
|
|
return rows as MessageRecord[];
|
2026-05-14 02:31:16 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
|
|
|
"Failed to get pending AI analysis messages",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
export async function getMessageById(
|
2026-05-14 15:02:23 +07:00
|
|
|
messageId: string,
|
2026-05-14 15:41:11 +07:00
|
|
|
): Promise<MessageRecord | null> {
|
|
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
const rows = await database
|
2026-05-14 15:41:11 +07:00
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(eq(messagesTable.id, messageId));
|
|
|
|
|
|
|
|
|
|
return (rows[0] as MessageRecord) ?? null;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{
|
|
|
|
|
messageId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
|
|
|
|
"Failed to get message by id",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
2026-05-14 02:31:16 +07:00
|
|
|
}
|
2026-05-14 18:48:02 +07:00
|
|
|
|
|
|
|
|
export async function listMessages(
|
|
|
|
|
query: MessageQuery,
|
|
|
|
|
): Promise<PageResult<MessageRecord>> {
|
|
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
|
|
|
|
const conditions: SQL[] = [];
|
2026-05-14 18:48:02 +07:00
|
|
|
|
|
|
|
|
// Apply filters
|
|
|
|
|
if (query.guildId) {
|
|
|
|
|
conditions.push(eq(messagesTable.guild_id, query.guildId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (query.channelId) {
|
|
|
|
|
conditions.push(
|
2026-05-15 07:13:37 +07:00
|
|
|
sql`(${messagesTable.channel_id} = ${query.channelId} or ${messagesTable.thread_id} = ${query.channelId})`,
|
2026-05-14 18:48:02 +07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-05-15 07:13:37 +07:00
|
|
|
conditions.push(sql`${messagesTable.ai_status} in ${query.status}`);
|
2026-05-14 18:48:02 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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(
|
2026-05-15 07:13:37 +07:00
|
|
|
sql`(${messagesTable.created_at} < ${cursorData.created_at} or (${messagesTable.created_at} = ${cursorData.created_at} and ${messagesTable.id} < ${cursorData.id}))`,
|
2026-05-14 18:48:02 +07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch limit + 1 to determine if there's a next page
|
|
|
|
|
const fetchLimit = query.limit + 1;
|
2026-05-15 07:13:37 +07:00
|
|
|
const rows = await database
|
2026-05-14 18:48:02 +07:00
|
|
|
.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;
|
2026-05-14 19:06:17 +07:00
|
|
|
const data = rows.slice(0, query.limit) as MessageRecord[];
|
2026-05-14 18:48:02 +07:00
|
|
|
|
|
|
|
|
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<MessageQuery, "status">,
|
|
|
|
|
): Promise<PageResult<MessageRecord>> {
|
|
|
|
|
return listMessages({
|
|
|
|
|
...query,
|
|
|
|
|
status: ["warn", "flagged", "error"],
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-14 19:10:13 +07:00
|
|
|
|
|
|
|
|
export async function getConversationContextBefore(input: {
|
|
|
|
|
channelId: string;
|
|
|
|
|
threadId: string | null;
|
|
|
|
|
beforeCreatedAt: number;
|
|
|
|
|
limit: number;
|
|
|
|
|
}): Promise<MessageRecord[]> {
|
|
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
2026-05-14 19:10:13 +07:00
|
|
|
const { channelId, threadId, beforeCreatedAt, limit } = input;
|
|
|
|
|
|
|
|
|
|
// Query same thread if threadId exists, otherwise channelId
|
|
|
|
|
const locationCondition = threadId
|
|
|
|
|
? eq(messagesTable.thread_id, threadId)
|
|
|
|
|
: eq(messagesTable.channel_id, channelId);
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
const rows = await database
|
2026-05-14 19:10:13 +07:00
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
locationCondition,
|
|
|
|
|
sql`${messagesTable.created_at} < ${beforeCreatedAt}`,
|
|
|
|
|
isNull(messagesTable.deleted_at),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.orderBy(desc(messagesTable.created_at))
|
|
|
|
|
.limit(limit);
|
|
|
|
|
|
|
|
|
|
// Return in chronological order (oldest first)
|
|
|
|
|
return (rows as MessageRecord[]).reverse();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{
|
|
|
|
|
channelId: input.channelId,
|
|
|
|
|
threadId: input.threadId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
|
|
|
|
"Failed to get conversation context before",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 19:32:44 +07:00
|
|
|
|
|
|
|
|
export async function getPendingMessagesByConversation(
|
|
|
|
|
conversationKey: string,
|
|
|
|
|
limit: number = 25,
|
|
|
|
|
): Promise<MessageRecord[]> {
|
|
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
2026-05-14 19:32:44 +07:00
|
|
|
|
|
|
|
|
// conversationKey is either thread_id or channel_id
|
2026-05-14 19:39:25 +07:00
|
|
|
// Query both to safely handle the key
|
2026-05-15 07:13:37 +07:00
|
|
|
const rows = await database
|
2026-05-14 19:32:44 +07:00
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
2026-05-14 19:39:25 +07:00
|
|
|
or(
|
|
|
|
|
eq(messagesTable.thread_id, conversationKey),
|
|
|
|
|
eq(messagesTable.channel_id, conversationKey),
|
|
|
|
|
),
|
2026-05-14 19:32:44 +07:00
|
|
|
eq(messagesTable.ai_status, "pending"),
|
|
|
|
|
isNull(messagesTable.deleted_at),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.orderBy(asc(messagesTable.created_at))
|
|
|
|
|
.limit(limit);
|
|
|
|
|
|
|
|
|
|
return rows as MessageRecord[];
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{
|
|
|
|
|
conversationKey,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
|
|
|
|
"Failed to get pending messages by conversation",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getPendingConversationKeys(
|
|
|
|
|
limit: number = 100,
|
|
|
|
|
): Promise<string[]> {
|
|
|
|
|
try {
|
2026-05-15 07:13:37 +07:00
|
|
|
const database = db();
|
2026-05-14 19:32:44 +07:00
|
|
|
|
|
|
|
|
// Get distinct conversation keys (thread_id or channel_id) for pending messages
|
2026-05-15 07:13:37 +07:00
|
|
|
const rows = await database
|
|
|
|
|
.selectDistinct<Array<{ thread_id: string | null; channel_id: string }>>({
|
2026-05-14 19:32:44 +07:00
|
|
|
thread_id: messagesTable.thread_id,
|
|
|
|
|
channel_id: messagesTable.channel_id,
|
|
|
|
|
})
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(messagesTable.ai_status, "pending"),
|
|
|
|
|
isNull(messagesTable.deleted_at),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.limit(limit);
|
|
|
|
|
|
|
|
|
|
const keys: string[] = [];
|
2026-05-15 07:13:37 +07:00
|
|
|
for (const row of rows) {
|
2026-05-14 19:32:44 +07:00
|
|
|
const key = row.thread_id || row.channel_id;
|
|
|
|
|
if (key && !keys.includes(key)) {
|
|
|
|
|
keys.push(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return keys;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
|
|
|
"Failed to get pending conversation keys",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|