Add adjustment preset node type validation and enhance render data normalization

- Introduced `adjustmentPresetNodeTypeValidator` for validating new adjustment preset node types.
- Added new constants for render output resolutions and formats, including custom dimension constraints.
- Implemented normalization functions for render data, ensuring proper validation and error handling.
- Updated node creation and update mutations to utilize normalized data for improved consistency.
This commit is contained in:
Matthias
2026-04-02 08:46:55 +02:00
parent 624beac6dc
commit 9bab9bb93d
5 changed files with 255 additions and 12 deletions

View File

@@ -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);

View File

@@ -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<string, unknown> {
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<string, unknown> {
if (!isRecord(data)) {
throw new Error("Render node data must be an object.");
}
assertNoAdjustmentImagePayload("render", data);
const outputResolution = parseRenderOutputResolution(data.outputResolution);
const normalized: Record<string, unknown> = {
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() });
},
});

72
convex/presets.ts Normal file
View File

@@ -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;
},
});

View File

@@ -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
// ==========================================================================

View File

@@ -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<CanvasNodeType>(CANVAS_NODE_TYPES);
const ADJUSTMENT_NODE_TYPE_SET = new Set<AdjustmentNodeType>(ADJUSTMENT_NODE_TYPES);
const ADJUSTMENT_PRESET_NODE_TYPE_SET = new Set<AdjustmentPresetNodeType>(
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);
}