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
This commit is contained in:
@@ -50,6 +50,24 @@ export function handleGetMessageById(
|
|||||||
})(req, res, next);
|
})(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(
|
export function handleGetAttachmentsByChannel(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PageResult } from "@bete/shared";
|
import type { PageResult } from "@bete/shared";
|
||||||
import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared";
|
import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
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 { getDatabase } from "../../shared/database/index.js";
|
||||||
import { mapMessageRow } from "../../shared/utils/messageMapper.js";
|
import { mapMessageRow } from "../../shared/utils/messageMapper.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -290,6 +290,43 @@ export class MessagesRepository {
|
|||||||
return (result.rowCount ?? 0) > 0;
|
return (result.rowCount ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getImageMessages(
|
||||||
|
guildId: string,
|
||||||
|
limit: number = 50,
|
||||||
|
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
|
||||||
|
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<string, unknown>));
|
||||||
|
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(
|
async getAttachmentsByChannel(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
query: MessageQuery,
|
query: MessageQuery,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import express from "express";
|
|||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import {
|
import {
|
||||||
handleGetAttachmentsByChannel,
|
handleGetAttachmentsByChannel,
|
||||||
|
handleGetImageMessages,
|
||||||
handleGetMessageById,
|
handleGetMessageById,
|
||||||
handleGetMessagesByChannel,
|
handleGetMessagesByChannel,
|
||||||
handleListMessages,
|
handleListMessages,
|
||||||
@@ -31,6 +32,11 @@ const reanalyzeBatchInFlight = new Set<string>();
|
|||||||
export function createMessagesRouter(): Router {
|
export function createMessagesRouter(): Router {
|
||||||
const router = express.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
|
// GET /api/messages - List messages
|
||||||
router.get("/messages", handleListMessages);
|
router.get("/messages", handleListMessages);
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ export class MessagesService {
|
|||||||
return messagesRepository.getAttachmentsByChannel(channelId, query);
|
return messagesRepository.getAttachmentsByChannel(channelId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getImageMessages(
|
||||||
|
guildId: string,
|
||||||
|
limit?: number,
|
||||||
|
): Promise<ReturnType<typeof messagesRepository.getImageMessages>> {
|
||||||
|
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<void> {
|
async markForReanalysis(id: string): Promise<void> {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new ValidationError("message ID is required");
|
throw new ValidationError("message ID is required");
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ pub async fn get_review_messages(
|
|||||||
request("GET", &path, None).await
|
request("GET", &path, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/messages/images?guildId=&limit=
|
||||||
|
pub async fn get_images(
|
||||||
|
guild_id: &str,
|
||||||
|
limit: Option<u32>,
|
||||||
|
) -> Result<PageResult<MessageRecord>, 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}
|
/// GET /api/messages/detail/{id}
|
||||||
pub async fn get_message_detail(id: &str) -> Result<Option<MessageRecord>, ApiError> {
|
pub async fn get_message_detail(id: &str) -> Result<Option<MessageRecord>, ApiError> {
|
||||||
request("GET", &format!("/api/messages/detail/{}", id), None).await
|
request("GET", &format!("/api/messages/detail/{}", id), None).await
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use shared_types::message::{AiStatus, MessageRecord};
|
use shared_types::message::{AiStatus, MessageRecord, PageResult};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ pub fn MessagesPanel() -> impl IntoView {
|
|||||||
let (is_searching, set_is_searching) = signal(false);
|
let (is_searching, set_is_searching) = signal(false);
|
||||||
let ai_filter = RwSignal::new("analyzed".to_string());
|
let ai_filter = RwSignal::new("analyzed".to_string());
|
||||||
let view_tab = RwSignal::new(ViewTab::All);
|
let view_tab = RwSignal::new(ViewTab::All);
|
||||||
|
let image_messages = RwSignal::new(Vec::<MessageRecord>::new());
|
||||||
let (retrying_all, set_retrying_all) = signal(false);
|
let (retrying_all, set_retrying_all) = signal(false);
|
||||||
|
|
||||||
// Stats derived from filtered messages
|
// 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::<crate::app::AppConfig>() {
|
||||||
|
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 ────────────────────────────────────────────────
|
// ─── View ────────────────────────────────────────────────
|
||||||
let get_stats = move || stats.get();
|
let get_stats = move || stats.get();
|
||||||
let (total, clean, flagged, error, pending, deleted, edited) = (
|
let (total, clean, flagged, error, pending, deleted, edited) = (
|
||||||
@@ -328,7 +364,7 @@ pub fn MessagesPanel() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::Images { "block" } else { "none" }>
|
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::Images { "block" } else { "none" }>
|
||||||
{move || view! {
|
{move || view! {
|
||||||
<ImageGrid messages=filtered_messages.get() />
|
<ImageGrid messages=image_messages.get() />
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user