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
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:
co-authored by
Claude Opus 4.8
parent
63f21513bd
commit
5802d02e29
+4
-6
@@ -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;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+20
-25
@@ -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));
|
||||
}
|
||||
});
|
||||
+1
@@ -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;
|
||||
+12
-9
@@ -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 });
|
||||
|
||||
+7
-7
@@ -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.
|
||||
+19
-12
@@ -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(
|
||||
{
|
||||
+3
-3
@@ -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}`,
|
||||
),
|
||||
);
|
||||
|
||||
+13
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
+57
-10
@@ -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;
|
||||
+15
-8
@@ -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
|
||||
Reference in New Issue
Block a user