Pre-fix: `getBaseUrl()` in /api/account/invitations defaulted to
`https://wacrm.tech` whenever `NEXT_PUBLIC_SITE_URL` was unset.
wacrm.tech is the docs/marketing site (separate ArnasDon/wacrm-site
repo); it has no `/join/[token]` route. Any fork that didn't set the
env var got invite links pointing into the marketing site, which
404'd on click. Reported by @ArnasDon while testing the flow on
production.
Post-fix: `getBaseUrl(request)` resolves in this order:
1. `NEXT_PUBLIC_SITE_URL` — explicit operator config, still wins.
2. `X-Forwarded-Host` (+ `X-Forwarded-Proto`) — set by every
reverse proxy in front of the app (Hostinger Managed Node.js,
Vercel, Cloudflare). This is what makes invite links Just
Work in production without any env-var fiddling.
3. `Host` header + the request's protocol — bare deployments
without a proxy in front.
4. `https://wacrm.tech` last-resort fallback with a console.warn
so operators spot the misconfig in logs. Effectively
unreachable from a real browser; only triggers if there's no
Host header at all.
Also tightened `.env.local.example` so the variable's purpose
matches the new behavior: it's now only needed when the request-
derived origin would be wrong (cron jobs, background workers).
Day-to-day forks don't have to remember it.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (after `rm -rf .next` to clear
stale [locale]/* type stubs from the
i18n revert that landed on main)
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the second half of #147. Users can now create a template in
wacrm and submit it to Meta for approval without leaving the app —
no more dummy local rows that fail with Meta error #132001 on send.
API surface
-----------
POST /api/whatsapp/templates/submit
Auth → fetch whatsapp_config → validate payload → build Meta
components → POST to /{waba_id}/message_templates → persist row
with meta_template_id + status + sample_values +
last_submitted_at. Per-field validators throw with specific
messages before the network call, so misconfigured templates
fail with "button #3 missing url" instead of a 400 from Meta.
429s are detected and surfaced as "rate limit hit (100/hr)".
WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and
issues a synthetic `dry-run-<uuid>` meta_template_id so CI and
local dev can exercise the full form without a real WABA.
New modules
-----------
src/lib/whatsapp/template-validators.ts
Pure functions encoding Meta's caps: 1024-char body, 60-char
footer/header, 25-char button text, 10 buttons max, 2 URL / 1
PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL
example required when {{1}} is present, COPY_CODE example
required, header media URL must be http(s), variables must be
contiguous starting at {{1}}. 41 unit tests cover each rule.
src/lib/whatsapp/template-components.ts
Translates our local row shape into Meta's components array
(HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D
array per spec, header_handle preferred over header_url when
both are present). Snapshot tests assert the wire shape.
src/lib/whatsapp/template-status-normalize.ts
Single source of truth for upstream→local status mapping.
Replaces the duplicated normalizer in sync/route.ts; the
upcoming webhook handler will share it too.
src/lib/whatsapp/meta-api.ts
New submitMessageTemplate() helper following the existing
named-args + throwMetaError pattern.
Form rewrite
------------
src/components/settings/template-manager.tsx replaces the flat
form with a component-based builder:
* Header: format select (None / Text / Image / Video / Document)
+ conditional inputs — text + variable-aware sample row for
TEXT, URL paste for media (file upload slot ready for the
Resumable Upload follow-up)
* Body: textarea with live {{N}} extraction → sample-value rows
auto-appear and shrink as variables are added/removed
* Footer: 60-char capped input
* Buttons editor: add up to 10, per-row type picker
(Quick Reply / URL / Phone / Copy Code), conditional URL +
example + phone + code fields, X to remove
* Card display now surfaces rejection_reason / submission_error
(red banner) and quality_score (GREEN/YELLOW/RED chip)
* AUTHENTICATION shows a "create in Meta Manager + Sync" banner
and disables Submit — that category has a different shape
(fixed body, mandatory OTP button) and lands in a follow-up
Side effects
------------
* Broadcast Step 1 picker (step1-choose-template.tsx) now
filters to status='APPROVED' to match the inbox picker —
selecting a non-approved template would 400 on send.
* .env.local.example documents META_APP_ID (Resumable Upload
follow-up) and WHATSAPP_TEMPLATES_DRY_RUN.
Out of scope (subsequent PRs):
* Edit / resubmit / delete endpoints (PR 4)
* Webhook handler for real-time status (PR 5)
* sendTemplateMessage media + button param support (PR 6)
* Resumable Upload + AUTHENTICATION builder (follow-up)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prep work so the repo is ready to be public. None of this is runtime
code; it's the governance a forker expects on day one.
- LICENSE: MIT at repo root. README already claimed MIT; this makes
GitHub actually recognise it and show the license badge.
- .github/SECURITY.md: private disclosure path via GitHub Security
Advisories or email. Spells out scope, safe harbour, and what the
reporter should expect — so a researcher finds it before they try
to file a public issue.
- .env.local.example: fleshed out with the three vars it was missing
— META_APP_SECRET (required, from PR 1), NEXT_PUBLIC_SITE_URL
(recommended), and AUTOMATION_CRON_SECRET (optional). Grouped as
Required / Recommended / Optional with a one-line comment per
entry. Includes the openssl/node generators for the secrets.
- package.json: description, repository, bugs, homepage, keywords,
license, author, engines.node >=20. No runtime impact; feeds npm
registry metadata, GitHub's repo sidebar, and prevents forkers from
silently installing on Node <20 (Next 16's floor).
Typecheck clean; npm install run to refresh the lockfile for the
package.json metadata additions (no dependency changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The webhook signature verifier used to fall open with a warning log
when META_APP_SECRET was unset — a deliberate soft-rollout choice that
is unsafe for a public template. A forker who forgets the env var
would be running a fully spoofable webhook: anyone knowing the URL
could POST fabricated status updates and drift broadcast counts.
Now:
- `verifyMetaWebhookSignature` returns false when the secret is missing
and logs at error level, so the route responds 401 to every POST
until the operator configures it.
- Docs mark META_APP_SECRET as **required**:
- docs/environment-variables.md lists it under Required, not
Recommended.
- docs/whatsapp-setup.md §6 is now "Signature verification (required)".
- docs/troubleshooting.md updated accordingly.
- .env.local.example lists the var with guidance on where to find it.
Also fixes a pre-existing gitignore bug: `.env*` was swallowing
`.env.local.example`, which meant forkers running
`cp .env.local.example .env.local` from the getting-started guide
hit "no such file". Added a negation for `*.example`.
Upgrade note for existing deploys: set META_APP_SECRET (Meta → App
Settings → Basic → App Secret) before updating, or inbound webhooks
will 401 until it's configured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>