refactor: large codebase cleanup - consolidate schemas, migrate to Drizzle ORM, extract frontend components, modernize Docker builds
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 2m22s
Build & Deploy / build-and-push (backend) (push) Failing after 3m22s
Build & Deploy / build-and-push (proxy) (push) Successful in 1m36s
Build & Deploy / deploy (push) Skipped

- Consolidate all DB schema definitions into packages/shared as single source of truth
- Migrate backend from raw SQL to Drizzle ORM across all modules
- Extract frontend inline UI into separate component files
- Refactor discord-gateway circuitBreaker into conversationState + moderationState
- Convert messageStore to Proxy singleton pattern
- Add validateBody/validateQuery middleware + Zod schemas for API endpoints
- Modernize Docker builds with multi-stage + pnpm deploy
- Migrate CI/CD from deployment to image-based pipeline
- Remove 60+ unused/dead files (~15K lines)
- Update color scheme from sky-blue to teal-cyan
- Move DB connection management to @bete/shared/database

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Developer
2026-07-27 21:54:31 +07:00
co-authored by Claude Opus 4.8
parent 63f21513bd
commit 5802d02e29
223 changed files with 11499 additions and 13350 deletions
@@ -1,9 +1,10 @@
import { createChildLogger, type Logger } from "@bete/shared/logger";
import { and, desc, eq, inArray, or, type SQL } from "drizzle-orm";
import { and, desc, eq, inArray, type SQL } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type * as schema from "../../shared/database/schema.js";
import { attachmentsTable } from "../../shared/database/schema.js";
import type { AttachmentRecord } from "../message-capture/types.js";
import { channelOrThreadCondition } from "./messagesCrud.js";
// ─── AttachmentsDb Class ────────────────────────────────────────────────────
@@ -14,7 +15,7 @@ export class AttachmentsDb {
private db: NodePgDatabase<typeof schema>,
_parentLogger?: Logger,
) {
this.logger = createChildLogger("attachments-db");
this.logger = _parentLogger ?? createChildLogger("attachments-db");
}
async insertAttachment(attachment: AttachmentRecord): Promise<void> {
@@ -51,10 +52,7 @@ export class AttachmentsDb {
);
try {
const conditions: SQL[] = [
or(
eq(attachmentsTable.channel_id, channelId),
eq(attachmentsTable.thread_id, channelId),
) as SQL,
channelOrThreadCondition(channelId, attachmentsTable),
];
if (guildId) {
@@ -3,13 +3,6 @@ export {
getMessageLocation,
getMessageMetadata,
} from "../message-capture/messageMetadata.js";
export {
getMessageById,
insertAttachment,
updateMessageAsDeleted,
updateMessageAsEdited,
upsertMessageForCapture,
} from "../message-capture/messageStore.js";
export type {
AIRecommendedAction,
AISeverity,
@@ -18,4 +11,10 @@ export type {
MessageRecord,
VoiceSegmentRecord,
} from "../message-capture/types.js";
export { registerMessageCapture } from "./messageCapture.js";
export type { TextCaptureTarget } from "./messageCapture.js";
export {
captureMessage,
registerMessageCapture,
setEventBroadcaster,
} from "./messageCapture.js";
export { messageStore } from "./messageStore.js";
@@ -10,14 +10,7 @@ import {
getMessageMetadata,
isAgeRestrictedMessage,
} from "../message-capture/messageMetadata.js";
import {
getMessageById,
insertAttachment,
insertMessageEdit,
updateMessageAsDeleted,
updateMessageAsEdited,
upsertMessageForCapture,
} from "../message-capture/messageStore.js";
import { messageStore } from "../message-capture/messageStore.js";
import type {
AttachmentRecord,
MessageRecord,
@@ -41,21 +34,8 @@ export interface MessageLocationInput {
channelId?: string | null;
}
const EXCLUDED_CHANNEL_IDS = new Set([
"1310988070996414494",
"1265679542144467035",
"1310867899745046558",
"1323365288447574128",
"1508059937031589949",
]);
/**
* Threads whose messages should be entirely ignored.
* Useful when a bot or selfbot is spamming inside a thread and
* you only want to ignore that one conversation, not the whole
* parent channel.
*/
const EXCLUDED_THREAD_IDS = new Set(["1522077685508083893"]);
const EXCLUDED_CHANNEL_IDS = new Set(config.EXCLUDED_CHANNEL_IDS);
const EXCLUDED_THREAD_IDS = new Set(config.EXCLUDED_THREAD_IDS);
function isExcludedThread(message: {
channel?: { isThread?: () => boolean; id?: string };
@@ -101,7 +81,7 @@ function getTextCaptureTarget(): TextCaptureTarget {
}
function getTextCaptureTargets(): TextCaptureTarget[] {
const { EFFECTIVE_MONITOR_GUILD_IDS, TEXT_CHANNEL_ID } = config as any;
const { EFFECTIVE_MONITOR_GUILD_IDS, TEXT_CHANNEL_ID } = config;
if (EFFECTIVE_MONITOR_GUILD_IDS?.length) {
if (TEXT_CHANNEL_ID) {
return EFFECTIVE_MONITOR_GUILD_IDS.map((guildId: string) => ({
@@ -219,7 +199,7 @@ export async function captureMessage(
const location = getMessageLocation(message);
const messageRecord = buildMessageRecord(message, type);
const inserted = await upsertMessageForCapture(messageRecord);
const inserted = await messageStore.upsertMessageForCapture(messageRecord);
if (!inserted) {
return;
}
@@ -242,7 +222,7 @@ export async function captureMessage(
url: attachment.url,
});
await insertAttachment(attachmentRecord);
await messageStore.insertAttachment(attachmentRecord);
if (!isBacklog) {
attachmentUploadTasks.push(
@@ -284,12 +264,7 @@ export async function captureMessage(
queueMessageAnalysis(message.id);
if (attachmentUploadTasks.length > 0) {
Promise.allSettled(attachmentUploadTasks).catch((err: unknown) => {
logger.error(
{ messageId: message.id, error: err },
"Attachment upload tasks failed",
);
});
await Promise.allSettled(attachmentUploadTasks);
}
}
}
@@ -323,7 +298,7 @@ export function registerMessageCapture(client: Client): void {
if (isExcludedThread(newMessage)) return;
try {
const existing = await getMessageById(newMessage.id);
const existing = await messageStore.getMessageById(newMessage.id);
if (existing) {
const newContent = getDisplayContent(newMessage as Message);
@@ -346,17 +321,17 @@ export function registerMessageCapture(client: Client): void {
// Save edit history snapshot before overwriting
if (oldContent) {
insertMessageEdit(newMessage.id, oldContent, editedAt).catch(
(err: unknown) => {
messageStore
.insertMessageEdit(newMessage.id, oldContent, editedAt)
.catch((err: unknown) => {
logger.error(
{ messageId: newMessage.id, error: err },
"Failed to save edit history",
);
},
);
});
}
await updateMessageAsEdited(
await messageStore.updateMessageAsEdited(
newMessage.id,
getDisplayContent(newMessage as Message),
editedAt,
@@ -391,7 +366,7 @@ export function registerMessageCapture(client: Client): void {
try {
const deletedAt = Date.now();
await updateMessageAsDeleted(message.id, deletedAt);
await messageStore.updateMessageAsDeleted(message.id, deletedAt);
if (_eventBroadcaster) {
_eventBroadcaster.messageDeleted({
@@ -11,27 +11,13 @@ import type {
PageResult,
RetentionPolicy,
} from "../message-capture/types.js";
import { AttachmentsDb } from "./attachments.db.js";
import { type AIAnalysisUpdate, MessagesDb } from "./messages.db.js";
import { ModerationActionsDb } from "./moderation-actions.db.js";
import { RetentionDb } from "./retention.db.js";
import { ReviewsDb } from "./reviews.db.js";
import { AttachmentsDb } from "./attachmentsDb.js";
import { type AIAnalysisUpdate, MessagesDb } from "./messagesDb.js";
import { ModerationActionsDb } from "./moderationActionsDb.js";
import { RetentionDb } from "./retentionDb.js";
import { ReviewsDb } from "./reviewsDb.js";
export { decodeCursor, encodeCursor } from "@bete/shared";
export type { AIAnalysisUpdate } from "./messages.db.js";
// ─── Lazy singleton ────────────────────────────────────────────────────────
let _instance: MessageStore | null = null;
function getInstance(): MessageStore {
if (!_instance) {
const database = getDatabase() as unknown as NodePgDatabase<typeof schema>;
const logger = createChildLogger("message-store");
_instance = new MessageStore(database, logger);
}
return _instance;
}
export type { AIAnalysisUpdate } from "./messagesDb.js";
// ─── MessageStore Facade ────────────────────────────────────────────────────
@@ -297,8 +283,11 @@ export class MessageStore {
// ── Retention ──────────────────────────────────────────────────────────
getRetentionPolicy(guildId: string): Promise<RetentionPolicy | null> {
return this.retention.getRetentionPolicy(guildId);
getRetentionPolicy(
guildId: string,
channelId?: string,
): Promise<RetentionPolicy | null> {
return this.retention.getRetentionPolicy(guildId, channelId);
}
upsertRetentionPolicy(
@@ -308,208 +297,38 @@ export class MessageStore {
}
}
// ─── Backward-compatible function exports ──────────────────────────────────
// These delegate to a lazy singleton MessageStore instance so existing
// code that imports individual functions continues to work unchanged.
// ─── Singleton instance ─────────────────────────────────────────────────────
export const insertMessageEdit = (
messageId: string,
oldContent: string,
editedAt: number,
): Promise<void> =>
getInstance().insertMessageEdit(messageId, oldContent, editedAt);
const singletonLogger = createChildLogger("message-store");
// Messages
export const insertMessage = (message: MessageRecord): Promise<void> =>
getInstance().insertMessage(message);
/**
* Lazily-initialized singleton via Proxy.
*
* `messageStore` is imported at module level across ~30 files, long before
* `initializeDatabase()` has been called in bootstrap. Instead of making every
* caller async-aware, we export a Proxy that defers MessageStore construction
* until the first method call.
*
* Once created, the real store is cached — subsequent calls resolve properties
* directly from the cached instance without re-binding or re-resolving.
*/
let _store: MessageStore | null = null;
export const upsertMessageForCapture = (
message: MessageRecord,
): Promise<boolean> => getInstance().upsertMessageForCapture(message);
function resolveStore(): MessageStore {
if (!_store) {
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
_store = new MessageStore(db, singletonLogger);
}
return _store;
}
export const updateMessageAsEdited = (
messageId: string,
editedContent: string,
editedAt: number,
): Promise<void> =>
getInstance().updateMessageAsEdited(messageId, editedContent, editedAt);
export const updateMessageAsDeleted = (
messageId: string,
deletedAt: number,
): Promise<void> => getInstance().updateMessageAsDeleted(messageId, deletedAt);
export const getMessagesByChannel = (
channelId: string,
limit?: number,
offset?: number,
guildId?: string,
): Promise<MessageRecord[]> =>
getInstance().getMessagesByChannel(channelId, limit, offset, guildId);
export const updateMessageAIAnalysis = (
messageId: string,
result: AIAnalysisUpdate,
): Promise<MessageRecord | null> =>
getInstance().updateMessageAIAnalysis(messageId, result);
export const updateMessagesAIAnalysisBulk = (
updates: Array<{ messageId: string; result: AIAnalysisUpdate }>,
): Promise<MessageRecord[]> =>
getInstance().updateMessagesAIAnalysisBulk(updates);
export const getPendingAIAnalysisMessages = (
limit?: number,
): Promise<MessageRecord[]> =>
getInstance().getPendingAIAnalysisMessages(limit);
export const getMessageById = (
messageId: string,
): Promise<MessageRecord | null> => getInstance().getMessageById(messageId);
export const listMessages = (
query: MessageQuery,
): Promise<PageResult<MessageRecord>> => getInstance().listMessages(query);
export const listReviewMessages = (
query: Omit<MessageQuery, "status">,
): Promise<PageResult<MessageRecord>> =>
getInstance().listReviewMessages(query);
export const getConversationContextBefore = (input: {
channelId: string;
threadId: string | null;
beforeCreatedAt: number;
limit: number;
}): Promise<MessageRecord[]> =>
getInstance().getConversationContextBefore(input);
export const getPendingMessagesByConversation = (
conversationKey: string,
limit?: number,
): Promise<MessageRecord[]> =>
getInstance().getPendingMessagesByConversation(conversationKey, limit);
export const getPendingConversationKeys = (limit?: number): Promise<string[]> =>
getInstance().getPendingConversationKeys(limit);
export const getConversationKeysWithIncompleteAnalysis = (
limit?: number,
): Promise<string[]> =>
getInstance().getConversationKeysWithIncompleteAnalysis(limit);
export const getIncompleteMessagesByConversation = (
conversationKey: string,
limit?: number,
): Promise<MessageRecord[]> =>
getInstance().getIncompleteMessagesByConversation(conversationKey, limit);
export const searchMessages = (input: {
query: string;
channelId?: string;
guildId?: string;
limit?: number;
}): Promise<MessageRecord[]> => getInstance().searchMessages(input);
export const getExpiredMessages = (
retentionDays: number,
): Promise<MessageRecord[]> => getInstance().getExpiredMessages(retentionDays);
export const revertStuckProcessingMessages = (
timeoutMs?: number,
): Promise<number> => getInstance().revertStuckProcessingMessages(timeoutMs);
// Attachments
export const insertAttachment = (attachment: AttachmentRecord): Promise<void> =>
getInstance().insertAttachment(attachment);
export const getAttachmentsByChannel = (
channelId: string,
limit?: number,
offset?: number,
guildId?: string,
): Promise<AttachmentRecord[]> =>
getInstance().getAttachmentsByChannel(channelId, limit, offset, guildId);
export const updateAttachmentAsUploaded = (
attachmentId: string,
uploadedUrl: string,
uploadedAt: number,
): Promise<void> =>
getInstance().updateAttachmentAsUploaded(
attachmentId,
uploadedUrl,
uploadedAt,
);
export const updateAttachmentDiscordUrl = (
attachmentId: string,
discordUrl: string,
): Promise<void> =>
getInstance().updateAttachmentDiscordUrl(attachmentId, discordUrl);
export const updateAttachmentAsFailedUpload = (
attachmentId: string,
error: string,
): Promise<void> =>
getInstance().updateAttachmentAsFailedUpload(attachmentId, error);
export const getAttachmentsForMessages = (
messageIds: string[],
): Promise<AttachmentRecord[]> =>
getInstance().getAttachmentsForMessages(messageIds);
// Reviews
export const createMessageReview = (
review: Omit<MessageReview, "id" | "created_at">,
): Promise<MessageReview> => getInstance().createMessageReview(review);
export const getMessageReview = (id: string): Promise<MessageReview | null> =>
getInstance().getMessageReview(id);
export const listMessageReviews = (query: {
guildId?: string;
channelId?: string;
status?: string[];
cursor?: string;
limit: number;
}): Promise<PageResult<MessageReview>> =>
getInstance().listMessageReviews(query);
export const updateMessageReview = (
id: string,
updates: Partial<Omit<MessageReview, "id" | "created_at">>,
): Promise<MessageReview | null> =>
getInstance().updateMessageReview(id, updates);
// Moderation Actions
export const createModerationAction = (
action: Omit<ModerationAction, "id" | "created_at">,
): Promise<ModerationAction> => getInstance().createModerationAction(action);
export const getModerationAction = (
id: string,
): Promise<ModerationAction | null> => getInstance().getModerationAction(id);
export const listModerationActions = (query: {
guildId?: string;
status?: string[];
cursor?: string;
limit: number;
}): Promise<PageResult<ModerationAction>> =>
getInstance().listModerationActions(query);
export const updateModerationAction = (
id: string,
updates: Partial<Omit<ModerationAction, "id" | "created_at">>,
): Promise<ModerationAction | null> =>
getInstance().updateModerationAction(id, updates);
// Retention
export const getRetentionPolicy = (
guildId: string,
): Promise<RetentionPolicy | null> => getInstance().getRetentionPolicy(guildId);
export const upsertRetentionPolicy = (
policy: Omit<RetentionPolicy, "created_at" | "updated_at">,
): Promise<RetentionPolicy> => getInstance().upsertRetentionPolicy(policy);
export const messageStore: MessageStore = new Proxy<MessageStore>(
{} as MessageStore,
{
get(_, prop: string | symbol) {
const store = resolveStore();
const value = (store as unknown as Record<string | symbol, unknown>)[prop];
return typeof value === "function" ? value.bind(store) : value;
},
},
);
@@ -29,10 +29,27 @@ export interface AIAnalysisUpdate {
error?: string | null;
}
// ─── Shared field mapping helper ─────────────────────────────────────────────
function buildAIAnalysisSet(result: AIAnalysisUpdate, now?: number) {
return {
ai_status: result.status,
ai_moderation_flags: result.flags ?? null,
ai_moderation_score: result.score ?? null,
ai_analysis: result.analysis ?? null,
ai_categories: stringifyAIList(result.categories),
ai_severity: result.severity ?? null,
ai_confidence: result.confidence ?? result.score ?? null,
ai_recommended_action: result.recommendedAction ?? null,
ai_analyzed_at: result.analyzedAt ?? now ?? Date.now(),
ai_error: result.error ?? null,
};
}
// ─── MessagesAnalysis Class ───────────────────────────────────────────────────
export class MessagesAnalysis {
protected logger: Logger;
private logger: Logger;
constructor(
protected db: NodePgDatabase<typeof schema>,
@@ -51,18 +68,7 @@ export class MessagesAnalysis {
try {
await this.db
.update(messagesTable)
.set({
ai_status: result.status,
ai_moderation_flags: result.flags ?? null,
ai_moderation_score: result.score ?? null,
ai_analysis: result.analysis ?? null,
ai_categories: stringifyAIList(result.categories),
ai_severity: result.severity ?? null,
ai_confidence: result.confidence ?? result.score ?? null,
ai_recommended_action: result.recommendedAction ?? null,
ai_analyzed_at: result.analyzedAt ?? Date.now(),
ai_error: result.error ?? null,
})
.set(buildAIAnalysisSet(result))
.where(eq(messagesTable.id, messageId));
const rows = await this.db
@@ -98,18 +104,7 @@ export class MessagesAnalysis {
for (const { messageId, result } of updates) {
await tx
.update(messagesTable)
.set({
ai_status: result.status,
ai_moderation_flags: result.flags ?? null,
ai_moderation_score: result.score ?? null,
ai_analysis: result.analysis ?? null,
ai_categories: stringifyAIList(result.categories),
ai_severity: result.severity ?? null,
ai_confidence: result.confidence ?? result.score ?? null,
ai_recommended_action: result.recommendedAction ?? null,
ai_analyzed_at: result.analyzedAt ?? now,
ai_error: result.error ?? null,
})
.set(buildAIAnalysisSet(result, now))
.where(eq(messagesTable.id, messageId));
}
});
@@ -18,6 +18,7 @@ export class MessagesCleanup {
}
async getExpiredMessages(retentionDays: number): Promise<MessageRecord[]> {
if (retentionDays <= 0) return [];
this.logger.debug({ retentionDays }, "getExpiredMessages entry");
try {
const cutoffTime = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
@@ -8,25 +8,28 @@ import {
} from "../../shared/database/schema.js";
import type { MessageRecord } from "../message-capture/types.js";
type MessageInsert = typeof messagesTable.$inferInsert;
// ─── Shared Helpers ──────────────────────────────────────────────────────────
export function channelOrThreadCondition(channelId: string): SQL {
return or(
eq(messagesTable.channel_id, channelId),
eq(messagesTable.thread_id, channelId),
) as SQL;
export function channelOrThreadCondition(
channelId: string,
table?: { channel_id: any; thread_id: any },
): SQL {
const t = table ?? messagesTable;
return or(eq(t.channel_id, channelId), eq(t.thread_id, channelId)) as SQL;
}
// ─── MessagesCrud Class ──────────────────────────────────────────────────────
export class MessagesCrud {
protected logger: Logger;
private logger: Logger;
constructor(
protected db: NodePgDatabase<typeof schema>,
_parentLogger?: Logger,
) {
this.logger = createChildLogger("messages-crud");
this.logger = _parentLogger ?? createChildLogger("messages-crud");
}
// ── INSERT ──────────────────────────────────────────────────────────────────
@@ -36,7 +39,7 @@ export class MessagesCrud {
try {
await this.db
.insert(messagesTable)
.values(message as any)
.values(message as MessageInsert)
.onConflictDoNothing();
} catch (error) {
this.logger.error(
@@ -63,7 +66,7 @@ export class MessagesCrud {
const rows = await this.db
.insert(messagesTable)
.values(messageWithAIStatus as any)
.values(messageWithAIStatus as MessageInsert)
.onConflictDoNothing()
.returning({ id: messagesTable.id });
@@ -6,15 +6,15 @@ import type {
MessageRecord,
PageResult,
} from "../message-capture/types.js";
import type { AIAnalysisUpdate } from "./messages.analysis.js";
import { MessagesAnalysis } from "./messages.analysis.js";
import { MessagesCleanup } from "./messages.cleanup.js";
import { MessagesCrud } from "./messages.crud.js";
import { MessagesPagination } from "./messages.pagination.js";
import { MessagesSearch } from "./messages.search.js";
import type { AIAnalysisUpdate } from "./messagesAnalysis.js";
import { MessagesAnalysis } from "./messagesAnalysis.js";
import { MessagesCleanup } from "./messagesCleanup.js";
import { MessagesCrud } from "./messagesCrud.js";
import { MessagesPagination } from "./messagesPagination.js";
import { MessagesSearch } from "./messagesSearch.js";
// Re-export AIAnalysisUpdate for consumers (messageStore.ts imports it)
export type { AIAnalysisUpdate } from "./messages.analysis.js";
export type { AIAnalysisUpdate } from "./messagesAnalysis.js";
// ─── MessagesDb Facade ────────────────────────────────────────────────────────
// Thin facade that delegates to domain-specific sub-modules.
@@ -1,6 +1,6 @@
import { decodeCursor, pageResult } from "@bete/shared";
import { buildCursorCondition, pageResult } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger";
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import { and, desc, eq, inArray, type SQL, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type * as schema from "../../shared/database/schema.js";
import { messagesTable } from "../../shared/database/schema.js";
@@ -9,7 +9,7 @@ import type {
MessageRecord,
PageResult,
} from "../message-capture/types.js";
import { channelOrThreadCondition } from "./messages.crud.js";
import { channelOrThreadCondition } from "./messagesCrud.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -33,7 +33,14 @@ export function buildListMessageConditions(query: MessageQuery): SQL[] {
}
if (query.status && query.status.length > 0) {
conditions.push(sql`${messagesTable.ai_status} in ${query.status}`);
conditions.push(
inArray(
messagesTable.ai_status,
query.status as Array<
"pending" | "processing" | "clean" | "warn" | "flagged" | "error"
>,
),
);
}
if (query.q) {
@@ -41,18 +48,18 @@ export function buildListMessageConditions(query: MessageQuery): SQL[] {
conditions.push(sql`lower(${messagesTable.content}) like ${pattern}`);
}
const cursorData = decodeCursor(query.cursor);
if (cursorData) {
conditions.push(
sql`(${messagesTable.created_at} < ${cursorData.created_at} or (${messagesTable.created_at} = ${cursorData.created_at} and ${messagesTable.id} < ${cursorData.id}))`,
);
const cursorCondition = buildCursorCondition(
messagesTable.created_at,
messagesTable.id,
query.cursor,
);
if (cursorCondition) {
conditions.push(cursorCondition);
}
return conditions;
}
const pageRows = pageResult;
// ─── MessagesPagination Class ────────────────────────────────────────────────
export class MessagesPagination {
@@ -76,7 +83,7 @@ export class MessagesPagination {
.orderBy(desc(messagesTable.created_at), desc(messagesTable.id))
.limit(query.limit + 1);
return pageRows<MessageRecord>(rows, query.limit);
return pageResult<MessageRecord>(rows, query.limit);
} catch (error) {
this.logger.error(
{
@@ -4,7 +4,7 @@ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type * as schema from "../../shared/database/schema.js";
import { messagesTable } from "../../shared/database/schema.js";
import type { MessageRecord } from "../message-capture/types.js";
import { channelOrThreadCondition } from "./messages.crud.js";
import { channelOrThreadCondition } from "./messagesCrud.js";
// ─── MessagesSearch Class ─────────────────────────────────────────────────────
@@ -43,8 +43,8 @@ export class MessagesSearch {
conditions.push(
or(
sql`${messagesTable.content} LIKE ${searchPattern}`,
sql`${messagesTable.edited_content} LIKE ${searchPattern}`,
sql`lower(${messagesTable.content}) LIKE ${searchPattern}`,
sql`lower(${messagesTable.edited_content}) LIKE ${searchPattern}`,
),
);
@@ -1,6 +1,6 @@
import { decodeCursor, pageResult } from "@bete/shared";
import { buildCursorCondition, pageResult } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger";
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import { and, desc, eq, inArray, type SQL, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type * as schema from "../../shared/database/schema.js";
import { moderationActionsTable } from "../../shared/database/schema.js";
@@ -88,15 +88,20 @@ export class ModerationActionsDb {
}
if (query.status && query.status.length > 0) {
conditions.push(
sql`${moderationActionsTable.status} in ${query.status}`,
inArray(
moderationActionsTable.status,
query.status as Array<"pending" | "failed" | "executed">,
),
);
}
const cursorData = decodeCursor(query.cursor);
if (cursorData) {
conditions.push(
sql`(${moderationActionsTable.created_at} < ${cursorData.created_at} or (${moderationActionsTable.created_at} = ${cursorData.created_at} and ${moderationActionsTable.id} < ${cursorData.id}))`,
);
const cursorCondition = buildCursorCondition(
moderationActionsTable.created_at,
moderationActionsTable.id,
query.cursor,
);
if (cursorCondition) {
conditions.push(cursorCondition);
}
const rows = await this.db
@@ -1,36 +0,0 @@
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("pagination");
export interface CursorData {
created_at: number;
id: string;
}
export function encodeCursor(data: CursorData): string {
const encoded = Buffer.from(JSON.stringify(data)).toString("base64");
logger.debug({ id: data.id, createdAt: data.created_at }, "Encoded cursor");
return encoded;
}
export function decodeCursor(cursor?: string): CursorData | null {
if (!cursor) {
logger.debug("No cursor provided to decode");
return null;
}
try {
const data = JSON.parse(Buffer.from(cursor, "base64").toString("utf-8"));
if (typeof data.created_at === "number" && typeof data.id === "string") {
logger.debug(
{ id: data.id, createdAt: data.created_at },
"Decoded cursor",
);
return data;
}
logger.warn({ cursor }, "Decoded cursor has invalid shape");
return null;
} catch (err) {
logger.warn({ cursor, error: String(err) }, "Failed to decode cursor");
return null;
}
}
@@ -1,5 +1,5 @@
import { createChildLogger, type Logger } from "@bete/shared/logger";
import { eq } from "drizzle-orm";
import { and, eq, isNull, or } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type * as schema from "../../shared/database/schema.js";
import { retentionPoliciesTable } from "../../shared/database/schema.js";
@@ -17,19 +17,50 @@ export class RetentionDb {
this.logger = createChildLogger("retention-db");
}
async getRetentionPolicy(guildId: string): Promise<RetentionPolicy | null> {
this.logger.debug({ guildId }, "getRetentionPolicy entry");
/**
* Look up the most specific retention policy for a guild + optional channel.
*
* Resolution order (most-specific-first):
* 1. Channel-level: guild_id + channel_id match
* 2. Guild default: guild_id match with channel_id IS NULL
* 3. No policy found null
*/
async getRetentionPolicy(
guildId: string,
channelId?: string,
): Promise<RetentionPolicy | null> {
this.logger.debug({ guildId, channelId }, "getRetentionPolicy entry");
try {
const rows = await this.db
const conditions = [eq(retentionPoliciesTable.guild_id, guildId)];
// If a channel is specified, look for a channel-specific policy first
if (channelId) {
const channelRows = await this.db
.select()
.from(retentionPoliciesTable)
.where(
and(
...conditions,
eq(retentionPoliciesTable.channel_id, channelId),
),
);
if (channelRows.length > 0)
return channelRows[0] as RetentionPolicy;
}
// Fall back to the guild default (channel_id IS NULL)
const guildRows = await this.db
.select()
.from(retentionPoliciesTable)
.where(eq(retentionPoliciesTable.guild_id, guildId));
.where(and(...conditions, isNull(retentionPoliciesTable.channel_id)));
return (rows[0] as RetentionPolicy) || null;
return (guildRows[0] as RetentionPolicy) || null;
} catch (error) {
this.logger.error(
{
guildId,
channelId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to get retention policy",
@@ -42,21 +73,37 @@ export class RetentionDb {
policy: Omit<RetentionPolicy, "created_at" | "updated_at">,
): Promise<RetentionPolicy> {
this.logger.debug(
{ guildId: policy.guild_id },
{ guildId: policy.guild_id, channelId: policy.channel_id },
"upsertRetentionPolicy entry",
);
try {
const now = Date.now();
const existing = await this.getRetentionPolicy(policy.guild_id);
if (existing) {
// Find existing policy that matches guild + optional channel
const conditions = [eq(retentionPoliciesTable.guild_id, policy.guild_id)];
if (policy.channel_id) {
conditions.push(
eq(retentionPoliciesTable.channel_id, policy.channel_id),
);
} else {
conditions.push(isNull(retentionPoliciesTable.channel_id));
}
const existing = await this.db
.select({ id: retentionPoliciesTable.id })
.from(retentionPoliciesTable)
.where(and(...conditions))
.limit(1);
const existingRow = existing[0];
if (existingRow) {
const rows = (await this.db
.update(retentionPoliciesTable)
.set({
...policy,
updated_at: now,
})
.where(eq(retentionPoliciesTable.id, existing.id))
.where(eq(retentionPoliciesTable.id, existingRow.id))
.returning()) as RetentionPolicy[];
return rows[0] as RetentionPolicy;
@@ -1,6 +1,6 @@
import { decodeCursor, pageResult } from "@bete/shared";
import { buildCursorCondition, pageResult } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger";
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import { and, desc, eq, inArray, type SQL, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type * as schema from "../../shared/database/schema.js";
import { messageReviewsTable } from "../../shared/database/schema.js";
@@ -91,14 +91,21 @@ export class ReviewsDb {
conditions.push(eq(messageReviewsTable.channel_id, query.channelId));
}
if (query.status && query.status.length > 0) {
conditions.push(sql`${messageReviewsTable.status} in ${query.status}`);
conditions.push(
inArray(
messageReviewsTable.status,
query.status as Array<"pending" | "approved" | "rejected" | "escalated">,
),
);
}
const cursorData = decodeCursor(query.cursor);
if (cursorData) {
conditions.push(
sql`(${messageReviewsTable.created_at} < ${cursorData.created_at} or (${messageReviewsTable.created_at} = ${cursorData.created_at} and ${messageReviewsTable.id} < ${cursorData.id}))`,
);
const cursorCondition = buildCursorCondition(
messageReviewsTable.created_at,
messageReviewsTable.id,
query.cursor,
);
if (cursorCondition) {
conditions.push(cursorCondition);
}
const rows = await this.db