feat(17-02): wire storage service into all routes and MCP tools, remove static /uploads/*

- Replace unlink() with deleteImage() in items and threads routes
- Add withImageUrl/withImageUrls to item, thread, setup GET responses
- Enrich MCP tool responses with presigned image URLs
- Remove /uploads/* static file serving from server index
- Update MCP image tool description (local -> storage)
This commit is contained in:
2026-04-05 12:22:41 +02:00
parent 5ce3f92a78
commit f5d79072f2
7 changed files with 34 additions and 28 deletions

View File

@@ -92,9 +92,6 @@ if (process.env.GEARBOX_MCP !== "false") {
app.route("/mcp", mcpRoutes); app.route("/mcp", mcpRoutes);
} }
// Serve uploaded images
app.use("/uploads/*", serveStatic({ root: "./" }));
// Serve Vite-built SPA in production // Serve Vite-built SPA in production
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
app.use("/*", serveStatic({ root: "./dist/client" })); app.use("/*", serveStatic({ root: "./dist/client" }));

View File

@@ -19,7 +19,7 @@ export const imageToolDefinitions = [
{ {
name: "upload_image_from_url", name: "upload_image_from_url",
description: description:
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.", "Fetch an image from a URL and upload it to storage. Returns the filename to use with create_item or add_candidate.",
inputSchema: { inputSchema: {
url: z url: z
.string() .string()

View File

@@ -7,6 +7,7 @@ import {
getItemById, getItemById,
updateItem, updateItem,
} from "../../services/item.service.ts"; } from "../../services/item.service.ts";
import { withImageUrl, withImageUrls } from "../../services/storage.service.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
@@ -95,13 +96,11 @@ export function registerItemTools(db: Db, userId: number) {
return { return {
list_items: async (args: { categoryId?: number }): Promise<ToolResult> => { list_items: async (args: { categoryId?: number }): Promise<ToolResult> => {
try { try {
const items = await getAllItems(db, userId); let items = await getAllItems(db, userId);
if (args.categoryId) { if (args.categoryId) {
return textResult( items = items.filter((i) => i.categoryId === args.categoryId);
items.filter((i) => i.categoryId === args.categoryId),
);
} }
return textResult(items); return textResult(await withImageUrls(items));
} catch (err) { } catch (err) {
return errorResult((err as Error).message); return errorResult((err as Error).message);
} }
@@ -111,7 +110,7 @@ export function registerItemTools(db: Db, userId: number) {
try { try {
const item = await getItemById(db, userId, args.id); const item = await getItemById(db, userId, args.id);
if (!item) return errorResult(`Item ${args.id} not found`); if (!item) return errorResult(`Item ${args.id} not found`);
return textResult(item); return textResult(await withImageUrl(item));
} catch (err) { } catch (err) {
return errorResult((err as Error).message); return errorResult((err as Error).message);
} }

View File

@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts"; import type { db as prodDb } from "../../../db/index.ts";
import { withImageUrls } from "../../services/storage.service.ts";
import { import {
createCandidate, createCandidate,
createThread, createThread,
@@ -134,7 +135,8 @@ export function registerThreadTools(db: Db, userId: number) {
try { try {
const thread = await getThreadWithCandidates(db, userId, args.id); const thread = await getThreadWithCandidates(db, userId, args.id);
if (!thread) return errorResult(`Thread ${args.id} not found`); if (!thread) return errorResult(`Thread ${args.id} not found`);
return textResult(thread); const enrichedCandidates = await withImageUrls(thread.candidates);
return textResult({ ...thread, candidates: enrichedCandidates });
} catch (err) { } catch (err) {
return errorResult((err as Error).message); return errorResult((err as Error).message);
} }

View File

@@ -1,5 +1,3 @@
import { unlink } from "node:fs/promises";
import { join } from "node:path";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono"; import { Hono } from "hono";
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts"; import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
@@ -13,6 +11,11 @@ import {
getItemById, getItemById,
updateItem, updateItem,
} from "../services/item.service.ts"; } from "../services/item.service.ts";
import {
deleteImage,
withImageUrl,
withImageUrls,
} from "../services/storage.service.ts";
type Env = { Variables: { db?: any; userId?: number } }; type Env = { Variables: { db?: any; userId?: number } };
@@ -45,7 +48,7 @@ app.get("/", async (c) => {
const db = c.get("db"); const db = c.get("db");
const userId = c.get("userId")!; const userId = c.get("userId")!;
const items = await getAllItems(db, userId); const items = await getAllItems(db, userId);
return c.json(items); return c.json(await withImageUrls(items));
}); });
app.get("/:id", async (c) => { app.get("/:id", async (c) => {
@@ -55,7 +58,7 @@ app.get("/:id", async (c) => {
if (!id) return c.json({ error: "Invalid item ID" }, 400); if (!id) return c.json({ error: "Invalid item ID" }, 400);
const item = await getItemById(db, userId, id); const item = await getItemById(db, userId, id);
if (!item) return c.json({ error: "Item not found" }, 404); if (!item) return c.json({ error: "Item not found" }, 404);
return c.json(item); return c.json(await withImageUrl(item));
}); });
app.post("/", zValidator("json", createItemSchema), async (c) => { app.post("/", zValidator("json", createItemSchema), async (c) => {
@@ -99,12 +102,12 @@ app.delete("/:id", async (c) => {
const deleted = await deleteItem(db, userId, id); const deleted = await deleteItem(db, userId, id);
if (!deleted) return c.json({ error: "Item not found" }, 404); if (!deleted) return c.json({ error: "Item not found" }, 404);
// Clean up image file if exists // Clean up image from object storage if exists
if (deleted.imageFilename) { if (deleted.imageFilename) {
try { try {
await unlink(join("uploads", deleted.imageFilename)); await deleteImage(deleted.imageFilename);
} catch { } catch {
// File missing is not an error worth failing the delete over // Missing object is not an error worth failing the delete over
} }
} }

View File

@@ -7,6 +7,7 @@ import {
updateSetupSchema, updateSetupSchema,
} from "../../shared/schemas.ts"; } from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts"; import { parseId } from "../lib/params.ts";
import { withImageUrls } from "../services/storage.service.ts";
import { import {
createSetup, createSetup,
deleteSetup, deleteSetup,
@@ -46,7 +47,8 @@ app.get("/:id", async (c) => {
if (!id) return c.json({ error: "Invalid setup ID" }, 400); if (!id) return c.json({ error: "Invalid setup ID" }, 400);
const setup = await getSetupWithItems(db, userId, id); const setup = await getSetupWithItems(db, userId, id);
if (!setup) return c.json({ error: "Setup not found" }, 404); if (!setup) return c.json({ error: "Setup not found" }, 404);
return c.json(setup); const enrichedItems = await withImageUrls(setup.items);
return c.json({ ...setup, items: enrichedItems });
}); });
app.put("/:id", zValidator("json", updateSetupSchema), async (c) => { app.put("/:id", zValidator("json", updateSetupSchema), async (c) => {

View File

@@ -1,5 +1,3 @@
import { unlink } from "node:fs/promises";
import { join } from "node:path";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono"; import { Hono } from "hono";
import { import {
@@ -11,6 +9,10 @@ import {
updateThreadSchema, updateThreadSchema,
} from "../../shared/schemas.ts"; } from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts"; import { parseId } from "../lib/params.ts";
import {
deleteImage,
withImageUrls,
} from "../services/storage.service.ts";
import { import {
createCandidate, createCandidate,
createThread, createThread,
@@ -53,7 +55,8 @@ app.get("/:id", async (c) => {
if (!id) return c.json({ error: "Invalid thread ID" }, 400); if (!id) return c.json({ error: "Invalid thread ID" }, 400);
const thread = await getThreadWithCandidates(db, userId, id); const thread = await getThreadWithCandidates(db, userId, id);
if (!thread) return c.json({ error: "Thread not found" }, 404); if (!thread) return c.json({ error: "Thread not found" }, 404);
return c.json(thread); const enrichedCandidates = await withImageUrls(thread.candidates);
return c.json({ ...thread, candidates: enrichedCandidates });
}); });
app.put("/:id", zValidator("json", updateThreadSchema), async (c) => { app.put("/:id", zValidator("json", updateThreadSchema), async (c) => {
@@ -75,12 +78,12 @@ app.delete("/:id", async (c) => {
const deleted = await deleteThread(db, userId, id); const deleted = await deleteThread(db, userId, id);
if (!deleted) return c.json({ error: "Thread not found" }, 404); if (!deleted) return c.json({ error: "Thread not found" }, 404);
// Clean up candidate image files // Clean up candidate images from object storage
for (const filename of deleted.candidateImages) { for (const filename of deleted.candidateImages) {
try { try {
await unlink(join("uploads", filename)); await deleteImage(filename);
} catch { } catch {
// File missing is not an error worth failing the delete over // Missing object is not an error worth failing the delete over
} }
} }
@@ -131,12 +134,12 @@ app.delete("/:threadId/candidates/:candidateId", async (c) => {
const deleted = await deleteCandidate(db, userId, candidateId); const deleted = await deleteCandidate(db, userId, candidateId);
if (!deleted) return c.json({ error: "Candidate not found" }, 404); if (!deleted) return c.json({ error: "Candidate not found" }, 404);
// Clean up image file if exists // Clean up image from object storage if exists
if (deleted.imageFilename) { if (deleted.imageFilename) {
try { try {
await unlink(join("uploads", deleted.imageFilename)); await deleteImage(deleted.imageFilename);
} catch { } catch {
// File missing is not an error // Missing object is not an error
} }
} }