diff --git a/services/backend/src/modules/dashboard/dashboard.repository.ts b/services/backend/src/modules/dashboard/dashboard.repository.ts index 229f9ef..6fabdc9 100644 --- a/services/backend/src/modules/dashboard/dashboard.repository.ts +++ b/services/backend/src/modules/dashboard/dashboard.repository.ts @@ -156,6 +156,149 @@ export class DashboardRepository { return { data, nextCursor }; } + async listChannels(query: ListUsersQuery & { guildId?: string }) { + const pool = getPool(); + const limit = query.limit ?? 20; + const conditions: string[] = []; + const params: unknown[] = []; + let paramIdx = 1; + + if (query.search) { + conditions.push( + `(m.channel_id ILIKE $${paramIdx} OR m.channel_name ILIKE $${paramIdx})`, + ); + params.push(`%${query.search}%`); + paramIdx++; + } + + if (query.guildId) { + conditions.push(`m.guild_id = $${paramIdx}`); + params.push(query.guildId); + paramIdx++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const { rows } = await pool.query( + ` + SELECT + m.channel_id, + m.channel_name, + m.guild_id, + m.total_messages, + m.flagged_count, + m.last_message_at, + c.culture_summary, + c.last_analyzed_at + FROM ( + SELECT + channel_id, + guild_id, + COALESCE(NULLIF((metadata::jsonb -> 'channel' ->> 'channelName'), ''), channel_id) AS channel_name, + COUNT(*)::int AS total_messages, + COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, + MAX(created_at) AS last_message_at + FROM messages + GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName') + ) m + LEFT JOIN channel_cultures c ON c.channel_id = m.channel_id + ${whereClause} + ORDER BY m.total_messages DESC + LIMIT $${paramIdx} + `, + [...params, limit + 1], + ); + + const data = ((rows as Record[]) || []).slice(0, limit).map((r) => ({ + channel_id: String(r.channel_id), + channel_name: r.channel_name as string | null, + guild_id: r.guild_id as string | null, + total_messages: Number(r.total_messages), + flagged_count: Number(r.flagged_count), + last_message_at: r.last_message_at ? Number(r.last_message_at) : null, + culture_summary: r.culture_summary as string | null, + last_analyzed_at: r.last_analyzed_at + ? Number(r.last_analyzed_at) + : null, + })); + + const lastRow = rows[limit - 1] as Record | undefined; + const nextCursor = + rows.length > limit + ? String(lastRow?.total_messages ?? "") + : null; + + return { data, nextCursor }; + } + + async getChannelDetail(channelId: string) { + const pool = getPool(); + + const channelResult = await pool.query( + ` + SELECT + m.channel_id, + m.channel_name, + m.guild_id, + m.total_messages, + m.flagged_count, + m.clean_count, + c.culture_summary, + c.last_analyzed_at + FROM ( + SELECT + channel_id, + guild_id, + COALESCE(NULLIF((metadata::jsonb -> 'channel' ->> 'channelName'), ''), channel_id) AS channel_name, + COUNT(*)::int AS total_messages, + COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, + COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count + FROM messages + WHERE channel_id = $1 + GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName') + ) m + LEFT JOIN channel_cultures c ON c.channel_id = m.channel_id + `, + [channelId], + ); + + const row = channelResult.rows[0] as Record | undefined; + if (!row) return null; + + const recent = await pool.query( + ` + SELECT id, content, channel_id, created_at, ai_status, username + FROM messages + WHERE channel_id = $1 + ORDER BY created_at DESC + LIMIT 20 + `, + [channelId], + ); + + return { + channel_id: String(row.channel_id), + channel_name: row.channel_name as string | null, + guild_id: row.guild_id as string | null, + total_messages: Number(row.total_messages), + flagged_count: Number(row.flagged_count), + clean_count: Number(row.clean_count), + culture_summary: row.culture_summary as string | null, + last_analyzed_at: row.last_analyzed_at + ? Number(row.last_analyzed_at) + : null, + recent_messages: (recent.rows as Record[]).map((r) => ({ + id: String(r.id), + content: String(r.content), + channel_id: String(r.channel_id), + created_at: Number(r.created_at), + ai_status: r.ai_status as string | null, + username: r.username as string | null, + })), + }; + } + async getUserDetail(userId: string) { const pool = getPool(); diff --git a/services/backend/src/modules/dashboard/dashboard.routes.ts b/services/backend/src/modules/dashboard/dashboard.routes.ts index 80f0eac..3030ab3 100644 --- a/services/backend/src/modules/dashboard/dashboard.routes.ts +++ b/services/backend/src/modules/dashboard/dashboard.routes.ts @@ -48,5 +48,36 @@ export function createDashboardRouter(): Router { }), ); + // 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); + }), + ); + return router; } diff --git a/services/backend/src/modules/dashboard/dashboard.service.ts b/services/backend/src/modules/dashboard/dashboard.service.ts index 690a06e..533afdf 100644 --- a/services/backend/src/modules/dashboard/dashboard.service.ts +++ b/services/backend/src/modules/dashboard/dashboard.service.ts @@ -24,6 +24,16 @@ export class DashboardService { logger.debug({ userId }, "Fetching user detail"); return dashboardRepository.getUserDetail(userId); } + + async listChannels(query: { limit: number; search?: string; guildId?: string }) { + logger.debug({ query }, "Listing dashboard channels"); + return dashboardRepository.listChannels(query); + } + + async getChannelDetail(channelId: string) { + logger.debug({ channelId }, "Fetching channel detail"); + return dashboardRepository.getChannelDetail(channelId); + } } export const dashboardService = new DashboardService(); diff --git a/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx b/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx new file mode 100644 index 0000000..cdbb6ed --- /dev/null +++ b/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx @@ -0,0 +1,247 @@ +import { motion } from "framer-motion"; +import { AlertCircle, ArrowLeft, Hash, RefreshCw } from "lucide-react"; +import type { DashboardChannelDetail } from "../../../shared/api/client"; +import { + cardItem, + cardStagger, +} from "../../../shared/hooks/useFramerStagger"; +import { cn } from "../../../shared/lib/utils"; +import { + Badge, + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, +} from "../../../shared/ui"; + +interface ChannelProfileDetailProps { + detail: DashboardChannelDetail | null; + loading: boolean; + error: string | null; + onBack: () => void; + onRefetch: () => void; +} + +export function ChannelProfileDetail({ + detail, + loading, + error, + onBack, + onRefetch, +}: ChannelProfileDetailProps) { + return ( + + {/* Back button */} + + + + + {/* Loading */} + {loading && } + + {/* Error */} + {error && ( + + +

{error}

+ +
+ )} + + {detail && !error && ( + <> + {/* Channel header */} + + + +
+
+ +
+ +
+

+ #{detail.channel_name ?? detail.channel_id} +

+

+ {detail.channel_id} +

+ +
+ {detail.flagged_count > 0 && ( + + {detail.flagged_count} flagged + + )} + {detail.culture_summary && ( + AI Profile + )} +
+
+
+ + {/* Culture summary */} + {detail.culture_summary && ( +
+

+ AI Channel Summary +

+

+ {detail.culture_summary} +

+ {detail.last_analyzed_at && ( +

+ Last analyzed:{" "} + {new Date(detail.last_analyzed_at).toLocaleString()} +

+ )} +
+ )} +
+
+
+ + {/* Stats grid */} + + + +

+ {detail.total_messages.toLocaleString()} +

+

+ Total Messages +

+
+
+ + +

+ {detail.clean_count.toLocaleString()} +

+

Clean

+
+
+ + +

0 + ? "text-destructive" + : "text-muted-foreground", + )} + > + {detail.flagged_count.toLocaleString()} +

+

Flagged

+
+
+
+ + {/* Recent messages */} + + + + Recent Messages + + + {detail.recent_messages.length === 0 ? ( +

+ No messages found. +

+ ) : ( +
+ {detail.recent_messages.map((msg) => ( +
+
+

+ {msg.content} +

+ {msg.ai_status && ( + + {msg.ai_status} + + )} +
+

+ {msg.username ?? "unknown"} ·{" "} + {new Date(msg.created_at).toLocaleString()} +

+
+ ))} +
+ )} +
+
+
+ + )} +
+ ); +} + +function DetailSkeleton() { + return ( +
+ + +
+ +
+ + +
+ + +
+
+
+ +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + ))} +
+
+ ); +} diff --git a/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx b/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx new file mode 100644 index 0000000..720634b --- /dev/null +++ b/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx @@ -0,0 +1,186 @@ +import { motion } from "framer-motion"; +import { + AlertCircle, + Hash, + Loader2, + RefreshCw, + Search, +} from "lucide-react"; +import type { DashboardChannel } from "../../../shared/api/client"; +import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; +import { cn } from "../../../shared/lib/utils"; +import { + Card, + CardContent, + Input, + Skeleton, +} from "../../../shared/ui"; + +interface ChannelSummaryListProps { + channels: DashboardChannel[]; + loading: boolean; + error: string | null; + search: string; + onSearchChange: (value: string) => void; + onLoadMore: () => void; + hasMore: boolean; + onRefetch: () => void; + onSelectChannel: (channelId: string) => void; +} + +export function ChannelSummaryList({ + channels, + loading, + error, + search, + onSearchChange, + onLoadMore, + hasMore, + onRefetch, + onSelectChannel, +}: ChannelSummaryListProps) { + return ( + + {/* Search bar */} + +
+ + onSearchChange(e.target.value)} + /> +
+
+ + {/* Error state */} + {error && ( + + +

{error}

+ +
+ )} + + {/* Loading state */} + {loading && channels.length === 0 && !error && } + + {/* Empty state */} + {!loading && !error && channels.length === 0 && ( + + +

No channels found.

+
+ )} + + {/* Channel cards */} + {channels.length > 0 && ( + + {channels.map((ch) => ( + + ))} + + )} + + {/* Load more */} + {hasMore && ( + + + + )} +
+ ); +} + +function ChannelListSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + +
+ +
+ + + +
+
+
+
+ ))} +
+ ); +} diff --git a/services/frontend/src/features/dashboard/hooks/useDashboard.ts b/services/frontend/src/features/dashboard/hooks/useDashboard.ts index 8259dd1..6bab453 100644 --- a/services/frontend/src/features/dashboard/hooks/useDashboard.ts +++ b/services/frontend/src/features/dashboard/hooks/useDashboard.ts @@ -1,10 +1,14 @@ import { useCallback, useEffect, useState } from "react"; import { + type DashboardChannel, + type DashboardChannelDetail, type DashboardStats, type DashboardUser, type DashboardUserDetail, + getDashboardChannelDetail, getDashboardStats, getDashboardUserDetail, + listDashboardChannels, listDashboardUsers, } from "../../../shared/api/client"; @@ -133,3 +137,97 @@ export function useDashboardUserDetail(userId: string | null) { return { detail, loading, error, refetch: fetch }; } + +/** + * Fetch paginated channel list with optional search. + */ +export function useDashboardChannels() { + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [nextCursor, setNextCursor] = useState(null); + const [search, setSearch] = useState(""); + + const fetchChannels = useCallback( + async (cursor?: string) => { + setLoading(true); + setError(null); + try { + const result = await listDashboardChannels({ + limit: 20, + search: search || undefined, + }); + if (cursor) { + setChannels((prev) => [...prev, ...result.data]); + } else { + setChannels(result.data); + } + setNextCursor(result.nextCursor); + } catch (e) { + const msg = e instanceof Error ? e.message : "Failed to load channels"; + setError(msg); + logger.error("[useDashboardChannels]", msg); + } finally { + setLoading(false); + } + }, + [search], + ); + + useEffect(() => { + fetchChannels().catch(() => undefined); + }, [fetchChannels]); + + const loadMore = useCallback(() => { + if (nextCursor && !loading) { + fetchChannels(nextCursor).catch(() => undefined); + } + }, [nextCursor, loading, fetchChannels]); + + return { + channels, + loading, + error, + search, + setSearch, + loadMore, + hasMore: !!nextCursor, + refetch: () => fetchChannels().catch(() => undefined), + }; +} + +/** + * Fetch a single channel detail by channelId. + */ +export function useDashboardChannelDetail(channelId: string | null) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetch = useCallback(async () => { + if (!channelId) return; + setLoading(true); + setError(null); + try { + const data = await getDashboardChannelDetail(channelId); + if (!data) { + setError("Channel not found"); + return; + } + setDetail(data); + } catch (e) { + const msg = + e instanceof Error ? e.message : "Failed to load channel detail"; + setError(msg); + logger.error("[useDashboardChannelDetail]", msg); + } finally { + setLoading(false); + } + }, [channelId]); + + useEffect(() => { + fetch().catch(() => undefined); + }, [fetch]); + + return { detail, loading, error, refetch: fetch }; +} diff --git a/services/frontend/src/features/dashboard/index.tsx b/services/frontend/src/features/dashboard/index.tsx index dadfb4d..f2086ef 100644 --- a/services/frontend/src/features/dashboard/index.tsx +++ b/services/frontend/src/features/dashboard/index.tsx @@ -1,9 +1,13 @@ import { useState } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui"; +import { ChannelProfileDetail } from "./components/ChannelProfileDetail"; +import { ChannelSummaryList } from "./components/ChannelSummaryList"; import { DashboardStatsContent } from "./components/DashboardStats"; import { UserProfileDetail } from "./components/UserProfileDetail"; import { UserSummaryList } from "./components/UserSummaryList"; import { + useDashboardChannelDetail, + useDashboardChannels, useDashboardUserDetail, useDashboardUsers, } from "./hooks/useDashboard"; @@ -11,34 +15,68 @@ import { export function DashboardPanel() { const [activeTab, setActiveTab] = useState("stats"); const [selectedUserId, setSelectedUserId] = useState(null); + const [selectedChannelId, setSelectedChannelId] = useState( + null, + ); const { users, loading: usersLoading, error: usersError, - search, - setSearch, - loadMore, - hasMore, + search: userSearch, + setSearch: setUserSearch, + loadMore: loadMoreUsers, + hasMore: hasMoreUsers, refetch: refetchUsers, } = useDashboardUsers(); const { - detail, - loading: detailLoading, - error: detailError, - refetch: refetchDetail, + detail: userDetail, + loading: userDetailLoading, + error: userDetailError, + refetch: refetchUserDetail, } = useDashboardUserDetail(selectedUserId); + const { + channels, + loading: channelsLoading, + error: channelsError, + search: channelSearch, + setSearch: setChannelSearch, + loadMore: loadMoreChannels, + hasMore: hasMoreChannels, + refetch: refetchChannels, + } = useDashboardChannels(); + const { + detail: channelDetail, + loading: channelDetailLoading, + error: channelDetailError, + refetch: refetchChannelDetail, + } = useDashboardChannelDetail(selectedChannelId); // Show user detail view if (selectedUserId) { return ( { setSelectedUserId(null); }} - onRefetch={refetchDetail} + onRefetch={refetchUserDetail} + /> + ); + } + + // Show channel detail view + if (selectedChannelId) { + return ( + { + setSelectedChannelId(null); + }} + onRefetch={refetchChannelDetail} /> ); } @@ -48,6 +86,7 @@ export function DashboardPanel() { Stats Users + Channels @@ -59,14 +98,28 @@ export function DashboardPanel() { users={users} loading={usersLoading} error={usersError} - search={search} - onSearchChange={setSearch} - onLoadMore={loadMore} - hasMore={hasMore} + search={userSearch} + onSearchChange={setUserSearch} + onLoadMore={loadMoreUsers} + hasMore={hasMoreUsers} onRefetch={refetchUsers} onSelectUser={setSelectedUserId} /> + + + + ); } diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index a0f8ce8..511ceae 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -336,6 +336,51 @@ export function getDashboardUserDetail( return request(`/api/dashboard/users/${userId}`); } +// ─── Dashboard Channels ───────────────────────────────────────────────────────── + +export interface DashboardChannel { + channel_id: string; + channel_name: string | null; + guild_id: string | null; + total_messages: number; + flagged_count: number; + last_message_at: number | null; + culture_summary: string | null; + last_analyzed_at: number | null; +} + +export interface DashboardChannelDetail extends DashboardChannel { + clean_count: number; + recent_messages: Array<{ + id: string; + content: string; + channel_id: string; + created_at: number; + ai_status: string | null; + username: string | null; + }>; +} + +export function listDashboardChannels( + params: { limit?: number; search?: string; guild_id?: string } = {}, +): Promise<{ data: DashboardChannel[]; nextCursor: string | null }> { + const sp = new URLSearchParams(); + if (params.limit) sp.set("limit", String(params.limit)); + if (params.search) sp.set("search", params.search); + if (params.guild_id) sp.set("guild_id", params.guild_id); + return request<{ data: DashboardChannel[]; nextCursor: string | null }>( + `/api/dashboard/channels?${sp}`, + ); +} + +export function getDashboardChannelDetail( + channelId: string, +): Promise { + return request( + `/api/dashboard/channels/${channelId}`, + ); +} + // ─── UI State ──────────────────────────────────────────────────────────────── export function getUIState(): Promise {