Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/green-emus-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@branchforge/frontend": patch
"@branchforge/backend": patch
---

Added per-project visual system configuration
2 changes: 2 additions & 0 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { statsRoutes } from "./routes/stats.routes.js";
import { zipImportRoutes } from "./routes/zip-import.routes.js";
import { flowRoutes } from "./routes/flow.routes.js";
import { exportsRoutes } from "./routes/exports.routes.js";
import { visualSystemsRoutes } from "./routes/visual-systems.routes.js";
import { createDrizzleSessionStore } from "./services/session-store.service.js";
import { setupShutdownHandlers } from "./lib/shutdown.js";
import { globalErrorHandler } from "./middleware/error-handler.middleware.js";
Expand Down Expand Up @@ -157,6 +158,7 @@ await server.register(statsRoutes, { prefix: basePath });
await server.register(zipImportRoutes, { prefix: basePath });
await server.register(flowRoutes, { prefix: basePath });
await server.register(exportsRoutes, { prefix: basePath });
await server.register(visualSystemsRoutes, { prefix: basePath });

// Register a global preValidation hook that enforces double-submit
// CSRF protection on every state-changing request. The hook itself
Expand Down
98 changes: 98 additions & 0 deletions apps/backend/src/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,103 @@ export const projectSettingsSchema = z
.strict()
.partial();

/**
* Padding value for zero-padded number fields in visual system config.
* Matches the `1 | 2` union declared in VisualSystemConfig.
*/
const paddingSchema = z.union([z.literal(1), z.literal(2)], {
message: "Padding must be 1 or 2",
});

/**
* Group prefix entry: maps a group value (e.g. "I", "1") to a short
* filename prefix (e.g. "ai", "ch1"). Keys are non-empty trimmed strings.
*/
const groupPrefixEntrySchema = z.record(
z.string().trim().min(1),
z
.string()
.trim()
.min(1, "Group prefix cannot be empty")
.max(50, "Group prefix is too long")
);

/**
* Visual system config validation.
*
* The wire shape matches the shared `VisualSystemConfig` interface so
* frontend and backend stay in lockstep. All fields are optional in
* the update schema (`.partial()`) so clients can PATCH a subset.
*
* Clearing semantics: `defaultGroupType: ""` and `placeholderBaseUrl: ""`
* are explicitly accepted so clients can send an empty string to clear
* a previously-set value back to NULL. `groupPrefixes: {}` clears the
* stored map back to NULL too. The service layer converts these
* sentinels into `null` on write.
*/
export const visualSystemConfigSchema = z
.object({
namingTemplate: z
.string()
.trim()
.min(1, "Naming template is required")
.max(500, "Naming template is too long"),
groupPrefixes: z
.record(z.string().trim().min(1), groupPrefixEntrySchema)
.optional(),
defaultGroupType: z
.string()
.trim()
.max(50, "Default group type is too long")
.optional()
.or(z.literal("")),
labelPadding: paddingSchema,
counterPadding: paddingSchema,
jumpPrefixShared: z
.string()
.trim()
.min(1, "Shared jump prefix is required")
.max(100, "Shared jump prefix is too long")
// Empty string is the default value (see
// VISUAL_SYSTEM_CONFIG_DEFAULTS) and the clearing sentinel; the
// service stores it as-is since the column is NOT NULL and the
// shared `VisualSystemConfig` type declares the field as required.
.or(z.literal("")),
placeholderBaseUrl: z
.string()
.trim()
.max(2000, "Placeholder base URL is too long")
.refine(
(value) => {
if (value === "") return true;
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
},
{ message: "Placeholder base URL must be an http or https URL" }
)
.optional()
.or(z.literal("")),
})
.strict()
.partial();

/**
* Visual system config defaults — applied when a project has no row in
* `visual_systems` yet (or when the client sends only a partial update).
*
* Kept in sync with the column defaults in `db/schema/tables/visual-systems.ts`.
*/
export const VISUAL_SYSTEM_CONFIG_DEFAULTS = {
namingTemplate: "{route}{group}_{label}_{counter}_{slug}",
labelPadding: 2,
counterPadding: 2,
jumpPrefixShared: "",
} as const;

/**
* Character ID params validation
*/
Expand Down Expand Up @@ -1133,6 +1230,7 @@ export type CreateCharacterInput = z.infer<typeof createCharacterSchema>;
export type UpdateCharacterInput = z.infer<typeof updateCharacterSchema>;
export type ImportCharactersInput = z.infer<typeof importCharactersSchema>;
export type ProjectSettingsInput = z.infer<typeof projectSettingsSchema>;
export type VisualSystemConfigInput = z.infer<typeof visualSystemConfigSchema>;
export type DetectedCharacterInput = z.infer<typeof detectedCharacterSchema>;
export type CreateGitLabIntegrationInput = z.infer<
typeof createGitLabIntegrationSchema
Expand Down
Loading
Loading