@fayz-ai/plugin-forms 0.2.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +2 -0
  2. package/dist/{FormBuilder-KDANEL6Z.js → FormBuilder-7SWZQKFN.js} +130 -23
  3. package/dist/FormBuilder-7SWZQKFN.js.map +1 -0
  4. package/dist/{FormBuilder-4VKYVZAC.cjs → FormBuilder-YB75LEEH.cjs} +129 -22
  5. package/dist/FormBuilder-YB75LEEH.cjs.map +1 -0
  6. package/dist/agent-tools.d.ts +4 -0
  7. package/dist/agent-tools.d.ts.map +1 -0
  8. package/dist/chunk-QM2AF3DZ.cjs +446 -0
  9. package/dist/chunk-QM2AF3DZ.cjs.map +1 -0
  10. package/dist/chunk-W2ZNJGQC.js +438 -0
  11. package/dist/chunk-W2ZNJGQC.js.map +1 -0
  12. package/dist/components/DocumentFormDialog.d.ts +3 -1
  13. package/dist/components/DocumentFormDialog.d.ts.map +1 -1
  14. package/dist/components/DocumentList.d.ts.map +1 -1
  15. package/dist/components/FieldConfigDialog.d.ts.map +1 -1
  16. package/dist/components/FormBuilder.d.ts.map +1 -1
  17. package/dist/components/FormViewer.d.ts.map +1 -1
  18. package/dist/components/PersonDocumentsWidget.d.ts.map +1 -1
  19. package/dist/data/supabase.d.ts.map +1 -1
  20. package/dist/data/tables.d.ts +7 -0
  21. package/dist/data/tables.d.ts.map +1 -0
  22. package/dist/index.cjs +168 -72
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +168 -72
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib/person-fields.d.ts +16 -0
  28. package/dist/lib/person-fields.d.ts.map +1 -0
  29. package/dist/lib/print.d.ts +17 -0
  30. package/dist/lib/print.d.ts.map +1 -0
  31. package/dist/lib/tenant.d.ts.map +1 -1
  32. package/dist/locales/en.d.ts.map +1 -1
  33. package/dist/locales/pt-BR.d.ts.map +1 -1
  34. package/dist/migrations/index.d.ts +9 -0
  35. package/dist/migrations/index.d.ts.map +1 -0
  36. package/dist/types.d.ts +15 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/views/CustomFormsSettingsTab.d.ts.map +1 -1
  39. package/dist/views/TemplateListView.d.ts.map +1 -1
  40. package/package.json +10 -4
  41. package/src/agent-tools.ts +73 -0
  42. package/src/components/DocumentFormDialog.tsx +37 -16
  43. package/src/components/DocumentList.tsx +12 -8
  44. package/src/components/FieldConfigDialog.tsx +40 -0
  45. package/src/components/FormBuilder.tsx +104 -22
  46. package/src/components/FormViewer.tsx +11 -1
  47. package/src/components/PersonDocumentsWidget.tsx +10 -1
  48. package/src/data/supabase.ts +22 -19
  49. package/src/data/tables.ts +7 -0
  50. package/src/index.ts +23 -1
  51. package/src/lib/person-fields.ts +67 -0
  52. package/src/lib/print.ts +207 -0
  53. package/src/lib/tenant.ts +6 -2
  54. package/src/locales/en.ts +20 -0
  55. package/src/locales/pt-BR.ts +20 -0
  56. package/src/migrations/000_plg_rename.sql +21 -0
  57. package/src/migrations/001_frm_base.sql +92 -56
  58. package/src/migrations/002_document_archetype.sql +130 -42
  59. package/src/migrations/003_agent_rpcs.sql +174 -0
  60. package/src/migrations/index.ts +610 -0
  61. package/src/types.ts +18 -4
  62. package/src/views/CustomFormsSettingsTab.tsx +23 -16
  63. package/src/views/TemplateListView.tsx +29 -18
  64. package/dist/FormBuilder-4VKYVZAC.cjs.map +0 -1
  65. package/dist/FormBuilder-KDANEL6Z.js.map +0 -1
  66. package/dist/chunk-32I43T37.js +0 -198
  67. package/dist/chunk-32I43T37.js.map +0 -1
  68. package/dist/chunk-XLUULIVL.cjs +0 -200
  69. package/dist/chunk-XLUULIVL.cjs.map +0 -1
@@ -1,16 +1,26 @@
1
1
  -- ============================================================
2
- -- Document Archetype — saas_core.documents
2
+ -- Document Archetype — public.documents
3
3
  -- A document is any record associated with a person (or standalone):
4
4
  -- forms, images, attachments, prescriptions, contracts, etc.
5
5
  -- The `kind` column discriminates the type.
6
+ --
7
+ -- DATA SAFETY (converted pools): plg_forms_documents may already hold REAL
8
+ -- rows in its PRE-archetype shape (its own `id` PK, no `document_id` column —
9
+ -- see 001_frm_base.sql). This migration MIGRATES those rows into the new
10
+ -- document archetype instead of dropping them:
11
+ -- * pre-archetype + rows -> copy each legacy row into public.documents
12
+ -- (reusing its id) + into the new extension
13
+ -- table, migrate its files, then drop legacy.
14
+ -- * pre-archetype + empty -> plain drop + create (fresh shape).
15
+ -- * already new shape -> no-op.
6
16
  -- ============================================================
7
17
 
8
- CREATE TABLE IF NOT EXISTS saas_core.documents (
18
+ CREATE TABLE IF NOT EXISTS public.documents (
9
19
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
10
- tenant_id uuid NOT NULL REFERENCES saas_core.tenants(id) ON DELETE CASCADE,
20
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
11
21
  kind text NOT NULL DEFAULT 'attachment',
12
22
  -- kind: 'form', 'image', 'attachment', 'prescription', 'contract', etc.
13
- person_id uuid REFERENCES saas_core.persons(id) ON DELETE SET NULL,
23
+ person_id uuid REFERENCES public.people(id) ON DELETE SET NULL,
14
24
  title text,
15
25
  description text,
16
26
  status text NOT NULL DEFAULT 'draft',
@@ -29,39 +39,63 @@ CREATE TABLE IF NOT EXISTS saas_core.documents (
29
39
  updated_at timestamptz NOT NULL DEFAULT now()
30
40
  );
31
41
 
32
- ALTER TABLE saas_core.documents ENABLE ROW LEVEL SECURITY;
42
+ ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
33
43
 
34
- CREATE INDEX IF NOT EXISTS idx_documents_tenant ON saas_core.documents(tenant_id, kind);
35
- CREATE INDEX IF NOT EXISTS idx_documents_person ON saas_core.documents(person_id);
36
- CREATE INDEX IF NOT EXISTS idx_documents_status ON saas_core.documents(tenant_id, status);
44
+ CREATE INDEX IF NOT EXISTS idx_documents_tenant ON public.documents(tenant_id, kind);
45
+ CREATE INDEX IF NOT EXISTS idx_documents_person ON public.documents(person_id);
46
+ CREATE INDEX IF NOT EXISTS idx_documents_status ON public.documents(tenant_id, status);
37
47
 
38
- CREATE POLICY "documents_select" ON saas_core.documents
48
+ DROP POLICY IF EXISTS "documents_select" ON public.documents;
49
+ CREATE POLICY "documents_select" ON public.documents
39
50
  FOR SELECT TO authenticated
40
51
  USING (tenant_id IN (SELECT public.user_tenant_ids()));
41
- CREATE POLICY "documents_insert" ON saas_core.documents
52
+ DROP POLICY IF EXISTS "documents_insert" ON public.documents;
53
+ CREATE POLICY "documents_insert" ON public.documents
42
54
  FOR INSERT TO authenticated
43
55
  WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
44
- CREATE POLICY "documents_update" ON saas_core.documents
56
+ DROP POLICY IF EXISTS "documents_update" ON public.documents;
57
+ CREATE POLICY "documents_update" ON public.documents
45
58
  FOR UPDATE TO authenticated
46
59
  USING (tenant_id IN (SELECT public.user_tenant_ids()));
47
- CREATE POLICY "documents_delete" ON saas_core.documents
60
+ DROP POLICY IF EXISTS "documents_delete" ON public.documents;
61
+ CREATE POLICY "documents_delete" ON public.documents
48
62
  FOR DELETE TO authenticated
49
63
  USING (tenant_id IN (SELECT public.user_tenant_ids()));
50
64
 
51
65
  -- ============================================================
52
- -- Alter frm_documents to become extension table for 'form' kind
66
+ -- Reconcile plg_forms_documents / plg_forms_document_files with the archetype.
53
67
  -- ============================================================
54
68
 
55
- -- Drop old frm_documents and recreate as extension
56
- DROP VIEW IF EXISTS public.v_frm_documents;
57
- DROP TABLE IF EXISTS public.frm_document_files;
58
- DROP TABLE IF EXISTS public.frm_documents;
69
+ -- Step 1: if a PRE-archetype plg_forms_documents is present (no document_id
70
+ -- column), rename it (and its files table + dependent view) out of the way so
71
+ -- the new archetype-shaped tables can be created and — when it holds rows —
72
+ -- its data copied across. Fully guarded, so already-migrated pools skip this.
73
+ DO $$
74
+ BEGIN
75
+ IF to_regclass('public.plg_forms_documents') IS NOT NULL
76
+ AND NOT EXISTS (
77
+ SELECT 1 FROM information_schema.columns
78
+ WHERE table_schema = 'public'
79
+ AND table_name = 'plg_forms_documents'
80
+ AND column_name = 'document_id'
81
+ ) THEN
82
+ DROP VIEW IF EXISTS public.v_frm_documents;
83
+ ALTER TABLE public.plg_forms_documents RENAME TO plg_forms_documents_legacy;
84
+ IF to_regclass('public.plg_forms_document_files') IS NOT NULL THEN
85
+ ALTER TABLE public.plg_forms_document_files RENAME TO plg_forms_document_files_legacy;
86
+ END IF;
87
+ END IF;
88
+ END $$;
59
89
 
60
- -- frm_documents: extension table for form-type documents
61
- CREATE TABLE public.frm_documents (
62
- document_id uuid PRIMARY KEY REFERENCES saas_core.documents(id) ON DELETE CASCADE,
63
- tenant_id uuid NOT NULL REFERENCES saas_core.tenants(id) ON DELETE CASCADE,
64
- template_id uuid NOT NULL REFERENCES public.frm_templates(id),
90
+ -- Step 2: create the new archetype-shaped extension tables. IF NOT EXISTS makes
91
+ -- this a no-op on pools already migrated to the new shape; after Step 1 the
92
+ -- names are free on pre-archetype pools.
93
+
94
+ -- plg_forms_documents: extension table for form-type documents
95
+ CREATE TABLE IF NOT EXISTS public.plg_forms_documents (
96
+ document_id uuid PRIMARY KEY REFERENCES public.documents(id) ON DELETE CASCADE,
97
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
98
+ template_id uuid NOT NULL REFERENCES public.plg_forms_templates(id),
65
99
  data jsonb NOT NULL DEFAULT '{}',
66
100
  signed_at timestamptz,
67
101
  signed_by uuid,
@@ -69,28 +103,32 @@ CREATE TABLE public.frm_documents (
69
103
  updated_at timestamptz NOT NULL DEFAULT now()
70
104
  );
71
105
 
72
- ALTER TABLE public.frm_documents ENABLE ROW LEVEL SECURITY;
106
+ ALTER TABLE public.plg_forms_documents ENABLE ROW LEVEL SECURITY;
73
107
 
74
- CREATE INDEX IF NOT EXISTS idx_frm_documents_template ON public.frm_documents(template_id);
108
+ CREATE INDEX IF NOT EXISTS idx_plg_forms_documents_template ON public.plg_forms_documents(template_id);
75
109
 
76
- CREATE POLICY "frm_documents_select" ON public.frm_documents
110
+ DROP POLICY IF EXISTS "plg_forms_documents_select" ON public.plg_forms_documents;
111
+ CREATE POLICY "plg_forms_documents_select" ON public.plg_forms_documents
77
112
  FOR SELECT TO authenticated
78
113
  USING (tenant_id IN (SELECT public.user_tenant_ids()));
79
- CREATE POLICY "frm_documents_insert" ON public.frm_documents
114
+ DROP POLICY IF EXISTS "plg_forms_documents_insert" ON public.plg_forms_documents;
115
+ CREATE POLICY "plg_forms_documents_insert" ON public.plg_forms_documents
80
116
  FOR INSERT TO authenticated
81
117
  WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
82
- CREATE POLICY "frm_documents_update" ON public.frm_documents
118
+ DROP POLICY IF EXISTS "plg_forms_documents_update" ON public.plg_forms_documents;
119
+ CREATE POLICY "plg_forms_documents_update" ON public.plg_forms_documents
83
120
  FOR UPDATE TO authenticated
84
121
  USING (tenant_id IN (SELECT public.user_tenant_ids()));
85
- CREATE POLICY "frm_documents_delete" ON public.frm_documents
122
+ DROP POLICY IF EXISTS "plg_forms_documents_delete" ON public.plg_forms_documents;
123
+ CREATE POLICY "plg_forms_documents_delete" ON public.plg_forms_documents
86
124
  FOR DELETE TO authenticated
87
125
  USING (tenant_id IN (SELECT public.user_tenant_ids()));
88
126
 
89
- -- frm_document_files: file attachments for form fields
90
- CREATE TABLE public.frm_document_files (
127
+ -- plg_forms_document_files: file attachments for form fields
128
+ CREATE TABLE IF NOT EXISTS public.plg_forms_document_files (
91
129
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
92
- tenant_id uuid NOT NULL REFERENCES saas_core.tenants(id) ON DELETE CASCADE,
93
- document_id uuid NOT NULL REFERENCES saas_core.documents(id) ON DELETE CASCADE,
130
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
131
+ document_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
94
132
  field_key text NOT NULL,
95
133
  file_url text NOT NULL,
96
134
  file_name text,
@@ -101,23 +139,73 @@ CREATE TABLE public.frm_document_files (
101
139
  created_at timestamptz NOT NULL DEFAULT now()
102
140
  );
103
141
 
104
- ALTER TABLE public.frm_document_files ENABLE ROW LEVEL SECURITY;
142
+ ALTER TABLE public.plg_forms_document_files ENABLE ROW LEVEL SECURITY;
105
143
 
106
- CREATE INDEX IF NOT EXISTS idx_frm_document_files_document ON public.frm_document_files(document_id);
144
+ CREATE INDEX IF NOT EXISTS idx_plg_forms_document_files_document ON public.plg_forms_document_files(document_id);
107
145
 
108
- CREATE POLICY "frm_document_files_select" ON public.frm_document_files
146
+ DROP POLICY IF EXISTS "plg_forms_document_files_select" ON public.plg_forms_document_files;
147
+ CREATE POLICY "plg_forms_document_files_select" ON public.plg_forms_document_files
109
148
  FOR SELECT TO authenticated
110
149
  USING (tenant_id IN (SELECT public.user_tenant_ids()));
111
- CREATE POLICY "frm_document_files_insert" ON public.frm_document_files
150
+ DROP POLICY IF EXISTS "plg_forms_document_files_insert" ON public.plg_forms_document_files;
151
+ CREATE POLICY "plg_forms_document_files_insert" ON public.plg_forms_document_files
112
152
  FOR INSERT TO authenticated
113
153
  WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
114
- CREATE POLICY "frm_document_files_update" ON public.frm_document_files
154
+ DROP POLICY IF EXISTS "plg_forms_document_files_update" ON public.plg_forms_document_files;
155
+ CREATE POLICY "plg_forms_document_files_update" ON public.plg_forms_document_files
115
156
  FOR UPDATE TO authenticated
116
157
  USING (tenant_id IN (SELECT public.user_tenant_ids()));
117
- CREATE POLICY "frm_document_files_delete" ON public.frm_document_files
158
+ DROP POLICY IF EXISTS "plg_forms_document_files_delete" ON public.plg_forms_document_files;
159
+ CREATE POLICY "plg_forms_document_files_delete" ON public.plg_forms_document_files
118
160
  FOR DELETE TO authenticated
119
161
  USING (tenant_id IN (SELECT public.user_tenant_ids()));
120
162
 
163
+ -- Step 3: if legacy rows exist, migrate them, then drop the quarantined legacy
164
+ -- tables. Reusing each legacy id as the documents.id keeps every legacy file's
165
+ -- document_id FK valid after the copy. ON CONFLICT DO NOTHING makes a partial
166
+ -- re-run safe.
167
+ DO $$
168
+ BEGIN
169
+ IF to_regclass('public.plg_forms_documents_legacy') IS NOT NULL THEN
170
+ -- 3a. base document (one per legacy form document; is_active mirrors NOT is_deleted)
171
+ INSERT INTO public.documents (
172
+ id, tenant_id, kind, person_id, title, status, notes, metadata,
173
+ is_active, created_by, updated_by, created_at, updated_at
174
+ )
175
+ SELECT
176
+ id, tenant_id, 'form', person_id, title, status, notes, metadata,
177
+ NOT COALESCE(is_deleted, false), created_by, updated_by, created_at, updated_at
178
+ FROM public.plg_forms_documents_legacy
179
+ ON CONFLICT (id) DO NOTHING;
180
+
181
+ -- 3b. form extension row (keyed by the reused document id)
182
+ INSERT INTO public.plg_forms_documents (
183
+ document_id, tenant_id, template_id, data, signed_at, signed_by, created_at, updated_at
184
+ )
185
+ SELECT
186
+ id, tenant_id, template_id, data, signed_at, signed_by, created_at, updated_at
187
+ FROM public.plg_forms_documents_legacy
188
+ ON CONFLICT (document_id) DO NOTHING;
189
+
190
+ -- 3c. file attachments (document_id already points at the reused id)
191
+ IF to_regclass('public.plg_forms_document_files_legacy') IS NOT NULL THEN
192
+ INSERT INTO public.plg_forms_document_files (
193
+ id, tenant_id, document_id, field_key, file_url, file_name,
194
+ file_size, mime_type, sort_order, metadata, created_at
195
+ )
196
+ SELECT
197
+ id, tenant_id, document_id, field_key, file_url, file_name,
198
+ file_size, mime_type, sort_order, metadata, created_at
199
+ FROM public.plg_forms_document_files_legacy
200
+ ON CONFLICT (id) DO NOTHING;
201
+ END IF;
202
+ END IF;
203
+
204
+ -- Drop legacy remnants now that any data has been copied across.
205
+ DROP TABLE IF EXISTS public.plg_forms_document_files_legacy;
206
+ DROP TABLE IF EXISTS public.plg_forms_documents_legacy;
207
+ END $$;
208
+
121
209
  -- View: all documents for a person with form data joined when applicable
122
210
  CREATE OR REPLACE VIEW public.v_documents AS
123
211
  SELECT
@@ -129,7 +217,7 @@ SELECT
129
217
  t.name AS template_name,
130
218
  t.category AS template_category,
131
219
  p.name AS person_name
132
- FROM saas_core.documents d
133
- LEFT JOIN public.frm_documents f ON f.document_id = d.id
134
- LEFT JOIN public.frm_templates t ON t.id = f.template_id
135
- LEFT JOIN saas_core.persons p ON p.id = d.person_id;
220
+ FROM public.documents d
221
+ LEFT JOIN public.plg_forms_documents f ON f.document_id = d.id
222
+ LEFT JOIN public.plg_forms_templates t ON t.id = f.template_id
223
+ LEFT JOIN public.people p ON p.id = d.person_id;
@@ -0,0 +1,174 @@
1
+ -- ============================================================================
2
+ -- plugin-forms 003: server-plane agent write RPC.
3
+ --
4
+ -- public.agent_forms_upsert_template — the assistant BUILDS or EDITS a form
5
+ -- template from any surface or channel (in-app FAB, WhatsApp, MCP). Same
6
+ -- contract as every agent_* RPC (agenda 005, financial 009):
7
+ -- (p_tenant_id, p_actor_user_id, p_payload jsonb) → jsonb
8
+ -- {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false, error}
9
+ --
10
+ -- The grid layout is computed HERE (col 0, sequential rows, full-width span) so
11
+ -- the model only ever sends {type,label,required?,options?} — never positions.
12
+ --
13
+ -- Payload:
14
+ -- template_id? uuid — omit to CREATE, provide to EDIT
15
+ -- name? text — required on create
16
+ -- category? text — anamnesis|evolution|report|contract|general
17
+ -- description? text
18
+ -- fields? [{type,label,required?,placeholder?,options?[]}] — REPLACE all
19
+ -- append_fields? [{...}] — APPEND to existing
20
+ --
21
+ -- GRANT authenticated+service_role — NEVER anon (the broker calls with the pool
22
+ -- service key and injects tenant/actor; in-browser the signed-in user calls it
23
+ -- directly and spine RLS/actor guards bind them to their own tenant).
24
+ -- ============================================================================
25
+
26
+ CREATE OR REPLACE FUNCTION public.agent_forms_upsert_template(
27
+ p_tenant_id uuid,
28
+ p_actor_user_id uuid,
29
+ p_payload jsonb
30
+ ) RETURNS jsonb
31
+ LANGUAGE plpgsql SECURITY DEFINER
32
+ SET search_path = public
33
+ AS $$
34
+ DECLARE
35
+ v_denial jsonb;
36
+ v_used int;
37
+ v_template_id uuid;
38
+ v_is_update boolean;
39
+ v_name text;
40
+ v_category text;
41
+ v_description text;
42
+ v_existing jsonb;
43
+ v_input jsonb;
44
+ v_merged jsonb;
45
+ v_fields jsonb;
46
+ v_schema jsonb;
47
+ v_final_id uuid;
48
+ BEGIN
49
+ -- ── payload ──────────────────────────────────────────────────────────────
50
+ BEGIN
51
+ v_template_id := NULLIF(p_payload->>'template_id','')::uuid;
52
+ EXCEPTION WHEN OTHERS THEN
53
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid template_id');
54
+ END;
55
+ v_is_update := v_template_id IS NOT NULL;
56
+ v_name := btrim(p_payload->>'name');
57
+ v_category := lower(COALESCE(NULLIF(p_payload->>'category',''), 'general'));
58
+ v_description := left(p_payload->>'description', 1000);
59
+ IF v_category NOT IN ('anamnesis','evolution','report','contract','general') THEN
60
+ v_category := 'general';
61
+ END IF;
62
+
63
+ IF NOT v_is_update AND (v_name IS NULL OR v_name = '') THEN
64
+ RETURN jsonb_build_object('ok', false, 'error', 'name is required to create a form');
65
+ END IF;
66
+
67
+ -- ── authorization: role → plan → form_templates cap ──────────────────────
68
+ SELECT count(*) INTO v_used FROM plg_forms_templates
69
+ WHERE tenant_id = p_tenant_id AND is_deleted = false;
70
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'custom_forms',
71
+ CASE WHEN v_is_update THEN 'update' ELSE 'create' END,
72
+ 'form_templates', v_used, CASE WHEN v_is_update THEN 0 ELSE 1 END);
73
+ IF v_denial IS NOT NULL THEN
74
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
75
+ END IF;
76
+
77
+ -- ── current fields (for APPEND / to reject an unknown id) ────────────────
78
+ IF v_is_update THEN
79
+ SELECT COALESCE(schema->'fields','[]'::jsonb) INTO v_existing
80
+ FROM plg_forms_templates
81
+ WHERE id = v_template_id AND tenant_id = p_tenant_id AND is_deleted = false;
82
+ IF v_existing IS NULL THEN
83
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown template for this tenant');
84
+ END IF;
85
+ ELSE
86
+ v_existing := '[]'::jsonb;
87
+ END IF;
88
+
89
+ -- ── normalize the incoming fields to the builder's FormFieldDef shape ────
90
+ -- (id, type, label, required, placeholder, col, colSpan, options) — row is
91
+ -- assigned last, across the merged list.
92
+ WITH src AS (
93
+ SELECT CASE
94
+ WHEN p_payload ? 'fields' THEN COALESCE(p_payload->'fields','[]'::jsonb)
95
+ WHEN p_payload ? 'append_fields' THEN COALESCE(p_payload->'append_fields','[]'::jsonb)
96
+ ELSE '[]'::jsonb
97
+ END AS arr
98
+ ),
99
+ norm AS (
100
+ SELECT f.ord, jsonb_strip_nulls(jsonb_build_object(
101
+ 'id', COALESCE(NULLIF(f.value->>'id',''), gen_random_uuid()::text),
102
+ 'type', COALESCE(NULLIF(f.value->>'type',''), 'text'),
103
+ 'label', COALESCE(NULLIF(f.value->>'label',''), 'Campo'),
104
+ 'required', COALESCE((f.value->>'required')::boolean, false),
105
+ 'placeholder', NULLIF(f.value->>'placeholder',''),
106
+ 'col', 0,
107
+ 'colSpan', 12,
108
+ 'options', CASE
109
+ WHEN jsonb_typeof(f.value->'options') = 'array' AND jsonb_array_length(f.value->'options') > 0
110
+ THEN (SELECT jsonb_agg(jsonb_build_object(
111
+ 'label', opt,
112
+ 'value', lower(regexp_replace(btrim(opt), '\s+', '_', 'g'))))
113
+ FROM jsonb_array_elements_text(f.value->'options') opt)
114
+ ELSE NULL END
115
+ )) AS field
116
+ FROM src, jsonb_array_elements(src.arr) WITH ORDINALITY AS f(value, ord)
117
+ )
118
+ SELECT COALESCE(jsonb_agg(field ORDER BY ord), '[]'::jsonb) INTO v_input FROM norm;
119
+
120
+ -- REPLACE with `fields`; otherwise APPEND onto what's already there.
121
+ IF p_payload ? 'fields' THEN
122
+ v_merged := v_input;
123
+ ELSE
124
+ v_merged := v_existing || v_input;
125
+ END IF;
126
+
127
+ -- sequential rows across the final list
128
+ SELECT COALESCE(jsonb_agg(jsonb_set(e.value, '{row}', to_jsonb((e.ord - 1))) ORDER BY e.ord), '[]'::jsonb)
129
+ INTO v_fields
130
+ FROM jsonb_array_elements(v_merged) WITH ORDINALITY AS e(value, ord);
131
+
132
+ v_schema := jsonb_build_object('layout', jsonb_build_object('columns', 12), 'fields', v_fields);
133
+
134
+ -- ── write + audit ────────────────────────────────────────────────────────
135
+ IF v_is_update THEN
136
+ UPDATE plg_forms_templates SET
137
+ name = COALESCE(NULLIF(v_name,''), name),
138
+ category = v_category,
139
+ description = COALESCE(v_description, description),
140
+ schema = v_schema,
141
+ updated_by = p_actor_user_id,
142
+ updated_at = now()
143
+ WHERE id = v_template_id AND tenant_id = p_tenant_id AND is_deleted = false
144
+ RETURNING id INTO v_final_id;
145
+ ELSE
146
+ INSERT INTO plg_forms_templates (tenant_id, name, category, description, schema, created_by, updated_by)
147
+ VALUES (p_tenant_id, v_name, v_category, v_description, v_schema, p_actor_user_id, p_actor_user_id)
148
+ RETURNING id INTO v_final_id;
149
+ END IF;
150
+
151
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
152
+ VALUES (p_tenant_id, p_actor_user_id,
153
+ CASE WHEN v_is_update THEN 'agent.updateFormTemplate' ELSE 'agent.createFormTemplate' END,
154
+ 'form_template', v_final_id::text,
155
+ jsonb_build_object('payload', p_payload));
156
+
157
+ RETURN jsonb_build_object(
158
+ 'ok', true,
159
+ 'id', v_final_id,
160
+ 'record', jsonb_build_object(
161
+ 'ref', jsonb_build_object('id', v_final_id, 'resource', 'plg_forms_templates',
162
+ 'archetype', 'custom_forms:template'),
163
+ 'name', COALESCE(NULLIF(v_name,''), (SELECT name FROM plg_forms_templates WHERE id = v_final_id)),
164
+ 'category', v_category,
165
+ 'fieldCount', jsonb_array_length(v_fields)
166
+ )
167
+ );
168
+ END;
169
+ $$;
170
+
171
+ REVOKE ALL ON FUNCTION public.agent_forms_upsert_template(uuid, uuid, jsonb) FROM public;
172
+ REVOKE EXECUTE ON FUNCTION public.agent_forms_upsert_template(uuid, uuid, jsonb) FROM anon;
173
+ GRANT EXECUTE ON FUNCTION public.agent_forms_upsert_template(uuid, uuid, jsonb)
174
+ TO authenticated, service_role;