create-nextblock 0.15.5 → 0.15.9
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 +50 -26
- package/templates/nextblock-template/app/ToasterProvider.tsx +26 -17
- package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -0
- package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -0
- package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -0
- package/templates/nextblock-template/app/actions/email.ts +241 -110
- package/templates/nextblock-template/app/actions/formActions.ts +245 -116
- package/templates/nextblock-template/app/actions/interactions.ts +489 -396
- package/templates/nextblock-template/app/actions/threadActions.ts +166 -0
- package/templates/nextblock-template/app/api/checkout/route.ts +162 -146
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +14 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +664 -1
- package/templates/nextblock-template/app/checkout/page.tsx +57 -52
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +552 -529
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -181
- package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -0
- package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -528
- package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -0
- package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -0
- package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -51
- package/templates/nextblock-template/app/cms/layout.tsx +101 -73
- package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -0
- package/templates/nextblock-template/app/cms/messages/actions.ts +404 -0
- package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -0
- package/templates/nextblock-template/app/cms/messages/page.tsx +87 -0
- package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -0
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -362
- package/templates/nextblock-template/app/cms/revisions/service.ts +20 -0
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -185
- package/templates/nextblock-template/app/layout.tsx +671 -667
- package/templates/nextblock-template/app/lib/seo.ts +319 -311
- package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -482
- package/templates/nextblock-template/app/providers.tsx +96 -96
- package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -0
- package/templates/nextblock-template/app/thread/[token]/route.ts +57 -0
- package/templates/nextblock-template/app/thread/layout.tsx +15 -0
- package/templates/nextblock-template/app/thread/page.tsx +98 -0
- package/templates/nextblock-template/components/BlockRenderer.tsx +312 -296
- package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -0
- package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -369
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -419
- package/templates/nextblock-template/components/StaffReplies.tsx +102 -0
- package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -17
- package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -19
- package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -22
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -381
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -92
- package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -31
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -600
- package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -0
- package/templates/nextblock-template/docs/05-DEVELOPER-GUIDE.md +25 -19
- package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +1 -1
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +2 -2
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +32 -9
- package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -0
- package/templates/nextblock-template/docs/README.md +42 -41
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +540 -542
- package/templates/nextblock-template/docs/assets/lighthouse-scores.png +0 -0
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +22 -2
- package/templates/nextblock-template/lib/blocks/blockColors.ts +44 -1
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -753
- package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -0
- package/templates/nextblock-template/lib/cms/payments-reminder.ts +98 -0
- package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -0
- package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -0
- package/templates/nextblock-template/lib/config/email-settings.ts +323 -254
- package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -0
- package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -0
- package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -0
- package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -0
- package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -0
- package/templates/nextblock-template/lib/messages/thread-token.ts +157 -0
- package/templates/nextblock-template/lib/messages/threads.ts +579 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +20 -0
- package/templates/nextblock-template/lib/site-url.test.ts +89 -0
- package/templates/nextblock-template/lib/site-url.ts +102 -48
- package/templates/nextblock-template/package.json +14 -1
- package/templates/nextblock-template/public/assets/nextblock-banner.jpg +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getStoreReadiness } from '@nextblock-cms/ecommerce/server';
|
|
2
|
+
import { PaymentReadinessProvider } from '@nextblock-cms/ecommerce/PaymentReadinessProvider';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Supplies payment readiness to the buy controls beneath it.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately NOT mounted in the root layout: readiness is only consulted by buy CTAs
|
|
8
|
+
* and the checkout, so a blog post or a landing page should not pay for the lookup. Each
|
|
9
|
+
* commerce block renderer wraps itself in this instead, which also means nested cases
|
|
10
|
+
* (a product grid inside a section) are covered without anyone having to walk the block
|
|
11
|
+
* tree looking for them.
|
|
12
|
+
*
|
|
13
|
+
* getStoreReadiness() is request-cached, so several of these on one page cost one read.
|
|
14
|
+
* Any failure yields no readiness at all, and the context default ("everything ready")
|
|
15
|
+
* keeps a working store working.
|
|
16
|
+
*/
|
|
17
|
+
export default async function PaymentReadinessBoundary({
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
let readiness: { stripe: boolean; freemius: boolean } | null = null;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const store = await getStoreReadiness();
|
|
26
|
+
readiness = { stripe: store.stripe.ready, freemius: store.freemius.ready };
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error('[PaymentReadinessBoundary] Could not resolve payment readiness:', error);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return <PaymentReadinessProvider readiness={readiness}>{children}</PaymentReadinessProvider>;
|
|
32
|
+
}
|
|
@@ -7,8 +7,8 @@ setup helper in `tools/scripts/setup.mjs`.
|
|
|
7
7
|
|
|
8
8
|
### Prerequisites
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
Configuration happens in the browser, not the terminal — but the wizard asks for
|
|
11
|
+
credentials from these services, so have them ready:
|
|
12
12
|
|
|
13
13
|
1. **Supabase project** (https://supabase.com/dashboard) — Reference ID
|
|
14
14
|
(Project Settings → General), connection string (Connect → Direct connection →
|
|
@@ -25,24 +25,30 @@ create them first:
|
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
npm install
|
|
28
|
-
npm run setup
|
|
29
|
-
npx nx serve nextblock
|
|
28
|
+
npm run setup # prints the next steps — it asks nothing
|
|
29
|
+
npx nx serve nextblock # then open http://localhost:4200/setup
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
`npm run setup` (`tools/scripts/setup.mjs`) is **informational only**. It writes
|
|
33
|
+
no files, prompts for nothing, and touches no database — it just points you at
|
|
34
|
+
the browser wizard. Terminal-based configuration was removed; everything below
|
|
35
|
+
now happens in the **First-Boot Setup Wizard** at `/setup`:
|
|
33
36
|
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
- applies the full schema baseline to the new database
|
|
40
|
-
(`npm run db:migrate:fresh`)
|
|
41
|
-
- syncs hosted Supabase Auth — custom SMTP and branded email templates
|
|
42
|
-
(`npm run configure:supabase-auth`)
|
|
37
|
+
- connecting Supabase and saving the credentials
|
|
38
|
+
- applying the schema to the new database
|
|
39
|
+
- configuring media storage (R2, or the connected Supabase project's storage)
|
|
40
|
+
and outbound email
|
|
41
|
+
- creating the first administrator
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
A fresh instance redirects every route to `/setup` until an admin exists, so you
|
|
44
|
+
cannot miss it.
|
|
45
|
+
|
|
46
|
+
> **Self-hosted Docker is the exception** — `npm run docker:setup` is a real,
|
|
47
|
+
> one-command, non-interactive bootstrap that brings up the whole stack and
|
|
48
|
+
> applies migrations. See [11-SELF-HOSTED-DOCKER.md](./11-SELF-HOSTED-DOCKER.md).
|
|
49
|
+
|
|
50
|
+
If you would rather configure by hand, the root sample file `.env.example` is the
|
|
51
|
+
reference template for a manual `.env.local`.
|
|
46
52
|
|
|
47
53
|
### First login
|
|
48
54
|
|
|
@@ -105,9 +111,9 @@ repo expects at least:
|
|
|
105
111
|
- `SUPABASE_PROJECT_ID` for Supabase CLI migration tooling
|
|
106
112
|
- `SUPABASE_ACCESS_TOKEN` for Supabase CLI linking
|
|
107
113
|
- `POSTGRES_URL` or `DATABASE_URL` for SQL fallback paths and db tooling
|
|
108
|
-
- `NEXT_PUBLIC_URL` —
|
|
114
|
+
- `NEXT_PUBLIC_URL` — set by the `/setup` wizard
|
|
109
115
|
- `CRON_SECRET`, `DRAFT_MODE_SECRET`, `REVALIDATE_SECRET_TOKEN` — auto-generated
|
|
110
|
-
by
|
|
116
|
+
by the `/setup` wizard
|
|
111
117
|
|
|
112
118
|
> **Supabase key aliases.** The names above are the local-dev canon, but the app also
|
|
113
119
|
> accepts the *new-style* names the hosted/Vercel Supabase Marketplace integration
|
|
@@ -120,7 +126,7 @@ repo expects at least:
|
|
|
120
126
|
> production — when unset they are derived from the service-role key (see
|
|
121
127
|
> `apps/nextblock/lib/app-secrets.ts`). See [12-VERCEL-DEPLOYMENT.md](./12-VERCEL-DEPLOYMENT.md).
|
|
122
128
|
|
|
123
|
-
Captured by
|
|
129
|
+
Captured by the `/setup` wizard and needed for a complete CMS:
|
|
124
130
|
|
|
125
131
|
- R2 credentials for media storage. The app builds and serves without them, but
|
|
126
132
|
uploads, image processing, and full-site backups return 500 until R2 is set.
|
|
@@ -87,7 +87,7 @@ It currently:
|
|
|
87
87
|
- copies `apps/nextblock` into `templates/nextblock-template`
|
|
88
88
|
- skips `node_modules`, `.next`, backups, and other generated folders
|
|
89
89
|
- copies the root `docs/` folder into the template docs directory
|
|
90
|
-
- copies `.env.example`
|
|
90
|
+
- copies `.env.example` (the legacy `.env.exemple` spelling is still accepted as a fallback)
|
|
91
91
|
- rewrites imports for packaged library consumption
|
|
92
92
|
- removes the copied `project.json`
|
|
93
93
|
- syncs package versions
|
|
@@ -75,7 +75,7 @@ Known incomplete or future work:
|
|
|
75
75
|
| `libs/utils/src/lib/nextblock-packages.ts` | Package registry. Contains `cortex-ai` metadata and Freemius product/plan ids. |
|
|
76
76
|
| `libs/cortex/src/lib/ai-config.ts` | Server-only Cortex AI constants and environment accessors. |
|
|
77
77
|
| `libs/cortex/src/lib/ai-key-crypto.ts` | AES-256-GCM encryption/decryption helpers for stored OpenRouter BYOK keys. |
|
|
78
|
-
| `.env.
|
|
78
|
+
| `.env.example` | Documents `FREEMIUS_AI_SANDBOX_KEY`, `OPENROUTER_API_KEY`, and `CORTEX_AI_ENCRYPTION_KEY`. |
|
|
79
79
|
| `libs/environment.d.ts` | Type declarations for Cortex AI environment variables. |
|
|
80
80
|
|
|
81
81
|
### Database and Sandbox
|
|
@@ -189,7 +189,7 @@ Current usage:
|
|
|
189
189
|
|
|
190
190
|
## Environment Variables
|
|
191
191
|
|
|
192
|
-
Environment variables are documented in `.env.
|
|
192
|
+
Environment variables are documented in `.env.example` and typed in `libs/environment.d.ts`.
|
|
193
193
|
|
|
194
194
|
```txt
|
|
195
195
|
FREEMIUS_AI_SANDBOX_KEY=
|
|
@@ -20,12 +20,33 @@ have to type (see "No environment variables required" below). The only interacti
|
|
|
20
20
|
Supabase integration's "create database" step, which can't be skipped because provisioning
|
|
21
21
|
a Postgres DB requires choosing a region/plan.
|
|
22
22
|
|
|
23
|
-
> **Why `stores`, not `integration-ids`?**
|
|
24
|
-
>
|
|
25
|
-
>
|
|
26
|
-
>
|
|
27
|
-
>
|
|
28
|
-
>
|
|
23
|
+
> **Why `stores`, not `integration-ids`?** Both parameters are current — neither is
|
|
24
|
+
> deprecated — but they do different jobs, and only one of them provisions a database.
|
|
25
|
+
>
|
|
26
|
+
> **`integration-ids=oac_…`** ([docs](https://vercel.com/docs/deploy-button/integrations))
|
|
27
|
+
> only forces an integration to be **installed and authorized before the project can be
|
|
28
|
+
> imported** (up to three per project). Vercel's documentation makes no promise about
|
|
29
|
+
> environment variables either way: whether credentials land in the project is a property
|
|
30
|
+
> of that integration's own OAuth flow, not of the parameter. Supabase's flow does set
|
|
31
|
+
> them — the `vercel/next.js` `with-supabase` example states that "all relevant environment
|
|
32
|
+
> variables will be assigned to the project" after installation — so this route can work.
|
|
33
|
+
> It just isn't Vercel guaranteeing it, and it says nothing about *when* they land.
|
|
34
|
+
>
|
|
35
|
+
> **`stores`** ([docs](https://vercel.com/docs/deploy-button/source)) is the native
|
|
36
|
+
> Marketplace path: it creates the store **as part of the deployment** and creates the
|
|
37
|
+
> environment variables for it. That timing is what NextBlock actually depends on — the
|
|
38
|
+
> build-time migration hook needs `POSTGRES_URL` to exist *during* the first build, not
|
|
39
|
+
> after it. See "Build configuration" below.
|
|
40
|
+
>
|
|
41
|
+
> The deciding evidence is Vercel's own Template Gallery entry for the Supabase Starter,
|
|
42
|
+
> which ships the **exact `stores` payload** used above.
|
|
43
|
+
>
|
|
44
|
+
> **If you ever add an `integration-ids` fallback, the only correct Supabase value is
|
|
45
|
+
> `oac_VqOgBHqhEoFTPzGkPd7L0iH6`** — confirmed in `supabase/supabase`'s own example READMEs
|
|
46
|
+
> and in the Vercel record behind `vercel.com/templates/next.js/supabase`. A plausible
|
|
47
|
+
> lookalike, `oac_VqOgBHqhvvqGe2YujqqiW0wo`, circulates in AI-generated snippets and is
|
|
48
|
+
> **not a real integration id**; it shares only the first twelve characters. An unknown id
|
|
49
|
+
> makes Vercel refuse the import outright, which breaks the deploy completely.
|
|
29
50
|
|
|
30
51
|
## Connect the database (Supabase integration)
|
|
31
52
|
|
|
@@ -88,9 +109,11 @@ imports the workspace libraries one level up, which a custom Root Directory woul
|
|
|
88
109
|
|
|
89
110
|
## No environment variables required
|
|
90
111
|
|
|
91
|
-
A Deploy-Button URL can
|
|
92
|
-
|
|
93
|
-
|
|
112
|
+
A Deploy-Button URL can carry environment variable **names** (`env`), and non-sensitive
|
|
113
|
+
**default values** (`envDefaults`) — but never secrets: Vercel's own docs warn that the URL
|
|
114
|
+
"is saved in the browser history, making it insecure" and to "never use default values for
|
|
115
|
+
sensitive data like passwords, API keys, tokens, database credentials." NextBlock sidesteps
|
|
116
|
+
the question entirely by resolving everything in-app, so the button prompts for nothing:
|
|
94
117
|
|
|
95
118
|
- **`NEXT_PUBLIC_URL`** — optional. When unset the app falls back to Vercel's
|
|
96
119
|
production URL (`VERCEL_PROJECT_PRODUCTION_URL` server-side /
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# 14 · Messages — the unified inbox
|
|
2
|
+
|
|
3
|
+
Everything a visitor sends the site arrives in one place: **CMS → Messages**
|
|
4
|
+
(`/cms/messages`). Four sources feed it, and the admin can reply to all of them from
|
|
5
|
+
there.
|
|
6
|
+
|
|
7
|
+
The inbox is one *view* over **two storage models**, because the sources are genuinely
|
|
8
|
+
different and flattening them would break something real.
|
|
9
|
+
|
|
10
|
+
| Source | Table | Visibility | What a reply is |
|
|
11
|
+
|---|---|---|---|
|
|
12
|
+
| Product enquiry | `message_threads` + `thread_messages` | private | a private message, delivered via a tokenised link |
|
|
13
|
+
| Contact form | `message_threads` + `thread_messages` | private | same |
|
|
14
|
+
| Product review | `cms_interactions` | already public | a **public** reply under the review |
|
|
15
|
+
| Post comment | `cms_interactions` | already public | a **public** reply under the comment |
|
|
16
|
+
|
|
17
|
+
`app/cms/messages/loadInbox.ts` performs the merge in the read path and normalises both
|
|
18
|
+
into an `InboxItem`. The detail pane branches on `item.kind`.
|
|
19
|
+
|
|
20
|
+
## Why not one table
|
|
21
|
+
|
|
22
|
+
`cms_interactions` cannot hold an enquiry, and the reasons are all enforced by the
|
|
23
|
+
schema rather than by convention:
|
|
24
|
+
|
|
25
|
+
- `user_id` is `NOT NULL` with a foreign key to `profiles` — every review and comment
|
|
26
|
+
belongs to a registered account. Enquiry senders are anonymous.
|
|
27
|
+
- `check_product_or_post` requires *exactly one* of `product_id` / `post_id`. A contact
|
|
28
|
+
form targets neither.
|
|
29
|
+
- Reviews and comments are readable by `anon` through an open `SELECT` policy once
|
|
30
|
+
approved. Enquiries carry visitor PII and have **no anon grant or policy at all**.
|
|
31
|
+
- `update_product_ratings()` fires on every `cms_interactions` write.
|
|
32
|
+
|
|
33
|
+
That last point is the sharp edge. The trigger aggregates
|
|
34
|
+
`WHERE product_id = ? AND type='review' AND status='approved'`, so anything stored as an
|
|
35
|
+
approved review moves the product's star average. **A staff reply is therefore stored as
|
|
36
|
+
`type='comment'` with a NULL rating**, carrying the parent's target. That is not a
|
|
37
|
+
workaround for convenience — `check_rating_only_for_review` is an exhaustive `OR` over
|
|
38
|
+
`('review','comment')`, so a `review` row with a NULL rating is rejected outright, and
|
|
39
|
+
adding a third enum value would violate the same constraint *and* hit PostgreSQL's rule
|
|
40
|
+
that an enum value cannot be used in the transaction that added it (i.e. in one
|
|
41
|
+
migration file). `cms_interactions_reply_check` pins the invariant in the schema.
|
|
42
|
+
|
|
43
|
+
## The private lane
|
|
44
|
+
|
|
45
|
+
`message_threads` is the spine; `thread_messages` holds the turns. Message *content* is
|
|
46
|
+
append-only, enforced by a trigger that binds the service role too — only the delivery
|
|
47
|
+
flags may change after insert. A consequence worth knowing: because the foreign key
|
|
48
|
+
cascades and the trigger blocks `DELETE`, **a thread cannot be deleted**. "Delete" in
|
|
49
|
+
the CMS means `status='closed'` plus a revoked token.
|
|
50
|
+
|
|
51
|
+
### The visitor's token
|
|
52
|
+
|
|
53
|
+
The only opaque credential this codebase issues to an anonymous person.
|
|
54
|
+
|
|
55
|
+
- 32 bytes of `crypto.randomBytes`, prefixed `nbt_`. Guessing is infeasible by
|
|
56
|
+
keyspace, which matters because the GET has no rate limit.
|
|
57
|
+
- Only `sha256` is stored, in `message_threads.token_hash` under a unique index. The
|
|
58
|
+
plaintext exists long enough to go into one email and is never written down.
|
|
59
|
+
- **Minted on the first admin reply, never at submission.** A store with a hundred
|
|
60
|
+
unanswered enquiries has zero live credentials.
|
|
61
|
+
- Rolling 90-day expiry, extended by each reply; revocable from the CMS.
|
|
62
|
+
|
|
63
|
+
`/thread/[token]` exchanges the token for an **HttpOnly cookie** and redirects to a
|
|
64
|
+
token-less `/thread`. This is the most important control in the feature: the app sends
|
|
65
|
+
`Referrer-Policy: strict-origin-when-cross-origin`, so a token left in the address bar
|
|
66
|
+
would travel in the `Referer` header of every same-origin navigation, and into history,
|
|
67
|
+
screenshots and proxy logs.
|
|
68
|
+
|
|
69
|
+
Every failure — expired, revoked, never existed — renders the *same* page. Distinguishing
|
|
70
|
+
them would make the route an oracle.
|
|
71
|
+
|
|
72
|
+
Reading the thread uses the service role **after** verifying the token in application
|
|
73
|
+
code. No RLS policy here authenticates an anonymous caller by token, and none was
|
|
74
|
+
invented; this matches how `/api/mcp` treats `mcp_access_tokens`.
|
|
75
|
+
|
|
76
|
+
## Contact forms and `form_key`
|
|
77
|
+
|
|
78
|
+
A form block used to store its destination in its own `content`. `FormBlockRenderer` is
|
|
79
|
+
a `"use client"` component that receives that content wholesale, so **the address was
|
|
80
|
+
serialized into the RSC payload of every page carrying a form** — published in the
|
|
81
|
+
markup.
|
|
82
|
+
|
|
83
|
+
Migration `00000000000027` moved every stored address into `form_endpoints` and left a
|
|
84
|
+
`form_key` behind: an opaque handle that grants nothing and is safe to serialize. The
|
|
85
|
+
migration walks three shapes, because a form nested in a section has no `blocks` row of
|
|
86
|
+
its own:
|
|
87
|
+
|
|
88
|
+
1. `blocks.content` where `block_type='form'`
|
|
89
|
+
2. `blocks.content` where `block_type='section'` (`column_blocks`, `slides`)
|
|
90
|
+
3. `content_drafts.blocks` / `product_drafts.blocks`
|
|
91
|
+
|
|
92
|
+
`form_endpoints.fields` is a **server-side snapshot** of the field manifest, so
|
|
93
|
+
notification emails and the inbox render labels the browser did not supply.
|
|
94
|
+
|
|
95
|
+
`BlockRenderer` and `SectionBlockRenderer` also strip `recipient_email` defensively
|
|
96
|
+
before handing content to the client — an import, a restored revision, or a fork that
|
|
97
|
+
has not migrated can still produce one.
|
|
98
|
+
|
|
99
|
+
## Delivery is best-effort; the row is the record
|
|
100
|
+
|
|
101
|
+
`sendEmail` **throws** when SMTP is unconfigured, and a store that has not finished one
|
|
102
|
+
piece of setup often has not finished the other. So every path writes first and notifies
|
|
103
|
+
afterwards, inside `after()`. A failed send sets `email_delivered = false` and records
|
|
104
|
+
`email_error`; the CMS shows "Not emailed" rather than pretending the owner was told.
|
|
105
|
+
|
|
106
|
+
The visitor is still shown success, because from their side it *was* one.
|
|
107
|
+
|
|
108
|
+
## Recipient resolution
|
|
109
|
+
|
|
110
|
+
Contact forms: per-form `form_endpoints.recipient_email` → `site_settings.forms_contact`
|
|
111
|
+
→ the seller-contact ladder (`lib/commerce/seller-contact.ts`: explicit store contact →
|
|
112
|
+
invoice email → privacy support email → oldest ADMIN's auth email). The sandbox
|
|
113
|
+
overrides everything.
|
|
114
|
+
|
|
115
|
+
**The address is never sent to the browser.** The form posts a `form_key`; the server
|
|
116
|
+
resolves the destination.
|
|
117
|
+
|
|
118
|
+
## The reply link and `NEXT_PUBLIC_URL`
|
|
119
|
+
|
|
120
|
+
`sendThreadNotice` refuses to send when the site URL was never configured, but happily
|
|
121
|
+
sends a local URL you chose deliberately.
|
|
122
|
+
|
|
123
|
+
`resolveSiteUrl()` falls back to `http://localhost:3000` when nothing is set, and a link
|
|
124
|
+
to that is dead for anyone not sitting at the machine. What must never happen is the code
|
|
125
|
+
*inventing* a localhost link because nothing was configured and mailing it to a customer.
|
|
126
|
+
A deliberate local URL is different: that is how you test the round trip, and it works.
|
|
127
|
+
|
|
128
|
+
So: honour an explicit choice, refuse an accidental default.
|
|
129
|
+
|
|
130
|
+
- Testing locally: set `NEXT_PUBLIC_URL=http://localhost:3000` (or your dev port). The
|
|
131
|
+
link is sent and works on that machine; a warning is logged saying who can open it.
|
|
132
|
+
- Production: set it to the public site URL. Vercel's project URL is picked up
|
|
133
|
+
automatically.
|
|
134
|
+
- Unset: the notice is refused and the reason is recorded on the message.
|
|
135
|
+
|
|
136
|
+
> An earlier version of this document blamed a `localhost` link for a quarantined
|
|
137
|
+
> message. That was wrong — a probe carrying exactly such a link was delivered normally.
|
|
138
|
+
> The real cause is below.
|
|
139
|
+
|
|
140
|
+
## Mail threading: two conversations, two roots
|
|
141
|
+
|
|
142
|
+
Outbound mail carries `In-Reply-To` and `References` pointing at a stable synthetic root,
|
|
143
|
+
because a `Re:` subject with no threading headers is itself a forged-reply heuristic, and
|
|
144
|
+
one consistent id per conversation groups the exchange in the recipient's client.
|
|
145
|
+
|
|
146
|
+
**The owner's notifications and the visitor's notices use DIFFERENT roots**, and that
|
|
147
|
+
separation is load-bearing:
|
|
148
|
+
|
|
149
|
+
- owner: `<nb-thread-{id}-admin@{from-domain}>`
|
|
150
|
+
- visitor: `<nb-thread-{id}-visitor@{from-domain}>`
|
|
151
|
+
|
|
152
|
+
They are two different exchanges with two different people. Sharing one root makes a mail
|
|
153
|
+
client fold them into a single conversation — and when an operator tests with their own
|
|
154
|
+
address as both the admin *and* the enquiring visitor (the obvious way to try the
|
|
155
|
+
feature), the reply is delivered, accepted, and then collapsed under the notification
|
|
156
|
+
they already read. It looks precisely like the email never arrived.
|
|
157
|
+
|
|
158
|
+
That symptom is a same-mailbox testing artifact, not a production fault: in real use the
|
|
159
|
+
customer and the shop owner are different mailboxes. But the shared root was a genuine
|
|
160
|
+
modelling error, and it is fixed.
|
|
161
|
+
|
|
162
|
+
## Where a form's mail goes
|
|
163
|
+
|
|
164
|
+
One rule, three rungs:
|
|
165
|
+
|
|
166
|
+
1. The form's own `form_endpoints.recipient_email`, if someone set one.
|
|
167
|
+
2. The site contact address from **CMS → Messages** (`site_settings.store_contact`).
|
|
168
|
+
3. The **first admin account's** own login address.
|
|
169
|
+
|
|
170
|
+
**A form has no address of its own by default**, and that is the intended state — since
|
|
171
|
+
the messaging system arrived, an operator does not need to think about per-form routing
|
|
172
|
+
at all. Submissions are stored as threads and answered in the CMS; the notification
|
|
173
|
+
address is one site-wide setting. A per-form address exists only to route one particular
|
|
174
|
+
form elsewhere — a careers form to HR, say.
|
|
175
|
+
|
|
176
|
+
Because rung 3 always resolves on a provisioned install, a fresh site reaches a real
|
|
177
|
+
human without configuring anything.
|
|
178
|
+
|
|
179
|
+
The sandbox overrides all of it: `resolveFormRecipient` returns `SANDBOX_CONTACT_EMAIL`
|
|
180
|
+
when `NEXT_PUBLIC_IS_SANDBOX` is set, so the hosted demo routes to the operator's inbox
|
|
181
|
+
without storing an address anywhere.
|
|
182
|
+
|
|
183
|
+
### Placeholder addresses count as unset
|
|
184
|
+
|
|
185
|
+
The starter content used to ship a contact form addressed to `contact@example.com`, and
|
|
186
|
+
migration 27 faithfully carried that into `form_endpoints`. Faithful was wrong:
|
|
187
|
+
`example.com` is reserved by RFC 2606 so it can never be registered, which makes the
|
|
188
|
+
address *guaranteed* undeliverable while looking like a real setting to every layer
|
|
189
|
+
downstream. The visitor is thanked, the relay accepts, nobody is notified, nothing errors.
|
|
190
|
+
An install ran that way without knowing.
|
|
191
|
+
|
|
192
|
+
Migration 29 clears those to NULL, so they fall through to the ladder above.
|
|
193
|
+
`lib/email/placeholder-address.ts` also treats the reserved domains (`example.com/.org/
|
|
194
|
+
.net/.edu` and the `.example`, `.invalid`, `.test`, `.localhost`, `.local` suffixes) as
|
|
195
|
+
unset at runtime, and `lib/cms/contact-reminder.ts` raises an ADMIN banner if one
|
|
196
|
+
reappears — via an import, a restored revision, or someone typing it.
|
|
197
|
+
|
|
198
|
+
The banner stays quiet when a form simply has no address, because that is now the correct
|
|
199
|
+
configuration rather than an omission.
|
|
200
|
+
|
|
201
|
+
## Subjects are per-conversation, and why that matters
|
|
202
|
+
|
|
203
|
+
| | contact form | product enquiry |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| **owner** | `Nicolas sent you a message [NRH-VWQOLB]` | `Nicolas asked about Brass Kettle [NRH-EMISCX]` |
|
|
206
|
+
| **visitor** | `New Roots Herbal replied to your message [NRH-VWQOLB]` | `New Roots Herbal replied about Brass Kettle [NRH-EMISCX]` |
|
|
207
|
+
|
|
208
|
+
`threadReference(threadId, siteName)` folds the first 32 bits of the thread id into six
|
|
209
|
+
base36 characters and prefixes the site's initials — about 2.2 billion references, read as
|
|
210
|
+
a ticket number rather than a hex dump.
|
|
211
|
+
|
|
212
|
+
**There is no `Re:` anywhere.** The visitor's message was never an email, so a reply
|
|
213
|
+
prefix is both a spam heuristic and a small lie; naming the site is what actually tells
|
|
214
|
+
them who is writing. The `In-Reply-To` / `References` headers still group each
|
|
215
|
+
conversation properly.
|
|
216
|
+
|
|
217
|
+
The reference is not decoration. **Exchange derives its ConversationTopic from the
|
|
218
|
+
subject, not from References**, so separating the References headers — necessary, and
|
|
219
|
+
done — was not sufficient. With a constant subject, every enquiry a site ever receives
|
|
220
|
+
collapses into one Outlook conversation, and anything applied to that conversation applies
|
|
221
|
+
to all of them: a rule, a filter, or Ignore Conversation.
|
|
222
|
+
|
|
223
|
+
A real install hit exactly this. Replies were accepted by the relay, accepted by Microsoft
|
|
224
|
+
with `250 Queued mail for delivery`, and routed straight to Deleted Items, because a
|
|
225
|
+
"Contact form" conversation had been ignored at some point. Nothing anywhere reported a
|
|
226
|
+
fault. It was isolated by sending two messages with byte-identical bodies and different
|
|
227
|
+
subjects: the unique subject arrived, `Re: Contact form` did not. When mail "doesn't
|
|
228
|
+
arrive", that A/B is the first thing to run — not a theory.
|
|
229
|
+
|
|
230
|
+
## Deliverability: accepted is not delivered
|
|
231
|
+
|
|
232
|
+
A reply can be accepted by the relay, accepted by the recipient's server with
|
|
233
|
+
`250 Queued mail for delivery`, appear in the inbox — and then be **removed from the
|
|
234
|
+
mailbox minutes later**. Microsoft calls this ZAP (Zero-hour Auto Purge): threat intel
|
|
235
|
+
updates after delivery and the message is retracted. There is no bounce and no SMTP
|
|
236
|
+
error, so `email_delivered = true` is accurate and every log upstream reports success.
|
|
237
|
+
|
|
238
|
+
**This is largely outside the application's control.** A strict corporate tenant can
|
|
239
|
+
purge a transactional message on its overall shape — a reply-styled body carrying a
|
|
240
|
+
quoted excerpt and a link to an opaque tokenised URL — and no rendering tested reliably
|
|
241
|
+
survived one such tenant. An intermediate result suggesting that showing the destination
|
|
242
|
+
URL was the deciding factor did not hold up against the real notice.
|
|
243
|
+
|
|
244
|
+
What the application does do:
|
|
245
|
+
|
|
246
|
+
**1. No secrecy language.** The message used to end "This link is personal to you —
|
|
247
|
+
please don't forward it." A secret one-off link, a prominent button, and an instruction
|
|
248
|
+
not to share it is a near-literal phishing template. The link's security comes from the
|
|
249
|
+
token rotating on every reply, not from asking the recipient to keep a secret.
|
|
250
|
+
|
|
251
|
+
**2. The destination is visible.** The call-to-action is a button, with the URL also
|
|
252
|
+
printed as plain text beneath it. Good practice regardless, and it survives clients that
|
|
253
|
+
strip styling.
|
|
254
|
+
|
|
255
|
+
**3. There is a manual channel.** `createVisitorLink` mints a link and hands it to the
|
|
256
|
+
admin without sending anything ("Copy visitor link" in the thread pane). When a
|
|
257
|
+
recipient's filtering removes the mail, the conversation is still intact and the link is
|
|
258
|
+
still obtainable — send it by whatever works.
|
|
259
|
+
|
|
260
|
+
### Diagnosing it
|
|
261
|
+
|
|
262
|
+
Send messages with **byte-identical bodies** differing in exactly one variable, and check
|
|
263
|
+
the recipient's Deleted Items as well as the inbox. Reasoning from correlation produced
|
|
264
|
+
five wrong answers here — a `localhost` link, spam filtering in general, a shared thread
|
|
265
|
+
root, the subject line, and the secrecy phrase — each of which fitted the evidence and
|
|
266
|
+
was killed by the next test.
|
|
267
|
+
|
|
268
|
+
The send path logs what actually went out:
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
[messages] Reply on thread <id> handed to SMTP — to="…" replyTo="…" subject="…" link=tokenised
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Start there. **Accepted by SMTP is not arrived, and arrived is not still there.** When a
|
|
275
|
+
tenant is purging on shape, testing against a different provider (a personal Gmail, say)
|
|
276
|
+
separates "the app is broken" from "this mailbox rejects this class of mail".
|
|
277
|
+
|
|
278
|
+
## Rate limiting
|
|
279
|
+
|
|
280
|
+
There is no shared rate limiter in this repo. Both public write paths throttle by
|
|
281
|
+
counting recent rows for a masked IP, and both **fail closed**: an absent or unparseable
|
|
282
|
+
`X-Forwarded-For` buckets under the literal `'unknown'` rather than skipping the check.
|
|
283
|
+
Trusted platform headers (`x-vercel-forwarded-for`, `x-real-ip`) are preferred over
|
|
284
|
+
`x-forwarded-for`, whose leftmost entry the client can write.
|
|
285
|
+
|
|
286
|
+
## Files
|
|
287
|
+
|
|
288
|
+
- `libs/db/src/supabase/migrations/00000000000027_message_threads.sql` — private lane, `form_endpoints`, the form-block data migration
|
|
289
|
+
- `libs/db/src/supabase/migrations/00000000000028_interaction_replies.sql` — `parent_id`, the reply CHECK, and the indexes `cms_interactions` never had
|
|
290
|
+
- `apps/nextblock/lib/messages/thread-token.ts` (+ `.test.ts`) — mint, parse, verify
|
|
291
|
+
- `apps/nextblock/lib/messages/threads.ts` — thread creation, recipient resolution, both notification emails
|
|
292
|
+
- `apps/nextblock/app/thread/**` — the visitor's page and the token-exchange route
|
|
293
|
+
- `apps/nextblock/app/actions/threadActions.ts` — visitor replies
|
|
294
|
+
- `apps/nextblock/app/cms/messages/**` — the inbox, its loader and its admin actions
|
|
295
|
+
- `apps/nextblock/app/actions/interactions.ts` — `replyToInteraction`, the public lane
|
|
296
|
+
- `apps/nextblock/components/StaffReplies.tsx` — nested public replies on the storefront
|
|
297
|
+
|
|
298
|
+
`/cms/inquiries` and `/cms/interactions` are now redirects into the inbox; both were
|
|
299
|
+
linked from notification emails and bookmarks, so neither was deleted.
|
|
300
|
+
|
|
301
|
+
## Known gaps
|
|
302
|
+
|
|
303
|
+
- Interactions have **no per-user read marker** anywhere in the schema, so the nav badge
|
|
304
|
+
counts "pending moderation". Two admins working the queue see the same number.
|
|
305
|
+
- Public replies do not email the review/comment author. They have a reachable address,
|
|
306
|
+
but that would be the first time the platform emails a registered user about content
|
|
307
|
+
activity, with no preference to opt out of.
|
|
308
|
+
- Visitors get no acknowledgement email at submission time — that would require minting
|
|
309
|
+
a token immediately, which is exactly the live-credential surface this design avoids.
|
|
@@ -1,41 +1,42 @@
|
|
|
1
|
-
# NextBlock CMS Documentation
|
|
2
|
-
|
|
3
|
-
This folder is the source-of-truth reference set for the NextBlock monorepo.
|
|
4
|
-
The numbered files are written from live code, routes, migrations, and shipped
|
|
5
|
-
library surfaces rather than historical planning notes.
|
|
6
|
-
|
|
7
|
-
## Start Here
|
|
8
|
-
|
|
9
|
-
- Product and architecture overview: [01-PROJECT-OVERVIEW.md](./01-PROJECT-OVERVIEW.md)
|
|
10
|
-
- Commerce capabilities: [02-ECOMMERCE-CAPABILITIES.md](./02-ECOMMERCE-CAPABILITIES.md)
|
|
11
|
-
- CMS editor and block system: [03-CMS-AND-EDITOR.md](./03-CMS-AND-EDITOR.md)
|
|
12
|
-
- Database, auth, and migrations: [04-DATABASE-AND-AUTH.md](./04-DATABASE-AND-AUTH.md)
|
|
13
|
-
- Contributor workflow and local operations: [05-DEVELOPER-GUIDE.md](./05-DEVELOPER-GUIDE.md)
|
|
14
|
-
- CLI and scaffolded project flow: [06-CLI-AND-SCAFFOLDING.md](./06-CLI-AND-SCAFFOLDING.md)
|
|
15
|
-
- Block SDK and extensibility surface: [07-BLOCK-SDK-AND-EXTENSIBILITY.md](./07-BLOCK-SDK-AND-EXTENSIBILITY.md)
|
|
16
|
-
- Cortex AI architecture: [08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md](./08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md)
|
|
17
|
-
- Live draft (visual editing) mode: [09-LIVE-DRAFT-MODE.md](./09-LIVE-DRAFT-MODE.md)
|
|
18
|
-
- Custom blocks (data-driven CRUD): [10-CUSTOM-BLOCKS.md](./10-CUSTOM-BLOCKS.md)
|
|
19
|
-
- Self-hosted local Docker stack: [11-SELF-HOSTED-DOCKER.md](./11-SELF-HOSTED-DOCKER.md)
|
|
20
|
-
- One-click cloud deploy (Deploy to Vercel): [12-VERCEL-DEPLOYMENT.md](./12-VERCEL-DEPLOYMENT.md)
|
|
21
|
-
- Updating an install (`npm run update`, upstream sync): [13-STAYING-UP-TO-DATE.md](./13-STAYING-UP-TO-DATE.md)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
1
|
+
# NextBlock CMS Documentation
|
|
2
|
+
|
|
3
|
+
This folder is the source-of-truth reference set for the NextBlock monorepo.
|
|
4
|
+
The numbered files are written from live code, routes, migrations, and shipped
|
|
5
|
+
library surfaces rather than historical planning notes.
|
|
6
|
+
|
|
7
|
+
## Start Here
|
|
8
|
+
|
|
9
|
+
- Product and architecture overview: [01-PROJECT-OVERVIEW.md](./01-PROJECT-OVERVIEW.md)
|
|
10
|
+
- Commerce capabilities: [02-ECOMMERCE-CAPABILITIES.md](./02-ECOMMERCE-CAPABILITIES.md)
|
|
11
|
+
- CMS editor and block system: [03-CMS-AND-EDITOR.md](./03-CMS-AND-EDITOR.md)
|
|
12
|
+
- Database, auth, and migrations: [04-DATABASE-AND-AUTH.md](./04-DATABASE-AND-AUTH.md)
|
|
13
|
+
- Contributor workflow and local operations: [05-DEVELOPER-GUIDE.md](./05-DEVELOPER-GUIDE.md)
|
|
14
|
+
- CLI and scaffolded project flow: [06-CLI-AND-SCAFFOLDING.md](./06-CLI-AND-SCAFFOLDING.md)
|
|
15
|
+
- Block SDK and extensibility surface: [07-BLOCK-SDK-AND-EXTENSIBILITY.md](./07-BLOCK-SDK-AND-EXTENSIBILITY.md)
|
|
16
|
+
- Cortex AI architecture: [08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md](./08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md)
|
|
17
|
+
- Live draft (visual editing) mode: [09-LIVE-DRAFT-MODE.md](./09-LIVE-DRAFT-MODE.md)
|
|
18
|
+
- Custom blocks (data-driven CRUD): [10-CUSTOM-BLOCKS.md](./10-CUSTOM-BLOCKS.md)
|
|
19
|
+
- Self-hosted local Docker stack: [11-SELF-HOSTED-DOCKER.md](./11-SELF-HOSTED-DOCKER.md)
|
|
20
|
+
- One-click cloud deploy (Deploy to Vercel): [12-VERCEL-DEPLOYMENT.md](./12-VERCEL-DEPLOYMENT.md)
|
|
21
|
+
- Updating an install (`npm run update`, upstream sync): [13-STAYING-UP-TO-DATE.md](./13-STAYING-UP-TO-DATE.md)
|
|
22
|
+
- Messages inbox (enquiries, contact forms, reviews, comments): [14-MESSAGES-INBOX.md](./14-MESSAGES-INBOX.md)
|
|
23
|
+
|
|
24
|
+
## Audience Guide
|
|
25
|
+
|
|
26
|
+
- New contributors: read `01`, then `04`, then `05`.
|
|
27
|
+
- Commerce work: read `02`, then `04`.
|
|
28
|
+
- Editor or page-builder work: read `03`, then `07`.
|
|
29
|
+
- Custom block work: read `03`, then `10`.
|
|
30
|
+
- AI / Cortex work: read `08`.
|
|
31
|
+
- CLI or template work: read `06`.
|
|
32
|
+
- Publishing the libraries / scaffold CLI: read `06`.
|
|
33
|
+
- Running everything locally without cloud accounts: read `11`.
|
|
34
|
+
- One-click cloud deploy and the browser setup wizard: read `12`.
|
|
35
|
+
- Updating an existing install, whichever way it was created: read `13`.
|
|
36
|
+
- AI agents: start with this index, then move directly to the subsystem file that
|
|
37
|
+
matches the task. Treat `apps/nextblock`, `libs/*`, and
|
|
38
|
+
`libs/db/src/supabase/migrations` as the final authority if a doc and code ever
|
|
39
|
+
disagree.
|
|
40
|
+
- AI agents touching migrations must also read the root `AGENTS.md` note:
|
|
41
|
+
production/shared database changes are append-only and non-destructive by
|
|
42
|
+
default.
|