refactor: atomic, DRY, and logging improvements across codebase
- Split llmModerationClient.ts (2170 lines) into 5 focused sub-modules - Split aiAnalyzer.ts (1282 lines) into 4 modular pipelines - Split messages.db.ts (826 lines) into 5 domain-specific modules - Moved shared schema to @bete/shared, eliminated backend duplication - Added createChildLogger to all voice-recording and AI moderation modules - Extracted tryCommandThenFallback, normalizeMediaState, DEFAULT_VOICE_STATUS - Created shared pagination.ts utility, eliminated 5+ cursor-pagination duplications - Created shared messageMapper.ts for row mapping - Standardized backend error handling with asyncHandler - Added frontend createLogger utility and useAsyncAction hook - Added structured logging to frontend hooks, socket, and API client Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b68789fffc
commit
07032ab521
@@ -1,5 +1,9 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import {
|
||||
type MappedMessage,
|
||||
mapMessageRow,
|
||||
} from "../../shared/utils/messageMapper.js";
|
||||
|
||||
const logger = createChildLogger("analysis.repository");
|
||||
|
||||
@@ -10,61 +14,8 @@ export interface AnalysisSearchQuery {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AnalysisSearchResult {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id: string | null;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
content: string;
|
||||
edited_content: string | null;
|
||||
created_at: number;
|
||||
edited_at: number | null;
|
||||
deleted_at: number | null;
|
||||
type: string;
|
||||
metadata: string | null;
|
||||
ai_status: string | null;
|
||||
ai_moderation_flags: string | null;
|
||||
ai_moderation_score: number | null;
|
||||
ai_analysis: string | null;
|
||||
ai_categories: string | null;
|
||||
ai_severity: string | null;
|
||||
ai_confidence: number | null;
|
||||
ai_recommended_action: string | null;
|
||||
ai_analyzed_at: number | null;
|
||||
ai_error: string | null;
|
||||
}
|
||||
|
||||
function mapSearchResult(row: Record<string, unknown>): AnalysisSearchResult {
|
||||
return {
|
||||
id: String(row.id ?? ""),
|
||||
guild_id: String(row.guild_id ?? ""),
|
||||
channel_id: String(row.channel_id ?? ""),
|
||||
thread_id: (row.thread_id as string | null) ?? null,
|
||||
user_id: String(row.user_id ?? ""),
|
||||
username: String(row.username ?? ""),
|
||||
avatar_url: (row.avatar_url as string | null) ?? null,
|
||||
content: String(row.content ?? ""),
|
||||
edited_content: (row.edited_content as string | null) ?? null,
|
||||
created_at: Number(row.created_at ?? 0),
|
||||
edited_at: (row.edited_at as number | null) ?? null,
|
||||
deleted_at: (row.deleted_at as number | null) ?? null,
|
||||
type: String(row.type ?? "text"),
|
||||
metadata: (row.metadata as string | null) ?? null,
|
||||
ai_status: (row.ai_status as string | null) ?? null,
|
||||
ai_moderation_flags: (row.ai_moderation_flags as string | null) ?? null,
|
||||
ai_moderation_score: (row.ai_moderation_score as number | null) ?? null,
|
||||
ai_analysis: (row.ai_analysis as string | null) ?? null,
|
||||
ai_categories: (row.ai_categories as string | null) ?? null,
|
||||
ai_severity: (row.ai_severity as string | null) ?? null,
|
||||
ai_confidence: (row.ai_confidence as number | null) ?? null,
|
||||
ai_recommended_action: (row.ai_recommended_action as string | null) ?? null,
|
||||
ai_analyzed_at: (row.ai_analyzed_at as number | null) ?? null,
|
||||
ai_error: (row.ai_error as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
// AnalysisSearchResult is identical to MappedMessage — reuse the shared mapper
|
||||
export type AnalysisSearchResult = MappedMessage;
|
||||
|
||||
export class AnalysisRepository {
|
||||
async search(query: AnalysisSearchQuery): Promise<AnalysisSearchResult[]> {
|
||||
@@ -107,7 +58,7 @@ export class AnalysisRepository {
|
||||
[...params, limit],
|
||||
);
|
||||
|
||||
return rows.map((r) => mapSearchResult(r as Record<string, unknown>));
|
||||
return rows.map((r) => mapMessageRow(r as Record<string, unknown>));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { healthService } from "./health.service.js";
|
||||
|
||||
export function handleHealthCheck(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
export const handleHealthCheck = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const verbose = req.query.verbose === "true";
|
||||
const result = await healthService.getHealth(verbose);
|
||||
const status = result.status === "healthy" ? 200 : 503;
|
||||
res.status(status).json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response } from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { mascotChatService } from "./mascot-chat.service.js";
|
||||
|
||||
const logger = createChildLogger("mascot-chat.controller");
|
||||
@@ -8,8 +9,8 @@ interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export async function handleMascotChat(req: Request, res: Response) {
|
||||
try {
|
||||
export const handleMascotChat = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const { message, context } = req.body;
|
||||
|
||||
if (!message || typeof message !== "string") {
|
||||
@@ -49,17 +50,11 @@ export async function handleMascotChat(req: Request, res: Response) {
|
||||
response,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error processing mascot chat");
|
||||
res.status(500).json({
|
||||
error: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to process mascot chat",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export async function getMascotChatHistory(req: Request, res: Response) {
|
||||
try {
|
||||
export const getMascotChatHistory = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
|
||||
@@ -69,17 +64,11 @@ export async function getMascotChatHistory(req: Request, res: Response) {
|
||||
history,
|
||||
total: history.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error fetching chat history");
|
||||
res.status(500).json({
|
||||
error: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch chat history",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export async function clearMascotChatHistory(req: Request, res: Response) {
|
||||
try {
|
||||
export const clearMascotChatHistory = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
||||
|
||||
await mascotChatService.clearChatHistory(userId);
|
||||
@@ -87,11 +76,5 @@ export async function clearMascotChatHistory(req: Request, res: Response) {
|
||||
res.status(200).json({
|
||||
message: "Chat history cleared successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error clearing chat history");
|
||||
res.status(500).json({
|
||||
error: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to clear chat history",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import express, { type Router } from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import {
|
||||
clearMascotChatHistory,
|
||||
getMascotChatHistory,
|
||||
@@ -9,9 +8,9 @@ import {
|
||||
export function createMascotChatRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/mascot/chat", asyncHandler(handleMascotChat));
|
||||
router.get("/mascot/chat/history", asyncHandler(getMascotChatHistory));
|
||||
router.delete("/mascot/chat/history", asyncHandler(clearMascotChatHistory));
|
||||
router.post("/mascot/chat", handleMascotChat);
|
||||
router.get("/mascot/chat/history", getMascotChatHistory);
|
||||
router.delete("/mascot/chat/history", clearMascotChatHistory);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
COMMAND_MEDIA_VOLUME,
|
||||
MEDIA_STATUS_KEY,
|
||||
} from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import {
|
||||
createChildLogger,
|
||||
tryCommandThenFallback,
|
||||
} from "../../shared/commandHelper.js";
|
||||
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||
|
||||
const logger = createChildLogger("media.service");
|
||||
@@ -43,6 +46,35 @@ const DEFAULT_STATE: MediaState = {
|
||||
queue: [],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalisation — handle both boolean (new) and string (legacy) playing values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeMediaState(raw: Record<string, unknown>): MediaState {
|
||||
const rawPlaying = raw.playing;
|
||||
const playing =
|
||||
rawPlaying === true ||
|
||||
rawPlaying === "playing" ||
|
||||
rawPlaying === "buffering";
|
||||
return {
|
||||
playing,
|
||||
musicVolume: Number(raw.musicVolume ?? 1.0),
|
||||
current: (raw.current as MediaItem | null) ?? null,
|
||||
queue: (raw.queue as MediaItem[]) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
type MediaReplyData = Record<string, unknown> | MediaState;
|
||||
|
||||
function fromReply(data: MediaReplyData): MediaState {
|
||||
return normalizeMediaState(data as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async function readStatusFallback(): Promise<MediaState> {
|
||||
const cached = await readRedisStatus(MEDIA_STATUS_KEY);
|
||||
return cached ? normalizeMediaState(cached) : DEFAULT_STATE;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service methods
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -52,24 +84,7 @@ const DEFAULT_STATE: MediaState = {
|
||||
*/
|
||||
export async function getStatus(): Promise<MediaState> {
|
||||
logger.debug("getStatus called");
|
||||
const cached = await readRedisStatus(MEDIA_STATUS_KEY);
|
||||
|
||||
if (cached) {
|
||||
const rawPlaying = cached.playing;
|
||||
// Handle both boolean (new) and string (legacy from String(discordPlayer.getStatus()))
|
||||
const playing =
|
||||
rawPlaying === true ||
|
||||
rawPlaying === "playing" ||
|
||||
rawPlaying === "buffering";
|
||||
return {
|
||||
playing,
|
||||
musicVolume: Number(cached.musicVolume ?? 1.0),
|
||||
current: (cached.current as MediaItem | null) ?? null,
|
||||
queue: (cached.queue as MediaItem[]) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
return DEFAULT_STATE;
|
||||
return readStatusFallback();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,26 +95,16 @@ export async function queue(
|
||||
mode: "music" | "screen" = "music",
|
||||
): Promise<MediaState> {
|
||||
logger.info({ source, mode }, "queue called");
|
||||
const reply = await publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_QUEUE,
|
||||
{ source, mode },
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
return tryCommandThenFallback(
|
||||
() =>
|
||||
publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_QUEUE,
|
||||
{ source, mode },
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
),
|
||||
() => readStatusFallback(),
|
||||
"queue",
|
||||
);
|
||||
|
||||
if (reply?.success && reply.data) {
|
||||
return {
|
||||
playing: reply.data.playing,
|
||||
musicVolume: reply.data.musicVolume,
|
||||
current: reply.data.current ?? null,
|
||||
queue: reply.data.queue ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
{ source, mode },
|
||||
"discord-gateway unreachable, returning current media status",
|
||||
);
|
||||
return getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,23 +112,16 @@ export async function queue(
|
||||
*/
|
||||
export async function skip(): Promise<MediaState> {
|
||||
logger.info("skip called");
|
||||
const reply = await publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_SKIP,
|
||||
{},
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
return tryCommandThenFallback(
|
||||
() =>
|
||||
publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_SKIP,
|
||||
{},
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
),
|
||||
() => readStatusFallback(),
|
||||
"skip",
|
||||
);
|
||||
|
||||
if (reply?.success && reply.data) {
|
||||
return {
|
||||
playing: reply.data.playing,
|
||||
musicVolume: reply.data.musicVolume,
|
||||
current: reply.data.current ?? null,
|
||||
queue: reply.data.queue ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn("discord-gateway unreachable, returning current media status");
|
||||
return getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,23 +129,16 @@ export async function skip(): Promise<MediaState> {
|
||||
*/
|
||||
export async function stop(): Promise<MediaState> {
|
||||
logger.info("stop called");
|
||||
const reply = await publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_STOP,
|
||||
{},
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
return tryCommandThenFallback(
|
||||
() =>
|
||||
publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_STOP,
|
||||
{},
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
),
|
||||
() => readStatusFallback(),
|
||||
"stop",
|
||||
);
|
||||
|
||||
if (reply?.success && reply.data) {
|
||||
return {
|
||||
playing: reply.data.playing,
|
||||
musicVolume: reply.data.musicVolume,
|
||||
current: reply.data.current ?? null,
|
||||
queue: reply.data.queue ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn("discord-gateway unreachable, returning current media status");
|
||||
return getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,24 +146,14 @@ export async function stop(): Promise<MediaState> {
|
||||
*/
|
||||
export async function setVolume(volume: number): Promise<MediaState> {
|
||||
logger.info({ volume }, "setVolume called");
|
||||
const reply = await publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_VOLUME,
|
||||
{ volume },
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
return tryCommandThenFallback(
|
||||
() =>
|
||||
publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_VOLUME,
|
||||
{ volume },
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
),
|
||||
() => readStatusFallback(),
|
||||
"setVolume",
|
||||
);
|
||||
|
||||
if (reply?.success && reply.data) {
|
||||
return {
|
||||
playing: reply.data.playing,
|
||||
musicVolume: reply.data.musicVolume,
|
||||
current: reply.data.current ?? null,
|
||||
queue: reply.data.queue ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
{ volume },
|
||||
"discord-gateway unreachable, returning current media status",
|
||||
);
|
||||
return getStatus();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import type { PageResult } from "@bete/shared";
|
||||
import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { and, desc, eq, inArray, lt, ne, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
bigint as pgBigint,
|
||||
integer as pgInteger,
|
||||
real as pgReal,
|
||||
pgTable,
|
||||
text as pgText,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { mapMessageRow } from "../../shared/utils/messageMapper.js";
|
||||
import type {
|
||||
MessageCreate,
|
||||
MessageQuery,
|
||||
@@ -17,71 +12,6 @@ import type {
|
||||
|
||||
const logger = createChildLogger("messages.repository");
|
||||
|
||||
/**
|
||||
* Local table definitions mirroring services/discord-gateway/src/shared/database/schema.ts.
|
||||
* These are query-building references only — schema source of truth remains in discord-gateway.
|
||||
*/
|
||||
const messages = pgTable("messages", {
|
||||
id: pgText("id").primaryKey(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
channel_id: pgText("channel_id").notNull(),
|
||||
thread_id: pgText("thread_id"),
|
||||
user_id: pgText("user_id").notNull(),
|
||||
username: pgText("username").notNull(),
|
||||
avatar_url: pgText("avatar_url"),
|
||||
content: pgText("content").notNull(),
|
||||
edited_content: pgText("edited_content"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
edited_at: pgBigint("edited_at", { mode: "number" }),
|
||||
deleted_at: pgBigint("deleted_at", { mode: "number" }),
|
||||
type: pgText("type", {
|
||||
enum: ["text", "edited", "deleted"],
|
||||
})
|
||||
.notNull()
|
||||
.default("text"),
|
||||
metadata: pgText("metadata"),
|
||||
ai_status: pgText("ai_status", {
|
||||
enum: ["pending", "processing", "clean", "warn", "flagged", "error"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
ai_moderation_flags: pgText("ai_moderation_flags"),
|
||||
ai_moderation_score: pgReal("ai_moderation_score"),
|
||||
ai_analysis: pgText("ai_analysis"),
|
||||
ai_categories: pgText("ai_categories"),
|
||||
ai_severity: pgText("ai_severity", {
|
||||
enum: ["none", "low", "medium", "high", "critical"],
|
||||
}),
|
||||
ai_confidence: pgReal("ai_confidence"),
|
||||
ai_recommended_action: pgText("ai_recommended_action", {
|
||||
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
||||
}),
|
||||
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
|
||||
ai_error: pgText("ai_error"),
|
||||
});
|
||||
|
||||
const attachments = pgTable("attachments", {
|
||||
id: pgText("id").primaryKey(),
|
||||
message_id: pgText("message_id").notNull(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
channel_id: pgText("channel_id").notNull(),
|
||||
thread_id: pgText("thread_id"),
|
||||
user_id: pgText("user_id").notNull(),
|
||||
filename: pgText("filename").notNull(),
|
||||
size: pgInteger("size").notNull(),
|
||||
type: pgText("type").notNull(),
|
||||
discord_url: pgText("discord_url").notNull(),
|
||||
uploaded_url: pgText("uploaded_url"),
|
||||
upload_status: pgText("upload_status", {
|
||||
enum: ["pending", "uploaded", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
upload_error: pgText("upload_error"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
uploaded_at: pgBigint("uploaded_at", { mode: "number" }),
|
||||
});
|
||||
|
||||
export interface AttachmentResult {
|
||||
id: string;
|
||||
message_id: string;
|
||||
@@ -100,35 +30,6 @@ export interface AttachmentResult {
|
||||
uploaded_at: number | null;
|
||||
}
|
||||
|
||||
function mapMessageRow(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: String(row.id ?? ""),
|
||||
guild_id: String(row.guild_id ?? ""),
|
||||
channel_id: String(row.channel_id ?? ""),
|
||||
thread_id: (row.thread_id as string | null) ?? null,
|
||||
user_id: String(row.user_id ?? ""),
|
||||
username: String(row.username ?? ""),
|
||||
avatar_url: (row.avatar_url as string | null) ?? null,
|
||||
content: String(row.content ?? ""),
|
||||
edited_content: (row.edited_content as string | null) ?? null,
|
||||
created_at: Number(row.created_at ?? 0),
|
||||
edited_at: (row.edited_at as number | null) ?? null,
|
||||
deleted_at: (row.deleted_at as number | null) ?? null,
|
||||
type: String(row.type ?? "text"),
|
||||
metadata: (row.metadata as string | null) ?? null,
|
||||
ai_status: (row.ai_status as string | null) ?? null,
|
||||
ai_moderation_flags: (row.ai_moderation_flags as string | null) ?? null,
|
||||
ai_moderation_score: (row.ai_moderation_score as number | null) ?? null,
|
||||
ai_analysis: (row.ai_analysis as string | null) ?? null,
|
||||
ai_categories: (row.ai_categories as string | null) ?? null,
|
||||
ai_severity: (row.ai_severity as string | null) ?? null,
|
||||
ai_confidence: (row.ai_confidence as number | null) ?? null,
|
||||
ai_recommended_action: (row.ai_recommended_action as string | null) ?? null,
|
||||
ai_analyzed_at: (row.ai_analyzed_at as number | null) ?? null,
|
||||
ai_error: (row.ai_error as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export class MessagesRepository {
|
||||
async findMany(
|
||||
query: MessageQuery,
|
||||
@@ -138,27 +39,27 @@ export class MessagesRepository {
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (query.guildId) {
|
||||
conditions.push(eq(messages.guild_id, query.guildId));
|
||||
conditions.push(eq(pgMessagesTable.guild_id, query.guildId));
|
||||
}
|
||||
if (query.channelId) {
|
||||
conditions.push(eq(messages.channel_id, query.channelId));
|
||||
conditions.push(eq(pgMessagesTable.channel_id, query.channelId));
|
||||
}
|
||||
if (query.userId) {
|
||||
conditions.push(eq(messages.user_id, query.userId));
|
||||
conditions.push(eq(pgMessagesTable.user_id, query.userId));
|
||||
}
|
||||
if (query.status) {
|
||||
conditions.push(eq(messages.ai_status, query.status));
|
||||
conditions.push(eq(pgMessagesTable.ai_status, query.status));
|
||||
}
|
||||
if (query.cursor) {
|
||||
conditions.push(lt(messages.created_at, Number(query.cursor)));
|
||||
conditions.push(lt(pgMessagesTable.created_at, Number(query.cursor)));
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.from(pgMessagesTable)
|
||||
.where(where)
|
||||
.orderBy(desc(messages.created_at))
|
||||
.orderBy(desc(pgMessagesTable.created_at))
|
||||
.limit(limit + 1);
|
||||
|
||||
const data = rows
|
||||
@@ -175,8 +76,8 @@ export class MessagesRepository {
|
||||
const db = getDatabase();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.id, id))
|
||||
.from(pgMessagesTable)
|
||||
.where(eq(pgMessagesTable.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
@@ -189,17 +90,17 @@ export class MessagesRepository {
|
||||
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
|
||||
const db = getDatabase();
|
||||
const limit = query.limit ?? 50;
|
||||
const conditions: SQL[] = [eq(messages.channel_id, channelId)];
|
||||
const conditions: SQL[] = [eq(pgMessagesTable.channel_id, channelId)];
|
||||
|
||||
if (query.cursor) {
|
||||
conditions.push(lt(messages.created_at, Number(query.cursor)));
|
||||
conditions.push(lt(pgMessagesTable.created_at, Number(query.cursor)));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.from(pgMessagesTable)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(messages.created_at))
|
||||
.orderBy(desc(pgMessagesTable.created_at))
|
||||
.limit(limit + 1);
|
||||
|
||||
const data = rows
|
||||
@@ -216,7 +117,7 @@ export class MessagesRepository {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
const [row] = await db
|
||||
.insert(messages)
|
||||
.insert(pgMessagesTable)
|
||||
.values({
|
||||
id,
|
||||
guild_id: data.guildId,
|
||||
@@ -242,7 +143,7 @@ export class MessagesRepository {
|
||||
async update(id: string, data: MessageUpdate) {
|
||||
const db = getDatabase();
|
||||
|
||||
const setData: Partial<typeof messages.$inferInsert> = {};
|
||||
const setData: Partial<typeof pgMessagesTable.$inferInsert> = {};
|
||||
|
||||
if (data.editedContent !== undefined) {
|
||||
setData.edited_content = data.editedContent;
|
||||
@@ -266,9 +167,9 @@ export class MessagesRepository {
|
||||
if (Object.keys(setData).length === 0) return this.findById(id);
|
||||
|
||||
const [row] = await db
|
||||
.update(messages)
|
||||
.update(pgMessagesTable)
|
||||
.set(setData)
|
||||
.where(eq(messages.id, id))
|
||||
.where(eq(pgMessagesTable.id, id))
|
||||
.returning();
|
||||
|
||||
if (!row) return null;
|
||||
@@ -288,20 +189,20 @@ export class MessagesRepository {
|
||||
messageIds?: string[];
|
||||
}): Promise<number> {
|
||||
const db = getDatabase();
|
||||
const conditions: SQL[] = [eq(messages.ai_status, "error")];
|
||||
const conditions: SQL[] = [eq(pgMessagesTable.ai_status, "error")];
|
||||
|
||||
if (opts.messageIds && opts.messageIds.length > 0) {
|
||||
conditions.push(inArray(messages.id, opts.messageIds));
|
||||
conditions.push(inArray(pgMessagesTable.id, opts.messageIds));
|
||||
}
|
||||
if (opts.guildId) {
|
||||
conditions.push(eq(messages.guild_id, opts.guildId));
|
||||
conditions.push(eq(pgMessagesTable.guild_id, opts.guildId));
|
||||
}
|
||||
if (opts.channelId) {
|
||||
conditions.push(eq(messages.channel_id, opts.channelId));
|
||||
conditions.push(eq(pgMessagesTable.channel_id, opts.channelId));
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.update(messages)
|
||||
.update(pgMessagesTable)
|
||||
.set({ ai_status: "pending" })
|
||||
.where(and(...conditions));
|
||||
|
||||
@@ -317,9 +218,14 @@ export class MessagesRepository {
|
||||
async markForReanalysis(id: string): Promise<void> {
|
||||
const db = getDatabase();
|
||||
await db
|
||||
.update(messages)
|
||||
.update(pgMessagesTable)
|
||||
.set({ ai_status: "pending" })
|
||||
.where(and(eq(messages.id, id), ne(messages.ai_status, "pending")));
|
||||
.where(
|
||||
and(
|
||||
eq(pgMessagesTable.id, id),
|
||||
ne(pgMessagesTable.ai_status, "pending"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -332,32 +238,32 @@ export class MessagesRepository {
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const db = getDatabase();
|
||||
const conditions: SQL[] = [
|
||||
inArray(messages.ai_status, ["warn", "flagged"]),
|
||||
inArray(pgMessagesTable.ai_status, ["warn", "flagged"]),
|
||||
];
|
||||
|
||||
if (channelId) {
|
||||
conditions.push(eq(messages.channel_id, channelId));
|
||||
conditions.push(eq(pgMessagesTable.channel_id, channelId));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: messages.id,
|
||||
guild_id: messages.guild_id,
|
||||
channel_id: messages.channel_id,
|
||||
user_id: messages.user_id,
|
||||
username: messages.username,
|
||||
avatar_url: messages.avatar_url,
|
||||
content: messages.content,
|
||||
type: messages.type,
|
||||
created_at: messages.created_at,
|
||||
ai_status: messages.ai_status,
|
||||
ai_severity: messages.ai_severity,
|
||||
ai_confidence: messages.ai_confidence,
|
||||
ai_analysis: messages.ai_analysis,
|
||||
id: pgMessagesTable.id,
|
||||
guild_id: pgMessagesTable.guild_id,
|
||||
channel_id: pgMessagesTable.channel_id,
|
||||
user_id: pgMessagesTable.user_id,
|
||||
username: pgMessagesTable.username,
|
||||
avatar_url: pgMessagesTable.avatar_url,
|
||||
content: pgMessagesTable.content,
|
||||
type: pgMessagesTable.type,
|
||||
created_at: pgMessagesTable.created_at,
|
||||
ai_status: pgMessagesTable.ai_status,
|
||||
ai_severity: pgMessagesTable.ai_severity,
|
||||
ai_confidence: pgMessagesTable.ai_confidence,
|
||||
ai_analysis: pgMessagesTable.ai_analysis,
|
||||
})
|
||||
.from(messages)
|
||||
.from(pgMessagesTable)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(messages.created_at))
|
||||
.orderBy(desc(pgMessagesTable.created_at))
|
||||
.limit(limit);
|
||||
|
||||
return rows as unknown as Record<string, unknown>[];
|
||||
@@ -365,7 +271,9 @@ export class MessagesRepository {
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const db = getDatabase();
|
||||
const result = await db.delete(messages).where(eq(messages.id, id));
|
||||
const result = await db
|
||||
.delete(pgMessagesTable)
|
||||
.where(eq(pgMessagesTable.id, id));
|
||||
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
@@ -376,17 +284,17 @@ export class MessagesRepository {
|
||||
): Promise<PageResult<AttachmentResult>> {
|
||||
const db = getDatabase();
|
||||
const limit = query.limit ?? 50;
|
||||
const conditions: SQL[] = [eq(attachments.channel_id, channelId)];
|
||||
const conditions: SQL[] = [eq(pgAttachmentsTable.channel_id, channelId)];
|
||||
|
||||
if (query.cursor) {
|
||||
conditions.push(lt(attachments.created_at, Number(query.cursor)));
|
||||
conditions.push(lt(pgAttachmentsTable.created_at, Number(query.cursor)));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(attachments)
|
||||
.from(pgAttachmentsTable)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(attachments.created_at))
|
||||
.orderBy(desc(pgAttachmentsTable.created_at))
|
||||
.limit(limit + 1);
|
||||
|
||||
const data = rows.map((r) => ({
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import type { Router } from "express";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { getGuilds, getTextChannels } from "./voice.service.js";
|
||||
|
||||
const logger = createChildLogger("guilds.routes");
|
||||
|
||||
export function createGuildsRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/guilds
|
||||
router.get("/", async (_req, res) => {
|
||||
const guilds = await getGuilds();
|
||||
res.json(guilds);
|
||||
});
|
||||
router.get(
|
||||
"/",
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
logger.debug("Fetching guilds");
|
||||
const guilds = await getGuilds();
|
||||
res.json(guilds);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /api/guilds/:guildId/channels
|
||||
router.get("/:guildId/channels", async (req, res) => {
|
||||
const channels = await getTextChannels(req.params.guildId);
|
||||
res.json(channels);
|
||||
});
|
||||
router.get(
|
||||
"/:guildId/channels",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = Array.isArray(req.params.guildId)
|
||||
? req.params.guildId[0]
|
||||
: req.params.guildId;
|
||||
logger.debug({ guildId }, "Fetching text channels");
|
||||
const channels = await getTextChannels(guildId);
|
||||
res.json(channels);
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response } from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { publishCommandNoReply } from "../../shared/redis/index.js";
|
||||
import {
|
||||
connectVoice,
|
||||
@@ -7,10 +9,14 @@ import {
|
||||
getVoiceStatus,
|
||||
} from "./voice.service.js";
|
||||
|
||||
export async function handleGetVoiceStatus(_req: Request, res: Response) {
|
||||
const status = await getVoiceStatus();
|
||||
res.json(status);
|
||||
}
|
||||
const logger = createChildLogger("voice.controller");
|
||||
|
||||
export const handleGetVoiceStatus = asyncHandler(
|
||||
async (_req: Request, res: Response) => {
|
||||
const status = await getVoiceStatus();
|
||||
res.json(status);
|
||||
},
|
||||
);
|
||||
|
||||
/** Safely extract a string value that may be a single string or string array. */
|
||||
function asString(val: unknown): string {
|
||||
@@ -18,47 +24,52 @@ function asString(val: unknown): string {
|
||||
return String(val ?? "");
|
||||
}
|
||||
|
||||
export async function handleConnectVoice(req: Request, res: Response) {
|
||||
const guildId = asString(req.body.guildId);
|
||||
const channelId = asString(req.body.channelId);
|
||||
if (!guildId || !channelId) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "guildId and channelId are required",
|
||||
});
|
||||
}
|
||||
const status = await connectVoice(guildId, channelId);
|
||||
res.json(status);
|
||||
}
|
||||
export const handleConnectVoice = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const guildId = asString(req.body.guildId);
|
||||
const channelId = asString(req.body.channelId);
|
||||
if (!guildId || !channelId) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "guildId and channelId are required",
|
||||
});
|
||||
}
|
||||
logger.debug({ guildId, channelId }, "Connecting to voice channel");
|
||||
const status = await connectVoice(guildId, channelId);
|
||||
res.json(status);
|
||||
},
|
||||
);
|
||||
|
||||
export async function handleDisconnectVoice(_req: Request, res: Response) {
|
||||
const status = await disconnectVoice();
|
||||
res.json(status);
|
||||
}
|
||||
export const handleDisconnectVoice = asyncHandler(
|
||||
async (_req: Request, res: Response) => {
|
||||
logger.debug("Disconnecting from voice");
|
||||
const status = await disconnectVoice();
|
||||
res.json(status);
|
||||
},
|
||||
);
|
||||
|
||||
export async function handleGetVoiceChannels(req: Request, res: Response) {
|
||||
const guildId = asString(req.params.guildId);
|
||||
const channels = await getVoiceChannels(guildId);
|
||||
res.json(channels);
|
||||
}
|
||||
export const handleGetVoiceChannels = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const guildId = asString(req.params.guildId);
|
||||
logger.debug({ guildId }, "Fetching voice channels");
|
||||
const channels = await getVoiceChannels(guildId);
|
||||
res.json(channels);
|
||||
},
|
||||
);
|
||||
|
||||
export async function handleVoiceCommand(req: Request, res: Response) {
|
||||
const command = asString(req.body.command);
|
||||
export const handleVoiceCommand = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const command = asString(req.body.command);
|
||||
|
||||
if (!command) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "command is required",
|
||||
});
|
||||
}
|
||||
if (!command) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "command is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug({ command }, "Publishing voice command");
|
||||
await publishCommandNoReply(command);
|
||||
res.json({ success: true, command });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: "COMMAND_FAILED",
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
COMMAND_VOICE_DISCONNECT,
|
||||
VOICE_STATUS_KEY,
|
||||
} from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import {
|
||||
createChildLogger,
|
||||
tryCommandThenFallback,
|
||||
} from "../../shared/commandHelper.js";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||
|
||||
@@ -31,29 +34,40 @@ export interface VoiceStatus {
|
||||
activeChannelName: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
};
|
||||
|
||||
function readVoiceStatusFallback(): Promise<VoiceStatus> {
|
||||
return readRedisStatus(VOICE_STATUS_KEY).then(
|
||||
(cached) => (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guilds — query from discord-gateway via Redis command for real names.
|
||||
* Falls back to database (distinct guild_id from messages) if gateway unreachable.
|
||||
*/
|
||||
export async function getGuilds(): Promise<Guild[]> {
|
||||
logger.info("getGuilds called");
|
||||
const reply = await publishCommand<Guild[]>(COMMAND_GUILDS_LIST, {});
|
||||
if (reply?.success && reply.data && reply.data.length > 0) return reply.data;
|
||||
|
||||
// Fallback: Postgres with synthetic names
|
||||
logger.warn(
|
||||
"discord-gateway unreachable, falling back to Postgres for guilds",
|
||||
return tryCommandThenFallback(
|
||||
() => publishCommand<Guild[]>(COMMAND_GUILDS_LIST, {}),
|
||||
async () => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT guild_id FROM messages ORDER BY guild_id`,
|
||||
);
|
||||
return rows.map((row: Record<string, unknown>) => ({
|
||||
id: String(row.guild_id ?? ""),
|
||||
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
|
||||
icon: null,
|
||||
}));
|
||||
},
|
||||
"getGuilds",
|
||||
);
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT guild_id FROM messages ORDER BY guild_id`,
|
||||
);
|
||||
|
||||
return rows.map((row: Record<string, unknown>) => ({
|
||||
id: String(row.guild_id ?? ""),
|
||||
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
|
||||
icon: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,27 +76,22 @@ export async function getGuilds(): Promise<Guild[]> {
|
||||
*/
|
||||
export async function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||
logger.info({ guildId }, "getTextChannels called");
|
||||
const reply = await publishCommand<Channel[]>(COMMAND_GUILDS_TEXT_CHANNELS, {
|
||||
guildId,
|
||||
});
|
||||
if (reply?.success && reply.data && reply.data.length > 0) return reply.data;
|
||||
|
||||
// Fallback: Postgres with synthetic names
|
||||
logger.warn(
|
||||
{ guildId },
|
||||
"discord-gateway unreachable, falling back to Postgres for text channels",
|
||||
return tryCommandThenFallback(
|
||||
() => publishCommand<Channel[]>(COMMAND_GUILDS_TEXT_CHANNELS, { guildId }),
|
||||
async () => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT channel_id FROM messages WHERE guild_id = $1 ORDER BY channel_id`,
|
||||
[guildId],
|
||||
);
|
||||
return rows.map((row: Record<string, unknown>) => ({
|
||||
id: String(row.channel_id ?? ""),
|
||||
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
|
||||
type: "text" as const,
|
||||
}));
|
||||
},
|
||||
"getTextChannels",
|
||||
);
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT channel_id FROM messages WHERE guild_id = $1 ORDER BY channel_id`,
|
||||
[guildId],
|
||||
);
|
||||
|
||||
return rows.map((row: Record<string, unknown>) => ({
|
||||
id: String(row.channel_id ?? ""),
|
||||
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
|
||||
type: "text" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,13 +111,7 @@ export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
|
||||
export async function getVoiceStatus(): Promise<VoiceStatus> {
|
||||
logger.debug("getVoiceStatus called");
|
||||
const cached = await readRedisStatus(VOICE_STATUS_KEY);
|
||||
if (cached) return cached as unknown as VoiceStatus;
|
||||
return {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
};
|
||||
return (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,21 +122,14 @@ export async function connectVoice(
|
||||
channelId: string,
|
||||
): Promise<VoiceStatus> {
|
||||
logger.info({ guildId, channelId }, "connectVoice called");
|
||||
const reply = await publishCommand<VoiceStatus>(COMMAND_VOICE_CONNECT, {
|
||||
guildId,
|
||||
channelId,
|
||||
});
|
||||
if (reply?.success && reply.data) return reply.data;
|
||||
|
||||
// Fallback: read from Redis status key
|
||||
const cached = await readRedisStatus(VOICE_STATUS_KEY);
|
||||
return (
|
||||
(cached as unknown as VoiceStatus) ?? {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
}
|
||||
return tryCommandThenFallback(
|
||||
() =>
|
||||
publishCommand<VoiceStatus>(COMMAND_VOICE_CONNECT, {
|
||||
guildId,
|
||||
channelId,
|
||||
}),
|
||||
() => readVoiceStatusFallback(),
|
||||
"connectVoice",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,16 +138,9 @@ export async function connectVoice(
|
||||
*/
|
||||
export async function disconnectVoice(): Promise<VoiceStatus> {
|
||||
logger.info("disconnectVoice called");
|
||||
const reply = await publishCommand<VoiceStatus>(COMMAND_VOICE_DISCONNECT, {});
|
||||
if (reply?.success && reply.data) return reply.data;
|
||||
|
||||
const cached = await readRedisStatus(VOICE_STATUS_KEY);
|
||||
return (
|
||||
(cached as unknown as VoiceStatus) ?? {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
}
|
||||
return tryCommandThenFallback(
|
||||
() => publishCommand<VoiceStatus>(COMMAND_VOICE_DISCONNECT, {}),
|
||||
() => readVoiceStatusFallback(),
|
||||
"disconnectVoice",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user