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:
co-authored by
Claude Opus 4.5
parent
d8552a9fb8
commit
2fa1827f17
@@ -0,0 +1,347 @@
|
||||
import { z } from "zod";
|
||||
import { analysisService } from "../modules/analysis/analysis.service";
|
||||
import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
|
||||
import { chatbotService } from "../modules/chatbot/chatbot.service";
|
||||
// ── Service imports ──────────────────────────────────────────────
|
||||
import { dashboardService } from "../modules/dashboard/dashboard.service";
|
||||
import {
|
||||
mediaLoopSchema,
|
||||
mediaQueueSchema,
|
||||
} from "../modules/media/media.schema";
|
||||
import {
|
||||
getStatus,
|
||||
queue,
|
||||
setLoop,
|
||||
skip,
|
||||
stop,
|
||||
} from "../modules/media/media.service";
|
||||
import { messageQuerySchema } from "../modules/messages/messages.schema";
|
||||
import { messagesService } from "../modules/messages/messages.service";
|
||||
import { moderationService } from "../modules/moderation/moderation.service";
|
||||
import { recordingsService } from "../modules/recordings/recordings.service";
|
||||
import { uiStateService } from "../modules/ui-state/ui-state.service";
|
||||
import {
|
||||
connectVoice,
|
||||
disconnectVoice,
|
||||
getGuilds,
|
||||
getTextChannels,
|
||||
getVoiceChannels,
|
||||
getVoiceStatus,
|
||||
} from "../modules/voice/voice.service";
|
||||
import { config } from "../shared/config/index";
|
||||
import { publishCommandNoReply } from "../shared/redis/index";
|
||||
import { logger, publicProcedure, router } from "./trpc";
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────────────
|
||||
const dashboardRouter = router({
|
||||
stats: publicProcedure.query(() => dashboardService.getStats()),
|
||||
activity: publicProcedure
|
||||
.input(
|
||||
z.object({ days: z.coerce.number().int().min(1).max(90).default(14) }),
|
||||
)
|
||||
.query(({ input }) => dashboardService.getActivity(input.days)),
|
||||
users: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(20),
|
||||
cursor: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
dashboardService.listUsers({
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
search: input.search,
|
||||
}),
|
||||
),
|
||||
userDetail: publicProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.query(({ input }) => dashboardService.getUserDetail(input.userId)),
|
||||
channels: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(20),
|
||||
search: z.string().optional(),
|
||||
guildId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
dashboardService.listChannels({
|
||||
limit: input.limit,
|
||||
search: input.search,
|
||||
guildId: input.guildId,
|
||||
}),
|
||||
),
|
||||
channelDetail: publicProcedure
|
||||
.input(z.object({ channelId: z.string() }))
|
||||
.query(({ input }) => dashboardService.getChannelDetail(input.channelId)),
|
||||
reactions: publicProcedure
|
||||
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
||||
.query(({ input }) => dashboardService.getTopReactions(input.limit)),
|
||||
reactors: publicProcedure
|
||||
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
||||
.query(({ input }) => dashboardService.getTopReactors(input.limit)),
|
||||
});
|
||||
|
||||
// ── Messages ─────────────────────────────────────────────────────
|
||||
const messagesRouter = router({
|
||||
list: publicProcedure
|
||||
.input(messageQuerySchema)
|
||||
.query(({ input }) => messagesService.listMessages(input)),
|
||||
byChannel: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
channelId: z.string(),
|
||||
query: messageQuerySchema,
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
messagesService.getMessagesByChannel(input.channelId, input.query),
|
||||
),
|
||||
detail: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(({ input }) => messagesService.getMessageById(input.id)),
|
||||
images: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
guildId: z.string(),
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
messagesService.getImageMessages(input.guildId, input.limit),
|
||||
),
|
||||
attachmentsByChannel: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
channelId: z.string(),
|
||||
query: messageQuerySchema,
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
messagesService.getAttachmentsByChannel(input.channelId, input.query),
|
||||
),
|
||||
review: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(20),
|
||||
channelId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const rows = await messagesService.getReviewMessages(
|
||||
input.channelId,
|
||||
input.limit,
|
||||
);
|
||||
return { results: rows, limit: input.limit, cursor: null };
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Moderation ───────────────────────────────────────────────────
|
||||
const moderationRouter = router({
|
||||
stats: publicProcedure.query(() => moderationService.getStats()),
|
||||
actions: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
status: z.string().optional(),
|
||||
actionType: z.string().optional(),
|
||||
cursor: z.coerce.number().int().optional(),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
moderationService.listActions({
|
||||
limit: input.limit,
|
||||
status: input.status,
|
||||
actionType: input.actionType,
|
||||
cursor: input.cursor,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ── Media ────────────────────────────────────────────────────────
|
||||
const mediaRouter = router({
|
||||
status: publicProcedure.query(() => getStatus()),
|
||||
queue: publicProcedure.input(mediaQueueSchema).mutation(async ({ input }) => {
|
||||
await queue(input.source, input.mode);
|
||||
return getStatus();
|
||||
}),
|
||||
skip: publicProcedure.mutation(async () => {
|
||||
await skip();
|
||||
return getStatus();
|
||||
}),
|
||||
stop: publicProcedure.mutation(async () => {
|
||||
await stop();
|
||||
return getStatus();
|
||||
}),
|
||||
loop: publicProcedure.input(mediaLoopSchema).mutation(async ({ input }) => {
|
||||
await setLoop(input.loop);
|
||||
return getStatus();
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Voice ─────────────────────────────────────────────────────────
|
||||
const voiceRouter = router({
|
||||
guilds: publicProcedure.query(() => getGuilds()),
|
||||
textChannels: publicProcedure
|
||||
.input(z.object({ guildId: z.string() }))
|
||||
.query(({ input }) => getTextChannels(input.guildId)),
|
||||
voiceChannels: publicProcedure
|
||||
.input(z.object({ guildId: z.string() }))
|
||||
.query(({ input }) => getVoiceChannels(input.guildId)),
|
||||
status: publicProcedure.query(() => getVoiceStatus()),
|
||||
connect: publicProcedure
|
||||
.input(z.object({ guildId: z.string(), channelId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await connectVoice(input.guildId, input.channelId);
|
||||
return getVoiceStatus();
|
||||
}),
|
||||
disconnect: publicProcedure.mutation(async () => {
|
||||
await disconnectVoice();
|
||||
return getVoiceStatus();
|
||||
}),
|
||||
command: publicProcedure
|
||||
.input(z.object({ command: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
await publishCommandNoReply(input.command);
|
||||
return { success: true, command: input.command };
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Recordings ───────────────────────────────────────────────────
|
||||
const recordingsRouter = router({
|
||||
list: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
channelId: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
recordingsService.getRecent(input.limit, {
|
||||
channelId: input.channelId,
|
||||
userId: input.userId,
|
||||
cursor: input.cursor,
|
||||
}),
|
||||
),
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await recordingsService.deleteById(input.id);
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Analysis (search) ──────────────────────────────────────────────
|
||||
const analysisRouter = router({
|
||||
search: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
q: z.string().default(""),
|
||||
channelId: z.string().optional(),
|
||||
limit: z.coerce.number().int().positive().default(20),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
analysisService.search({
|
||||
q: input.q,
|
||||
channelId: input.channelId,
|
||||
limit: input.limit,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ── Chatbot ───────────────────────────────────────────────────────
|
||||
const chatbotRouter = router({
|
||||
chat: publicProcedure
|
||||
.input(
|
||||
chatRequestSchema.extend({
|
||||
// Per-device actor id; the old REST layer used an X-User-Id header.
|
||||
// Anonymous sessions use a stable "anonymous" id.
|
||||
userId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const userId = input.userId ?? "anonymous";
|
||||
const response = await chatbotService.processMessage(
|
||||
input.message,
|
||||
input.context,
|
||||
userId,
|
||||
);
|
||||
await chatbotService.saveConversation({
|
||||
userId,
|
||||
userMessage: input.message,
|
||||
botResponse: response,
|
||||
context: input.context,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
return { response, timestamp: new Date().toISOString() };
|
||||
}),
|
||||
history: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||
userId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const userId = input.userId ?? "anonymous";
|
||||
const history = await chatbotService.getChatHistory(userId, input.limit);
|
||||
return { history, total: history.length };
|
||||
}),
|
||||
clearHistory: publicProcedure
|
||||
.input(z.object({ userId: z.string().optional() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const userId = input.userId ?? "anonymous";
|
||||
await chatbotService.clearChatHistory(userId);
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Config (public dashboard config snapshot) ──────────────────────
|
||||
const configRouter = router({
|
||||
get: publicProcedure.query(() => ({
|
||||
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,
|
||||
})),
|
||||
});
|
||||
|
||||
// ── UI State ──────────────────────────────────────────────────────
|
||||
const uiStateRouter = router({
|
||||
get: publicProcedure.query(() => uiStateService.getState()),
|
||||
update: publicProcedure
|
||||
.input(z.record(z.string(), z.unknown()))
|
||||
.mutation(({ input }) => uiStateService.updateState(input)),
|
||||
});
|
||||
|
||||
// ── Root router ───────────────────────────────────────────────────
|
||||
export const appRouter = router({
|
||||
dashboard: dashboardRouter,
|
||||
messages: messagesRouter,
|
||||
moderation: moderationRouter,
|
||||
media: mediaRouter,
|
||||
voice: voiceRouter,
|
||||
recordings: recordingsRouter,
|
||||
analysis: analysisRouter,
|
||||
chatbot: chatbotRouter,
|
||||
config: configRouter,
|
||||
uiState: uiStateRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
logger.info("tRPC appRouter constructed");
|
||||
Reference in New Issue
Block a user