diff --git a/convex/node-type-validator.ts b/convex/node-type-validator.ts index 3e9a104..5b06d9d 100644 --- a/convex/node-type-validator.ts +++ b/convex/node-type-validator.ts @@ -2,6 +2,7 @@ import { v, type Validator } from "convex/values"; import { ADJUSTMENT_NODE_TYPES, + ADJUSTMENT_PRESET_NODE_TYPES, CANVAS_NODE_TYPES, PHASE1_CANVAS_NODE_TYPES, } from "../lib/canvas-node-types"; @@ -21,3 +22,4 @@ function buildNodeTypeUnion< export const phase1NodeTypeValidator = buildNodeTypeUnion(PHASE1_CANVAS_NODE_TYPES); export const nodeTypeValidator = buildNodeTypeUnion(CANVAS_NODE_TYPES); export const adjustmentNodeTypeValidator = buildNodeTypeUnion(ADJUSTMENT_NODE_TYPES); +export const adjustmentPresetNodeTypeValidator = buildNodeTypeUnion(ADJUSTMENT_PRESET_NODE_TYPES); diff --git a/convex/nodes.ts b/convex/nodes.ts index d9199c1..9da9aa3 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -43,13 +43,27 @@ type NodeCreateMutationName = | "nodes.createWithEdgeToTarget"; const DISALLOWED_ADJUSTMENT_DATA_KEYS = [ - "storageId", - "url", "blob", "blobUrl", "imageData", ] as const; +const DISALLOWED_NON_RENDER_ADJUSTMENT_DATA_KEYS = [ + "storageId", + "url", +] as const; + +const RENDER_OUTPUT_RESOLUTIONS = ["original", "2x", "custom"] as const; +const RENDER_FORMATS = ["png", "jpeg", "webp"] as const; +const CUSTOM_RENDER_DIMENSION_MIN = 1; +const CUSTOM_RENDER_DIMENSION_MAX = 16384; +const DEFAULT_RENDER_OUTPUT_RESOLUTION = "original" as const; +const DEFAULT_RENDER_FORMAT = "png" as const; +const DEFAULT_RENDER_JPEG_QUALITY = 90; + +type RenderOutputResolution = (typeof RENDER_OUTPUT_RESOLUTIONS)[number]; +type RenderFormat = (typeof RENDER_FORMATS)[number]; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -69,6 +83,132 @@ function assertNoAdjustmentImagePayload( ); } } + + if (nodeType === "render") { + return; + } + + for (const key of DISALLOWED_NON_RENDER_ADJUSTMENT_DATA_KEYS) { + if (key in data) { + throw new Error( + `Adjustment nodes '${nodeType}' do not allow '${key}' in data.`, + ); + } + } +} + +function parseRenderOutputResolution(value: unknown): RenderOutputResolution { + if (value === undefined) { + return DEFAULT_RENDER_OUTPUT_RESOLUTION; + } + if ( + typeof value !== "string" || + !RENDER_OUTPUT_RESOLUTIONS.includes(value as RenderOutputResolution) + ) { + throw new Error("Render data 'outputResolution' must be one of: original, 2x, custom."); + } + return value as RenderOutputResolution; +} + +function parseRenderCustomDimension(fieldName: "customWidth" | "customHeight", value: unknown): number { + if ( + !Number.isInteger(value) || + (value as number) < CUSTOM_RENDER_DIMENSION_MIN || + (value as number) > CUSTOM_RENDER_DIMENSION_MAX + ) { + throw new Error( + `Render data '${fieldName}' must be an integer between ${CUSTOM_RENDER_DIMENSION_MIN} and ${CUSTOM_RENDER_DIMENSION_MAX}.`, + ); + } + return value as number; +} + +function parseRenderFormat(value: unknown): RenderFormat { + if (value === undefined) { + return DEFAULT_RENDER_FORMAT; + } + if (typeof value !== "string" || !RENDER_FORMATS.includes(value as RenderFormat)) { + throw new Error("Render data 'format' must be one of: png, jpeg, webp."); + } + return value as RenderFormat; +} + +function parseRenderJpegQuality(value: unknown): number { + if (value === undefined) { + return DEFAULT_RENDER_JPEG_QUALITY; + } + if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 100) { + throw new Error("Render data 'jpegQuality' must be an integer between 1 and 100."); + } + return value as number; +} + +function normalizeRenderData(data: unknown): Record { + if (!isRecord(data)) { + throw new Error("Render node data must be an object."); + } + + assertNoAdjustmentImagePayload("render", data); + + const outputResolution = parseRenderOutputResolution(data.outputResolution); + + const normalized: Record = { + outputResolution, + format: parseRenderFormat(data.format), + jpegQuality: parseRenderJpegQuality(data.jpegQuality), + }; + + if (outputResolution === "custom") { + if (data.customWidth !== undefined) { + normalized.customWidth = parseRenderCustomDimension("customWidth", data.customWidth); + } + if (data.customHeight !== undefined) { + normalized.customHeight = parseRenderCustomDimension("customHeight", data.customHeight); + } + } + + if (data.lastRenderedAt !== undefined) { + if (typeof data.lastRenderedAt !== "number" || !Number.isFinite(data.lastRenderedAt)) { + throw new Error("Render data 'lastRenderedAt' must be a finite number."); + } + normalized.lastRenderedAt = data.lastRenderedAt; + } + + if (data.storageId !== undefined) { + if (typeof data.storageId !== "string" || data.storageId.length === 0) { + throw new Error("Render data 'storageId' must be a non-empty string when provided."); + } + normalized.storageId = data.storageId; + } + + if (data.url !== undefined) { + if (typeof data.url !== "string" || data.url.length === 0) { + throw new Error("Render data 'url' must be a non-empty string when provided."); + } + normalized.url = data.url; + } + + return normalized; +} + +function normalizeNodeDataForWrite( + nodeType: Doc<"nodes">["type"], + data: unknown, +): unknown { + if (!isAdjustmentNodeType(nodeType)) { + return data; + } + + if (!isRecord(data)) { + throw new Error(`Adjustment node '${nodeType}' data must be an object.`); + } + + if (nodeType === "render") { + return normalizeRenderData(data); + } + + assertNoAdjustmentImagePayload(nodeType, data); + return data; } async function getIdempotentNodeCreateResult( @@ -243,7 +383,7 @@ export const create = mutation({ return existingNodeId; } - assertNoAdjustmentImagePayload(args.type, args.data); + const normalizedData = normalizeNodeDataForWrite(args.type, args.data); const nodeId = await ctx.db.insert("nodes", { canvasId: args.canvasId, @@ -254,7 +394,7 @@ export const create = mutation({ height: args.height, status: "idle", retryCount: 0, - data: args.data, + data: normalizedData, parentId: args.parentId, zIndex: args.zIndex, }); @@ -313,7 +453,7 @@ export const createWithEdgeSplit = mutation({ throw new Error("Edge not found"); } - assertNoAdjustmentImagePayload(args.type, args.data); + const normalizedData = normalizeNodeDataForWrite(args.type, args.data); const nodeId = await ctx.db.insert("nodes", { canvasId: args.canvasId, @@ -324,7 +464,7 @@ export const createWithEdgeSplit = mutation({ height: args.height, status: "idle", retryCount: 0, - data: args.data, + data: normalizedData, parentId: args.parentId, zIndex: args.zIndex, }); @@ -501,7 +641,7 @@ export const createWithEdgeFromSource = mutation({ throw new Error("Source node not found"); } - assertNoAdjustmentImagePayload(args.type, args.data); + const normalizedData = normalizeNodeDataForWrite(args.type, args.data); const nodeId = await ctx.db.insert("nodes", { canvasId: args.canvasId, @@ -512,7 +652,7 @@ export const createWithEdgeFromSource = mutation({ height: args.height, status: "idle", retryCount: 0, - data: args.data, + data: normalizedData, parentId: args.parentId, zIndex: args.zIndex, }); @@ -577,7 +717,7 @@ export const createWithEdgeToTarget = mutation({ throw new Error("Target node not found"); } - assertNoAdjustmentImagePayload(args.type, args.data); + const normalizedData = normalizeNodeDataForWrite(args.type, args.data); const nodeId = await ctx.db.insert("nodes", { canvasId: args.canvasId, @@ -588,7 +728,7 @@ export const createWithEdgeToTarget = mutation({ height: args.height, status: "idle", retryCount: 0, - data: args.data, + data: normalizedData, parentId: args.parentId, zIndex: args.zIndex, }); @@ -698,8 +838,8 @@ export const updateData = mutation({ if (!node) throw new Error("Node not found"); await getCanvasOrThrow(ctx, node.canvasId, user.userId); - assertNoAdjustmentImagePayload(node.type, data); - await ctx.db.patch(nodeId, { data }); + const normalizedData = normalizeNodeDataForWrite(node.type, data); + await ctx.db.patch(nodeId, { data: normalizedData }); await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); }, }); diff --git a/convex/presets.ts b/convex/presets.ts new file mode 100644 index 0000000..dbb5af1 --- /dev/null +++ b/convex/presets.ts @@ -0,0 +1,72 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +import { requireAuth } from "./helpers"; +import { adjustmentPresetNodeTypeValidator } from "./node-type-validator"; + +export const list = query({ + args: { + nodeType: v.optional(adjustmentPresetNodeTypeValidator), + }, + handler: async (ctx, { nodeType }) => { + const user = await requireAuth(ctx); + + if (nodeType) { + return await ctx.db + .query("adjustmentPresets") + .withIndex("by_userId_nodeType", (q) => + q.eq("userId", user.userId).eq("nodeType", nodeType), + ) + .order("desc") + .collect(); + } + + return await ctx.db + .query("adjustmentPresets") + .withIndex("by_userId", (q) => q.eq("userId", user.userId)) + .order("desc") + .collect(); + }, +}); + +export const save = mutation({ + args: { + name: v.string(), + nodeType: adjustmentPresetNodeTypeValidator, + params: v.any(), + }, + handler: async (ctx, { name, nodeType, params }) => { + const user = await requireAuth(ctx); + const normalizedName = name.trim(); + if (!normalizedName) { + throw new Error("Preset name cannot be empty."); + } + + return await ctx.db.insert("adjustmentPresets", { + userId: user.userId, + name: normalizedName, + nodeType, + params, + createdAt: Date.now(), + }); + }, +}); + +export const remove = mutation({ + args: { + presetId: v.id("adjustmentPresets"), + }, + handler: async (ctx, { presetId }) => { + const user = await requireAuth(ctx); + const preset = await ctx.db.get(presetId); + if (!preset) { + return false; + } + if (preset.userId !== user.userId) { + throw new Error("Forbidden"); + } + + await ctx.db.delete(presetId); + return true; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 3d606ff..1b97263 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -3,6 +3,7 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; import { + adjustmentPresetNodeTypeValidator, nodeTypeValidator, phase1NodeTypeValidator, } from "./node-type-validator"; @@ -19,6 +20,7 @@ const phase1NodeTypes = phase1NodeTypeValidator; // jedem Phasenübergang migriert werden muss. Die UI zeigt nur die Typen // der jeweiligen Phase an. const nodeType = nodeTypeValidator; +const adjustmentPresetNodeType = adjustmentPresetNodeTypeValidator; // Node Status — direkt am Node sichtbar (UX-Strategie aus dem PRD) const nodeStatus = v.union( @@ -175,6 +177,16 @@ export default defineSchema({ "clientRequestId", ]), + adjustmentPresets: defineTable({ + userId: v.string(), + name: v.string(), + nodeType: adjustmentPresetNodeType, + params: v.any(), + createdAt: v.number(), + }) + .index("by_userId", ["userId"]) + .index("by_userId_nodeType", ["userId", "nodeType"]), + // ========================================================================== // Credit-System // ========================================================================== diff --git a/lib/canvas-node-types.ts b/lib/canvas-node-types.ts index 53a2ee6..5af772e 100644 --- a/lib/canvas-node-types.ts +++ b/lib/canvas-node-types.ts @@ -52,12 +52,23 @@ export const ADJUSTMENT_NODE_TYPES = [ "render", ] as const; +export const ADJUSTMENT_PRESET_NODE_TYPES = [ + "curves", + "color-adjust", + "light-adjust", + "detail-adjust", +] as const; + export type CanvasNodeType = (typeof CANVAS_NODE_TYPES)[number]; export type Phase1CanvasNodeType = (typeof PHASE1_CANVAS_NODE_TYPES)[number]; export type AdjustmentNodeType = (typeof ADJUSTMENT_NODE_TYPES)[number]; +export type AdjustmentPresetNodeType = (typeof ADJUSTMENT_PRESET_NODE_TYPES)[number]; const CANVAS_NODE_TYPE_SET = new Set(CANVAS_NODE_TYPES); const ADJUSTMENT_NODE_TYPE_SET = new Set(ADJUSTMENT_NODE_TYPES); +const ADJUSTMENT_PRESET_NODE_TYPE_SET = new Set( + ADJUSTMENT_PRESET_NODE_TYPES, +); export function isCanvasNodeType(value: string): value is CanvasNodeType { return CANVAS_NODE_TYPE_SET.has(value as CanvasNodeType); @@ -66,3 +77,9 @@ export function isCanvasNodeType(value: string): value is CanvasNodeType { export function isAdjustmentNodeType(value: string): value is AdjustmentNodeType { return ADJUSTMENT_NODE_TYPE_SET.has(value as AdjustmentNodeType); } + +export function isAdjustmentPresetNodeType( + value: string, +): value is AdjustmentPresetNodeType { + return ADJUSTMENT_PRESET_NODE_TYPE_SET.has(value as AdjustmentPresetNodeType); +}