feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
|
import {
|
|
|
|
|
DndContext,
|
|
|
|
|
type DragEndEvent,
|
|
|
|
|
KeyboardSensor,
|
|
|
|
|
PointerSensor,
|
|
|
|
|
closestCenter,
|
|
|
|
|
useSensor,
|
|
|
|
|
useSensors,
|
|
|
|
|
} from "@dnd-kit/core";
|
|
|
|
|
import {
|
|
|
|
|
SortableContext,
|
|
|
|
|
arrayMove,
|
|
|
|
|
sortableKeyboardCoordinates,
|
|
|
|
|
verticalListSortingStrategy,
|
|
|
|
|
} from "@dnd-kit/sortable";
|
|
|
|
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { api } from "@/lib/trpc";
|
|
|
|
|
|
|
|
|
|
import { FormFieldCard } from "./form-field-card";
|
|
|
|
|
import { FormFieldConfig } from "./form-field-config";
|
|
|
|
|
import { FormFieldTypePicker } from "./form-field-type-picker";
|
|
|
|
|
|
|
|
|
|
type FormField = {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
type: string;
|
|
|
|
|
required: boolean;
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
helpText?: string;
|
|
|
|
|
options?: { label: string; value: string }[];
|
|
|
|
|
defaultValue?: unknown;
|
|
|
|
|
mappedProperty: string | null;
|
|
|
|
|
validation?: {
|
|
|
|
|
min?: number;
|
|
|
|
|
max?: number;
|
|
|
|
|
pattern?: string;
|
|
|
|
|
maxLength?: number;
|
|
|
|
|
};
|
|
|
|
|
conditionals?: {
|
|
|
|
|
fieldId: string;
|
|
|
|
|
operator: string;
|
|
|
|
|
value: unknown;
|
|
|
|
|
action: string;
|
|
|
|
|
}[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Draft = {
|
|
|
|
|
title: string;
|
|
|
|
|
description: string | null;
|
|
|
|
|
isPublished: boolean;
|
|
|
|
|
fields: FormField[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function normalizeFields(raw: unknown): FormField[] {
|
|
|
|
|
if (!Array.isArray(raw)) return [];
|
|
|
|
|
return raw.map((item, i) => {
|
|
|
|
|
const o = item as Record<string, unknown>;
|
|
|
|
|
return {
|
|
|
|
|
id: typeof o.id === "string" ? o.id : `field_${i}`,
|
|
|
|
|
label: typeof o.label === "string" ? o.label : "Untitled",
|
|
|
|
|
type: typeof o.type === "string" ? o.type : "short_text",
|
|
|
|
|
required: Boolean(o.required),
|
|
|
|
|
placeholder:
|
|
|
|
|
typeof o.placeholder === "string" ? o.placeholder : undefined,
|
|
|
|
|
helpText: typeof o.helpText === "string" ? o.helpText : undefined,
|
|
|
|
|
options: Array.isArray(o.options) ? (o.options as FormField["options"]) : undefined,
|
|
|
|
|
defaultValue: o.defaultValue,
|
|
|
|
|
mappedProperty:
|
|
|
|
|
o.mappedProperty === null || typeof o.mappedProperty === "string"
|
|
|
|
|
? (o.mappedProperty as string | null)
|
|
|
|
|
: null,
|
|
|
|
|
validation:
|
|
|
|
|
o.validation && typeof o.validation === "object"
|
|
|
|
|
? (o.validation as FormField["validation"])
|
|
|
|
|
: undefined,
|
|
|
|
|
conditionals: Array.isArray(o.conditionals)
|
|
|
|
|
? (o.conditionals as FormField["conditionals"])
|
|
|
|
|
: undefined,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createField(type: string): FormField {
|
|
|
|
|
const id =
|
|
|
|
|
typeof crypto !== "undefined" && crypto.randomUUID
|
|
|
|
|
? crypto.randomUUID()
|
|
|
|
|
: `fld_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
|
|
|
|
|
|
|
|
const field: FormField = {
|
|
|
|
|
id,
|
|
|
|
|
label: "Untitled",
|
|
|
|
|
type,
|
|
|
|
|
required: false,
|
|
|
|
|
mappedProperty: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (type === "select" || type === "multi_select" || type === "radio") {
|
|
|
|
|
field.options = [
|
|
|
|
|
{ label: "Option 1", value: "option_1" },
|
|
|
|
|
{ label: "Option 2", value: "option_2" },
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return field;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function FormBuilder({
|
|
|
|
|
formId,
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
workspaceHandle,
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
}: {
|
|
|
|
|
formId: string;
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
workspaceHandle: string;
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
}) {
|
|
|
|
|
const utils = api.useUtils();
|
|
|
|
|
const [draft, setDraft] = useState<Draft | null>(null);
|
|
|
|
|
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
|
|
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
|
|
|
const hydratedRef = useRef(false);
|
|
|
|
|
const skipSaveRef = useRef(false);
|
|
|
|
|
|
|
|
|
|
const formQuery = api.forms.getById.useQuery(
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
{ workspace: workspaceHandle, id: formId },
|
|
|
|
|
{ enabled: Boolean(formId) && Boolean(workspaceHandle) },
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const updateMutation = api.forms.update.useMutation({
|
|
|
|
|
onSuccess: () => {
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
void utils.forms.getById.invalidate({ workspace: workspaceHandle, id: formId });
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
hydratedRef.current = false;
|
|
|
|
|
setDraft(null);
|
|
|
|
|
setSelectedFieldId(null);
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
}, [formId, workspaceHandle]);
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (
|
|
|
|
|
!formQuery.isSuccess ||
|
|
|
|
|
!formQuery.data ||
|
|
|
|
|
formQuery.data.id !== formId
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (hydratedRef.current) return;
|
|
|
|
|
const row = formQuery.data;
|
|
|
|
|
setDraft({
|
|
|
|
|
title: row.title,
|
|
|
|
|
description: row.description ?? null,
|
|
|
|
|
isPublished: row.isPublished,
|
|
|
|
|
fields: normalizeFields(row.fields),
|
|
|
|
|
});
|
|
|
|
|
hydratedRef.current = true;
|
|
|
|
|
skipSaveRef.current = true;
|
|
|
|
|
}, [formQuery.isSuccess, formQuery.data, formId]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!draft || !hydratedRef.current) return;
|
|
|
|
|
if (skipSaveRef.current) {
|
|
|
|
|
skipSaveRef.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
const t = setTimeout(() => {
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
updateMutation.mutate({
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
workspace: workspaceHandle,
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
id: formId,
|
|
|
|
|
title: draft.title,
|
|
|
|
|
description: draft.description,
|
|
|
|
|
fields: draft.fields,
|
|
|
|
|
isPublished: draft.isPublished,
|
|
|
|
|
});
|
|
|
|
|
}, 550);
|
|
|
|
|
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
return () => clearTimeout(t);
|
|
|
|
|
}, [draft, formId, workspaceHandle, updateMutation]);
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
|
|
|
|
|
const sensors = useSensors(
|
|
|
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
|
|
|
|
useSensor(KeyboardSensor, {
|
|
|
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onDragEnd = (event: DragEndEvent) => {
|
|
|
|
|
const { active, over } = event;
|
|
|
|
|
if (!over || active.id === over.id || !draft) return;
|
|
|
|
|
|
|
|
|
|
const oldIndex = draft.fields.findIndex((f) => f.id === active.id);
|
|
|
|
|
const newIndex = draft.fields.findIndex((f) => f.id === over.id);
|
|
|
|
|
if (oldIndex < 0 || newIndex < 0) return;
|
|
|
|
|
|
|
|
|
|
setDraft({
|
|
|
|
|
...draft,
|
|
|
|
|
fields: arrayMove(draft.fields, oldIndex, newIndex),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateField = (id: string, patch: Partial<FormField>) => {
|
|
|
|
|
setDraft((d) => {
|
|
|
|
|
if (!d) return d;
|
|
|
|
|
return {
|
|
|
|
|
...d,
|
|
|
|
|
fields: d.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deleteField = (id: string) => {
|
|
|
|
|
setDraft((d) => {
|
|
|
|
|
if (!d) return d;
|
|
|
|
|
return {
|
|
|
|
|
...d,
|
|
|
|
|
fields: d.fields.filter((f) => f.id !== id),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
setSelectedFieldId((cur) => (cur === id ? null : cur));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const selectedField = draft?.fields.find((f) => f.id === selectedFieldId);
|
|
|
|
|
|
|
|
|
|
if (formQuery.isLoading || draft === null) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg border border-dashed border-border bg-muted/20 p-12 text-center text-sm text-muted-foreground">
|
|
|
|
|
Loading form…
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (formQuery.isError) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-sm text-destructive">
|
|
|
|
|
Could not load this form.
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
|
|
|
|
|
<div className="min-w-0 flex-1 space-y-6">
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<Input
|
|
|
|
|
value={draft.title}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
setDraft((d) => (d ? { ...d, title: e.target.value } : d))
|
|
|
|
|
}
|
|
|
|
|
className="text-2xl font-semibold tracking-tight"
|
|
|
|
|
placeholder="Form title"
|
|
|
|
|
/>
|
|
|
|
|
<textarea
|
|
|
|
|
value={draft.description ?? ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
setDraft((d) =>
|
|
|
|
|
d
|
|
|
|
|
? {
|
|
|
|
|
...d,
|
|
|
|
|
description: e.target.value || null,
|
|
|
|
|
}
|
|
|
|
|
: d,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
placeholder="Description (optional)"
|
|
|
|
|
rows={3}
|
|
|
|
|
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 text-sm font-medium shadow-sm">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={draft.isPublished}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
setDraft((d) =>
|
|
|
|
|
d ? { ...d, isPublished: e.target.checked } : d,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
className="size-4 rounded border-input accent-primary"
|
|
|
|
|
/>
|
|
|
|
|
Published
|
|
|
|
|
<span className="text-xs font-normal text-muted-foreground">
|
|
|
|
|
When on, the form can accept responses (when wired).
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
|
|
|
<h2 className="text-sm font-semibold text-foreground">Fields</h2>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setPickerOpen((o) => !o)}
|
|
|
|
|
>
|
|
|
|
|
{pickerOpen ? "Close picker" : "Add field"}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{pickerOpen ? (
|
|
|
|
|
<div className="rounded-lg border border-border bg-muted/20 p-3">
|
|
|
|
|
<p className="mb-2 text-xs text-muted-foreground">
|
|
|
|
|
Choose a field type
|
|
|
|
|
</p>
|
|
|
|
|
<FormFieldTypePicker
|
|
|
|
|
onSelect={(type) => {
|
|
|
|
|
const next = createField(type);
|
|
|
|
|
setDraft((d) =>
|
|
|
|
|
d ? { ...d, fields: [...d.fields, next] } : d,
|
|
|
|
|
);
|
|
|
|
|
setSelectedFieldId(next.id);
|
|
|
|
|
setPickerOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<DndContext
|
|
|
|
|
sensors={sensors}
|
|
|
|
|
collisionDetection={closestCenter}
|
|
|
|
|
onDragEnd={onDragEnd}
|
|
|
|
|
>
|
|
|
|
|
<SortableContext
|
|
|
|
|
items={draft.fields.map((f) => f.id)}
|
|
|
|
|
strategy={verticalListSortingStrategy}
|
|
|
|
|
>
|
|
|
|
|
{draft.fields.length === 0 ? (
|
|
|
|
|
<p className="rounded-lg border border-dashed border-border py-10 text-center text-sm text-muted-foreground">
|
|
|
|
|
No fields yet. Add a field to start building.
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="flex flex-col gap-2">
|
|
|
|
|
{draft.fields.map((field) => (
|
|
|
|
|
<li key={field.id}>
|
|
|
|
|
<FormFieldCard
|
|
|
|
|
field={field}
|
|
|
|
|
isSelected={field.id === selectedFieldId}
|
|
|
|
|
onSelect={() => setSelectedFieldId(field.id)}
|
|
|
|
|
onChange={(patch) => updateField(field.id, patch)}
|
|
|
|
|
onDelete={() => deleteField(field.id)}
|
|
|
|
|
/>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</DndContext>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{updateMutation.isError ? (
|
|
|
|
|
<p className="text-xs text-destructive">
|
|
|
|
|
Failed to save changes. Try again.
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<aside className="w-full shrink-0 lg:sticky lg:top-6 lg:w-96">
|
|
|
|
|
{selectedField ? (
|
|
|
|
|
<FormFieldConfig
|
|
|
|
|
key={selectedField.id}
|
|
|
|
|
field={selectedField}
|
|
|
|
|
allFields={draft.fields}
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
workspaceHandle={workspaceHandle}
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
onChange={(patch) => updateField(selectedField.id, patch)}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="rounded-lg border border-dashed border-border bg-muted/10 p-6 text-center text-sm text-muted-foreground">
|
|
|
|
|
Select a field to edit its settings, validation, mapping, and
|
|
|
|
|
conditional rules.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</aside>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|