From 57fb979754c005dce802c4f232b17eed760b50e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 08:57:21 +0200 Subject: [PATCH] refactor(api): extract project cover procedures --- packages/api/src/router/project-cover.ts | 212 ++++++++++++++++++++++ packages/api/src/router/project.ts | 213 +---------------------- 2 files changed, 215 insertions(+), 210 deletions(-) create mode 100644 packages/api/src/router/project-cover.ts diff --git a/packages/api/src/router/project-cover.ts b/packages/api/src/router/project-cover.ts new file mode 100644 index 0000000..6c3943e --- /dev/null +++ b/packages/api/src/router/project-cover.ts @@ -0,0 +1,212 @@ +import { PermissionKey } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js"; +import { validateImageDataUrl } from "../lib/image-validation.js"; +import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; +import { managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; + +const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload) + +async function readImageGenerationStatus(db: { + systemSettings: { + findUnique: (args: { where: { id: string } }) => Promise | null>; + }; +}) { + const settings = await db.systemSettings.findUnique({ + where: { id: "singleton" }, + }); + const imageProvider = settings?.["imageProvider"] === "gemini" ? "gemini" : "dalle"; + const configured = imageProvider === "gemini" + ? isGeminiConfigured(settings) + : isDalleConfigured(settings); + + return { + configured, + provider: imageProvider, + }; +} + +export const projectCoverProcedures = { + generateCover: managerProcedure + .input(z.object({ + projectId: z.string(), + prompt: z.string().max(500).optional(), + })) + .mutation(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); + + const project = await findUniqueOrThrow( + ctx.db.project.findUnique({ + where: { id: input.projectId }, + include: { client: { select: { name: true } } }, + }), + "Project", + ); + + const settings = await ctx.db.systemSettings.findUnique({ + where: { id: "singleton" }, + }); + + const runtimeSettings = resolveSystemSettingsRuntime(settings); + const imageProvider = runtimeSettings.imageProvider ?? "dalle"; + const useGemini = imageProvider === "gemini" && isGeminiConfigured(runtimeSettings); + const useDalle = imageProvider === "dalle" && isDalleConfigured(runtimeSettings); + + if (!useGemini && !useDalle) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "No image provider configured. Set up DALL-E or Gemini in Admin → Settings.", + }); + } + + const clientName = project.client?.name ? ` for ${project.client.name}` : ""; + const basePrompt = `Professional cover art for a 3D automotive visualization project: "${project.name}"${clientName}. Style: cinematic, modern, photorealistic CGI rendering, dramatic lighting, studio environment. No text or typography in the image.`; + const finalPrompt = input.prompt + ? `${basePrompt} Additional direction: ${input.prompt}` + : basePrompt; + + let coverImageUrl: string; + + if (useGemini) { + try { + coverImageUrl = await generateGeminiImage( + runtimeSettings.geminiApiKey!, + finalPrompt, + runtimeSettings.geminiModel ?? undefined, + ); + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Gemini error: ${parseGeminiError(err)}`, + }); + } + } else { + const dalleClient = createDalleClient(runtimeSettings); + const model = runtimeSettings.aiProvider === "azure" ? runtimeSettings.azureDalleDeployment! : "dall-e-3"; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let response: any; + try { + response = await loggedAiCall("dalle", model, finalPrompt.length, () => + dalleClient.images.generate({ + model, + prompt: finalPrompt, + size: "1024x1024", + n: 1, + response_format: "b64_json", + }), + ); + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `DALL-E error: ${parseAiError(err)}`, + }); + } + + const b64 = response.data?.[0]?.b64_json; + if (!b64) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "No image data returned from DALL-E", + }); + } + + coverImageUrl = `data:image/png;base64,${b64}`; + } + + await ctx.db.project.update({ + where: { id: input.projectId }, + data: { coverImageUrl }, + }); + + return { coverImageUrl }; + }), + + uploadCover: managerProcedure + .input(z.object({ + projectId: z.string(), + imageDataUrl: z.string(), + })) + .mutation(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); + + if (!input.imageDataUrl.startsWith("data:image/")) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid image format. Must be a data URL starting with 'data:image/'.", + }); + } + + const magicCheck = validateImageDataUrl(input.imageDataUrl); + if (!magicCheck.valid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `File validation failed: ${magicCheck.reason}`, + }); + } + + if (input.imageDataUrl.length > MAX_COVER_SIZE) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Image too large. Maximum compressed size is 4 MB.", + }); + } + + await findUniqueOrThrow( + ctx.db.project.findUnique({ where: { id: input.projectId } }), + "Project", + ); + + await ctx.db.project.update({ + where: { id: input.projectId }, + data: { coverImageUrl: input.imageDataUrl }, + }); + + return { coverImageUrl: input.imageDataUrl }; + }), + + removeCover: managerProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); + + await findUniqueOrThrow( + ctx.db.project.findUnique({ where: { id: input.projectId } }), + "Project", + ); + + await ctx.db.project.update({ + where: { id: input.projectId }, + data: { coverImageUrl: null }, + }); + + return { ok: true }; + }), + + updateCoverFocus: managerProcedure + .input(z.object({ + projectId: z.string(), + coverFocusY: z.number().int().min(0).max(100), + })) + .mutation(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); + await ctx.db.project.update({ + where: { id: input.projectId }, + data: { coverFocusY: input.coverFocusY }, + }); + return { ok: true }; + }), + + isImageGenConfigured: protectedProcedure + .query(async ({ ctx }) => readImageGenerationStatus(ctx.db)), + + /** @deprecated Use isImageGenConfigured instead */ + isDalleConfigured: protectedProcedure + .query(async ({ ctx }) => { + const { configured } = await readImageGenerationStatus(ctx.db); + return { configured }; + }), +}; diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index 60eec70..9c1979e 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -11,29 +11,18 @@ import { findUniqueOrThrow } from "../db/helpers.js"; import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js"; import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js"; +import { projectCoverProcedures } from "./project-cover.js"; import { projectIdentifierReadProcedures } from "./project-identifier-read.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; -import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, planningReadProcedure, protectedProcedure, requirePermission } from "../trpc.js"; -import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; -import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js"; +import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; import { invalidateDashboardCache } from "../lib/cache.js"; import { logger } from "../lib/logger.js"; -import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; -import { validateImageDataUrl } from "../lib/image-validation.js"; import type { TRPCContext } from "../trpc.js"; import { calculateEffectiveBookedHours, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; -import { - PROJECT_DETAIL_SELECT, - PROJECT_IDENTIFIER_SELECT, - PROJECT_SUMMARY_DETAIL_SELECT, - PROJECT_SUMMARY_SELECT, -} from "./project-read-shared.js"; - -const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload) function runProjectBackgroundEffect( effectName: string, @@ -67,6 +56,7 @@ function dispatchProjectWebhookInBackground( } export const projectRouter = createTRPCRouter({ + ...projectCoverProcedures, ...projectIdentifierReadProcedures, list: controllerProcedure @@ -541,201 +531,4 @@ export const projectRouter = createTRPCRouter({ return { count: projects.length }; }), - // ─── Cover Art ────────────────────────────────────────────────────────────── - - generateCover: managerProcedure - .input(z.object({ - projectId: z.string(), - prompt: z.string().max(500).optional(), - })) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - - const project = await findUniqueOrThrow( - ctx.db.project.findUnique({ - where: { id: input.projectId }, - include: { client: { select: { name: true } } }, - }), - "Project", - ); - - const settings = await ctx.db.systemSettings.findUnique({ - where: { id: "singleton" }, - }); - - const runtimeSettings = resolveSystemSettingsRuntime(settings); - const imageProvider = runtimeSettings.imageProvider ?? "dalle"; - const useGemini = imageProvider === "gemini" && isGeminiConfigured(runtimeSettings); - const useDalle = imageProvider === "dalle" && isDalleConfigured(runtimeSettings); - - if (!useGemini && !useDalle) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "No image provider configured. Set up DALL-E or Gemini in Admin → Settings.", - }); - } - - const clientName = project.client?.name ? ` for ${project.client.name}` : ""; - const basePrompt = `Professional cover art for a 3D automotive visualization project: "${project.name}"${clientName}. Style: cinematic, modern, photorealistic CGI rendering, dramatic lighting, studio environment. No text or typography in the image.`; - const finalPrompt = input.prompt - ? `${basePrompt} Additional direction: ${input.prompt}` - : basePrompt; - - let coverImageUrl: string; - - if (useGemini) { - try { - coverImageUrl = await generateGeminiImage( - runtimeSettings.geminiApiKey!, - finalPrompt, - runtimeSettings.geminiModel ?? undefined, - ); - } catch (err) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Gemini error: ${parseGeminiError(err)}`, - }); - } - } else { - const dalleClient = createDalleClient(runtimeSettings); - const model = runtimeSettings.aiProvider === "azure" ? runtimeSettings.azureDalleDeployment! : "dall-e-3"; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let response: any; - try { - response = await loggedAiCall("dalle", model, finalPrompt.length, () => - dalleClient.images.generate({ - model, - prompt: finalPrompt, - size: "1024x1024", - n: 1, - response_format: "b64_json", - }), - ); - } catch (err) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `DALL-E error: ${parseAiError(err)}`, - }); - } - - const b64 = response.data?.[0]?.b64_json; - if (!b64) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "No image data returned from DALL-E", - }); - } - - coverImageUrl = `data:image/png;base64,${b64}`; - } - - await ctx.db.project.update({ - where: { id: input.projectId }, - data: { coverImageUrl }, - }); - - return { coverImageUrl }; - }), - - uploadCover: managerProcedure - .input(z.object({ - projectId: z.string(), - imageDataUrl: z.string(), - })) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - - if (!input.imageDataUrl.startsWith("data:image/")) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid image format. Must be a data URL starting with 'data:image/'.", - }); - } - - // Validate magic bytes match declared MIME type - const magicCheck = validateImageDataUrl(input.imageDataUrl); - if (!magicCheck.valid) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `File validation failed: ${magicCheck.reason}`, - }); - } - - if (input.imageDataUrl.length > MAX_COVER_SIZE) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Image too large. Maximum compressed size is 4 MB.", - }); - } - - await findUniqueOrThrow( - ctx.db.project.findUnique({ where: { id: input.projectId } }), - "Project", - ); - - await ctx.db.project.update({ - where: { id: input.projectId }, - data: { coverImageUrl: input.imageDataUrl }, - }); - - return { coverImageUrl: input.imageDataUrl }; - }), - - removeCover: managerProcedure - .input(z.object({ projectId: z.string() })) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - - await findUniqueOrThrow( - ctx.db.project.findUnique({ where: { id: input.projectId } }), - "Project", - ); - - await ctx.db.project.update({ - where: { id: input.projectId }, - data: { coverImageUrl: null }, - }); - - return { ok: true }; - }), - - updateCoverFocus: managerProcedure - .input(z.object({ - projectId: z.string(), - coverFocusY: z.number().int().min(0).max(100), - })) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - await ctx.db.project.update({ - where: { id: input.projectId }, - data: { coverFocusY: input.coverFocusY }, - }); - return { ok: true }; - }), - - isImageGenConfigured: protectedProcedure - .query(async ({ ctx }) => { - const settings = await ctx.db.systemSettings.findUnique({ - where: { id: "singleton" }, - }); - const imageProvider = settings?.imageProvider ?? "dalle"; - const configured = imageProvider === "gemini" - ? isGeminiConfigured(settings) - : isDalleConfigured(settings); - return { configured, provider: imageProvider }; - }), - - /** @deprecated Use isImageGenConfigured instead */ - isDalleConfigured: protectedProcedure - .query(async ({ ctx }) => { - const settings = await ctx.db.systemSettings.findUnique({ - where: { id: "singleton" }, - }); - const imageProvider = settings?.imageProvider ?? "dalle"; - const configured = imageProvider === "gemini" - ? isGeminiConfigured(settings) - : isDalleConfigured(settings); - return { configured }; - }), });