From c3b2b3f33429415fe5d8a4cafb5b35bc0f8e03df Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 5 Jul 2026 04:48:29 +0700 Subject: [PATCH] feat: add /api/messages/images endpoint + ImageGrid fetch Backend: - New GET /api/messages/images?guildId=&limit= endpoint - Queries attachments table for image/* MIME type, returns distinct messages - Repository uses Drizzle subquery + join pattern Frontend: - New get_images() API client function - ImageGrid fetches from /api/messages/images when Images tab selected - Separate image_messages signal, fetched on tab switch Deploy: hot-patched JS/WASM files to running containers --- .../modules/messages/messages.controller.ts | 18 +++++++++ .../modules/messages/messages.repository.ts | 39 +++++++++++++++++- .../src/modules/messages/messages.routes.ts | 6 +++ .../src/modules/messages/messages.service.ts | 12 ++++++ .../frontend/frontend/src/api/messages.rs | 12 ++++++ .../frontend/src/features/messages/mod.rs | 40 ++++++++++++++++++- 6 files changed, 124 insertions(+), 3 deletions(-) diff --git a/services/backend/src/modules/messages/messages.controller.ts b/services/backend/src/modules/messages/messages.controller.ts index f7f4938..9097fc9 100644 --- a/services/backend/src/modules/messages/messages.controller.ts +++ b/services/backend/src/modules/messages/messages.controller.ts @@ -50,6 +50,24 @@ export function handleGetMessageById( })(req, res, next); } +export function handleGetImageMessages( + req: Request, + res: Response, + next: NextFunction, +) { + return asyncHandler(async (req: Request, res: Response) => { + const guildId = requireParam( + req.query.guildId as string, + "query parameter", + "guildId", + ); + 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); + })(req, res, next); +} + export function handleGetAttachmentsByChannel( req: Request, res: Response, diff --git a/services/backend/src/modules/messages/messages.repository.ts b/services/backend/src/modules/messages/messages.repository.ts index f13b85a..088f2ec 100644 --- a/services/backend/src/modules/messages/messages.repository.ts +++ b/services/backend/src/modules/messages/messages.repository.ts @@ -1,7 +1,7 @@ import type { PageResult } from "@bete/shared"; import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared"; import { createChildLogger } from "@bete/shared/logger"; -import { and, desc, eq, inArray, lt, ne, type SQL } from "drizzle-orm"; +import { and, desc, eq, inArray, like, lt, ne, type SQL } from "drizzle-orm"; import { getDatabase } from "../../shared/database/index.js"; import { mapMessageRow } from "../../shared/utils/messageMapper.js"; import type { @@ -290,6 +290,43 @@ export class MessagesRepository { return (result.rowCount ?? 0) > 0; } + async getImageMessages( + guildId: string, + limit: number = 50, + ): Promise>> { + const db = getDatabase(); + + // Subquery: find distinct message_ids from attachments with image MIME type + const imageMsgIds = db + .select({ id: pgAttachmentsTable.message_id }) + .from(pgAttachmentsTable) + .where( + and( + eq(pgAttachmentsTable.guild_id, guildId), + like(pgAttachmentsTable.type, "image/%"), + ), + ) + .orderBy(desc(pgAttachmentsTable.created_at)) + .limit(limit + 1); + + // Fetch full message rows for those IDs + const rows = await db + .select() + .from(pgMessagesTable) + .where(inArray(pgMessagesTable.id, imageMsgIds)) + .orderBy(desc(pgMessagesTable.created_at)) + .limit(limit + 1); + + const data = rows + .slice(0, limit) + .map((r) => mapMessageRow(r as Record)); + const nextCursor = + rows.length > limit ? String(rows[limit].created_at) : null; + + logger.debug({ count: data.length, nextCursor }, "Found image messages"); + return { data, nextCursor }; + } + async getAttachmentsByChannel( channelId: string, query: MessageQuery, diff --git a/services/backend/src/modules/messages/messages.routes.ts b/services/backend/src/modules/messages/messages.routes.ts index fb02238..380b79d 100644 --- a/services/backend/src/modules/messages/messages.routes.ts +++ b/services/backend/src/modules/messages/messages.routes.ts @@ -4,6 +4,7 @@ import express from "express"; import { asyncHandler } from "../../shared/middlewares/index.js"; import { handleGetAttachmentsByChannel, + handleGetImageMessages, handleGetMessageById, handleGetMessagesByChannel, handleListMessages, @@ -31,6 +32,11 @@ const reanalyzeBatchInFlight = new Set(); 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); diff --git a/services/backend/src/modules/messages/messages.service.ts b/services/backend/src/modules/messages/messages.service.ts index 210cee9..a7dc56f 100644 --- a/services/backend/src/modules/messages/messages.service.ts +++ b/services/backend/src/modules/messages/messages.service.ts @@ -46,6 +46,18 @@ export class MessagesService { return messagesRepository.getAttachmentsByChannel(channelId, query); } + async getImageMessages( + guildId: string, + limit?: number, + ): Promise> { + if (!guildId) { + throw new ValidationError("guildId is required"); + } + + logger.debug({ guildId, limit }, "Getting image messages"); + return messagesRepository.getImageMessages(guildId, limit); + } + async markForReanalysis(id: string): Promise { if (!id) { throw new ValidationError("message ID is required"); diff --git a/services/frontend/frontend/src/api/messages.rs b/services/frontend/frontend/src/api/messages.rs index 2d9e36d..750d11f 100644 --- a/services/frontend/frontend/src/api/messages.rs +++ b/services/frontend/frontend/src/api/messages.rs @@ -41,6 +41,18 @@ pub async fn get_review_messages( request("GET", &path, None).await } +/// GET /api/messages/images?guildId=&limit= +pub async fn get_images( + guild_id: &str, + limit: Option, +) -> Result, ApiError> { + let mut path = format!("/api/messages/images?guildId={}", guild_id); + if let Some(l) = limit { + path.push_str(&format!("&limit={}", l)); + } + request("GET", &path, None).await +} + /// GET /api/messages/detail/{id} pub async fn get_message_detail(id: &str) -> Result, ApiError> { request("GET", &format!("/api/messages/detail/{}", id), None).await diff --git a/services/frontend/frontend/src/features/messages/mod.rs b/services/frontend/frontend/src/features/messages/mod.rs index 4bc0da5..870192a 100644 --- a/services/frontend/frontend/src/features/messages/mod.rs +++ b/services/frontend/frontend/src/features/messages/mod.rs @@ -1,5 +1,5 @@ use leptos::prelude::*; -use shared_types::message::{AiStatus, MessageRecord}; +use shared_types::message::{AiStatus, MessageRecord, PageResult}; use std::sync::Arc; use wasm_bindgen_futures::spawn_local; @@ -28,6 +28,7 @@ pub fn MessagesPanel() -> impl IntoView { let (is_searching, set_is_searching) = signal(false); let ai_filter = RwSignal::new("analyzed".to_string()); let view_tab = RwSignal::new(ViewTab::All); + let image_messages = RwSignal::new(Vec::::new()); let (retrying_all, set_retrying_all) = signal(false); // Stats derived from filtered messages @@ -171,6 +172,41 @@ pub fn MessagesPanel() -> impl IntoView { } }); + // Fetch image messages when Images tab is selected + let fetch_images = { + move || { + spawn_local({ + async move { + if let Some(config) = use_context::() { + if let Some(ref guild_id) = config.monitor_guild_id.get() { + match crate::api::messages::get_images(guild_id, Some(100)).await { + Ok(PageResult { data, .. }) => { + web_sys::console::log_2( + &"[images] fetch OK".into(), + &format!("count={}", data.len()).into(), + ); + image_messages.set(data); + } + Err(e) => { + web_sys::console::log_2( + &"[images] fetch ERROR".into(), + &format!("{}", e).into(), + ); + } + } + } + } + } + }); + } + }; + // Fetch images when tab changes to Images + Effect::new(move |_| { + if view_tab.get() == ViewTab::Images { + fetch_images(); + } + }); + // ─── View ──────────────────────────────────────────────── let get_stats = move || stats.get(); let (total, clean, flagged, error, pending, deleted, edited) = ( @@ -328,7 +364,7 @@ pub fn MessagesPanel() -> impl IntoView {
{move || view! { - + }}