feat(backend,frontend): migrate data APIs from REST to native tRPC over WebSocket

Replace REST module routers with a single typed tRPC appRouter served over
/trpc (HTTP + WebSocket), and rewire the frontend to call it via
@trpc/client wsLink (browser) and httpLink (RSC data layer). Existing
/api/health + /api/metrics stay as plain Express for infra scraping.

Notable fixes surfaced by the live smoke test:
- Express 5 / path-to-regexp v8 rejects the /trpc/* wildcard route; use a
  prefix middleware that computes opts.path from the URL instead.
- nodeHTTPRequestHandler treats opts.path as the literal procedure path, so
  it is derived per-request from req.url.
- Two ws servers on one http.Server (the /ws voice socket + /trpc) collided
  and returned 400 on upgrade; both now use noServer + a manually routed
  server.on('upgrade') keyed by path.

Verified: BE tsc+biome+40 vitest green; FE tsc+biome green; live
HTTP and WebSocket calls returned real prod data.

Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-08-16 13:25:10 +07:00
co-authored by Claude Opus 4.5
parent d8552a9fb8
commit 2fa1827f17
47 changed files with 3318 additions and 1049 deletions
@@ -1,27 +0,0 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
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;
}
@@ -1 +0,0 @@
export { createAnalysisRouter } from "./analysis.routes.js";
@@ -1,96 +0,0 @@
import type { Request, Response } from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { chatbotService } from "./chatbot.service.js";
const logger = createChildLogger("chatbot.controller");
interface AuthenticatedRequest extends Request {
userId?: string;
}
/**
* Resolve the actor id for a request. Frontend (no-login) sends a per-device
* UUID via X-User-Id so chat history stays isolated per visitor; a registered
* auth middleware userId takes precedence when present.
*/
function resolveUserId(req: Request): string {
const authId = (req as AuthenticatedRequest).userId;
if (authId) return authId;
const header = (req.headers["x-user-id"] as string | undefined)?.trim();
return header || "anonymous";
}
export const handleChatbotChat = asyncHandler(
async (req: Request, res: Response) => {
const { message, context } = req.body as {
message: string;
context?: Record<string, unknown>;
};
// Validate required fields
if (!message || typeof message !== "string") {
return res.status(400).json({
error: "INVALID_INPUT",
message: "Message is required and must be a string",
});
}
// Get user ID from X-User-Id header (no-login device uuid) or auth
const userId = resolveUserId(req);
logger.debug(
{ userId, messageLength: message.length, context },
"Received chatbot chat message",
);
// Process message & generate response
const response = await chatbotService.processMessage(
message,
context,
userId,
);
// Save conversation to database
await chatbotService.saveConversation({
userId,
userMessage: message,
botResponse: response,
context,
timestamp: new Date(),
});
logger.info({ userId }, "Chatbot chat processed successfully");
res.status(200).json({
response,
timestamp: new Date().toISOString(),
});
},
);
export const getChatbotHistory = asyncHandler(
async (req: Request, res: Response) => {
const userId = resolveUserId(req);
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const history = await chatbotService.getChatHistory(userId, limit);
res.status(200).json({
history,
total: history.length,
});
},
);
export const clearChatbotHistory = asyncHandler(
async (req: Request, res: Response) => {
const userId = resolveUserId(req);
await chatbotService.clearChatHistory(userId);
res.status(200).json({
message: "Chat history cleared successfully",
});
},
);
@@ -1,18 +0,0 @@
import express, { type Router } from "express";
import { validateBody } from "../../shared/middlewares/index.js";
import {
clearChatbotHistory,
getChatbotHistory,
handleChatbotChat,
} from "./chatbot.controller.js";
import { chatRequestSchema } from "./chatbot.schema.js";
export function createChatbotRouter(): Router {
const router = express.Router();
router.post("/chat", validateBody(chatRequestSchema), handleChatbotChat);
router.get("/chat/history", getChatbotHistory);
router.delete("/chat/history", clearChatbotHistory);
return router;
}
@@ -1 +0,0 @@
export { createChatbotRouter } from "./chatbot.routes.js";
@@ -1,28 +0,0 @@
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,
webserverPort: config.WEBSERVER_PORT,
nodeEnv: config.NODE_ENV,
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
voiceGuildId: config.VOICE_GUILD_ID || null,
voiceChannelId: config.VOICE_CHANNEL_ID || null,
logLevel: config.LOG_LEVEL,
});
});
return router;
}
@@ -1 +0,0 @@
export { createConfigRouter } from "./config.routes.js";
@@ -1,111 +0,0 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { dashboardService } from "./dashboard.service.js";
const logger = createChildLogger("dashboard.routes");
export function createDashboardRouter(): Router {
const router = express.Router();
// GET /api/dashboard/stats — aggregated server statistics
router.get(
"/dashboard/stats",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Fetching dashboard stats");
const stats = await dashboardService.getStats();
res.json(stats);
}),
);
// GET /api/dashboard/activity?days=14 — message volume over time
router.get(
"/dashboard/activity",
asyncHandler(async (req: Request, res: Response) => {
const days = Math.min(Math.max(Number(req.query.days) || 14, 1), 90);
const activity = await dashboardService.getActivity(days);
res.json(activity);
}),
);
// GET /api/dashboard/users — paginated user list with profiles
router.get(
"/dashboard/users",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const cursor =
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
const search =
typeof req.query.search === "string" ? req.query.search : undefined;
const result = await dashboardService.listUsers({
limit,
cursor,
search,
});
res.json(result);
}),
);
// GET /api/dashboard/users/:userId — single user detail
router.get(
"/dashboard/users/:userId",
asyncHandler(async (req: Request, res: Response) => {
const userId = String(req.params.userId);
const detail = await dashboardService.getUserDetail(userId);
res.json(detail);
}),
);
// GET /api/dashboard/channels — paginated channel list with culture summaries
router.get(
"/dashboard/channels",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const search =
typeof req.query.search === "string" ? req.query.search : undefined;
const guildId =
typeof req.query.guild_id === "string" ? req.query.guild_id : undefined;
const result = await dashboardService.listChannels({
limit,
search,
guildId,
});
res.json(result);
}),
);
// GET /api/dashboard/channels/:channelId — single channel detail
router.get(
"/dashboard/channels/:channelId",
asyncHandler(async (req: Request, res: Response) => {
const channelId = String(req.params.channelId);
const detail = await dashboardService.getChannelDetail(channelId);
res.json(detail);
}),
);
// GET /api/dashboard/reactions — top reacted messages
router.get(
"/dashboard/reactions",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const reactions = await dashboardService.getTopReactions(limit);
res.json(reactions);
}),
);
// GET /api/dashboard/reactors — top users by reactions given
router.get(
"/dashboard/reactors",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const reactors = await dashboardService.getTopReactors(limit);
res.json(reactors);
}),
);
return router;
}
@@ -1 +0,0 @@
export { createDashboardRouter } from "./dashboard.routes.js";
@@ -1 +0,0 @@
export { createMediaRouter } from "./media.routes.js";
@@ -1,71 +0,0 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
import { mediaLoopSchema, mediaQueueSchema } from "./media.schema.js";
import { getStatus, queue, setLoop, skip, stop } from "./media.service.js";
const logger = createChildLogger("media.routes");
export function createMediaRouter(): Router {
const router = express.Router();
// GET /api/media/status
router.get(
"/media/status",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Media status requested");
const status = await getStatus();
res.json(status);
}),
);
// POST /api/media/queue
router.post(
"/media/queue",
validateBody(mediaQueueSchema),
asyncHandler(async (req: Request, res: Response) => {
const { source, mode } = req.body as {
source: string;
mode: "music" | "screen";
};
logger.debug({ source, mode }, "Media queue requested");
const state = await queue(source, mode);
res.json(state);
}),
);
// POST /api/media/skip
router.post(
"/media/skip",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Media skip requested");
const state = await skip();
res.json(state);
}),
);
// POST /api/media/stop
router.post(
"/media/stop",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Media stop requested");
const state = await stop();
res.json(state);
}),
);
// POST /api/media/loop
router.post(
"/media/loop",
validateBody(mediaLoopSchema),
asyncHandler(async (req: Request, res: Response) => {
const { loop } = req.body as { loop: boolean };
logger.debug({ loop }, "Media loop requested");
const state = await setLoop(loop);
res.json(state);
}),
);
return router;
}
@@ -1 +0,0 @@
export { createMessagesRouter } from "./messages.routes.js";
@@ -1,74 +0,0 @@
import type { Request, Response } from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { messageQuerySchema } from "./messages.schema.js";
import { messagesService } from "./messages.service.js";
const logger = createChildLogger("messages.controller");
export const handleListMessages = asyncHandler(
async (req: Request, res: Response) => {
const query = messageQuerySchema.parse(req.query);
logger.debug({ query }, "Handling list messages request");
const result = await messagesService.listMessages(query);
res.json(result);
},
);
export const handleGetMessagesByChannel = asyncHandler(
async (req: Request, res: Response) => {
if (!req.params.channelId) {
res.status(400).json({ error: "Missing route parameter: channelId" });
return;
}
const channelId = req.params.channelId as string;
const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get messages by channel");
const result = await messagesService.getMessagesByChannel(channelId, query);
res.json(result);
},
);
export const handleGetMessageById = asyncHandler(
async (req: Request, res: Response) => {
if (!req.params.id) {
res.status(400).json({ error: "Missing route parameter: id" });
return;
}
const id = req.params.id as string;
logger.debug({ id }, "Handling get message by ID");
const result = await messagesService.getMessageById(id);
res.json(result);
},
);
export const handleGetImageMessages = asyncHandler(
async (req: Request, res: Response) => {
const guildId = req.query.guildId as string | undefined;
if (!guildId) {
res.status(400).json({ error: "Missing query parameter: guildId" });
return;
}
const limit = Number(req.query.limit) || 50;
logger.debug({ guildId, limit }, "Handling get image messages");
const result = await messagesService.getImageMessages(guildId, limit);
res.json(result);
},
);
export const handleGetAttachmentsByChannel = asyncHandler(
async (req: Request, res: Response) => {
if (!req.params.channelId) {
res.status(400).json({ error: "Missing route parameter: channelId" });
return;
}
const channelId = req.params.channelId as string;
const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get attachments by channel");
const result = await messagesService.getAttachmentsByChannel(
channelId,
query,
);
res.json(result);
},
);
@@ -1,51 +0,0 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import {
handleGetAttachmentsByChannel,
handleGetImageMessages,
handleGetMessageById,
handleGetMessagesByChannel,
handleListMessages,
} from "./messages.controller.js";
import { messagesService } from "./messages.service.js";
const logger = createChildLogger("messages.routes");
export function createMessagesRouter(): Router {
const router = express.Router();
// GET /api/messages/images - Get messages with image attachments
// MUST be registered BEFORE /messages/:channelId so "images" is not
// captured as a channelId param.
router.get("/messages/images", handleGetImageMessages);
// GET /api/messages - List messages
router.get("/messages", handleListMessages);
// GET /api/messages/:channelId - Get messages by channel
router.get("/messages/:channelId", handleGetMessagesByChannel);
// GET /api/messages/:channelId/attachments - Get attachments by channel
router.get("/messages/:channelId/attachments", handleGetAttachmentsByChannel);
// GET /api/messages/detail/:id - Get single message by ID
// (uses /detail/ prefix to avoid collision with :channelId route above)
router.get("/messages/detail/:id", handleGetMessageById);
// 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 rows = await messagesService.getReviewMessages(channelId, limit);
logger.debug({ limit, channelId }, "Review query executed");
res.json({ results: rows, limit, cursor: null });
}),
);
return router;
}
@@ -1 +0,0 @@
export { createModerationRouter } from "./moderation.routes.js";
@@ -1,43 +0,0 @@
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 { moderationService } from "./moderation.service.js";
const logger = createChildLogger("moderation.routes");
export function createModerationRouter(): Router {
const router = express.Router();
// GET /api/moderation/stats — moderation action summary
router.get(
"/moderation/stats",
asyncHandler(async (_req: Request, res: Response) => {
const stats = await moderationService.getStats();
res.json(stats);
}),
);
// GET /api/moderation/actions — paginated moderation action log
router.get(
"/moderation/actions",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 50;
const status = req.query.status as string | undefined;
const actionType = req.query.actionType as string | undefined;
const cursor = req.query.cursor as string | undefined;
const result = await moderationService.listActions({
limit,
status,
actionType,
cursor: cursor ? Number(cursor) : undefined,
});
logger.debug({ count: result.data.length }, "Moderation actions listed");
res.json(result);
}),
);
return router;
}
@@ -1 +0,0 @@
export { createRecordingsRouter } from "./recordings.routes.js";
@@ -1,41 +0,0 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
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;
const channelId = req.query.channelId as string | undefined;
const userId = req.query.userId as string | undefined;
const cursor = req.query.cursor as string | undefined;
logger.debug({ limit, channelId, userId, cursor }, "Fetching recordings");
const result = await recordingsService.getRecent(limit, {
channelId,
userId,
cursor,
});
res.json(result);
}),
);
// DELETE /api/recordings/:id
router.delete(
"/recordings/:id",
asyncHandler(async (req: Request, res: Response) => {
const id = req.params.id as string;
await recordingsService.deleteById(id);
res.json({ ok: true });
}),
);
return router;
}
@@ -1 +0,0 @@
export { createUiStateRouter } from "./ui-state.routes.js";
@@ -1,34 +0,0 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
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;
}
@@ -1 +0,0 @@
export { createVoiceRouter } from "./voice.routes.js";
@@ -1,45 +0,0 @@
import type { Request, Response } from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { publishCommandNoReply } from "../../shared/redis/index.js";
import type { ConnectVoiceInput, VoiceCommandInput } from "./voice.schema.js";
import {
connectVoice,
disconnectVoice,
getVoiceStatus,
} from "./voice.service.js";
const logger = createChildLogger("voice.controller");
export const handleGetVoiceStatus = asyncHandler(
async (_req: Request, res: Response) => {
const status = await getVoiceStatus();
res.json(status);
},
);
export const handleConnectVoice = asyncHandler(
async (req: Request, res: Response) => {
const { guildId, channelId } = req.body as ConnectVoiceInput;
logger.debug({ guildId, channelId }, "Connecting to voice channel");
const status = await connectVoice(guildId, channelId);
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 const handleVoiceCommand = asyncHandler(
async (req: Request, res: Response) => {
const { command } = req.body as VoiceCommandInput;
logger.debug({ command }, "Publishing voice command");
await publishCommandNoReply(command);
res.json({ success: true, command });
},
);
@@ -1,80 +0,0 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
import {
handleConnectVoice,
handleDisconnectVoice,
handleGetVoiceStatus,
handleVoiceCommand,
} from "./voice.controller.js";
import { connectVoiceSchema, voiceCommandSchema } from "./voice.schema.js";
import {
getGuilds,
getTextChannels,
getVoiceChannels,
} from "./voice.service.js";
const logger = createChildLogger("voice.routes");
export function createVoiceRouter(): Router {
const router = express.Router();
// ── Guilds ──────────────────────────────────────────────────────────────
// GET /api/guilds
router.get(
"/guilds",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Fetching guilds");
const guilds = await getGuilds();
res.json(guilds);
}),
);
// GET /api/guilds/:guildId/channels
router.get(
"/guilds/:guildId/channels",
asyncHandler(async (req: Request, res: Response) => {
const guildId = req.params.guildId as string;
logger.debug({ guildId }, "Fetching text channels");
const channels = await getTextChannels(guildId);
res.json(channels);
}),
);
// GET /api/guilds/:guildId/voice-channels
router.get(
"/guilds/:guildId/voice-channels",
asyncHandler(async (req: Request, res: Response) => {
const guildId = req.params.guildId as string;
logger.debug({ guildId }, "Fetching voice channels");
const channels = await getVoiceChannels(guildId);
res.json(channels);
}),
);
// ── Voice connection ────────────────────────────────────────────────────
// GET /api/voice/status
router.get("/voice/status", handleGetVoiceStatus);
// POST /api/voice/connect
router.post(
"/voice/connect",
validateBody(connectVoiceSchema),
handleConnectVoice,
);
// POST /api/voice/disconnect
router.post("/voice/disconnect", handleDisconnectVoice);
// POST /api/voice/command — send arbitrary voice command (transmit start/stop)
router.post(
"/voice/command",
validateBody(voiceCommandSchema),
handleVoiceCommand,
);
return router;
}