@nextblock-cms/db 0.16.1 → 0.16.2
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.
- package/package.json +1 -1
- package/supabase/migrations/00000000000003_baseline_seed.sql +468 -468
- package/supabase/migrations/00000000000006_home_live_demo_promo.sql +67 -67
- package/supabase/migrations/00000000000007_home_live_demo_promo_copy_fix.sql +49 -49
- package/supabase/migrations/00000000000009_home_live_demo_promo_contrast.sql +52 -52
- package/supabase/migrations/00000000000014_site_themes.sql +254 -254
- package/supabase/migrations/00000000000015_scheduled_publishing.sql +43 -43
- package/supabase/migrations/00000000000016_product_revisions_and_revision_baseline.sql +310 -310
- package/supabase/migrations/00000000000017_cortex_ai_mcp_server.sql +91 -91
- package/supabase/migrations/00000000000018_site_scripts.sql +96 -96
- package/supabase/migrations/00000000000019_site_script_revisions.sql +85 -85
- package/supabase/migrations/00000000000020_updating_article.sql +162 -162
- package/supabase/migrations/00000000000021_updating_article_git_merge.sql +91 -91
- package/supabase/migrations/00000000000022_updating_article_accuracy.sql +94 -94
- package/supabase/migrations/00000000000023_updating_article_layout_not_host.sql +87 -87
- package/supabase/migrations/00000000000024_updating_article_newline_fix.sql +70 -70
- package/supabase/migrations/00000000000025_rebrand_nextblock_dev.sql +53 -53
- package/supabase/migrations/00000000000026_product_inquiries.sql +118 -118
- package/supabase/migrations/00000000000027_message_threads.sql +391 -391
- package/supabase/migrations/00000000000028_interaction_replies.sql +86 -86
- package/supabase/migrations/00000000000029_form_endpoints_default_empty.sql +52 -52
- package/supabase/migrations/00000000000030_seo_redirects_and_robots.sql +149 -149
- package/supabase/migrations/00000000000031_seo_robots_settings_admin_only.sql +35 -35
|
@@ -1,391 +1,391 @@
|
|
|
1
|
-
-- Private conversations with anonymous visitors, and the end of the exposed form address.
|
|
2
|
-
--
|
|
3
|
-
-- Two problems, one shape.
|
|
4
|
-
--
|
|
5
|
-
-- FIRST: a contact-form block stores its destination address in the block's own
|
|
6
|
-
-- content, and `FormBlockRenderer` is a "use client" component that receives that
|
|
7
|
-
-- content wholesale. Everything crossing that boundary is serialized into the RSC
|
|
8
|
-
-- payload, so the shop owner's inbox is published in the markup of every page
|
|
9
|
-
-- carrying a form. `form_endpoints` moves the address server-side and leaves behind a
|
|
10
|
-
-- `form_key` — an opaque handle that grants nothing and is safe to serialize.
|
|
11
|
-
--
|
|
12
|
-
-- SECOND: a visitor who sends a message has nowhere to receive an answer. They are
|
|
13
|
-
-- anonymous — `cms_interactions` cannot hold them, because its `user_id` is NOT NULL
|
|
14
|
-
-- with a foreign key to `profiles`. So this is a separate lane: `message_threads` plus
|
|
15
|
-
-- `thread_messages`, reached by a tokenised link rather than an account.
|
|
16
|
-
--
|
|
17
|
-
-- WHY REVIEWS AND COMMENTS ARE NOT HERE. They are already public, already tied to a
|
|
18
|
-
-- registered account, and a staff answer to them is published content, not a private
|
|
19
|
-
-- reply. That lane stays in `cms_interactions` and is handled by migration 28. One
|
|
20
|
-
-- inbox reads both; the storage stays honest about the difference.
|
|
21
|
-
--
|
|
22
|
-
-- SECURITY POSTURE. Rows hold visitor PII (name, email, free text) and a masked IP.
|
|
23
|
-
-- There is NO anon grant and NO anon policy: the tokenised visitor page verifies the
|
|
24
|
-
-- token in application code and then reads with the service role, the same posture the
|
|
25
|
-
-- MCP token route takes against `mcp_access_tokens`. ADMINs read; nobody else does.
|
|
26
|
-
--
|
|
27
|
-
-- `token_hash` stores only a SHA-256 of the visitor's token. The plaintext exists long
|
|
28
|
-
-- enough to be placed in one outbound email and is never written down. A token is
|
|
29
|
-
-- minted on the FIRST ADMIN REPLY, never at submission — a store that receives a
|
|
30
|
-
-- hundred enquiries and answers three has three live credentials, not a hundred.
|
|
31
|
-
--
|
|
32
|
-
-- `source` is a text column with a CHECK rather than an enum, deliberately.
|
|
33
|
-
-- `interaction_type` in this same schema is the cautionary tale: a two-value enum
|
|
34
|
-
-- guarded by an exhaustive CHECK, where adding a third value needs ALTER TYPE ... ADD
|
|
35
|
-
-- VALUE — and PostgreSQL forbids using a value added in the transaction that added it,
|
|
36
|
-
-- which is exactly one migration file. A CHECK is replaceable in one statement.
|
|
37
|
-
--
|
|
38
|
-
-- Forward-only and idempotent.
|
|
39
|
-
|
|
40
|
-
-- ---------------------------------------------------------------------------
|
|
41
|
-
-- 1. form_endpoints — where a contact-form block's mail goes, and what its fields
|
|
42
|
-
-- are called. `fields` is a SERVER-SIDE snapshot of the field manifest so the
|
|
43
|
-
-- notification email renders labels the browser did not supply; the same rule
|
|
44
|
-
-- the product-enquiry action follows when it looks the product title up itself.
|
|
45
|
-
-- ---------------------------------------------------------------------------
|
|
46
|
-
CREATE TABLE IF NOT EXISTS public.form_endpoints (
|
|
47
|
-
form_key uuid NOT NULL,
|
|
48
|
-
label text DEFAULT 'Contact form' NOT NULL,
|
|
49
|
-
-- NULL/empty means "fall back to the resolver ladder", like store_contact.
|
|
50
|
-
recipient_email text,
|
|
51
|
-
fields jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
52
|
-
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
53
|
-
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
54
|
-
CONSTRAINT form_endpoints_pkey PRIMARY KEY (form_key),
|
|
55
|
-
CONSTRAINT form_endpoints_label_not_blank CHECK ((char_length(btrim(label)) > 0)),
|
|
56
|
-
CONSTRAINT form_endpoints_fields_array CHECK ((jsonb_typeof(fields) = 'array'))
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
COMMENT ON TABLE public.form_endpoints IS 'Server-side destination and field manifest for a contact-form block, keyed by the non-secret form_key carried in block content. The address never reaches the browser.';
|
|
60
|
-
|
|
61
|
-
COMMENT ON COLUMN public.form_endpoints.form_key IS 'Non-secret opaque handle. Safe in the RSC payload: it grants nothing, it is only a lookup key.';
|
|
62
|
-
|
|
63
|
-
COMMENT ON COLUMN public.form_endpoints.fields IS 'Snapshot of [{temp_id,label,field_type}] written by the CMS editor, so labels in emails and the inbox are server-trusted.';
|
|
64
|
-
|
|
65
|
-
DROP TRIGGER IF EXISTS set_form_endpoints_updated_at ON public.form_endpoints;
|
|
66
|
-
CREATE TRIGGER set_form_endpoints_updated_at BEFORE UPDATE ON public.form_endpoints
|
|
67
|
-
FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
|
|
68
|
-
|
|
69
|
-
ALTER TABLE public.form_endpoints ENABLE ROW LEVEL SECURITY;
|
|
70
|
-
|
|
71
|
-
-- anon deliberately absent: the public submit path goes through the service role.
|
|
72
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.form_endpoints TO authenticated;
|
|
73
|
-
GRANT ALL ON TABLE public.form_endpoints TO service_role;
|
|
74
|
-
|
|
75
|
-
DROP POLICY IF EXISTS form_endpoints_editor_read_policy ON public.form_endpoints;
|
|
76
|
-
CREATE POLICY form_endpoints_editor_read_policy ON public.form_endpoints FOR SELECT TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])));
|
|
77
|
-
|
|
78
|
-
DROP POLICY IF EXISTS form_endpoints_admin_write_policy ON public.form_endpoints;
|
|
79
|
-
CREATE POLICY form_endpoints_admin_write_policy ON public.form_endpoints FOR ALL TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)) WITH CHECK ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));
|
|
80
|
-
|
|
81
|
-
DROP POLICY IF EXISTS form_endpoints_service_role_policy ON public.form_endpoints;
|
|
82
|
-
CREATE POLICY form_endpoints_service_role_policy ON public.form_endpoints TO service_role USING (true) WITH CHECK (true);
|
|
83
|
-
|
|
84
|
-
-- ---------------------------------------------------------------------------
|
|
85
|
-
-- 2. message_threads — the private-lane spine and the only home of a thread token.
|
|
86
|
-
-- `subject_id` and `form_key` are PLAIN uuids with NO foreign key: a conversation
|
|
87
|
-
-- outlives the product or the form block it started from.
|
|
88
|
-
-- ---------------------------------------------------------------------------
|
|
89
|
-
CREATE TABLE IF NOT EXISTS public.message_threads (
|
|
90
|
-
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
91
|
-
source text NOT NULL,
|
|
92
|
-
-- product_inquiries.id when source = 'product_inquiry'.
|
|
93
|
-
subject_id uuid,
|
|
94
|
-
-- form_endpoints.form_key when source = 'contact_form'.
|
|
95
|
-
form_key uuid,
|
|
96
|
-
-- Denormalised so a deleted product or removed form block stays identifiable.
|
|
97
|
-
subject_label text DEFAULT 'Message' NOT NULL,
|
|
98
|
-
sender_name text,
|
|
99
|
-
-- NULL when a contact form collected no email field. Such a thread can never be
|
|
100
|
-
-- answered, and the CMS says so rather than failing silently at reply time.
|
|
101
|
-
sender_email text,
|
|
102
|
-
locale text,
|
|
103
|
-
-- Submitted field map for a contact form: {temp_id: value}.
|
|
104
|
-
fields jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
105
|
-
status text DEFAULT 'open' NOT NULL,
|
|
106
|
-
unread_for_admin boolean DEFAULT true NOT NULL,
|
|
107
|
-
unread_for_visitor boolean DEFAULT false NOT NULL,
|
|
108
|
-
-- SHA-256 hex of the visitor's token; NULL until the first admin reply.
|
|
109
|
-
token_hash text,
|
|
110
|
-
token_expires_at timestamp with time zone,
|
|
111
|
-
token_revoked_at timestamp with time zone,
|
|
112
|
-
token_last_used_at timestamp with time zone,
|
|
113
|
-
last_message_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
114
|
-
ip_masked text,
|
|
115
|
-
user_agent text,
|
|
116
|
-
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
117
|
-
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
118
|
-
CONSTRAINT message_threads_pkey PRIMARY KEY (id),
|
|
119
|
-
CONSTRAINT message_threads_token_hash_key UNIQUE (token_hash),
|
|
120
|
-
CONSTRAINT message_threads_source_check
|
|
121
|
-
CHECK ((source = ANY (ARRAY['product_inquiry'::text, 'contact_form'::text]))),
|
|
122
|
-
CONSTRAINT message_threads_status_check
|
|
123
|
-
CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text]))),
|
|
124
|
-
CONSTRAINT message_threads_subject_check CHECK (
|
|
125
|
-
(((source = 'product_inquiry'::text) AND (subject_id IS NOT NULL)) OR
|
|
126
|
-
((source = 'contact_form'::text) AND (form_key IS NOT NULL)))),
|
|
127
|
-
CONSTRAINT message_threads_fields_object CHECK ((jsonb_typeof(fields) = 'object'))
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
COMMENT ON TABLE public.message_threads IS 'Private conversations with anonymous visitors (product enquiries and contact-form submissions). Written by the service role from public server actions; read by ADMINs only. Public reviews and comments are NOT here - they live in cms_interactions.';
|
|
131
|
-
|
|
132
|
-
COMMENT ON COLUMN public.message_threads.token_hash IS 'SHA-256 hex of the visitor thread token. The raw token is never stored; it is minted on the first admin reply and mailed once.';
|
|
133
|
-
|
|
134
|
-
COMMENT ON COLUMN public.message_threads.subject_id IS 'product_inquiries.id. Plain uuid, no FK: a conversation outlives the enquiry record it grew from.';
|
|
135
|
-
|
|
136
|
-
CREATE INDEX IF NOT EXISTS message_threads_last_message_idx
|
|
137
|
-
ON public.message_threads USING btree (last_message_at DESC);
|
|
138
|
-
|
|
139
|
-
CREATE INDEX IF NOT EXISTS message_threads_source_last_idx
|
|
140
|
-
ON public.message_threads USING btree (source, last_message_at DESC);
|
|
141
|
-
|
|
142
|
-
-- Backs the CMS nav unread badge without scanning read history.
|
|
143
|
-
CREATE INDEX IF NOT EXISTS message_threads_unread_idx
|
|
144
|
-
ON public.message_threads USING btree (last_message_at DESC)
|
|
145
|
-
WHERE (unread_for_admin = true);
|
|
146
|
-
|
|
147
|
-
-- Idempotent backfill / dedupe of enquiry threads.
|
|
148
|
-
CREATE INDEX IF NOT EXISTS message_threads_subject_idx
|
|
149
|
-
ON public.message_threads USING btree (subject_id) WHERE (subject_id IS NOT NULL);
|
|
150
|
-
|
|
151
|
-
-- No separate token index: message_threads_token_hash_key is already the unique
|
|
152
|
-
-- btree the /thread lookup probes.
|
|
153
|
-
|
|
154
|
-
DROP TRIGGER IF EXISTS set_message_threads_updated_at ON public.message_threads;
|
|
155
|
-
CREATE TRIGGER set_message_threads_updated_at BEFORE UPDATE ON public.message_threads
|
|
156
|
-
FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
|
|
157
|
-
|
|
158
|
-
ALTER TABLE public.message_threads ENABLE ROW LEVEL SECURITY;
|
|
159
|
-
|
|
160
|
-
GRANT SELECT, UPDATE ON TABLE public.message_threads TO authenticated;
|
|
161
|
-
GRANT ALL ON TABLE public.message_threads TO service_role;
|
|
162
|
-
|
|
163
|
-
DROP POLICY IF EXISTS message_threads_admin_read_policy ON public.message_threads;
|
|
164
|
-
CREATE POLICY message_threads_admin_read_policy ON public.message_threads FOR SELECT TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));
|
|
165
|
-
|
|
166
|
-
DROP POLICY IF EXISTS message_threads_admin_update_policy ON public.message_threads;
|
|
167
|
-
CREATE POLICY message_threads_admin_update_policy ON public.message_threads FOR UPDATE TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)) WITH CHECK ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));
|
|
168
|
-
|
|
169
|
-
DROP POLICY IF EXISTS message_threads_service_role_policy ON public.message_threads;
|
|
170
|
-
CREATE POLICY message_threads_service_role_policy ON public.message_threads TO service_role USING (true) WITH CHECK (true);
|
|
171
|
-
|
|
172
|
-
-- ---------------------------------------------------------------------------
|
|
173
|
-
-- 3. thread_messages — one turn of a private conversation.
|
|
174
|
-
-- ---------------------------------------------------------------------------
|
|
175
|
-
CREATE TABLE IF NOT EXISTS public.thread_messages (
|
|
176
|
-
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
177
|
-
thread_id uuid NOT NULL,
|
|
178
|
-
direction text NOT NULL,
|
|
179
|
-
body text NOT NULL,
|
|
180
|
-
-- profiles.id for an outbound reply. No FK: a departing admin must not take the
|
|
181
|
-
-- conversation with them.
|
|
182
|
-
author_id uuid,
|
|
183
|
-
author_name text,
|
|
184
|
-
-- False when SMTP was unconfigured or the send failed. sendEmail() throws when
|
|
185
|
-
-- unconfigured, so this row - not the mail - is the record.
|
|
186
|
-
email_delivered boolean DEFAULT false NOT NULL,
|
|
187
|
-
email_error text,
|
|
188
|
-
ip_masked text,
|
|
189
|
-
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
190
|
-
CONSTRAINT thread_messages_pkey PRIMARY KEY (id),
|
|
191
|
-
CONSTRAINT thread_messages_direction_check
|
|
192
|
-
CHECK ((direction = ANY (ARRAY['inbound'::text, 'outbound'::text]))),
|
|
193
|
-
CONSTRAINT thread_messages_body_not_blank CHECK ((char_length(btrim(body)) > 0))
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
COMMENT ON TABLE public.thread_messages IS 'Turns of a private conversation. Content is append-only; only the delivery flags may change after insert.';
|
|
197
|
-
|
|
198
|
-
-- A real foreign key here, unlike the outward pointers above: a message has no
|
|
199
|
-
-- meaning without its thread.
|
|
200
|
-
DO $rb$ BEGIN
|
|
201
|
-
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
202
|
-
WHERE conname = 'thread_messages_thread_id_fkey'
|
|
203
|
-
AND conrelid = 'public.thread_messages'::regclass) THEN
|
|
204
|
-
ALTER TABLE ONLY public.thread_messages
|
|
205
|
-
ADD CONSTRAINT thread_messages_thread_id_fkey FOREIGN KEY (thread_id)
|
|
206
|
-
REFERENCES public.message_threads(id) ON DELETE CASCADE;
|
|
207
|
-
END IF;
|
|
208
|
-
END $rb$;
|
|
209
|
-
|
|
210
|
-
CREATE INDEX IF NOT EXISTS thread_messages_thread_created_idx
|
|
211
|
-
ON public.thread_messages USING btree (thread_id, created_at);
|
|
212
|
-
|
|
213
|
-
-- Backs the per-IP reply throttle on the public thread page.
|
|
214
|
-
CREATE INDEX IF NOT EXISTS thread_messages_ip_created_idx
|
|
215
|
-
ON public.thread_messages USING btree (ip_masked, created_at DESC);
|
|
216
|
-
|
|
217
|
-
-- Message TEXT is append-only — not the whole row, because email_delivered has to be
|
|
218
|
-
-- settable after the send completes. The trigger blocks rewrites of the load-bearing
|
|
219
|
-
-- columns only, and it binds the service role too, which otherwise bypasses RLS.
|
|
220
|
-
CREATE OR REPLACE FUNCTION public.prevent_thread_message_rewrite() RETURNS trigger
|
|
221
|
-
LANGUAGE plpgsql SET search_path = '' AS $$
|
|
222
|
-
BEGIN
|
|
223
|
-
IF TG_OP = 'DELETE' THEN
|
|
224
|
-
RAISE EXCEPTION 'thread_messages is append-only; DELETE is not permitted'
|
|
225
|
-
USING ERRCODE = 'restrict_violation';
|
|
226
|
-
END IF;
|
|
227
|
-
IF NEW.body IS DISTINCT FROM OLD.body
|
|
228
|
-
OR NEW.direction IS DISTINCT FROM OLD.direction
|
|
229
|
-
OR NEW.thread_id IS DISTINCT FROM OLD.thread_id
|
|
230
|
-
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
|
|
231
|
-
RAISE EXCEPTION 'thread_messages content is append-only; only delivery flags may change'
|
|
232
|
-
USING ERRCODE = 'restrict_violation';
|
|
233
|
-
END IF;
|
|
234
|
-
RETURN NEW;
|
|
235
|
-
END;
|
|
236
|
-
$$;
|
|
237
|
-
|
|
238
|
-
DROP TRIGGER IF EXISTS trg_thread_messages_append_only ON public.thread_messages;
|
|
239
|
-
CREATE TRIGGER trg_thread_messages_append_only BEFORE UPDATE OR DELETE ON public.thread_messages
|
|
240
|
-
FOR EACH ROW EXECUTE FUNCTION public.prevent_thread_message_rewrite();
|
|
241
|
-
|
|
242
|
-
-- NB: the ON DELETE CASCADE above fires this trigger, so deleting a thread is
|
|
243
|
-
-- impossible by design. "Delete" in the CMS means status='closed' plus a revoked
|
|
244
|
-
-- token, and the UI says so.
|
|
245
|
-
|
|
246
|
-
ALTER TABLE public.thread_messages ENABLE ROW LEVEL SECURITY;
|
|
247
|
-
|
|
248
|
-
GRANT SELECT ON TABLE public.thread_messages TO authenticated;
|
|
249
|
-
GRANT ALL ON TABLE public.thread_messages TO service_role;
|
|
250
|
-
|
|
251
|
-
DROP POLICY IF EXISTS thread_messages_admin_read_policy ON public.thread_messages;
|
|
252
|
-
CREATE POLICY thread_messages_admin_read_policy ON public.thread_messages FOR SELECT TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));
|
|
253
|
-
|
|
254
|
-
DROP POLICY IF EXISTS thread_messages_service_role_policy ON public.thread_messages;
|
|
255
|
-
CREATE POLICY thread_messages_service_role_policy ON public.thread_messages TO service_role USING (true) WITH CHECK (true);
|
|
256
|
-
|
|
257
|
-
-- ---------------------------------------------------------------------------
|
|
258
|
-
-- 4. Backfill: every existing enquiry becomes a thread whose first inbound turn is
|
|
259
|
-
-- the original message. product_inquiries is KEPT and kept written — it is the
|
|
260
|
-
-- enquiry's own record and owns the ip_masked the submission throttle counts.
|
|
261
|
-
-- The thread is a conversation ABOUT the enquiry, not a replacement for it.
|
|
262
|
-
-- ---------------------------------------------------------------------------
|
|
263
|
-
INSERT INTO public.message_threads
|
|
264
|
-
(source, subject_id, subject_label, sender_name, sender_email, locale,
|
|
265
|
-
ip_masked, user_agent, unread_for_admin, status, last_message_at, created_at)
|
|
266
|
-
SELECT 'product_inquiry', i.id, COALESCE(i.product_title, 'Product enquiry'),
|
|
267
|
-
i.sender_name, i.sender_email, i.locale, i.ip_masked, i.user_agent,
|
|
268
|
-
NOT i.is_resolved, CASE WHEN i.is_resolved THEN 'closed' ELSE 'open' END,
|
|
269
|
-
i.created_at, i.created_at
|
|
270
|
-
FROM public.product_inquiries i
|
|
271
|
-
WHERE NOT EXISTS (SELECT 1 FROM public.message_threads t WHERE t.subject_id = i.id);
|
|
272
|
-
|
|
273
|
-
INSERT INTO public.thread_messages (thread_id, direction, body, email_delivered, ip_masked, created_at)
|
|
274
|
-
SELECT t.id, 'inbound', i.message, i.email_delivered, i.ip_masked, i.created_at
|
|
275
|
-
FROM public.product_inquiries i
|
|
276
|
-
JOIN public.message_threads t ON t.subject_id = i.id
|
|
277
|
-
WHERE NOT EXISTS (SELECT 1 FROM public.thread_messages m WHERE m.thread_id = t.id);
|
|
278
|
-
|
|
279
|
-
-- ---------------------------------------------------------------------------
|
|
280
|
-
-- 5. The form-block data migration.
|
|
281
|
-
-- Rewrites every stored form block: mints a form_key, moves recipient_email into
|
|
282
|
-
-- form_endpoints, snapshots the field manifest, and DELETES recipient_email from
|
|
283
|
-
-- the jsonb. Three shapes have to be handled, because a form nested inside a
|
|
284
|
-
-- section has no blocks row of its own — it lives in the section's content:
|
|
285
|
-
-- a) blocks.content where block_type = 'form'
|
|
286
|
-
-- b) blocks.content where block_type = 'section' (column_blocks, slides)
|
|
287
|
-
-- c) content_drafts.blocks / product_drafts.blocks (jsonb arrays of snapshots)
|
|
288
|
-
-- ---------------------------------------------------------------------------
|
|
289
|
-
CREATE OR REPLACE FUNCTION public.nb_migrate_form_columns(p_cols jsonb)
|
|
290
|
-
RETURNS jsonb LANGUAGE plpgsql VOLATILE SET search_path = '' AS $fn$
|
|
291
|
-
DECLARE v_col jsonb; v_blk jsonb; v_new_cols jsonb := '[]'::jsonb; v_new_col jsonb;
|
|
292
|
-
BEGIN
|
|
293
|
-
FOR v_col IN SELECT * FROM jsonb_array_elements(p_cols) LOOP
|
|
294
|
-
v_new_col := '[]'::jsonb;
|
|
295
|
-
FOR v_blk IN SELECT * FROM jsonb_array_elements(coalesce(v_col, '[]'::jsonb)) LOOP
|
|
296
|
-
v_new_col := v_new_col || jsonb_build_array(
|
|
297
|
-
v_blk || jsonb_build_object('content', public.nb_migrate_form_content(v_blk->'content')));
|
|
298
|
-
END LOOP;
|
|
299
|
-
v_new_cols := v_new_cols || jsonb_build_array(v_new_col);
|
|
300
|
-
END LOOP;
|
|
301
|
-
RETURN v_new_cols;
|
|
302
|
-
END;
|
|
303
|
-
$fn$;
|
|
304
|
-
|
|
305
|
-
CREATE OR REPLACE FUNCTION public.nb_migrate_form_content(p_content jsonb)
|
|
306
|
-
RETURNS jsonb LANGUAGE plpgsql VOLATILE SET search_path = '' AS $fn$
|
|
307
|
-
DECLARE
|
|
308
|
-
v_key uuid; v_email text; v_fields jsonb; v_slide jsonb; v_new_slides jsonb;
|
|
309
|
-
BEGIN
|
|
310
|
-
IF p_content IS NULL OR jsonb_typeof(p_content) <> 'object' THEN
|
|
311
|
-
RETURN p_content;
|
|
312
|
-
END IF;
|
|
313
|
-
|
|
314
|
-
-- Leaf: this object IS a form block's content.
|
|
315
|
-
IF (p_content ? 'recipient_email') AND (p_content ? 'fields') THEN
|
|
316
|
-
v_key := gen_random_uuid();
|
|
317
|
-
v_email := nullif(btrim(coalesce(p_content->>'recipient_email','')), '');
|
|
318
|
-
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
319
|
-
'temp_id', f->>'temp_id', 'label', f->>'label', 'field_type', f->>'field_type')), '[]'::jsonb)
|
|
320
|
-
INTO v_fields
|
|
321
|
-
FROM jsonb_array_elements(coalesce(p_content->'fields', '[]'::jsonb)) f;
|
|
322
|
-
INSERT INTO public.form_endpoints (form_key, label, recipient_email, fields)
|
|
323
|
-
VALUES (v_key, 'Contact form', v_email, v_fields)
|
|
324
|
-
ON CONFLICT (form_key) DO NOTHING;
|
|
325
|
-
RETURN (p_content - 'recipient_email') || jsonb_build_object('form_key', v_key::text);
|
|
326
|
-
END IF;
|
|
327
|
-
|
|
328
|
-
-- Container: a section's slides, each with column_blocks.
|
|
329
|
-
IF (p_content ? 'slides') AND jsonb_typeof(p_content->'slides') = 'array' THEN
|
|
330
|
-
v_new_slides := '[]'::jsonb;
|
|
331
|
-
FOR v_slide IN SELECT * FROM jsonb_array_elements(p_content->'slides') LOOP
|
|
332
|
-
v_new_slides := v_new_slides || jsonb_build_array(
|
|
333
|
-
v_slide || jsonb_build_object('column_blocks',
|
|
334
|
-
public.nb_migrate_form_columns(coalesce(v_slide->'column_blocks','[]'::jsonb))));
|
|
335
|
-
END LOOP;
|
|
336
|
-
p_content := p_content || jsonb_build_object('slides', v_new_slides);
|
|
337
|
-
END IF;
|
|
338
|
-
|
|
339
|
-
-- Container: a standard section's column_blocks.
|
|
340
|
-
IF (p_content ? 'column_blocks') AND jsonb_typeof(p_content->'column_blocks') = 'array' THEN
|
|
341
|
-
p_content := p_content || jsonb_build_object('column_blocks',
|
|
342
|
-
public.nb_migrate_form_columns(p_content->'column_blocks'));
|
|
343
|
-
END IF;
|
|
344
|
-
|
|
345
|
-
RETURN p_content;
|
|
346
|
-
END;
|
|
347
|
-
$fn$;
|
|
348
|
-
|
|
349
|
-
-- The LIKE guard keeps this from rewriting (and bumping updated_at on) every block.
|
|
350
|
-
UPDATE public.blocks
|
|
351
|
-
SET content = public.nb_migrate_form_content(content)
|
|
352
|
-
WHERE content::text LIKE '%recipient_email%';
|
|
353
|
-
|
|
354
|
-
UPDATE public.content_drafts d
|
|
355
|
-
SET blocks = (SELECT coalesce(jsonb_agg(b || jsonb_build_object(
|
|
356
|
-
'content', public.nb_migrate_form_content(b->'content'))), '[]'::jsonb)
|
|
357
|
-
FROM jsonb_array_elements(d.blocks) b)
|
|
358
|
-
WHERE d.blocks::text LIKE '%recipient_email%';
|
|
359
|
-
|
|
360
|
-
UPDATE public.product_drafts d
|
|
361
|
-
SET blocks = (SELECT coalesce(jsonb_agg(b || jsonb_build_object(
|
|
362
|
-
'content', public.nb_migrate_form_content(b->'content'))), '[]'::jsonb)
|
|
363
|
-
FROM jsonb_array_elements(d.blocks) b)
|
|
364
|
-
WHERE d.blocks::text LIKE '%recipient_email%';
|
|
365
|
-
|
|
366
|
-
-- One-shot helpers. Dropping them keeps the public schema (and db:types) clean.
|
|
367
|
-
DROP FUNCTION IF EXISTS public.nb_migrate_form_content(jsonb);
|
|
368
|
-
DROP FUNCTION IF EXISTS public.nb_migrate_form_columns(jsonb);
|
|
369
|
-
|
|
370
|
-
-- ---------------------------------------------------------------------------
|
|
371
|
-
-- 6. Seeds. Thread-page copy is public-facing so it must be translatable; the
|
|
372
|
-
-- components pass an English literal as the fallback, so an install that never
|
|
373
|
-
-- runs this seed still renders correctly.
|
|
374
|
-
-- ---------------------------------------------------------------------------
|
|
375
|
-
INSERT INTO public.site_settings (key, value)
|
|
376
|
-
VALUES ('forms_contact', '{"contactEmail": ""}'::jsonb)
|
|
377
|
-
ON CONFLICT (key) DO NOTHING;
|
|
378
|
-
|
|
379
|
-
INSERT INTO public.translations (key, translations, created_at, updated_at) VALUES
|
|
380
|
-
('thread.heading', '{"en": "Your conversation", "fr": "Votre conversation"}', now(), now()),
|
|
381
|
-
('thread.reply_label', '{"en": "Write a reply", "fr": "Écrire une réponse"}', now(), now()),
|
|
382
|
-
('thread.send', '{"en": "Send reply", "fr": "Envoyer la réponse"}', now(), now()),
|
|
383
|
-
('thread.sending', '{"en": "Sending...", "fr": "Envoi..."}', now(), now()),
|
|
384
|
-
('thread.sent', '{"en": "Thanks - your reply has been sent.", "fr": "Merci - votre réponse a été envoyée."}', now(), now()),
|
|
385
|
-
('thread.error', '{"en": "Sorry, your reply couldn''t be sent. Please try again in a moment.", "fr": "Désolé, votre réponse n''a pas pu être envoyée. Veuillez réessayer dans un instant."}', now(), now()),
|
|
386
|
-
('thread.throttled', '{"en": "You''ve sent several replies already. Please wait a few minutes.", "fr": "Vous avez déjà envoyé plusieurs réponses. Veuillez patienter quelques minutes."}', now(), now()),
|
|
387
|
-
('thread.closed', '{"en": "This conversation has been closed.", "fr": "Cette conversation est fermée."}', now(), now()),
|
|
388
|
-
('thread.invalid', '{"en": "This link has expired or is no longer valid. If you still need help, please contact us again from our website.", "fr": "Ce lien a expiré ou n''est plus valide. Si vous avez encore besoin d''aide, contactez-nous de nouveau depuis notre site."}', now(), now()),
|
|
389
|
-
('thread.you', '{"en": "You", "fr": "Vous"}', now(), now()),
|
|
390
|
-
('forms.submission_stored', '{"en": "Thanks - your message has been received.", "fr": "Merci - votre message a bien été reçu."}', now(), now())
|
|
391
|
-
ON CONFLICT (key) DO NOTHING;
|
|
1
|
+
-- Private conversations with anonymous visitors, and the end of the exposed form address.
|
|
2
|
+
--
|
|
3
|
+
-- Two problems, one shape.
|
|
4
|
+
--
|
|
5
|
+
-- FIRST: a contact-form block stores its destination address in the block's own
|
|
6
|
+
-- content, and `FormBlockRenderer` is a "use client" component that receives that
|
|
7
|
+
-- content wholesale. Everything crossing that boundary is serialized into the RSC
|
|
8
|
+
-- payload, so the shop owner's inbox is published in the markup of every page
|
|
9
|
+
-- carrying a form. `form_endpoints` moves the address server-side and leaves behind a
|
|
10
|
+
-- `form_key` — an opaque handle that grants nothing and is safe to serialize.
|
|
11
|
+
--
|
|
12
|
+
-- SECOND: a visitor who sends a message has nowhere to receive an answer. They are
|
|
13
|
+
-- anonymous — `cms_interactions` cannot hold them, because its `user_id` is NOT NULL
|
|
14
|
+
-- with a foreign key to `profiles`. So this is a separate lane: `message_threads` plus
|
|
15
|
+
-- `thread_messages`, reached by a tokenised link rather than an account.
|
|
16
|
+
--
|
|
17
|
+
-- WHY REVIEWS AND COMMENTS ARE NOT HERE. They are already public, already tied to a
|
|
18
|
+
-- registered account, and a staff answer to them is published content, not a private
|
|
19
|
+
-- reply. That lane stays in `cms_interactions` and is handled by migration 28. One
|
|
20
|
+
-- inbox reads both; the storage stays honest about the difference.
|
|
21
|
+
--
|
|
22
|
+
-- SECURITY POSTURE. Rows hold visitor PII (name, email, free text) and a masked IP.
|
|
23
|
+
-- There is NO anon grant and NO anon policy: the tokenised visitor page verifies the
|
|
24
|
+
-- token in application code and then reads with the service role, the same posture the
|
|
25
|
+
-- MCP token route takes against `mcp_access_tokens`. ADMINs read; nobody else does.
|
|
26
|
+
--
|
|
27
|
+
-- `token_hash` stores only a SHA-256 of the visitor's token. The plaintext exists long
|
|
28
|
+
-- enough to be placed in one outbound email and is never written down. A token is
|
|
29
|
+
-- minted on the FIRST ADMIN REPLY, never at submission — a store that receives a
|
|
30
|
+
-- hundred enquiries and answers three has three live credentials, not a hundred.
|
|
31
|
+
--
|
|
32
|
+
-- `source` is a text column with a CHECK rather than an enum, deliberately.
|
|
33
|
+
-- `interaction_type` in this same schema is the cautionary tale: a two-value enum
|
|
34
|
+
-- guarded by an exhaustive CHECK, where adding a third value needs ALTER TYPE ... ADD
|
|
35
|
+
-- VALUE — and PostgreSQL forbids using a value added in the transaction that added it,
|
|
36
|
+
-- which is exactly one migration file. A CHECK is replaceable in one statement.
|
|
37
|
+
--
|
|
38
|
+
-- Forward-only and idempotent.
|
|
39
|
+
|
|
40
|
+
-- ---------------------------------------------------------------------------
|
|
41
|
+
-- 1. form_endpoints — where a contact-form block's mail goes, and what its fields
|
|
42
|
+
-- are called. `fields` is a SERVER-SIDE snapshot of the field manifest so the
|
|
43
|
+
-- notification email renders labels the browser did not supply; the same rule
|
|
44
|
+
-- the product-enquiry action follows when it looks the product title up itself.
|
|
45
|
+
-- ---------------------------------------------------------------------------
|
|
46
|
+
CREATE TABLE IF NOT EXISTS public.form_endpoints (
|
|
47
|
+
form_key uuid NOT NULL,
|
|
48
|
+
label text DEFAULT 'Contact form' NOT NULL,
|
|
49
|
+
-- NULL/empty means "fall back to the resolver ladder", like store_contact.
|
|
50
|
+
recipient_email text,
|
|
51
|
+
fields jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
52
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
53
|
+
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
54
|
+
CONSTRAINT form_endpoints_pkey PRIMARY KEY (form_key),
|
|
55
|
+
CONSTRAINT form_endpoints_label_not_blank CHECK ((char_length(btrim(label)) > 0)),
|
|
56
|
+
CONSTRAINT form_endpoints_fields_array CHECK ((jsonb_typeof(fields) = 'array'))
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
COMMENT ON TABLE public.form_endpoints IS 'Server-side destination and field manifest for a contact-form block, keyed by the non-secret form_key carried in block content. The address never reaches the browser.';
|
|
60
|
+
|
|
61
|
+
COMMENT ON COLUMN public.form_endpoints.form_key IS 'Non-secret opaque handle. Safe in the RSC payload: it grants nothing, it is only a lookup key.';
|
|
62
|
+
|
|
63
|
+
COMMENT ON COLUMN public.form_endpoints.fields IS 'Snapshot of [{temp_id,label,field_type}] written by the CMS editor, so labels in emails and the inbox are server-trusted.';
|
|
64
|
+
|
|
65
|
+
DROP TRIGGER IF EXISTS set_form_endpoints_updated_at ON public.form_endpoints;
|
|
66
|
+
CREATE TRIGGER set_form_endpoints_updated_at BEFORE UPDATE ON public.form_endpoints
|
|
67
|
+
FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
|
|
68
|
+
|
|
69
|
+
ALTER TABLE public.form_endpoints ENABLE ROW LEVEL SECURITY;
|
|
70
|
+
|
|
71
|
+
-- anon deliberately absent: the public submit path goes through the service role.
|
|
72
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.form_endpoints TO authenticated;
|
|
73
|
+
GRANT ALL ON TABLE public.form_endpoints TO service_role;
|
|
74
|
+
|
|
75
|
+
DROP POLICY IF EXISTS form_endpoints_editor_read_policy ON public.form_endpoints;
|
|
76
|
+
CREATE POLICY form_endpoints_editor_read_policy ON public.form_endpoints FOR SELECT TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])));
|
|
77
|
+
|
|
78
|
+
DROP POLICY IF EXISTS form_endpoints_admin_write_policy ON public.form_endpoints;
|
|
79
|
+
CREATE POLICY form_endpoints_admin_write_policy ON public.form_endpoints FOR ALL TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)) WITH CHECK ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));
|
|
80
|
+
|
|
81
|
+
DROP POLICY IF EXISTS form_endpoints_service_role_policy ON public.form_endpoints;
|
|
82
|
+
CREATE POLICY form_endpoints_service_role_policy ON public.form_endpoints TO service_role USING (true) WITH CHECK (true);
|
|
83
|
+
|
|
84
|
+
-- ---------------------------------------------------------------------------
|
|
85
|
+
-- 2. message_threads — the private-lane spine and the only home of a thread token.
|
|
86
|
+
-- `subject_id` and `form_key` are PLAIN uuids with NO foreign key: a conversation
|
|
87
|
+
-- outlives the product or the form block it started from.
|
|
88
|
+
-- ---------------------------------------------------------------------------
|
|
89
|
+
CREATE TABLE IF NOT EXISTS public.message_threads (
|
|
90
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
91
|
+
source text NOT NULL,
|
|
92
|
+
-- product_inquiries.id when source = 'product_inquiry'.
|
|
93
|
+
subject_id uuid,
|
|
94
|
+
-- form_endpoints.form_key when source = 'contact_form'.
|
|
95
|
+
form_key uuid,
|
|
96
|
+
-- Denormalised so a deleted product or removed form block stays identifiable.
|
|
97
|
+
subject_label text DEFAULT 'Message' NOT NULL,
|
|
98
|
+
sender_name text,
|
|
99
|
+
-- NULL when a contact form collected no email field. Such a thread can never be
|
|
100
|
+
-- answered, and the CMS says so rather than failing silently at reply time.
|
|
101
|
+
sender_email text,
|
|
102
|
+
locale text,
|
|
103
|
+
-- Submitted field map for a contact form: {temp_id: value}.
|
|
104
|
+
fields jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
105
|
+
status text DEFAULT 'open' NOT NULL,
|
|
106
|
+
unread_for_admin boolean DEFAULT true NOT NULL,
|
|
107
|
+
unread_for_visitor boolean DEFAULT false NOT NULL,
|
|
108
|
+
-- SHA-256 hex of the visitor's token; NULL until the first admin reply.
|
|
109
|
+
token_hash text,
|
|
110
|
+
token_expires_at timestamp with time zone,
|
|
111
|
+
token_revoked_at timestamp with time zone,
|
|
112
|
+
token_last_used_at timestamp with time zone,
|
|
113
|
+
last_message_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
114
|
+
ip_masked text,
|
|
115
|
+
user_agent text,
|
|
116
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
117
|
+
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
118
|
+
CONSTRAINT message_threads_pkey PRIMARY KEY (id),
|
|
119
|
+
CONSTRAINT message_threads_token_hash_key UNIQUE (token_hash),
|
|
120
|
+
CONSTRAINT message_threads_source_check
|
|
121
|
+
CHECK ((source = ANY (ARRAY['product_inquiry'::text, 'contact_form'::text]))),
|
|
122
|
+
CONSTRAINT message_threads_status_check
|
|
123
|
+
CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text]))),
|
|
124
|
+
CONSTRAINT message_threads_subject_check CHECK (
|
|
125
|
+
(((source = 'product_inquiry'::text) AND (subject_id IS NOT NULL)) OR
|
|
126
|
+
((source = 'contact_form'::text) AND (form_key IS NOT NULL)))),
|
|
127
|
+
CONSTRAINT message_threads_fields_object CHECK ((jsonb_typeof(fields) = 'object'))
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
COMMENT ON TABLE public.message_threads IS 'Private conversations with anonymous visitors (product enquiries and contact-form submissions). Written by the service role from public server actions; read by ADMINs only. Public reviews and comments are NOT here - they live in cms_interactions.';
|
|
131
|
+
|
|
132
|
+
COMMENT ON COLUMN public.message_threads.token_hash IS 'SHA-256 hex of the visitor thread token. The raw token is never stored; it is minted on the first admin reply and mailed once.';
|
|
133
|
+
|
|
134
|
+
COMMENT ON COLUMN public.message_threads.subject_id IS 'product_inquiries.id. Plain uuid, no FK: a conversation outlives the enquiry record it grew from.';
|
|
135
|
+
|
|
136
|
+
CREATE INDEX IF NOT EXISTS message_threads_last_message_idx
|
|
137
|
+
ON public.message_threads USING btree (last_message_at DESC);
|
|
138
|
+
|
|
139
|
+
CREATE INDEX IF NOT EXISTS message_threads_source_last_idx
|
|
140
|
+
ON public.message_threads USING btree (source, last_message_at DESC);
|
|
141
|
+
|
|
142
|
+
-- Backs the CMS nav unread badge without scanning read history.
|
|
143
|
+
CREATE INDEX IF NOT EXISTS message_threads_unread_idx
|
|
144
|
+
ON public.message_threads USING btree (last_message_at DESC)
|
|
145
|
+
WHERE (unread_for_admin = true);
|
|
146
|
+
|
|
147
|
+
-- Idempotent backfill / dedupe of enquiry threads.
|
|
148
|
+
CREATE INDEX IF NOT EXISTS message_threads_subject_idx
|
|
149
|
+
ON public.message_threads USING btree (subject_id) WHERE (subject_id IS NOT NULL);
|
|
150
|
+
|
|
151
|
+
-- No separate token index: message_threads_token_hash_key is already the unique
|
|
152
|
+
-- btree the /thread lookup probes.
|
|
153
|
+
|
|
154
|
+
DROP TRIGGER IF EXISTS set_message_threads_updated_at ON public.message_threads;
|
|
155
|
+
CREATE TRIGGER set_message_threads_updated_at BEFORE UPDATE ON public.message_threads
|
|
156
|
+
FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
|
|
157
|
+
|
|
158
|
+
ALTER TABLE public.message_threads ENABLE ROW LEVEL SECURITY;
|
|
159
|
+
|
|
160
|
+
GRANT SELECT, UPDATE ON TABLE public.message_threads TO authenticated;
|
|
161
|
+
GRANT ALL ON TABLE public.message_threads TO service_role;
|
|
162
|
+
|
|
163
|
+
DROP POLICY IF EXISTS message_threads_admin_read_policy ON public.message_threads;
|
|
164
|
+
CREATE POLICY message_threads_admin_read_policy ON public.message_threads FOR SELECT TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));
|
|
165
|
+
|
|
166
|
+
DROP POLICY IF EXISTS message_threads_admin_update_policy ON public.message_threads;
|
|
167
|
+
CREATE POLICY message_threads_admin_update_policy ON public.message_threads FOR UPDATE TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)) WITH CHECK ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));
|
|
168
|
+
|
|
169
|
+
DROP POLICY IF EXISTS message_threads_service_role_policy ON public.message_threads;
|
|
170
|
+
CREATE POLICY message_threads_service_role_policy ON public.message_threads TO service_role USING (true) WITH CHECK (true);
|
|
171
|
+
|
|
172
|
+
-- ---------------------------------------------------------------------------
|
|
173
|
+
-- 3. thread_messages — one turn of a private conversation.
|
|
174
|
+
-- ---------------------------------------------------------------------------
|
|
175
|
+
CREATE TABLE IF NOT EXISTS public.thread_messages (
|
|
176
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
177
|
+
thread_id uuid NOT NULL,
|
|
178
|
+
direction text NOT NULL,
|
|
179
|
+
body text NOT NULL,
|
|
180
|
+
-- profiles.id for an outbound reply. No FK: a departing admin must not take the
|
|
181
|
+
-- conversation with them.
|
|
182
|
+
author_id uuid,
|
|
183
|
+
author_name text,
|
|
184
|
+
-- False when SMTP was unconfigured or the send failed. sendEmail() throws when
|
|
185
|
+
-- unconfigured, so this row - not the mail - is the record.
|
|
186
|
+
email_delivered boolean DEFAULT false NOT NULL,
|
|
187
|
+
email_error text,
|
|
188
|
+
ip_masked text,
|
|
189
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
190
|
+
CONSTRAINT thread_messages_pkey PRIMARY KEY (id),
|
|
191
|
+
CONSTRAINT thread_messages_direction_check
|
|
192
|
+
CHECK ((direction = ANY (ARRAY['inbound'::text, 'outbound'::text]))),
|
|
193
|
+
CONSTRAINT thread_messages_body_not_blank CHECK ((char_length(btrim(body)) > 0))
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
COMMENT ON TABLE public.thread_messages IS 'Turns of a private conversation. Content is append-only; only the delivery flags may change after insert.';
|
|
197
|
+
|
|
198
|
+
-- A real foreign key here, unlike the outward pointers above: a message has no
|
|
199
|
+
-- meaning without its thread.
|
|
200
|
+
DO $rb$ BEGIN
|
|
201
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
202
|
+
WHERE conname = 'thread_messages_thread_id_fkey'
|
|
203
|
+
AND conrelid = 'public.thread_messages'::regclass) THEN
|
|
204
|
+
ALTER TABLE ONLY public.thread_messages
|
|
205
|
+
ADD CONSTRAINT thread_messages_thread_id_fkey FOREIGN KEY (thread_id)
|
|
206
|
+
REFERENCES public.message_threads(id) ON DELETE CASCADE;
|
|
207
|
+
END IF;
|
|
208
|
+
END $rb$;
|
|
209
|
+
|
|
210
|
+
CREATE INDEX IF NOT EXISTS thread_messages_thread_created_idx
|
|
211
|
+
ON public.thread_messages USING btree (thread_id, created_at);
|
|
212
|
+
|
|
213
|
+
-- Backs the per-IP reply throttle on the public thread page.
|
|
214
|
+
CREATE INDEX IF NOT EXISTS thread_messages_ip_created_idx
|
|
215
|
+
ON public.thread_messages USING btree (ip_masked, created_at DESC);
|
|
216
|
+
|
|
217
|
+
-- Message TEXT is append-only — not the whole row, because email_delivered has to be
|
|
218
|
+
-- settable after the send completes. The trigger blocks rewrites of the load-bearing
|
|
219
|
+
-- columns only, and it binds the service role too, which otherwise bypasses RLS.
|
|
220
|
+
CREATE OR REPLACE FUNCTION public.prevent_thread_message_rewrite() RETURNS trigger
|
|
221
|
+
LANGUAGE plpgsql SET search_path = '' AS $$
|
|
222
|
+
BEGIN
|
|
223
|
+
IF TG_OP = 'DELETE' THEN
|
|
224
|
+
RAISE EXCEPTION 'thread_messages is append-only; DELETE is not permitted'
|
|
225
|
+
USING ERRCODE = 'restrict_violation';
|
|
226
|
+
END IF;
|
|
227
|
+
IF NEW.body IS DISTINCT FROM OLD.body
|
|
228
|
+
OR NEW.direction IS DISTINCT FROM OLD.direction
|
|
229
|
+
OR NEW.thread_id IS DISTINCT FROM OLD.thread_id
|
|
230
|
+
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
|
|
231
|
+
RAISE EXCEPTION 'thread_messages content is append-only; only delivery flags may change'
|
|
232
|
+
USING ERRCODE = 'restrict_violation';
|
|
233
|
+
END IF;
|
|
234
|
+
RETURN NEW;
|
|
235
|
+
END;
|
|
236
|
+
$$;
|
|
237
|
+
|
|
238
|
+
DROP TRIGGER IF EXISTS trg_thread_messages_append_only ON public.thread_messages;
|
|
239
|
+
CREATE TRIGGER trg_thread_messages_append_only BEFORE UPDATE OR DELETE ON public.thread_messages
|
|
240
|
+
FOR EACH ROW EXECUTE FUNCTION public.prevent_thread_message_rewrite();
|
|
241
|
+
|
|
242
|
+
-- NB: the ON DELETE CASCADE above fires this trigger, so deleting a thread is
|
|
243
|
+
-- impossible by design. "Delete" in the CMS means status='closed' plus a revoked
|
|
244
|
+
-- token, and the UI says so.
|
|
245
|
+
|
|
246
|
+
ALTER TABLE public.thread_messages ENABLE ROW LEVEL SECURITY;
|
|
247
|
+
|
|
248
|
+
GRANT SELECT ON TABLE public.thread_messages TO authenticated;
|
|
249
|
+
GRANT ALL ON TABLE public.thread_messages TO service_role;
|
|
250
|
+
|
|
251
|
+
DROP POLICY IF EXISTS thread_messages_admin_read_policy ON public.thread_messages;
|
|
252
|
+
CREATE POLICY thread_messages_admin_read_policy ON public.thread_messages FOR SELECT TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));
|
|
253
|
+
|
|
254
|
+
DROP POLICY IF EXISTS thread_messages_service_role_policy ON public.thread_messages;
|
|
255
|
+
CREATE POLICY thread_messages_service_role_policy ON public.thread_messages TO service_role USING (true) WITH CHECK (true);
|
|
256
|
+
|
|
257
|
+
-- ---------------------------------------------------------------------------
|
|
258
|
+
-- 4. Backfill: every existing enquiry becomes a thread whose first inbound turn is
|
|
259
|
+
-- the original message. product_inquiries is KEPT and kept written — it is the
|
|
260
|
+
-- enquiry's own record and owns the ip_masked the submission throttle counts.
|
|
261
|
+
-- The thread is a conversation ABOUT the enquiry, not a replacement for it.
|
|
262
|
+
-- ---------------------------------------------------------------------------
|
|
263
|
+
INSERT INTO public.message_threads
|
|
264
|
+
(source, subject_id, subject_label, sender_name, sender_email, locale,
|
|
265
|
+
ip_masked, user_agent, unread_for_admin, status, last_message_at, created_at)
|
|
266
|
+
SELECT 'product_inquiry', i.id, COALESCE(i.product_title, 'Product enquiry'),
|
|
267
|
+
i.sender_name, i.sender_email, i.locale, i.ip_masked, i.user_agent,
|
|
268
|
+
NOT i.is_resolved, CASE WHEN i.is_resolved THEN 'closed' ELSE 'open' END,
|
|
269
|
+
i.created_at, i.created_at
|
|
270
|
+
FROM public.product_inquiries i
|
|
271
|
+
WHERE NOT EXISTS (SELECT 1 FROM public.message_threads t WHERE t.subject_id = i.id);
|
|
272
|
+
|
|
273
|
+
INSERT INTO public.thread_messages (thread_id, direction, body, email_delivered, ip_masked, created_at)
|
|
274
|
+
SELECT t.id, 'inbound', i.message, i.email_delivered, i.ip_masked, i.created_at
|
|
275
|
+
FROM public.product_inquiries i
|
|
276
|
+
JOIN public.message_threads t ON t.subject_id = i.id
|
|
277
|
+
WHERE NOT EXISTS (SELECT 1 FROM public.thread_messages m WHERE m.thread_id = t.id);
|
|
278
|
+
|
|
279
|
+
-- ---------------------------------------------------------------------------
|
|
280
|
+
-- 5. The form-block data migration.
|
|
281
|
+
-- Rewrites every stored form block: mints a form_key, moves recipient_email into
|
|
282
|
+
-- form_endpoints, snapshots the field manifest, and DELETES recipient_email from
|
|
283
|
+
-- the jsonb. Three shapes have to be handled, because a form nested inside a
|
|
284
|
+
-- section has no blocks row of its own — it lives in the section's content:
|
|
285
|
+
-- a) blocks.content where block_type = 'form'
|
|
286
|
+
-- b) blocks.content where block_type = 'section' (column_blocks, slides)
|
|
287
|
+
-- c) content_drafts.blocks / product_drafts.blocks (jsonb arrays of snapshots)
|
|
288
|
+
-- ---------------------------------------------------------------------------
|
|
289
|
+
CREATE OR REPLACE FUNCTION public.nb_migrate_form_columns(p_cols jsonb)
|
|
290
|
+
RETURNS jsonb LANGUAGE plpgsql VOLATILE SET search_path = '' AS $fn$
|
|
291
|
+
DECLARE v_col jsonb; v_blk jsonb; v_new_cols jsonb := '[]'::jsonb; v_new_col jsonb;
|
|
292
|
+
BEGIN
|
|
293
|
+
FOR v_col IN SELECT * FROM jsonb_array_elements(p_cols) LOOP
|
|
294
|
+
v_new_col := '[]'::jsonb;
|
|
295
|
+
FOR v_blk IN SELECT * FROM jsonb_array_elements(coalesce(v_col, '[]'::jsonb)) LOOP
|
|
296
|
+
v_new_col := v_new_col || jsonb_build_array(
|
|
297
|
+
v_blk || jsonb_build_object('content', public.nb_migrate_form_content(v_blk->'content')));
|
|
298
|
+
END LOOP;
|
|
299
|
+
v_new_cols := v_new_cols || jsonb_build_array(v_new_col);
|
|
300
|
+
END LOOP;
|
|
301
|
+
RETURN v_new_cols;
|
|
302
|
+
END;
|
|
303
|
+
$fn$;
|
|
304
|
+
|
|
305
|
+
CREATE OR REPLACE FUNCTION public.nb_migrate_form_content(p_content jsonb)
|
|
306
|
+
RETURNS jsonb LANGUAGE plpgsql VOLATILE SET search_path = '' AS $fn$
|
|
307
|
+
DECLARE
|
|
308
|
+
v_key uuid; v_email text; v_fields jsonb; v_slide jsonb; v_new_slides jsonb;
|
|
309
|
+
BEGIN
|
|
310
|
+
IF p_content IS NULL OR jsonb_typeof(p_content) <> 'object' THEN
|
|
311
|
+
RETURN p_content;
|
|
312
|
+
END IF;
|
|
313
|
+
|
|
314
|
+
-- Leaf: this object IS a form block's content.
|
|
315
|
+
IF (p_content ? 'recipient_email') AND (p_content ? 'fields') THEN
|
|
316
|
+
v_key := gen_random_uuid();
|
|
317
|
+
v_email := nullif(btrim(coalesce(p_content->>'recipient_email','')), '');
|
|
318
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
319
|
+
'temp_id', f->>'temp_id', 'label', f->>'label', 'field_type', f->>'field_type')), '[]'::jsonb)
|
|
320
|
+
INTO v_fields
|
|
321
|
+
FROM jsonb_array_elements(coalesce(p_content->'fields', '[]'::jsonb)) f;
|
|
322
|
+
INSERT INTO public.form_endpoints (form_key, label, recipient_email, fields)
|
|
323
|
+
VALUES (v_key, 'Contact form', v_email, v_fields)
|
|
324
|
+
ON CONFLICT (form_key) DO NOTHING;
|
|
325
|
+
RETURN (p_content - 'recipient_email') || jsonb_build_object('form_key', v_key::text);
|
|
326
|
+
END IF;
|
|
327
|
+
|
|
328
|
+
-- Container: a section's slides, each with column_blocks.
|
|
329
|
+
IF (p_content ? 'slides') AND jsonb_typeof(p_content->'slides') = 'array' THEN
|
|
330
|
+
v_new_slides := '[]'::jsonb;
|
|
331
|
+
FOR v_slide IN SELECT * FROM jsonb_array_elements(p_content->'slides') LOOP
|
|
332
|
+
v_new_slides := v_new_slides || jsonb_build_array(
|
|
333
|
+
v_slide || jsonb_build_object('column_blocks',
|
|
334
|
+
public.nb_migrate_form_columns(coalesce(v_slide->'column_blocks','[]'::jsonb))));
|
|
335
|
+
END LOOP;
|
|
336
|
+
p_content := p_content || jsonb_build_object('slides', v_new_slides);
|
|
337
|
+
END IF;
|
|
338
|
+
|
|
339
|
+
-- Container: a standard section's column_blocks.
|
|
340
|
+
IF (p_content ? 'column_blocks') AND jsonb_typeof(p_content->'column_blocks') = 'array' THEN
|
|
341
|
+
p_content := p_content || jsonb_build_object('column_blocks',
|
|
342
|
+
public.nb_migrate_form_columns(p_content->'column_blocks'));
|
|
343
|
+
END IF;
|
|
344
|
+
|
|
345
|
+
RETURN p_content;
|
|
346
|
+
END;
|
|
347
|
+
$fn$;
|
|
348
|
+
|
|
349
|
+
-- The LIKE guard keeps this from rewriting (and bumping updated_at on) every block.
|
|
350
|
+
UPDATE public.blocks
|
|
351
|
+
SET content = public.nb_migrate_form_content(content)
|
|
352
|
+
WHERE content::text LIKE '%recipient_email%';
|
|
353
|
+
|
|
354
|
+
UPDATE public.content_drafts d
|
|
355
|
+
SET blocks = (SELECT coalesce(jsonb_agg(b || jsonb_build_object(
|
|
356
|
+
'content', public.nb_migrate_form_content(b->'content'))), '[]'::jsonb)
|
|
357
|
+
FROM jsonb_array_elements(d.blocks) b)
|
|
358
|
+
WHERE d.blocks::text LIKE '%recipient_email%';
|
|
359
|
+
|
|
360
|
+
UPDATE public.product_drafts d
|
|
361
|
+
SET blocks = (SELECT coalesce(jsonb_agg(b || jsonb_build_object(
|
|
362
|
+
'content', public.nb_migrate_form_content(b->'content'))), '[]'::jsonb)
|
|
363
|
+
FROM jsonb_array_elements(d.blocks) b)
|
|
364
|
+
WHERE d.blocks::text LIKE '%recipient_email%';
|
|
365
|
+
|
|
366
|
+
-- One-shot helpers. Dropping them keeps the public schema (and db:types) clean.
|
|
367
|
+
DROP FUNCTION IF EXISTS public.nb_migrate_form_content(jsonb);
|
|
368
|
+
DROP FUNCTION IF EXISTS public.nb_migrate_form_columns(jsonb);
|
|
369
|
+
|
|
370
|
+
-- ---------------------------------------------------------------------------
|
|
371
|
+
-- 6. Seeds. Thread-page copy is public-facing so it must be translatable; the
|
|
372
|
+
-- components pass an English literal as the fallback, so an install that never
|
|
373
|
+
-- runs this seed still renders correctly.
|
|
374
|
+
-- ---------------------------------------------------------------------------
|
|
375
|
+
INSERT INTO public.site_settings (key, value)
|
|
376
|
+
VALUES ('forms_contact', '{"contactEmail": ""}'::jsonb)
|
|
377
|
+
ON CONFLICT (key) DO NOTHING;
|
|
378
|
+
|
|
379
|
+
INSERT INTO public.translations (key, translations, created_at, updated_at) VALUES
|
|
380
|
+
('thread.heading', '{"en": "Your conversation", "fr": "Votre conversation"}', now(), now()),
|
|
381
|
+
('thread.reply_label', '{"en": "Write a reply", "fr": "Écrire une réponse"}', now(), now()),
|
|
382
|
+
('thread.send', '{"en": "Send reply", "fr": "Envoyer la réponse"}', now(), now()),
|
|
383
|
+
('thread.sending', '{"en": "Sending...", "fr": "Envoi..."}', now(), now()),
|
|
384
|
+
('thread.sent', '{"en": "Thanks - your reply has been sent.", "fr": "Merci - votre réponse a été envoyée."}', now(), now()),
|
|
385
|
+
('thread.error', '{"en": "Sorry, your reply couldn''t be sent. Please try again in a moment.", "fr": "Désolé, votre réponse n''a pas pu être envoyée. Veuillez réessayer dans un instant."}', now(), now()),
|
|
386
|
+
('thread.throttled', '{"en": "You''ve sent several replies already. Please wait a few minutes.", "fr": "Vous avez déjà envoyé plusieurs réponses. Veuillez patienter quelques minutes."}', now(), now()),
|
|
387
|
+
('thread.closed', '{"en": "This conversation has been closed.", "fr": "Cette conversation est fermée."}', now(), now()),
|
|
388
|
+
('thread.invalid', '{"en": "This link has expired or is no longer valid. If you still need help, please contact us again from our website.", "fr": "Ce lien a expiré ou n''est plus valide. Si vous avez encore besoin d''aide, contactez-nous de nouveau depuis notre site."}', now(), now()),
|
|
389
|
+
('thread.you', '{"en": "You", "fr": "Vous"}', now(), now()),
|
|
390
|
+
('forms.submission_stored', '{"en": "Thanks - your message has been received.", "fr": "Merci - votre message a bien été reçu."}', now(), now())
|
|
391
|
+
ON CONFLICT (key) DO NOTHING;
|