create-nextblock 0.14.4 → 0.14.5
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/templates/nextblock-template/app/[slug]/page.tsx +7 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
- package/templates/nextblock-template/app/actions/postActions.ts +3 -0
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +362 -1
- package/templates/nextblock-template/app/api/view/route.ts +114 -0
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
- package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
- package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
- package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
- package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
- package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
- package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
- package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
- package/templates/nextblock-template/app/page.tsx +6 -3
- package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
- package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
- package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
- package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
- package/templates/nextblock-template/lib/search/server.ts +3 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -6154,6 +6154,365 @@ VALUES
|
|
|
6154
6154
|
ON CONFLICT (slug) DO NOTHING;
|
|
6155
6155
|
|
|
6156
6156
|
|
|
6157
|
+
-- >>> FROM: 00000000000015_scheduled_publishing.sql <<<
|
|
6158
|
+
-- Scheduled publishing for pages and products.
|
|
6159
|
+
--
|
|
6160
|
+
-- Posts already support scheduling: \`posts.published_at\` exists and every public
|
|
6161
|
+
-- read gates on \`published_at IS NULL OR published_at <= now()\`, so a row with
|
|
6162
|
+
-- status='published' and a future date is withheld until the date passes. Pages
|
|
6163
|
+
-- and products had no equivalent column, so "go live on Tuesday" was impossible
|
|
6164
|
+
-- for them. This adds the same column with the same semantics.
|
|
6165
|
+
--
|
|
6166
|
+
-- Visibility is derived from the (status, published_at) PAIR — no new enum value:
|
|
6167
|
+
--
|
|
6168
|
+
-- status = draft/archived -> not public, whatever the date
|
|
6169
|
+
-- status = published|active, published_at NULL -> public now
|
|
6170
|
+
-- status = published|active, date <= now() -> public now
|
|
6171
|
+
-- status = published|active, date > now() -> SCHEDULED (withheld)
|
|
6172
|
+
--
|
|
6173
|
+
-- Deriving "scheduled" instead of storing it keeps \`page_status\` unchanged (adding
|
|
6174
|
+
-- an enum value can't be done inside a transaction with other DDL in Postgres) and
|
|
6175
|
+
-- matches what posts have always done, so one code path covers all three types.
|
|
6176
|
+
--
|
|
6177
|
+
-- NULL is the safe default: every existing published row keeps rendering exactly as
|
|
6178
|
+
-- before, so this migration needs no backfill and changes no current behavior.
|
|
6179
|
+
--
|
|
6180
|
+
-- Forward-only and idempotent.
|
|
6181
|
+
|
|
6182
|
+
ALTER TABLE public.pages
|
|
6183
|
+
ADD COLUMN IF NOT EXISTS published_at timestamp with time zone;
|
|
6184
|
+
|
|
6185
|
+
COMMENT ON COLUMN public.pages.published_at IS
|
|
6186
|
+
'Optional go-live moment. NULL = live as soon as status is published. A future value withholds the page from public reads until it passes (status stays "published"; the CMS renders that pair as "Scheduled").';
|
|
6187
|
+
|
|
6188
|
+
ALTER TABLE public.products
|
|
6189
|
+
ADD COLUMN IF NOT EXISTS published_at timestamp with time zone;
|
|
6190
|
+
|
|
6191
|
+
COMMENT ON COLUMN public.products.published_at IS
|
|
6192
|
+
'Optional go-live moment. NULL = live as soon as status is active. A future value withholds the product from public reads until it passes (status stays "active"; the CMS renders that pair as "Scheduled").';
|
|
6193
|
+
|
|
6194
|
+
-- Public listing/index queries filter on the (status, published_at) pair together
|
|
6195
|
+
-- (catalog, sitemap, page lookups), so a composite index serves them in one pass.
|
|
6196
|
+
CREATE INDEX IF NOT EXISTS pages_status_published_at_idx
|
|
6197
|
+
ON public.pages (status, published_at);
|
|
6198
|
+
|
|
6199
|
+
CREATE INDEX IF NOT EXISTS products_status_published_at_idx
|
|
6200
|
+
ON public.products (status, published_at);
|
|
6201
|
+
|
|
6202
|
+
|
|
6203
|
+
-- >>> FROM: 00000000000016_product_revisions_and_revision_baseline.sql <<<
|
|
6204
|
+
-- 00000000000016_product_revisions_and_revision_baseline.sql
|
|
6205
|
+
--
|
|
6206
|
+
-- Revision History, part 1 of 2 (schema). The application-side rewrite lives in
|
|
6207
|
+
-- apps/nextblock/app/cms/revisions/**.
|
|
6208
|
+
--
|
|
6209
|
+
-- Three things happen here:
|
|
6210
|
+
--
|
|
6211
|
+
-- 1. products.version — the monotonic counter the hybrid revision engine drives,
|
|
6212
|
+
-- mirroring pages.version / posts.version.
|
|
6213
|
+
--
|
|
6214
|
+
-- 2. product_revisions — a structural mirror of page_revisions / post_revisions.
|
|
6215
|
+
-- product_id is uuid (products.id is uuid, not bigint), and
|
|
6216
|
+
-- writes are gated on is_admin() to match products_*_policy
|
|
6217
|
+
-- rather than the ADMIN|WRITER pattern the page/post revision
|
|
6218
|
+
-- tables use. A WRITER who could insert a revision but not
|
|
6219
|
+
-- apply a restore would get a silent no-op restore, because
|
|
6220
|
+
-- PostgREST returns no error for an UPDATE matching zero rows.
|
|
6221
|
+
--
|
|
6222
|
+
-- 3. Revision baseline — every page, post and product gets a real \`snapshot\` row to
|
|
6223
|
+
-- restore to. Until now the CMS synthesised a fake "Initial
|
|
6224
|
+
-- Version" entry in the UI whose Restore button resolved to
|
|
6225
|
+
-- "current metadata + zero blocks" and wiped the content.
|
|
6226
|
+
-- There is now an actual stored baseline instead.
|
|
6227
|
+
--
|
|
6228
|
+
-- Case A (version = 1, no revisions at all): the live row IS
|
|
6229
|
+
-- version 1. This covers seeded content — 00000000000003
|
|
6230
|
+
-- inserts every page and post at version 1 and writes no
|
|
6231
|
+
-- revision rows — and everything authored since the CMS save
|
|
6232
|
+
-- path stopped recording revisions. Snapshotting it at
|
|
6233
|
+
-- version 1 is what makes "restore the original seeded page"
|
|
6234
|
+
-- real for the first time.
|
|
6235
|
+
--
|
|
6236
|
+
-- Case B (version > 1 but no snapshot at or below it): the
|
|
6237
|
+
-- true v1 is unrecoverable and is NOT fabricated. A snapshot
|
|
6238
|
+
-- of the current state is stored at the current version so the
|
|
6239
|
+
-- diff chain has a valid base and future restores resolve.
|
|
6240
|
+
--
|
|
6241
|
+
-- Forward-only, idempotent, and it modifies no existing row: every backfill is an
|
|
6242
|
+
-- INSERT ... WHERE NOT EXISTS ... ON CONFLICT DO NOTHING.
|
|
6243
|
+
|
|
6244
|
+
-- ---------------------------------------------------------------------------
|
|
6245
|
+
-- 1. products.version
|
|
6246
|
+
-- ---------------------------------------------------------------------------
|
|
6247
|
+
|
|
6248
|
+
ALTER TABLE public.products
|
|
6249
|
+
ADD COLUMN IF NOT EXISTS version integer DEFAULT 1 NOT NULL;
|
|
6250
|
+
|
|
6251
|
+
COMMENT ON COLUMN public.products.version IS 'Monotonic version number for hybrid revisions.';
|
|
6252
|
+
|
|
6253
|
+
-- ---------------------------------------------------------------------------
|
|
6254
|
+
-- 2. product_revisions
|
|
6255
|
+
-- ---------------------------------------------------------------------------
|
|
6256
|
+
|
|
6257
|
+
CREATE TABLE IF NOT EXISTS public.product_revisions (
|
|
6258
|
+
id bigint NOT NULL,
|
|
6259
|
+
product_id uuid NOT NULL,
|
|
6260
|
+
author_id uuid,
|
|
6261
|
+
version integer NOT NULL,
|
|
6262
|
+
revision_type public.revision_type NOT NULL,
|
|
6263
|
+
content jsonb NOT NULL,
|
|
6264
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL
|
|
6265
|
+
);
|
|
6266
|
+
|
|
6267
|
+
COMMENT ON TABLE public.product_revisions IS 'Hybrid (snapshot/diff) revisions for products.';
|
|
6268
|
+
COMMENT ON COLUMN public.product_revisions.content IS 'If snapshot: full content; if diff: JSON Patch array.';
|
|
6269
|
+
|
|
6270
|
+
DO $rb$
|
|
6271
|
+
BEGIN
|
|
6272
|
+
IF NOT EXISTS (
|
|
6273
|
+
SELECT 1 FROM pg_attribute
|
|
6274
|
+
WHERE attrelid = 'public.product_revisions'::regclass
|
|
6275
|
+
AND attname = 'id'
|
|
6276
|
+
AND attidentity <> ''
|
|
6277
|
+
) THEN
|
|
6278
|
+
ALTER TABLE public.product_revisions
|
|
6279
|
+
ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
|
|
6280
|
+
SEQUENCE NAME public.product_revisions_id_seq
|
|
6281
|
+
START WITH 1
|
|
6282
|
+
INCREMENT BY 1
|
|
6283
|
+
NO MINVALUE
|
|
6284
|
+
NO MAXVALUE
|
|
6285
|
+
CACHE 1
|
|
6286
|
+
);
|
|
6287
|
+
END IF;
|
|
6288
|
+
END $rb$;
|
|
6289
|
+
|
|
6290
|
+
DO $rb$
|
|
6291
|
+
BEGIN
|
|
6292
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
6293
|
+
WHERE conname = 'product_revisions_pkey'
|
|
6294
|
+
AND conrelid = 'public.product_revisions'::regclass) THEN
|
|
6295
|
+
ALTER TABLE ONLY public.product_revisions
|
|
6296
|
+
ADD CONSTRAINT product_revisions_pkey PRIMARY KEY (id);
|
|
6297
|
+
END IF;
|
|
6298
|
+
END $rb$;
|
|
6299
|
+
|
|
6300
|
+
DO $rb$
|
|
6301
|
+
BEGIN
|
|
6302
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
6303
|
+
WHERE conname = 'product_revisions_product_version_key'
|
|
6304
|
+
AND conrelid = 'public.product_revisions'::regclass) THEN
|
|
6305
|
+
ALTER TABLE ONLY public.product_revisions
|
|
6306
|
+
ADD CONSTRAINT product_revisions_product_version_key UNIQUE (product_id, version);
|
|
6307
|
+
END IF;
|
|
6308
|
+
END $rb$;
|
|
6309
|
+
|
|
6310
|
+
DO $rb$
|
|
6311
|
+
BEGIN
|
|
6312
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
6313
|
+
WHERE conname = 'product_revisions_author_id_fkey'
|
|
6314
|
+
AND conrelid = 'public.product_revisions'::regclass) THEN
|
|
6315
|
+
ALTER TABLE ONLY public.product_revisions
|
|
6316
|
+
ADD CONSTRAINT product_revisions_author_id_fkey
|
|
6317
|
+
FOREIGN KEY (author_id) REFERENCES public.profiles(id) ON DELETE SET NULL;
|
|
6318
|
+
END IF;
|
|
6319
|
+
END $rb$;
|
|
6320
|
+
|
|
6321
|
+
DO $rb$
|
|
6322
|
+
BEGIN
|
|
6323
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
6324
|
+
WHERE conname = 'product_revisions_product_id_fkey'
|
|
6325
|
+
AND conrelid = 'public.product_revisions'::regclass) THEN
|
|
6326
|
+
ALTER TABLE ONLY public.product_revisions
|
|
6327
|
+
ADD CONSTRAINT product_revisions_product_id_fkey
|
|
6328
|
+
FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;
|
|
6329
|
+
END IF;
|
|
6330
|
+
END $rb$;
|
|
6331
|
+
|
|
6332
|
+
CREATE INDEX IF NOT EXISTS idx_product_revisions_author_id
|
|
6333
|
+
ON public.product_revisions USING btree (author_id);
|
|
6334
|
+
|
|
6335
|
+
CREATE INDEX IF NOT EXISTS idx_product_revisions_product_id_version
|
|
6336
|
+
ON public.product_revisions USING btree (product_id, version);
|
|
6337
|
+
|
|
6338
|
+
ALTER TABLE public.product_revisions ENABLE ROW LEVEL SECURITY;
|
|
6339
|
+
|
|
6340
|
+
DROP POLICY IF EXISTS product_revisions_read_policy ON public.product_revisions;
|
|
6341
|
+
CREATE POLICY product_revisions_read_policy ON public.product_revisions
|
|
6342
|
+
FOR SELECT TO authenticated USING (true);
|
|
6343
|
+
|
|
6344
|
+
DROP POLICY IF EXISTS product_revisions_insert_policy ON public.product_revisions;
|
|
6345
|
+
CREATE POLICY product_revisions_insert_policy ON public.product_revisions
|
|
6346
|
+
FOR INSERT TO authenticated
|
|
6347
|
+
WITH CHECK (((SELECT public.is_admin() AS is_admin) IS TRUE));
|
|
6348
|
+
|
|
6349
|
+
DROP POLICY IF EXISTS product_revisions_update_policy ON public.product_revisions;
|
|
6350
|
+
CREATE POLICY product_revisions_update_policy ON public.product_revisions
|
|
6351
|
+
FOR UPDATE TO authenticated
|
|
6352
|
+
USING (((SELECT public.is_admin() AS is_admin) IS TRUE))
|
|
6353
|
+
WITH CHECK (((SELECT public.is_admin() AS is_admin) IS TRUE));
|
|
6354
|
+
|
|
6355
|
+
DROP POLICY IF EXISTS product_revisions_delete_policy ON public.product_revisions;
|
|
6356
|
+
CREATE POLICY product_revisions_delete_policy ON public.product_revisions
|
|
6357
|
+
FOR DELETE TO authenticated
|
|
6358
|
+
USING (((SELECT public.is_admin() AS is_admin) IS TRUE));
|
|
6359
|
+
|
|
6360
|
+
GRANT ALL ON TABLE public.product_revisions TO anon;
|
|
6361
|
+
GRANT ALL ON TABLE public.product_revisions TO authenticated;
|
|
6362
|
+
GRANT ALL ON TABLE public.product_revisions TO service_role;
|
|
6363
|
+
GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO anon;
|
|
6364
|
+
GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO authenticated;
|
|
6365
|
+
GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO service_role;
|
|
6366
|
+
|
|
6367
|
+
-- ---------------------------------------------------------------------------
|
|
6368
|
+
-- 3. Revision baseline backfill
|
|
6369
|
+
--
|
|
6370
|
+
-- The JSON shape must match FullPageContent / FullPostContent / FullProductContent
|
|
6371
|
+
-- in apps/nextblock/app/cms/revisions/utils.ts exactly, or the first diff taken
|
|
6372
|
+
-- against a baseline row will be full of phantom operations.
|
|
6373
|
+
--
|
|
6374
|
+
-- Timestamps are rendered with an explicit millisecond-precision UTC format so they
|
|
6375
|
+
-- match JavaScript's Date#toISOString() ("2026-07-03T17:52:15.643Z"). Postgres'
|
|
6376
|
+
-- default jsonb rendering of timestamptz ("2026-07-03T17:52:15.643901+00:00") would
|
|
6377
|
+
-- differ from the value the application writes and produce a spurious diff on the
|
|
6378
|
+
-- very next save.
|
|
6379
|
+
-- ---------------------------------------------------------------------------
|
|
6380
|
+
|
|
6381
|
+
-- 3a. Pages
|
|
6382
|
+
INSERT INTO public.page_revisions (page_id, author_id, version, revision_type, content)
|
|
6383
|
+
SELECT
|
|
6384
|
+
p.id,
|
|
6385
|
+
NULL::uuid,
|
|
6386
|
+
p.version,
|
|
6387
|
+
'snapshot'::public.revision_type,
|
|
6388
|
+
jsonb_build_object(
|
|
6389
|
+
'meta', jsonb_build_object(
|
|
6390
|
+
'title', p.title,
|
|
6391
|
+
'slug', p.slug,
|
|
6392
|
+
'language_id', p.language_id,
|
|
6393
|
+
'status', p.status,
|
|
6394
|
+
'meta_title', p.meta_title,
|
|
6395
|
+
'meta_description', p.meta_description,
|
|
6396
|
+
'custom_canonical', p.custom_canonical,
|
|
6397
|
+
'published_at', to_char(p.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
|
6398
|
+
'feature_image_id', p.feature_image_id
|
|
6399
|
+
),
|
|
6400
|
+
'blocks', COALESCE((
|
|
6401
|
+
SELECT jsonb_agg(
|
|
6402
|
+
jsonb_build_object(
|
|
6403
|
+
'language_id', b.language_id,
|
|
6404
|
+
'block_type', b.block_type,
|
|
6405
|
+
'content', b.content,
|
|
6406
|
+
'order', b."order"
|
|
6407
|
+
) ORDER BY b."order" ASC, b.id ASC
|
|
6408
|
+
)
|
|
6409
|
+
FROM public.blocks b
|
|
6410
|
+
WHERE b.page_id = p.id
|
|
6411
|
+
), '[]'::jsonb)
|
|
6412
|
+
)
|
|
6413
|
+
FROM public.pages p
|
|
6414
|
+
WHERE NOT EXISTS (
|
|
6415
|
+
SELECT 1 FROM public.page_revisions r
|
|
6416
|
+
WHERE r.page_id = p.id
|
|
6417
|
+
AND r.revision_type = 'snapshot'
|
|
6418
|
+
AND r.version <= p.version
|
|
6419
|
+
)
|
|
6420
|
+
ON CONFLICT (page_id, version) DO NOTHING;
|
|
6421
|
+
|
|
6422
|
+
-- 3b. Posts
|
|
6423
|
+
INSERT INTO public.post_revisions (post_id, author_id, version, revision_type, content)
|
|
6424
|
+
SELECT
|
|
6425
|
+
po.id,
|
|
6426
|
+
NULL::uuid,
|
|
6427
|
+
po.version,
|
|
6428
|
+
'snapshot'::public.revision_type,
|
|
6429
|
+
jsonb_build_object(
|
|
6430
|
+
'meta', jsonb_build_object(
|
|
6431
|
+
'title', po.title,
|
|
6432
|
+
'slug', po.slug,
|
|
6433
|
+
'language_id', po.language_id,
|
|
6434
|
+
'status', po.status,
|
|
6435
|
+
'meta_title', po.meta_title,
|
|
6436
|
+
'meta_description', po.meta_description,
|
|
6437
|
+
'custom_canonical', po.custom_canonical,
|
|
6438
|
+
'published_at', to_char(po.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
|
6439
|
+
'feature_image_id', po.feature_image_id,
|
|
6440
|
+
'label', po.label,
|
|
6441
|
+
'excerpt', po.excerpt,
|
|
6442
|
+
'subtitle', po.subtitle
|
|
6443
|
+
),
|
|
6444
|
+
'blocks', COALESCE((
|
|
6445
|
+
SELECT jsonb_agg(
|
|
6446
|
+
jsonb_build_object(
|
|
6447
|
+
'language_id', b.language_id,
|
|
6448
|
+
'block_type', b.block_type,
|
|
6449
|
+
'content', b.content,
|
|
6450
|
+
'order', b."order"
|
|
6451
|
+
) ORDER BY b."order" ASC, b.id ASC
|
|
6452
|
+
)
|
|
6453
|
+
FROM public.blocks b
|
|
6454
|
+
WHERE b.post_id = po.id
|
|
6455
|
+
), '[]'::jsonb)
|
|
6456
|
+
)
|
|
6457
|
+
FROM public.posts po
|
|
6458
|
+
WHERE NOT EXISTS (
|
|
6459
|
+
SELECT 1 FROM public.post_revisions r
|
|
6460
|
+
WHERE r.post_id = po.id
|
|
6461
|
+
AND r.revision_type = 'snapshot'
|
|
6462
|
+
AND r.version <= po.version
|
|
6463
|
+
)
|
|
6464
|
+
ON CONFLICT (post_id, version) DO NOTHING;
|
|
6465
|
+
|
|
6466
|
+
-- 3c. Products.
|
|
6467
|
+
--
|
|
6468
|
+
-- Content only. price/prices/sale_*/scheduled_*/stock/sku/average_rating/total_reviews
|
|
6469
|
+
-- are deliberately excluded from the snapshot: pricing and inventory are mutated from
|
|
6470
|
+
-- outside the editor (promotions, Freemius sync, order fulfilment), ratings are derived
|
|
6471
|
+
-- aggregates, and inventory_items is keyed by bare SKU text with no FK to products — so
|
|
6472
|
+
-- replaying commerce state on restore would reach rows the editor never touched.
|
|
6473
|
+
-- Restoring a product restores its content, not its commerce state.
|
|
6474
|
+
INSERT INTO public.product_revisions (product_id, author_id, version, revision_type, content)
|
|
6475
|
+
SELECT
|
|
6476
|
+
pr.id,
|
|
6477
|
+
NULL::uuid,
|
|
6478
|
+
pr.version,
|
|
6479
|
+
'snapshot'::public.revision_type,
|
|
6480
|
+
jsonb_build_object(
|
|
6481
|
+
'meta', jsonb_build_object(
|
|
6482
|
+
'title', pr.title,
|
|
6483
|
+
'slug', pr.slug,
|
|
6484
|
+
'language_id', pr.language_id,
|
|
6485
|
+
'status', pr.status,
|
|
6486
|
+
'meta_title', pr.meta_title,
|
|
6487
|
+
'meta_description', pr.meta_description,
|
|
6488
|
+
'custom_canonical', pr.custom_canonical,
|
|
6489
|
+
'published_at', to_char(pr.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
|
6490
|
+
'short_description', pr.short_description,
|
|
6491
|
+
'description_json', pr.description_json
|
|
6492
|
+
),
|
|
6493
|
+
'blocks', COALESCE((
|
|
6494
|
+
SELECT jsonb_agg(
|
|
6495
|
+
jsonb_build_object(
|
|
6496
|
+
'language_id', b.language_id,
|
|
6497
|
+
'block_type', b.block_type,
|
|
6498
|
+
'content', b.content,
|
|
6499
|
+
'order', b."order"
|
|
6500
|
+
) ORDER BY b."order" ASC, b.id ASC
|
|
6501
|
+
)
|
|
6502
|
+
FROM public.blocks b
|
|
6503
|
+
WHERE b.product_id = pr.id
|
|
6504
|
+
), '[]'::jsonb)
|
|
6505
|
+
)
|
|
6506
|
+
FROM public.products pr
|
|
6507
|
+
WHERE NOT EXISTS (
|
|
6508
|
+
SELECT 1 FROM public.product_revisions r
|
|
6509
|
+
WHERE r.product_id = pr.id
|
|
6510
|
+
AND r.revision_type = 'snapshot'
|
|
6511
|
+
AND r.version <= pr.version
|
|
6512
|
+
)
|
|
6513
|
+
ON CONFLICT (product_id, version) DO NOTHING;
|
|
6514
|
+
|
|
6515
|
+
|
|
6157
6516
|
-- Step D: Record the applied migrations in history (truncated in Step B) so
|
|
6158
6517
|
-- \`npm run db:migrate:check\` reports up to date instead of listing every file as pending.
|
|
6159
6518
|
INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES
|
|
@@ -6171,7 +6530,9 @@ ON CONFLICT (slug) DO NOTHING;
|
|
|
6171
6530
|
('00000000000011', 'language_detection_admin_only'),
|
|
6172
6531
|
('00000000000012', 'cortex_ai_stock_photo_settings'),
|
|
6173
6532
|
('00000000000013', 'youtube_nocookie_embeds'),
|
|
6174
|
-
('00000000000014', 'site_themes')
|
|
6533
|
+
('00000000000014', 'site_themes'),
|
|
6534
|
+
('00000000000015', 'scheduled_publishing'),
|
|
6535
|
+
('00000000000016', 'product_revisions_and_revision_baseline')
|
|
6175
6536
|
ON CONFLICT (version) DO NOTHING;
|
|
6176
6537
|
|
|
6177
6538
|
-- Step E: Anchor preserved profiles
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { draftMode } from "next/headers";
|
|
2
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
3
|
+
import { getCurrentUserCanEdit } from "../../../lib/visual-editing/draft-content";
|
|
4
|
+
import {
|
|
5
|
+
normalizeDraftRedirectPath,
|
|
6
|
+
resolveDraftPathTarget,
|
|
7
|
+
resolveRequestOrigin,
|
|
8
|
+
} from "../../../lib/visual-editing/draft-route";
|
|
9
|
+
|
|
10
|
+
export const runtime = "nodejs";
|
|
11
|
+
export const dynamic = "force-dynamic";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Entry point behind the CMS "Preview" and "View Live" buttons.
|
|
15
|
+
*
|
|
16
|
+
* The public site carries no locale in its URLs: `/[slug]`, `/article/[slug]`,
|
|
17
|
+
* `/product/[slug]` and `/` all resolve their language per-request from the
|
|
18
|
+
* `NEXT_USER_LOCALE` cookie (see proxy.ts). A link built from the slug alone
|
|
19
|
+
* therefore renders in whatever language the *editor's own* cookie says — which,
|
|
20
|
+
* for an admin working in the CMS, is almost always the default one. Opening the
|
|
21
|
+
* French version of a page landed you on the English one, three different ways:
|
|
22
|
+
*
|
|
23
|
+
* 1. `getPageDataBySlug(slug, cookieLocale)` prefers the row matching the
|
|
24
|
+
* cookie, so when two translations share a slug the cookie's language wins.
|
|
25
|
+
* 2. `PageClientContent` / `PostClientContent` navigate to
|
|
26
|
+
* `translatedSlugs[currentLocale]` whenever the rendered row's language
|
|
27
|
+
* differs from the cookie — bouncing distinct French slugs back to English.
|
|
28
|
+
* 3. `/` (the homepage) resolves its language from the cookie with no slug to
|
|
29
|
+
* go on at all, so the French homepage was unreachable by URL.
|
|
30
|
+
*
|
|
31
|
+
* Pinning the locale here fixes all three at once, because after the redirect the
|
|
32
|
+
* cookie *agrees* with the content: the server picks the right row, the client
|
|
33
|
+
* effect is a no-op, and the surrounding chrome (nav, footer, UI strings) renders
|
|
34
|
+
* in the same language as the body — so the preview isn't lying about the page.
|
|
35
|
+
*
|
|
36
|
+
* The alternative, a `?lang=` param read by each route, would leave a second
|
|
37
|
+
* cacheable variant of every public URL behind and can leak into shares and
|
|
38
|
+
* search indexes as duplicate content. The redirect lands on the clean canonical
|
|
39
|
+
* URL instead, and the param never reaches the public route.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const LANGUAGE_COOKIE_KEY = "NEXT_USER_LOCALE";
|
|
43
|
+
|
|
44
|
+
function redirectNoStore(url: URL) {
|
|
45
|
+
const response = NextResponse.redirect(url);
|
|
46
|
+
response.headers.set("Cache-Control", "no-store");
|
|
47
|
+
return response;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function redirectToSignIn(request: NextRequest, search: string) {
|
|
51
|
+
const origin = resolveRequestOrigin(request);
|
|
52
|
+
const signInUrl = new URL("/sign-in", origin);
|
|
53
|
+
signInUrl.searchParams.set("redirect", `${request.nextUrl.pathname}${search}`);
|
|
54
|
+
return redirectNoStore(signInUrl);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function GET(request: NextRequest) {
|
|
58
|
+
const params = request.nextUrl.searchParams;
|
|
59
|
+
const normalizedPath = normalizeDraftRedirectPath(params.get("path") ?? "/");
|
|
60
|
+
|
|
61
|
+
if (!normalizedPath) {
|
|
62
|
+
return NextResponse.json({ error: "Invalid path." }, { status: 400 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const target = resolveDraftPathTarget(normalizedPath);
|
|
66
|
+
if (!target) {
|
|
67
|
+
return NextResponse.json({ error: "Unsupported target path." }, { status: 400 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const wantsDraft = params.get("draft") === "1";
|
|
71
|
+
|
|
72
|
+
const auth = await getCurrentUserCanEdit();
|
|
73
|
+
if (!auth.user) {
|
|
74
|
+
return redirectToSignIn(request, request.nextUrl.search);
|
|
75
|
+
}
|
|
76
|
+
if (!auth.canEdit) {
|
|
77
|
+
return NextResponse.json(
|
|
78
|
+
{ error: "You do not have permission to preview content." },
|
|
79
|
+
{ status: 403 },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Only ever write a language the CMS actually has configured — the value lands
|
|
84
|
+
// in a cookie every public request reads, so it must not be attacker-supplied.
|
|
85
|
+
let locale: string | null = null;
|
|
86
|
+
const requestedLang = params.get("lang")?.trim();
|
|
87
|
+
if (requestedLang) {
|
|
88
|
+
const { data: language } = await (auth.supabase as any)
|
|
89
|
+
.from("languages")
|
|
90
|
+
.select("code")
|
|
91
|
+
.eq("code", requestedLang)
|
|
92
|
+
.maybeSingle();
|
|
93
|
+
locale = (language as { code?: string } | null)?.code ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (wantsDraft) {
|
|
97
|
+
const draft = await draftMode();
|
|
98
|
+
draft.enable();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const response = redirectNoStore(new URL(target.path, resolveRequestOrigin(request)));
|
|
102
|
+
|
|
103
|
+
if (locale) {
|
|
104
|
+
// Session-scoped on purpose: previewing French shouldn't pin the editor's own
|
|
105
|
+
// browsing language for a year. The proxy leaves a matching cookie alone, so
|
|
106
|
+
// this survives the preview and expires with the browser session.
|
|
107
|
+
response.cookies.set(LANGUAGE_COOKIE_KEY, locale, {
|
|
108
|
+
path: "/",
|
|
109
|
+
sameSite: "lax",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return response;
|
|
114
|
+
}
|
|
@@ -84,14 +84,13 @@ function applyDraftToPost(post: any, draft: ContentDraftRow) {
|
|
|
84
84
|
slug: draftString(draft, "slug", post.slug),
|
|
85
85
|
language_id: languageId,
|
|
86
86
|
languages: languageId === post.language_id ? post.languages : null,
|
|
87
|
-
|
|
87
|
+
// Visibility is never taken from a draft — it lives on the row.
|
|
88
88
|
meta_title: draftNullableString(draft, "meta_title", post.meta_title),
|
|
89
89
|
meta_description: draftNullableString(draft, "meta_description", post.meta_description),
|
|
90
90
|
custom_canonical: draftNullableString(draft, "custom_canonical", post.custom_canonical),
|
|
91
91
|
label: draftNullableString(draft, "label", post.label),
|
|
92
92
|
excerpt: draftNullableString(draft, "excerpt", post.excerpt),
|
|
93
93
|
subtitle: draftNullableString(draft, "subtitle", post.subtitle),
|
|
94
|
-
published_at: draftNullableString(draft, "published_at", post.published_at),
|
|
95
94
|
feature_image_id: draftNullableString(draft, "feature_image_id", post.feature_image_id),
|
|
96
95
|
translation_group_id: draftString(
|
|
97
96
|
draft,
|
|
@@ -45,8 +45,18 @@ export default function DraftStatusActions({
|
|
|
45
45
|
);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const warning = res && "success" in res ? res.warning : undefined;
|
|
49
|
+
|
|
48
50
|
if (res && "error" in res && res.error) {
|
|
49
51
|
toast.error(`Publish failed: ${res.error}`, { id: toastId });
|
|
52
|
+
} else if (warning) {
|
|
53
|
+
// The content IS live — only the revision failed to record. Say so plainly
|
|
54
|
+
// rather than claiming an unqualified success.
|
|
55
|
+
toast.error(warning, { id: toastId, duration: 8000 });
|
|
56
|
+
router.refresh();
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
window.location.reload();
|
|
59
|
+
}, 800);
|
|
50
60
|
} else {
|
|
51
61
|
toast.success("Changes published live successfully!", { id: toastId });
|
|
52
62
|
router.refresh();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Badge } from "@nextblock-cms/ui";
|
|
2
|
+
import {
|
|
3
|
+
LIVE_STATUS,
|
|
4
|
+
resolveVisibilityState,
|
|
5
|
+
type PublishableType,
|
|
6
|
+
type VisibilityState,
|
|
7
|
+
} from "@nextblock-cms/utils";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Status badge for the CMS list views.
|
|
11
|
+
*
|
|
12
|
+
* Reads the same (status, published_at) pair as the editor's top-bar control, so a
|
|
13
|
+
* scheduled row is labelled "Scheduled" here instead of claiming to be published
|
|
14
|
+
* while its URL still 404s.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const LABEL: Record<VisibilityState, string> = {
|
|
18
|
+
draft: "Draft",
|
|
19
|
+
scheduled: "Scheduled",
|
|
20
|
+
published: "Published",
|
|
21
|
+
archived: "Archived",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const CLASS_NAME: Record<VisibilityState, string> = {
|
|
25
|
+
published:
|
|
26
|
+
"bg-green-100 text-green-700 dark:bg-green-700/30 dark:text-green-300 dark:border-green-700/50",
|
|
27
|
+
scheduled:
|
|
28
|
+
"bg-amber-100 text-amber-700 dark:bg-amber-700/30 dark:text-amber-300 dark:border-amber-700/50",
|
|
29
|
+
draft:
|
|
30
|
+
"bg-yellow-100 text-yellow-700 dark:bg-yellow-700/30 dark:text-yellow-300 dark:border-yellow-700/50",
|
|
31
|
+
archived:
|
|
32
|
+
"bg-slate-100 text-slate-700 dark:bg-slate-700/30 dark:text-slate-300 dark:border-slate-600",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const VARIANT: Record<VisibilityState, "default" | "secondary" | "destructive"> = {
|
|
36
|
+
published: "default",
|
|
37
|
+
scheduled: "secondary",
|
|
38
|
+
draft: "secondary",
|
|
39
|
+
archived: "destructive",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default function VisibilityBadge({
|
|
43
|
+
type,
|
|
44
|
+
status,
|
|
45
|
+
publishedAt,
|
|
46
|
+
}: {
|
|
47
|
+
type: PublishableType;
|
|
48
|
+
status: string;
|
|
49
|
+
publishedAt?: string | null;
|
|
50
|
+
}) {
|
|
51
|
+
const state = resolveVisibilityState({
|
|
52
|
+
status,
|
|
53
|
+
publishedAt,
|
|
54
|
+
liveStatus: LIVE_STATUS[type],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Badge variant={VARIANT[state]} className={CLASS_NAME[state]}>
|
|
59
|
+
{LABEL[state]}
|
|
60
|
+
</Badge>
|
|
61
|
+
);
|
|
62
|
+
}
|