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
+30 -26
View File
@@ -1,3 +1,4 @@
import { trpc } from "@/lib/trpc/client";
import type {
DashboardActivity,
DashboardChannelDetail,
@@ -8,44 +9,47 @@ import type {
TopReactedMessage,
TopReactor,
} from "@/lib/types";
import { api } from "./client";
export const dashboardApi = {
getStats: () => api.get<DashboardStats>("/api/dashboard/stats"),
getStats: () =>
trpc.dashboard.stats.query() as unknown as Promise<DashboardStats>,
getActivity: (days = 14) =>
api.get<DashboardActivity>(`/api/dashboard/activity?days=${days}`),
trpc.dashboard.activity.query({
days,
}) as unknown as Promise<DashboardActivity>,
listUsers: (limit?: number, cursor?: string, search?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (cursor) params.set("cursor", cursor);
if (search) params.set("search", search);
const qs = params.toString();
return api.get<PaginatedUsers>(`/api/dashboard/users${qs ? `?${qs}` : ""}`);
},
listUsers: (limit?: number, cursor?: string, search?: string) =>
trpc.dashboard.users.query({
limit,
cursor,
search,
}) as unknown as Promise<PaginatedUsers>,
getUserDetail: (userId: string) =>
api.get<DashboardUserDetail>(`/api/dashboard/users/${userId}`),
trpc.dashboard.userDetail.query({
userId,
}) as unknown as Promise<DashboardUserDetail>,
listChannels: (limit?: number, search?: string, guildId?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (search) params.set("search", search);
// Backend reads req.query.guild_id (snake_case) — see createDashboardRouter in dashboard.routes.ts
if (guildId) params.set("guild_id", guildId);
const qs = params.toString();
return api.get<PaginatedChannels>(
`/api/dashboard/channels${qs ? `?${qs}` : ""}`,
);
},
listChannels: (limit?: number, search?: string, guildId?: string) =>
trpc.dashboard.channels.query({
limit,
search,
guildId,
}) as unknown as Promise<PaginatedChannels>,
getChannelDetail: (channelId: string) =>
api.get<DashboardChannelDetail>(`/api/dashboard/channels/${channelId}`),
trpc.dashboard.channelDetail.query({
channelId,
}) as unknown as Promise<DashboardChannelDetail>,
getTopReactions: (limit = 20) =>
api.get<TopReactedMessage[]>(`/api/dashboard/reactions?limit=${limit}`),
trpc.dashboard.reactions.query({ limit }) as unknown as Promise<
TopReactedMessage[]
>,
getTopReactors: (limit = 20) =>
api.get<TopReactor[]>(`/api/dashboard/reactors?limit=${limit}`),
trpc.dashboard.reactors.query({ limit }) as unknown as Promise<
TopReactor[]
>,
};