feat(backend): implement all missing endpoints, real analytics queries, and WebSocket server
- Replace stub analytics.repository.ts with real PostgreSQL queries using pg.Pool - Add /api/guilds, /api/config, /api/auth/login, /api/ui-state (GET/POST) - Add /api/review, /api/recordings, /api/analysis/search - Add /api/messages/:id/reanalyze endpoint - Add /api/analytics/heatmap and /api/analytics/topics - Implement media routes (stub responses, backend has no Discord voice client) - Add WebSocket server at /ws with heartbeat and broadcast functions - Fix analytics route paths to match frontend contract (dual paths for backward compat) - Export getPool() from database module for raw SQL queries - Register all new routers in app.ts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5a094c926a
commit
9b41eb9c12
@@ -5,11 +5,17 @@ import express, {
|
|||||||
type Response,
|
type Response,
|
||||||
} from "express";
|
} from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
|
import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js";
|
||||||
import { createAnalyticsRouter } from "../modules/analytics/analytics.routes.js";
|
import { createAnalyticsRouter } from "../modules/analytics/analytics.routes.js";
|
||||||
|
import { createAuthRouter } from "../modules/auth/auth.routes.js";
|
||||||
|
import { createConfigRouter } from "../modules/config/config.routes.js";
|
||||||
import { createHealthRouter } from "../modules/health/health.routes.js";
|
import { createHealthRouter } from "../modules/health/health.routes.js";
|
||||||
import { createMediaRouter } from "../modules/media/media.routes.js";
|
import { createMediaRouter } from "../modules/media/media.routes.js";
|
||||||
import { createMessagesRouter } from "../modules/messages/messages.routes.js";
|
import { createMessagesRouter } from "../modules/messages/messages.routes.js";
|
||||||
|
import { createRecordingsRouter } from "../modules/recordings/recordings.routes.js";
|
||||||
|
import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js";
|
||||||
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
|
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
|
||||||
|
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
|
||||||
import { createChildLogger } from "../shared/logger/index.js";
|
import { createChildLogger } from "../shared/logger/index.js";
|
||||||
import { errorHandler } from "../shared/middlewares/index.js";
|
import { errorHandler } from "../shared/middlewares/index.js";
|
||||||
|
|
||||||
@@ -55,10 +61,18 @@ export function createHttpApp(): Express {
|
|||||||
app.use("/api", createHealthRouter());
|
app.use("/api", createHealthRouter());
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
|
app.use("/api", createAuthRouter());
|
||||||
|
app.use("/api", createConfigRouter());
|
||||||
app.use("/api", createMessagesRouter());
|
app.use("/api", createMessagesRouter());
|
||||||
|
app.use("/api", createAnalysisRouter());
|
||||||
app.use("/api", createAnalyticsRouter());
|
app.use("/api", createAnalyticsRouter());
|
||||||
app.use("/api", createMediaRouter());
|
app.use("/api", createMediaRouter());
|
||||||
app.use("/api", createVoiceRouter());
|
app.use("/api", createVoiceRouter());
|
||||||
|
app.use("/api", createRecordingsRouter());
|
||||||
|
app.use("/api", createUiStateRouter());
|
||||||
|
|
||||||
|
// Guilds routes (must be before catch-all, after other /api routes)
|
||||||
|
app.use("/api/guilds", createGuildsRouter());
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
|
import { createServer, type Server } from "node:http";
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import { initializeDatabase } from "../shared/database/index.js";
|
import { initializeDatabase } from "../shared/database/index.js";
|
||||||
import { createChildLogger } from "../shared/logger/index.js";
|
import { createChildLogger } from "../shared/logger/index.js";
|
||||||
import { createHttpApp } from "./app.js";
|
import { createHttpApp } from "./app.js";
|
||||||
|
import { createWebSocketServer } from "../ws/server.js";
|
||||||
|
|
||||||
const logger = createChildLogger("http.server");
|
const logger = createChildLogger("http.server");
|
||||||
|
|
||||||
export async function startHttpServer() {
|
export async function startHttpServer(): Promise<Server> {
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
|
||||||
const app = createHttpApp();
|
const app = createHttpApp();
|
||||||
const port = config.WEBSERVER_PORT;
|
const port = config.WEBSERVER_PORT;
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
const server = createServer(app);
|
||||||
const server = app.listen(port, () => {
|
|
||||||
|
// Attach WebSocket server to the same HTTP server
|
||||||
|
createWebSocketServer(server);
|
||||||
|
|
||||||
|
return new Promise<Server>((resolve, reject) => {
|
||||||
|
server.listen(port, () => {
|
||||||
logger.info({ port }, "HTTP server started");
|
logger.info({ port }, "HTTP server started");
|
||||||
resolve();
|
resolve(server);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("error", (err) => {
|
server.on("error", (err) => {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { startHttpServer } from "./http/server.js";
|
import { startHttpServer } from "./http/server.js";
|
||||||
import { createChildLogger } from "./shared/logger/index.js";
|
import { createChildLogger } from "./shared/logger/index.js";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
const logger = createChildLogger("backend");
|
const logger = createChildLogger("backend");
|
||||||
|
|
||||||
|
let httpServer: Server | undefined;
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
logger.info("Starting Discord Moderation Backend Service");
|
logger.info("Starting Discord Moderation Backend Service");
|
||||||
await startHttpServer();
|
httpServer = await startHttpServer();
|
||||||
logger.info("Backend service ready");
|
logger.info("Backend service ready");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, "Failed to start backend service");
|
logger.error({ err }, "Failed to start backend service");
|
||||||
@@ -14,16 +17,27 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
function shutdown(signal: string) {
|
||||||
process.on("SIGINT", () => {
|
logger.info({ signal }, "Shutting down gracefully");
|
||||||
logger.info("Received SIGINT, shutting down gracefully");
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
if (httpServer) {
|
||||||
logger.info("Received SIGTERM, shutting down gracefully");
|
httpServer.close(() => {
|
||||||
process.exit(0);
|
logger.info("HTTP server closed");
|
||||||
});
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force exit after 10s if connections don't close
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.error("Forced shutdown after timeout");
|
||||||
|
process.exit(1);
|
||||||
|
}, 10_000).unref();
|
||||||
|
} else {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|
||||||
process.on("uncaughtException", (err) => {
|
process.on("uncaughtException", (err) => {
|
||||||
logger.error({ err }, "Uncaught exception");
|
logger.error({ err }, "Uncaught exception");
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Request, Response, Router } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
|
import { analysisService } from "./analysis.service.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("analysis.routes");
|
||||||
|
|
||||||
|
export function createAnalysisRouter(): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/analysis/search
|
||||||
|
router.get(
|
||||||
|
"/analysis/search",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const q = (req.query.q as string) || "";
|
||||||
|
const channelId = (req.query.channelId as string) || undefined;
|
||||||
|
const limit = Number(req.query.limit) || 20;
|
||||||
|
|
||||||
|
logger.debug({ q, channelId, limit }, "Analysis search requested");
|
||||||
|
const result = await analysisService.search({ q, channelId, limit });
|
||||||
|
res.json(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
import { config } from "../../shared/config/index.js";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("analysis.service");
|
||||||
|
|
||||||
|
export interface AnalysisSearchQuery {
|
||||||
|
q?: string;
|
||||||
|
channelId?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnalysisService {
|
||||||
|
async search(query: AnalysisSearchQuery) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const { q = "", channelId, limit = 20 } = query;
|
||||||
|
const guildId = config.MONITOR_GUILD_ID;
|
||||||
|
|
||||||
|
logger.debug({ q, channelId, limit, guildId }, "Searching analysis");
|
||||||
|
|
||||||
|
const searchPattern = `%${q}%`;
|
||||||
|
const limitVal = limit;
|
||||||
|
|
||||||
|
let sqlQuery;
|
||||||
|
if (channelId && guildId) {
|
||||||
|
sqlQuery = sql`
|
||||||
|
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
||||||
|
content, type, created_at, ai_status, ai_severity, ai_confidence
|
||||||
|
FROM messages
|
||||||
|
WHERE guild_id = ${guildId}
|
||||||
|
AND channel_id = ${channelId}
|
||||||
|
AND content ILIKE ${searchPattern}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limitVal}
|
||||||
|
`;
|
||||||
|
} else if (channelId) {
|
||||||
|
sqlQuery = sql`
|
||||||
|
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
||||||
|
content, type, created_at, ai_status, ai_severity, ai_confidence
|
||||||
|
FROM messages
|
||||||
|
WHERE channel_id = ${channelId}
|
||||||
|
AND content ILIKE ${searchPattern}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limitVal}
|
||||||
|
`;
|
||||||
|
} else if (guildId) {
|
||||||
|
sqlQuery = sql`
|
||||||
|
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
||||||
|
content, type, created_at, ai_status, ai_severity, ai_confidence
|
||||||
|
FROM messages
|
||||||
|
WHERE guild_id = ${guildId}
|
||||||
|
AND content ILIKE ${searchPattern}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limitVal}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
sqlQuery = sql`
|
||||||
|
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
||||||
|
content, type, created_at, ai_status, ai_severity, ai_confidence
|
||||||
|
FROM messages
|
||||||
|
WHERE content ILIKE ${searchPattern}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limitVal}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await db.execute(sqlQuery);
|
||||||
|
return { results: rows };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analysisService = new AnalysisService();
|
||||||
@@ -46,10 +46,13 @@ export function handleGetHourlyStats(
|
|||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) {
|
) {
|
||||||
return asyncHandler(async (req: Request, res: Response) => {
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
const guildId = requireQueryString(req.query.guildId, "guildId");
|
const query = analyticsQuerySchema.parse(req.query);
|
||||||
const hours = req.query.hours ? Number(req.query.hours) : 24;
|
logger.debug({ query }, "Handling get hourly stats");
|
||||||
logger.debug({ guildId, hours }, "Handling get hourly stats");
|
const result = await analyticsService.getHourlyStats(
|
||||||
const result = await analyticsService.getHourlyStats(guildId, hours);
|
query.guildId,
|
||||||
|
query.channelId,
|
||||||
|
query.hours,
|
||||||
|
);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
})(req, res, next);
|
})(req, res, next);
|
||||||
}
|
}
|
||||||
@@ -60,10 +63,15 @@ export function handleGetTopViolators(
|
|||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) {
|
) {
|
||||||
return asyncHandler(async (req: Request, res: Response) => {
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
const guildId = requireQueryString(req.query.guildId, "guildId");
|
const query = analyticsQuerySchema.parse(req.query);
|
||||||
const limit = req.query.limit ? Number(req.query.limit) : 10;
|
const limit = req.query.limit ? Number(req.query.limit) : 10;
|
||||||
logger.debug({ guildId, limit }, "Handling get top violators");
|
logger.debug({ query, limit }, "Handling get top violators");
|
||||||
const result = await analyticsService.getTopViolators(guildId, limit);
|
const result = await analyticsService.getTopViolators(
|
||||||
|
query.guildId,
|
||||||
|
query.channelId,
|
||||||
|
query.hours,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
})(req, res, next);
|
})(req, res, next);
|
||||||
}
|
}
|
||||||
@@ -74,10 +82,15 @@ export function handleGetUserLeaderboard(
|
|||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) {
|
) {
|
||||||
return asyncHandler(async (req: Request, res: Response) => {
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
const guildId = requireQueryString(req.query.guildId, "guildId");
|
const query = analyticsQuerySchema.parse(req.query);
|
||||||
const limit = req.query.limit ? Number(req.query.limit) : 10;
|
const limit = req.query.limit ? Number(req.query.limit) : 10;
|
||||||
logger.debug({ guildId, limit }, "Handling get user leaderboard");
|
logger.debug({ query, limit }, "Handling get user leaderboard");
|
||||||
const result = await analyticsService.getUserLeaderboard(guildId, limit);
|
const result = await analyticsService.getUserLeaderboard(
|
||||||
|
query.guildId,
|
||||||
|
query.channelId,
|
||||||
|
query.hours,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
})(req, res, next);
|
})(req, res, next);
|
||||||
}
|
}
|
||||||
@@ -88,9 +101,47 @@ export function handleGetModerationStats(
|
|||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) {
|
) {
|
||||||
return asyncHandler(async (req: Request, res: Response) => {
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
const guildId = requireQueryString(req.query.guildId, "guildId");
|
const query = analyticsQuerySchema.parse(req.query);
|
||||||
logger.debug({ guildId }, "Handling get moderation stats");
|
logger.debug({ query }, "Handling get moderation stats");
|
||||||
const result = await analyticsService.getModerationStats(guildId);
|
const result = await analyticsService.getModerationStats(
|
||||||
|
query.guildId,
|
||||||
|
query.channelId,
|
||||||
|
query.hours,
|
||||||
|
);
|
||||||
|
res.json(result);
|
||||||
|
})(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleGetHeatmap(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const query = analyticsQuerySchema.parse(req.query);
|
||||||
|
logger.debug({ query }, "Handling get heatmap");
|
||||||
|
const result = await analyticsService.getHeatmap(
|
||||||
|
query.guildId,
|
||||||
|
query.channelId,
|
||||||
|
query.hours,
|
||||||
|
);
|
||||||
|
res.json(result);
|
||||||
|
})(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleGetTopics(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const query = analyticsQuerySchema.parse(req.query);
|
||||||
|
logger.debug({ query }, "Handling get topics");
|
||||||
|
const result = await analyticsService.getTopics(
|
||||||
|
query.guildId,
|
||||||
|
query.channelId,
|
||||||
|
query.hours,
|
||||||
|
);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
})(req, res, next);
|
})(req, res, next);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,333 @@
|
|||||||
|
import { getPool } from "../../shared/database/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("analytics.repository");
|
const logger = createChildLogger("analytics.repository");
|
||||||
|
|
||||||
|
interface TimeFilter {
|
||||||
|
where: string;
|
||||||
|
params: Array<string | number>;
|
||||||
|
paramOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimeFilter(
|
||||||
|
guildId: string,
|
||||||
|
channelId: string | undefined,
|
||||||
|
hours: number,
|
||||||
|
offset = 1,
|
||||||
|
): TimeFilter {
|
||||||
|
const clauses: string[] = ["guild_id = $" + offset];
|
||||||
|
const params: Array<string | number> = [guildId];
|
||||||
|
let p = offset + 1;
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
clauses.push("channel_id = $" + p);
|
||||||
|
params.push(channelId);
|
||||||
|
p++;
|
||||||
|
}
|
||||||
|
|
||||||
|
clauses.push("created_at > (EXTRACT(EPOCH FROM NOW()) * 1000 - $" + p + ")");
|
||||||
|
params.push(hours * 3_600_000);
|
||||||
|
|
||||||
|
return { where: "WHERE " + clauses.join(" AND "), params, paramOffset: p };
|
||||||
|
}
|
||||||
|
|
||||||
export class AnalyticsRepository {
|
export class AnalyticsRepository {
|
||||||
async getOverview(guildId: string, channelId?: string, hours = 24) {
|
async getOverview(guildId: string, channelId?: string, hours = 24) {
|
||||||
logger.debug({ guildId, channelId, hours }, "Getting analytics overview");
|
logger.debug({ guildId, channelId, hours }, "Getting analytics overview");
|
||||||
// TODO: Implement actual Drizzle ORM queries
|
const pool = getPool();
|
||||||
|
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS total_messages,
|
||||||
|
COUNT(DISTINCT user_id)::int AS active_users_count,
|
||||||
|
COUNT(DISTINCT channel_id)::int AS total_channels,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS error,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS pending,
|
||||||
|
COALESCE(AVG(ai_moderation_score), 0)::real AS average_score
|
||||||
|
FROM messages
|
||||||
|
${filter.where}
|
||||||
|
`,
|
||||||
|
filter.params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = rows[0] as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalMessages: 0,
|
period: { hours },
|
||||||
totalUsers: 0,
|
messages: {
|
||||||
flaggedMessages: 0,
|
total: Number(row?.total_messages ?? 0),
|
||||||
averageSeverity: 0,
|
clean: Number(row?.clean ?? 0),
|
||||||
|
warned: Number(row?.warned ?? 0),
|
||||||
|
flagged: Number(row?.flagged ?? 0),
|
||||||
|
error: Number(row?.error ?? 0),
|
||||||
|
pending: Number(row?.pending ?? 0),
|
||||||
|
average_score: Number(row?.average_score ?? 0),
|
||||||
|
},
|
||||||
|
hourly: [],
|
||||||
|
topics: [],
|
||||||
|
top_users: [],
|
||||||
|
active_users_count: Number(row?.active_users_count ?? 0),
|
||||||
|
total_channels: Number(row?.total_channels ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDailyTrend(guildId: string, hours = 24) {
|
async getDailyTrend(guildId: string, hours = 24) {
|
||||||
logger.debug({ guildId, hours }, "Getting daily trend");
|
logger.debug({ guildId, hours }, "Getting daily trend");
|
||||||
// TODO: Implement actual Drizzle ORM queries
|
const pool = getPool();
|
||||||
return [];
|
const filter = buildTimeFilter(guildId, undefined, hours);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(to_timestamp(created_at / 1000), 'YYYY-MM-DD') AS date,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS error
|
||||||
|
FROM messages
|
||||||
|
${filter.where}
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC
|
||||||
|
`,
|
||||||
|
filter.params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
date: r.date as string,
|
||||||
|
count: Number(r.count ?? 0),
|
||||||
|
clean: Number(r.clean ?? 0),
|
||||||
|
warned: Number(r.warned ?? 0),
|
||||||
|
flagged: Number(r.flagged ?? 0),
|
||||||
|
error: Number(r.error ?? 0),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHourlyStats(guildId: string, hours = 24) {
|
async getHourlyStats(guildId: string, channelId?: string, hours = 24) {
|
||||||
logger.debug({ guildId, hours }, "Getting hourly stats");
|
logger.debug({ guildId, channelId, hours }, "Getting hourly stats");
|
||||||
// TODO: Implement actual Drizzle ORM queries
|
const pool = getPool();
|
||||||
return [];
|
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(to_timestamp(created_at / 1000), 'HH24') AS hour,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS error
|
||||||
|
FROM messages
|
||||||
|
${filter.where}
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour ASC
|
||||||
|
`,
|
||||||
|
filter.params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
hour: r.hour as string,
|
||||||
|
count: Number(r.count ?? 0),
|
||||||
|
clean: Number(r.clean ?? 0),
|
||||||
|
warned: Number(r.warned ?? 0),
|
||||||
|
flagged: Number(r.flagged ?? 0),
|
||||||
|
error: Number(r.error ?? 0),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTopViolators(guildId: string, limit = 10) {
|
async getTopViolators(
|
||||||
logger.debug({ guildId, limit }, "Getting top violators");
|
guildId: string,
|
||||||
// TODO: Implement actual Drizzle ORM queries
|
channelId?: string,
|
||||||
return [];
|
hours = 24,
|
||||||
|
limit = 10,
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
{ guildId, channelId, hours, limit },
|
||||||
|
"Getting top violators",
|
||||||
|
);
|
||||||
|
const pool = getPool();
|
||||||
|
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
MAX(username) AS username,
|
||||||
|
MAX(avatar_url) AS avatar_url,
|
||||||
|
COUNT(*)::int AS total_messages,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status IN ('warn', 'flagged', 'error'))::int AS flagged_count,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned_count,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS hard_flagged_count,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS error_count,
|
||||||
|
COALESCE(AVG(ai_moderation_score), 0)::real AS violation_score,
|
||||||
|
MAX(ai_moderation_flags) AS worst_flags,
|
||||||
|
MAX(created_at) AS last_violation
|
||||||
|
FROM messages
|
||||||
|
${filter.where}
|
||||||
|
AND ai_status IN ('warn', 'flagged', 'error')
|
||||||
|
GROUP BY user_id
|
||||||
|
ORDER BY flagged_count DESC
|
||||||
|
LIMIT $${filter.params.length + 1}
|
||||||
|
`,
|
||||||
|
[...filter.params, limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
user_id: r.user_id as string,
|
||||||
|
username: (r.username as string) ?? "",
|
||||||
|
avatar_url: (r.avatar_url as string | null) ?? null,
|
||||||
|
total_messages: Number(r.total_messages ?? 0),
|
||||||
|
flagged_count: Number(r.flagged_count ?? 0),
|
||||||
|
warned_count: Number(r.warned_count ?? 0),
|
||||||
|
violation_score: Number(r.violation_score ?? 0),
|
||||||
|
worst_flags: (r.worst_flags as string | null) ?? null,
|
||||||
|
last_violation: Number(r.last_violation ?? 0),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserLeaderboard(guildId: string, limit = 10) {
|
async getUserLeaderboard(
|
||||||
logger.debug({ guildId, limit }, "Getting user leaderboard");
|
guildId: string,
|
||||||
// TODO: Implement actual Drizzle ORM queries
|
channelId?: string,
|
||||||
return [];
|
hours = 24,
|
||||||
|
limit = 10,
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
{ guildId, channelId, hours, limit },
|
||||||
|
"Getting user leaderboard",
|
||||||
|
);
|
||||||
|
const pool = getPool();
|
||||||
|
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
MAX(username) AS username,
|
||||||
|
MAX(avatar_url) AS avatar_url,
|
||||||
|
COUNT(*)::int AS message_count,
|
||||||
|
COUNT(*) FILTER (WHERE type = 'edited')::int AS edited_count,
|
||||||
|
COUNT(*) FILTER (WHERE type = 'deleted')::int AS deleted_count,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status IN ('warn', 'flagged', 'error'))::int AS flagged_count,
|
||||||
|
MAX(created_at) AS last_active
|
||||||
|
FROM messages
|
||||||
|
${filter.where}
|
||||||
|
GROUP BY user_id
|
||||||
|
ORDER BY message_count DESC
|
||||||
|
LIMIT $${filter.params.length + 1}
|
||||||
|
`,
|
||||||
|
[...filter.params, limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
user_id: r.user_id as string,
|
||||||
|
username: (r.username as string) ?? "",
|
||||||
|
avatar_url: (r.avatar_url as string | null) ?? null,
|
||||||
|
message_count: Number(r.message_count ?? 0),
|
||||||
|
edited_count: Number(r.edited_count ?? 0),
|
||||||
|
deleted_count: Number(r.deleted_count ?? 0),
|
||||||
|
flagged_count: Number(r.flagged_count ?? 0),
|
||||||
|
last_active: Number(r.last_active ?? 0),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getModerationStats(guildId: string) {
|
async getModerationStats(guildId: string, channelId?: string, hours = 24) {
|
||||||
logger.debug({ guildId }, "Getting moderation stats");
|
logger.debug({ guildId, channelId, hours }, "Getting moderation stats");
|
||||||
// TODO: Implement actual Drizzle ORM queries
|
const pool = getPool();
|
||||||
|
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS total,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS error,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS pending,
|
||||||
|
COALESCE(AVG(ai_moderation_score), 0)::real AS average_score
|
||||||
|
FROM messages
|
||||||
|
${filter.where}
|
||||||
|
`,
|
||||||
|
filter.params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = rows[0] as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clean: 0,
|
total: Number(row?.total ?? 0),
|
||||||
warn: 0,
|
clean: Number(row?.clean ?? 0),
|
||||||
flagged: 0,
|
warned: Number(row?.warned ?? 0),
|
||||||
error: 0,
|
flagged: Number(row?.flagged ?? 0),
|
||||||
|
error: Number(row?.error ?? 0),
|
||||||
|
pending: Number(row?.pending ?? 0),
|
||||||
|
average_score: Number(row?.average_score ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getHeatmap(guildId: string, channelId?: string, hours = 24) {
|
||||||
|
logger.debug({ guildId, channelId, hours }, "Getting heatmap data");
|
||||||
|
const pool = getPool();
|
||||||
|
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(DOW FROM to_timestamp(created_at / 1000))::int AS day_of_week,
|
||||||
|
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged
|
||||||
|
FROM messages
|
||||||
|
${filter.where}
|
||||||
|
GROUP BY day_of_week, hour
|
||||||
|
ORDER BY day_of_week, hour
|
||||||
|
`,
|
||||||
|
filter.params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
dayOfWeek: Number(r.day_of_week ?? 0),
|
||||||
|
hour: Number(r.hour ?? 0),
|
||||||
|
count: Number(r.count ?? 0),
|
||||||
|
clean: Number(r.clean ?? 0),
|
||||||
|
warned: Number(r.warned ?? 0),
|
||||||
|
flagged: Number(r.flagged ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopics(guildId: string, channelId?: string, hours = 24) {
|
||||||
|
logger.debug({ guildId, channelId, hours }, "Getting topics");
|
||||||
|
const pool = getPool();
|
||||||
|
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
TRIM(UNNEST(STRING_TO_ARRAY(ai_categories, ','))) AS topic,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
COALESCE(AVG(ai_moderation_score), 0)::real AS score
|
||||||
|
FROM messages
|
||||||
|
${filter.where}
|
||||||
|
AND ai_categories IS NOT NULL
|
||||||
|
AND ai_categories != ''
|
||||||
|
GROUP BY topic
|
||||||
|
ORDER BY count DESC
|
||||||
|
`,
|
||||||
|
filter.params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
topic: (r.topic as string) ?? "",
|
||||||
|
count: Number(r.count ?? 0),
|
||||||
|
score: Number(r.score ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const analyticsRepository = new AnalyticsRepository();
|
export const analyticsRepository = new AnalyticsRepository();
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import type { Router } from "express";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import {
|
import {
|
||||||
handleGetDailyTrend,
|
handleGetDailyTrend,
|
||||||
|
handleGetHeatmap,
|
||||||
handleGetHourlyStats,
|
handleGetHourlyStats,
|
||||||
handleGetModerationStats,
|
handleGetModerationStats,
|
||||||
handleGetOverview,
|
handleGetOverview,
|
||||||
handleGetTopViolators,
|
handleGetTopViolators,
|
||||||
|
handleGetTopics,
|
||||||
handleGetUserLeaderboard,
|
handleGetUserLeaderboard,
|
||||||
} from "./analytics.controller.js";
|
} from "./analytics.controller.js";
|
||||||
|
|
||||||
@@ -13,11 +15,13 @@ export function createAnalyticsRouter(): Router {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get("/analytics/overview", handleGetOverview);
|
router.get("/analytics/overview", handleGetOverview);
|
||||||
router.get("/analytics/daily-trend", handleGetDailyTrend);
|
router.get("/analytics/trend", handleGetDailyTrend);
|
||||||
router.get("/analytics/hourly-stats", handleGetHourlyStats);
|
router.get("/analytics/hourly", handleGetHourlyStats);
|
||||||
router.get("/analytics/top-violators", handleGetTopViolators);
|
router.get("/analytics/violators", handleGetTopViolators);
|
||||||
router.get("/analytics/user-leaderboard", handleGetUserLeaderboard);
|
router.get("/analytics/leaderboard", handleGetUserLeaderboard);
|
||||||
router.get("/analytics/moderation-stats", handleGetModerationStats);
|
router.get("/analytics/stats", handleGetModerationStats);
|
||||||
|
router.get("/analytics/heatmap", handleGetHeatmap);
|
||||||
|
router.get("/analytics/topics", handleGetTopics);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,28 +33,63 @@ export class AnalyticsService {
|
|||||||
return analyticsRepository.getDailyTrend(guildId, hours);
|
return analyticsRepository.getDailyTrend(guildId, hours);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHourlyStats(guildId: string, hours = 24) {
|
async getHourlyStats(guildId: string, channelId?: string, hours = 24) {
|
||||||
this.assertMonitorGuild(guildId);
|
this.assertMonitorGuild(guildId);
|
||||||
logger.debug({ guildId, hours }, "Getting hourly stats");
|
logger.debug({ guildId, channelId, hours }, "Getting hourly stats");
|
||||||
return analyticsRepository.getHourlyStats(guildId, hours);
|
return analyticsRepository.getHourlyStats(guildId, channelId, hours);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTopViolators(guildId: string, limit = 10) {
|
async getTopViolators(
|
||||||
|
guildId: string,
|
||||||
|
channelId?: string,
|
||||||
|
hours = 24,
|
||||||
|
limit = 10,
|
||||||
|
) {
|
||||||
this.assertMonitorGuild(guildId);
|
this.assertMonitorGuild(guildId);
|
||||||
logger.debug({ guildId, limit }, "Getting top violators");
|
logger.debug({ guildId, channelId, hours, limit }, "Getting top violators");
|
||||||
return analyticsRepository.getTopViolators(guildId, limit);
|
return analyticsRepository.getTopViolators(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserLeaderboard(guildId: string, limit = 10) {
|
async getUserLeaderboard(
|
||||||
|
guildId: string,
|
||||||
|
channelId?: string,
|
||||||
|
hours = 24,
|
||||||
|
limit = 10,
|
||||||
|
) {
|
||||||
this.assertMonitorGuild(guildId);
|
this.assertMonitorGuild(guildId);
|
||||||
logger.debug({ guildId, limit }, "Getting user leaderboard");
|
logger.debug(
|
||||||
return analyticsRepository.getUserLeaderboard(guildId, limit);
|
{ guildId, channelId, hours, limit },
|
||||||
|
"Getting user leaderboard",
|
||||||
|
);
|
||||||
|
return analyticsRepository.getUserLeaderboard(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getModerationStats(guildId: string) {
|
async getModerationStats(guildId: string, channelId?: string, hours = 24) {
|
||||||
this.assertMonitorGuild(guildId);
|
this.assertMonitorGuild(guildId);
|
||||||
logger.debug({ guildId }, "Getting moderation stats");
|
logger.debug({ guildId, channelId, hours }, "Getting moderation stats");
|
||||||
return analyticsRepository.getModerationStats(guildId);
|
return analyticsRepository.getModerationStats(guildId, channelId, hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHeatmap(guildId: string, channelId?: string, hours = 24) {
|
||||||
|
this.assertMonitorGuild(guildId);
|
||||||
|
logger.debug({ guildId, channelId, hours }, "Getting heatmap");
|
||||||
|
return analyticsRepository.getHeatmap(guildId, channelId, hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopics(guildId: string, channelId?: string, hours = 24) {
|
||||||
|
this.assertMonitorGuild(guildId);
|
||||||
|
logger.debug({ guildId, channelId, hours }, "Getting topics");
|
||||||
|
return analyticsRepository.getTopics(guildId, channelId, hours);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Request, Response, Router } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { config } from "../../shared/config/index.js";
|
||||||
|
import { UnauthorizedError } from "../../shared/errors/index.js";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("auth.routes");
|
||||||
|
|
||||||
|
const adminPassword = config.ADMIN_PASSWORD || "admin";
|
||||||
|
|
||||||
|
export function createAuthRouter(): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
router.post(
|
||||||
|
"/auth/login",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { password } = req.body as { password?: string };
|
||||||
|
|
||||||
|
logger.debug("Auth login attempt");
|
||||||
|
|
||||||
|
if (!password || password !== adminPassword) {
|
||||||
|
throw new UnauthorizedError("Invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Router } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { config } from "../../shared/config/index.js";
|
||||||
|
|
||||||
|
export function createConfigRouter(): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/config
|
||||||
|
router.get("/config", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -1,13 +1,64 @@
|
|||||||
import type { Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("media.routes");
|
||||||
|
|
||||||
|
const stubResponse = {
|
||||||
|
playing: false,
|
||||||
|
musicVolume: 1.0,
|
||||||
|
current: null,
|
||||||
|
queue: [],
|
||||||
|
};
|
||||||
|
|
||||||
export function createMediaRouter(): Router {
|
export function createMediaRouter(): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// TODO: Implement media routes
|
// GET /api/media/status
|
||||||
// GET /api/media/list
|
router.get(
|
||||||
// POST /api/media/upload
|
"/media/status",
|
||||||
// GET /api/media/:id
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
logger.debug("Media status requested");
|
||||||
|
res.json(stubResponse);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/media/queue
|
||||||
|
router.post(
|
||||||
|
"/media/queue",
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
logger.debug("Media queue requested (stub)");
|
||||||
|
res.json(stubResponse);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/media/skip
|
||||||
|
router.post(
|
||||||
|
"/media/skip",
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
logger.debug("Media skip requested (stub)");
|
||||||
|
res.json(stubResponse);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/media/stop
|
||||||
|
router.post(
|
||||||
|
"/media/stop",
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
logger.debug("Media stop requested (stub)");
|
||||||
|
res.json(stubResponse);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/media/volume
|
||||||
|
router.post(
|
||||||
|
"/media/volume",
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
logger.debug("Media volume requested (stub)");
|
||||||
|
res.json(stubResponse);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { getPool } from "../../shared/database/index.js";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import {
|
import {
|
||||||
handleGetAttachmentsByChannel,
|
handleGetAttachmentsByChannel,
|
||||||
handleGetMessageById,
|
handleGetMessageById,
|
||||||
@@ -7,6 +10,8 @@ import {
|
|||||||
handleListMessages,
|
handleListMessages,
|
||||||
} from "./messages.controller.js";
|
} from "./messages.controller.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("messages.routes");
|
||||||
|
|
||||||
export function createMessagesRouter(): Router {
|
export function createMessagesRouter(): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -22,5 +27,68 @@ export function createMessagesRouter(): Router {
|
|||||||
// GET /api/messages/:id - Get single message by ID
|
// GET /api/messages/:id - Get single message by ID
|
||||||
router.get("/messages/:id", handleGetMessageById);
|
router.get("/messages/:id", handleGetMessageById);
|
||||||
|
|
||||||
|
// POST /api/messages/:id/reanalyze - Mark message for re-analysis
|
||||||
|
router.post(
|
||||||
|
"/messages/:id/reanalyze",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
if (!id) {
|
||||||
|
res.status(400).json({ error: "MISSING_ID" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE messages SET ai_status = 'pending' WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug({ id }, "Message marked for re-analysis");
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/review - Get flagged/warned messages for review
|
||||||
|
router.get(
|
||||||
|
"/review",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const limit = Number(req.query.limit) || 20;
|
||||||
|
const channelId = (req.query.channelId as string) || undefined;
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
let sqlQuery: string;
|
||||||
|
let params: (string | number)[];
|
||||||
|
if (channelId) {
|
||||||
|
sqlQuery = `
|
||||||
|
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
||||||
|
content, type, created_at, ai_status, ai_severity,
|
||||||
|
ai_confidence, ai_analysis
|
||||||
|
FROM messages
|
||||||
|
WHERE ai_status IN ('warn', 'flagged')
|
||||||
|
AND channel_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`;
|
||||||
|
params = [channelId, limit];
|
||||||
|
} else {
|
||||||
|
sqlQuery = `
|
||||||
|
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
||||||
|
content, type, created_at, ai_status, ai_severity,
|
||||||
|
ai_confidence, ai_analysis
|
||||||
|
FROM messages
|
||||||
|
WHERE ai_status IN ('warn', 'flagged')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1
|
||||||
|
`;
|
||||||
|
params = [limit];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sqlQuery, params);
|
||||||
|
logger.debug({ limit, channelId }, "Review query executed");
|
||||||
|
res.json({ results: rows, limit, cursor: null });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Request, Response, Router } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
|
import { recordingsService } from "./recordings.service.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("recordings.routes");
|
||||||
|
|
||||||
|
export function createRecordingsRouter(): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/recordings
|
||||||
|
router.get(
|
||||||
|
"/recordings",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const limit = Number(req.query.limit) || 50;
|
||||||
|
logger.debug({ limit }, "Fetching recordings");
|
||||||
|
const result = await recordingsService.getRecent(limit);
|
||||||
|
res.json(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("recordings.service");
|
||||||
|
|
||||||
|
export class RecordingsService {
|
||||||
|
async getRecent(limit = 50) {
|
||||||
|
const db = getDatabase();
|
||||||
|
logger.debug({ limit }, "Fetching recent voice recordings");
|
||||||
|
|
||||||
|
const { rows } = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
id, user_id, username, avatar_url, guild_id, channel_id,
|
||||||
|
channel_name, filename, size_bytes, download_url,
|
||||||
|
upload_status, upload_error, created_at, uploaded_at
|
||||||
|
FROM voice_recordings
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recordingsService = new RecordingsService();
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Request, Response, Router } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
|
import { uiStateService } from "./ui-state.service.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("ui-state.routes");
|
||||||
|
|
||||||
|
export function createUiStateRouter(): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/ui-state
|
||||||
|
router.get(
|
||||||
|
"/ui-state",
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
logger.debug("Fetching UI state");
|
||||||
|
const state = await uiStateService.getState();
|
||||||
|
res.json(state);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/ui-state
|
||||||
|
router.post(
|
||||||
|
"/ui-state",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const updates = req.body as Record<string, unknown>;
|
||||||
|
logger.debug({ keys: Object.keys(updates) }, "Updating UI state");
|
||||||
|
const result = await uiStateService.updateState(updates);
|
||||||
|
res.json(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("ui-state.service");
|
||||||
|
|
||||||
|
export class UiStateService {
|
||||||
|
async getState() {
|
||||||
|
const db = getDatabase();
|
||||||
|
logger.debug("Fetching UI state");
|
||||||
|
|
||||||
|
const { rows } = await db.execute(
|
||||||
|
sql`SELECT key, value, updated_at FROM ui_state ORDER BY key`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
result[row.key as string] = JSON.parse(row.value as string);
|
||||||
|
} catch {
|
||||||
|
result[row.key as string] = row.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateState(updates: Record<string, unknown>) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
logger.debug({ keys: Object.keys(updates) }, "Updating UI state");
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
const serialized =
|
||||||
|
typeof value === "string" ? value : JSON.stringify(value);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO ui_state (key, value, updated_at)
|
||||||
|
VALUES (${key}, ${serialized}, ${now})
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.getState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uiStateService = new UiStateService();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Router } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { getGuilds, getTextChannels } from "./voice.service.js";
|
||||||
|
|
||||||
|
export function createGuildsRouter(): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/guilds
|
||||||
|
router.get("/", async (_req, res) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import {
|
||||||
|
connectVoice,
|
||||||
|
disconnectVoice,
|
||||||
|
getVoiceChannels,
|
||||||
|
getVoiceStatus,
|
||||||
|
} from "./voice.service.js";
|
||||||
|
|
||||||
|
export async function handleGetVoiceStatus(_req: Request, res: Response) {
|
||||||
|
const status = getVoiceStatus();
|
||||||
|
res.json(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleConnectVoice(req: Request, res: Response) {
|
||||||
|
const guildId = Array.isArray(req.body.guildId)
|
||||||
|
? req.body.guildId[0]
|
||||||
|
: req.body.guildId;
|
||||||
|
const channelId = Array.isArray(req.body.channelId)
|
||||||
|
? req.body.channelId[0]
|
||||||
|
: 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 async function handleDisconnectVoice(_req: Request, res: Response) {
|
||||||
|
const status = await disconnectVoice();
|
||||||
|
res.json(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetVoiceChannels(req: Request, res: Response) {
|
||||||
|
const guildId = Array.isArray(req.params.guildId)
|
||||||
|
? req.params.guildId[0]
|
||||||
|
: req.params.guildId;
|
||||||
|
const channels = await getVoiceChannels(guildId);
|
||||||
|
res.json(channels);
|
||||||
|
}
|
||||||
@@ -1,14 +1,26 @@
|
|||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import {
|
||||||
|
handleConnectVoice,
|
||||||
|
handleDisconnectVoice,
|
||||||
|
handleGetVoiceChannels,
|
||||||
|
handleGetVoiceStatus,
|
||||||
|
} from "./voice.controller.js";
|
||||||
|
|
||||||
export function createVoiceRouter(): Router {
|
export function createVoiceRouter(): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// TODO: Implement voice routes
|
// GET /api/status
|
||||||
// GET /api/voice/recordings
|
router.get("/status", handleGetVoiceStatus);
|
||||||
// GET /api/voice/recordings/:userId
|
|
||||||
// POST /api/voice/connect
|
// POST /api/connect
|
||||||
// POST /api/voice/disconnect
|
router.post("/connect", handleConnectVoice);
|
||||||
|
|
||||||
|
// POST /api/disconnect
|
||||||
|
router.post("/disconnect", handleDisconnectVoice);
|
||||||
|
|
||||||
|
// GET /api/guilds/:guildId/voice-channels
|
||||||
|
router.get("/guilds/:guildId/voice-channels", handleGetVoiceChannels);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,94 @@
|
|||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("voice.service");
|
export interface Guild {
|
||||||
|
id: string;
|
||||||
export class VoiceService {
|
name: string;
|
||||||
// TODO: Implement voice service methods
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const voiceService = new VoiceService();
|
export interface Channel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "voice" | "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceStatus {
|
||||||
|
connected: boolean;
|
||||||
|
guildId: string | null;
|
||||||
|
channelId: string | null;
|
||||||
|
users: Array<{ id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get guilds from database (distinct guild_id from messages).
|
||||||
|
*/
|
||||||
|
export async function getGuilds(): Promise<Guild[]> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const result = await db.execute(
|
||||||
|
"SELECT DISTINCT guild_id FROM messages ORDER BY guild_id",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result?.rows?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows.map((row: Record<string, unknown>) => ({
|
||||||
|
id: String(row.guild_id ?? ""),
|
||||||
|
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get text channels from database (distinct channel_id for a guild).
|
||||||
|
*/
|
||||||
|
export async function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const result = await db.execute(
|
||||||
|
`SELECT DISTINCT channel_id FROM messages WHERE guild_id = '${guildId.replace(/'/g, "''")}' ORDER BY channel_id`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result?.rows?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows.map((row: Record<string, unknown>) => ({
|
||||||
|
id: String(row.channel_id ?? ""),
|
||||||
|
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
|
||||||
|
type: "text" as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get voice channels — not available via API-only backend.
|
||||||
|
*/
|
||||||
|
export async function getVoiceChannels(_guildId: string): Promise<Channel[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current voice connection status.
|
||||||
|
*/
|
||||||
|
export function getVoiceStatus(): VoiceStatus {
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
guildId: null,
|
||||||
|
channelId: null,
|
||||||
|
users: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to a voice channel — not supported via API-only backend.
|
||||||
|
*/
|
||||||
|
export async function connectVoice(
|
||||||
|
_guildId: string,
|
||||||
|
_channelId: string,
|
||||||
|
): Promise<VoiceStatus> {
|
||||||
|
return getVoiceStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from voice — not supported via API-only backend.
|
||||||
|
*/
|
||||||
|
export async function disconnectVoice(): Promise<VoiceStatus> {
|
||||||
|
return getVoiceStatus();
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ export function getDatabase() {
|
|||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPool() {
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error(
|
||||||
|
"Database not initialized. Call initializeDatabase() first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
export async function closeDatabase() {
|
export async function closeDatabase() {
|
||||||
if (pool) {
|
if (pool) {
|
||||||
await pool.end();
|
await pool.end();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Global broadcast functions for WebSocket events.
|
||||||
|
*
|
||||||
|
* These are assigned by ws/server.ts when the WebSocket server initializes.
|
||||||
|
* Other modules call them to push real-time events to connected frontend clients.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { broadcastMessageCreated } from "../ws/broadcast.js";
|
||||||
|
* broadcastMessageCreated(messageData);
|
||||||
|
*/
|
||||||
|
|
||||||
|
type BroadcastFn = (data: unknown) => void;
|
||||||
|
|
||||||
|
// Extend globalThis with broadcast function types
|
||||||
|
declare global {
|
||||||
|
// biome-ignore lint/suspicious/noAssignInExpressions: intentional global broadcast registry
|
||||||
|
var broadcastMessageCreated: BroadcastFn | undefined;
|
||||||
|
var broadcastMessageUpdated: BroadcastFn | undefined;
|
||||||
|
var broadcastMessageDeleted: BroadcastFn | undefined;
|
||||||
|
var broadcastAttachmentUploaded: BroadcastFn | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noop: BroadcastFn = () => {};
|
||||||
|
|
||||||
|
export const broadcastMessageCreated: BroadcastFn = (...args) =>
|
||||||
|
(globalThis.broadcastMessageCreated ?? noop)(...args);
|
||||||
|
|
||||||
|
export const broadcastMessageUpdated: BroadcastFn = (...args) =>
|
||||||
|
(globalThis.broadcastMessageUpdated ?? noop)(...args);
|
||||||
|
|
||||||
|
export const broadcastMessageDeleted: BroadcastFn = (...args) =>
|
||||||
|
(globalThis.broadcastMessageDeleted ?? noop)(...args);
|
||||||
|
|
||||||
|
export const broadcastAttachmentUploaded: BroadcastFn = (...args) =>
|
||||||
|
(globalThis.broadcastAttachmentUploaded ?? noop)(...args);
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
import { createChildLogger } from "../shared/logger/index.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("ws.server");
|
||||||
|
|
||||||
|
interface BroadcastEvent {
|
||||||
|
type: string;
|
||||||
|
data: unknown;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BroadcastFn = (data: unknown) => void;
|
||||||
|
|
||||||
|
// Extend globalThis with broadcast function types
|
||||||
|
declare global {
|
||||||
|
// biome-ignore lint/suspicious/noAssignInExpressions: intentional global broadcast registry
|
||||||
|
var broadcastMessageCreated: BroadcastFn | undefined;
|
||||||
|
var broadcastMessageUpdated: BroadcastFn | undefined;
|
||||||
|
var broadcastMessageDeleted: BroadcastFn | undefined;
|
||||||
|
var broadcastAttachmentUploaded: BroadcastFn | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWebSocketServer(server: Server): WebSocketServer {
|
||||||
|
const clients = new Set<WebSocket>();
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
|
|
||||||
|
wss.on("connection", (ws: WebSocket) => {
|
||||||
|
clients.add(ws);
|
||||||
|
logger.info(`Client connected (${clients.size} total)`);
|
||||||
|
|
||||||
|
// Send initial user state
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "user_state",
|
||||||
|
users: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.on("message", (data: Buffer) => {
|
||||||
|
// Binary PCM data received from browser.
|
||||||
|
// Since backend has no Discord client to relay to, drop it.
|
||||||
|
if (Buffer.isBuffer(data) && data.length > 0) {
|
||||||
|
logger.debug({ bytes: data.length }, "Dropping binary PCM (no Discord client)");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
clients.delete(ws);
|
||||||
|
logger.info(`Client disconnected (${clients.size} total)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (err: Error) => {
|
||||||
|
logger.error({ err }, "WebSocket client error");
|
||||||
|
clients.delete(ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heartbeat every 30s
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
const message = JSON.stringify({ type: "heartbeat" });
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
// Don't let the interval keep the process alive after wss closes
|
||||||
|
heartbeatInterval.unref();
|
||||||
|
|
||||||
|
// Expose broadcast functions on globalThis
|
||||||
|
function broadcast(event: Omit<BroadcastEvent, "timestamp">) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
...event,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
client.send(payload);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Failed to broadcast to client");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.broadcastMessageCreated = (data: unknown) =>
|
||||||
|
broadcast({ type: "message_created", data });
|
||||||
|
globalThis.broadcastMessageUpdated = (data: unknown) =>
|
||||||
|
broadcast({ type: "message_updated", data });
|
||||||
|
globalThis.broadcastMessageDeleted = (data: unknown) =>
|
||||||
|
broadcast({ type: "message_deleted", data });
|
||||||
|
globalThis.broadcastAttachmentUploaded = (data: unknown) =>
|
||||||
|
broadcast({ type: "attachment_uploaded", data });
|
||||||
|
|
||||||
|
// Cleanup on close
|
||||||
|
wss.on("close", () => {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ path: "/ws" }, "WebSocket server created");
|
||||||
|
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user