Commit Graph

84 Commits

Author SHA1 Message Date
Arnas Donauskas
1f2849df75
Merge remote-tracking branch 'origin/main' into chore/whatsapp-trademark-symbol
# Conflicts:
#	README.md
#	docs/README.md
#	docs/automations-and-cron.md
#	docs/supabase-setup.md
#	docs/whatsapp-setup.md
#	src/app/opengraph-image.tsx
#	src/components/landing/hero.tsx
#	src/lib/seo/site-config.ts
2026-05-06 13:50:03 +03:00
Arnas Donauskas
4bb157c705
Merge remote-tracking branch 'origin/main' into chore/hostinger-accent-palette
# Conflicts:
#	src/app/icon.tsx
#	src/components/docs/shell.tsx
#	src/components/landing/footer.tsx
#	src/components/landing/nav.tsx
2026-05-06 13:35:46 +03:00
Arnas Donauskas
e46844d527
chore: add WhatsApp® on first mention across user-facing surfaces
Per Meta's brand-use guidance for third-party products, the registered
trademark symbol (®) should appear on the FIRST mention of "WhatsApp"
on each user-facing page. Subsequent mentions on the same page are
left plain.

Surfaces touched (one-per-page first mention):
- Landing hero badge ("Built on the official WhatsApp® Business API").
- src/lib/seo/site-config.ts: SITE_TAGLINE and SITE_DESCRIPTION
  (rendered into <title> and <meta description> on every page).
- src/app/opengraph-image.tsx: hero text in social-share image.
- Settings, Inbox connection banner, Automations page subtitle.
- README.md tagline.
- All public docs pages (docs/README.md, docs/whatsapp-setup.md,
  docs/getting-started.md, docs/supabase-setup.md,
  docs/environment-variables.md, docs/deployment-hostinger.md,
  docs/automations-and-cron.md, docs/architecture.md,
  docs/troubleshooting.md).

Out of scope: SITE_KEYWORDS (the meta-keywords array isn't
user-rendered text — putting ® on every keyword would look broken in
the meta tag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:13:26 +03:00
Arnas Donauskas
77dfa4f165
chore: switch accent palette to Hostinger violet, drop emerald green
The legal brief on third-party trademark use says: avoid the WhatsApp
teal/green palette and any visual treatment that could read as a
WhatsApp/Meta product. Emerald-500 was used as the primary brand colour
across hero, CTAs, sidebar, charts, badges, and the favicon — close
enough in hue to WhatsApp's signature green to muddy the line.

Swapping to Tailwind violet (which matches the Hostinger brand purple
#7c3aed) makes the visual identity unambiguously ours.

Scope:
- Mechanical Tailwind class swap: emerald-* → violet-* across src/.
- src/app/globals.css: --primary, --ring, --sidebar-primary, --sidebar-ring,
  --chart-1 oklch tokens swapped from emerald (oklch 0.696 0.17 162) to
  Hostinger-aligned violet (oklch 0.526 0.247 293).
- src/app/icon.tsx: favicon background #10b981 → #7c3aed.
- src/app/opengraph-image.tsx: badge bg, accent text, gradient.
- src/components/landing/hero.tsx + cta-banner.tsx: radial-gradient hex.
- src/components/landing/mock/analytics-mock.tsx: marketing chart line.
- src/components/dashboard/conversations-chart.tsx + response-time-chart.tsx:
  "Outgoing" series colour. Distinguishable from "Incoming" blue at a
  glance; we can revisit the chart pair later for stronger colourblind
  contrast (separate concern).
- README.md: license badge colour parameter.

Out of scope:
- src/components/settings/tag-manager.tsx still offers "Emerald" as one
  of the user-pickable contact-tag colours alongside Blue / Yellow / Red
  / etc. — that's a user data choice, not a brand mark.

Contrast check: white text on #7c3aed measures ~4.78:1 (WCAG AA passing
for normal body text). Violet-400 (#a78bfa) on slate-950 measures ~10:1
for accent text, well clear of AA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:57:12 +03:00
Arnas Donauskas
faee4eea44
chore: rename product to "CRM Template for WhatsApp"
Drops the "WaCRM" branded name across user-facing surfaces in favour
of a descriptive name. Branded names that imply WhatsApp ownership or
sponsorship are a trademark fair-use risk; a relationship-based name
("CRM Template for WhatsApp") sidesteps that.

Scope:
- Brand text in nav, sidebar, footer, docs shell, mock window chrome.
- SITE_NAME / SITE_DESCRIPTION / legalName in src/lib/seo/site-config.ts.
- README, CONTRIBUTING, docs/, .github/ templates and policies.
- package.json description.
- Auth pages, settings copy, FAQ, code comments.

Out of scope (intentionally — these are infra renames the project
owner should drive separately so links and the package lockfile stay
consistent):
- The github.com/ArnasDon/wacrm repo URL.
- The wacrm.tech domain / homepage field.
- The package.json "name" field.
- The Hostinger application name and Meta system-user name in deploy
  docs (existing-deployment instructions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:52:11 +03:00
Arnas Donauskas
a9df9cd3bf
docs: drop alternative-hosts page, keep Hostinger as the only path
The template is about shipping on Hostinger. Offering Vercel / Railway
/ raw VPS / Docker alongside it blurs that story and creates support
surface for hosts we don't actually test against.

- Delete docs/deployment-alternatives.md.
- README Deploy list drops the "Alternative hosts" bullet.
- docs/README.md reading-order drops step 6 and renumbers.
- docs/architecture.md drops its trailing "Next step" pointer to
  the removed page.
- src/lib/docs/content.ts drops the deployment-alternatives entry;
  remaining pages renumbered to 5..8.

Verified: /docs sidebar now lists 8 pages,
/docs/deployment-alternatives → 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:51:10 +03:00
Arnas Donauskas
7cd0f46369
docs: README polish + architecture + alt hosts
Finishes the template-readiness sequence. All docs — nothing runtime.

README.md
  Rewrites the landing page of the repo to actually sell what it is
  to someone finding the repo cold on GitHub search.
  - Shields.io badges: MIT, CI status, Next.js 16, Supabase, GitHub
    stars.
  - "What you get out of the box" bullet list — inbox, contacts,
    pipelines, broadcasts, automations, dashboard, account
    management — so the feature surface is visible before the reader
    scrolls.
  - "Why fork this?" section: full ownership, full customisation,
    zero ops to start, real security primitives. Frames WaCRM as a
    template, not an SDK.
  - Docs section reorganised into Setup / Deploy / Reference to
    match the in-app /docs viewer and docs/README.md.
  - Contributing section rewrites the stale "Pull requests welcome"
    line to match the fork-first framing from PR #73.

docs/architecture.md  (new)
  One-page tour for forkers: stack table, full folder layout,
  two ASCII request-lifecycle diagrams (inbound webhook + outbound
  send), security primitives rundown, and a "where to change things"
  index that maps common customisation tasks to source files. This
  is the thing I wish I'd had the first time I opened a forked
  template.

docs/deployment-alternatives.md  (new)
  Short blocks for Vercel, Railway, raw VPS, and Docker. Each covers
  env-var setup, cron strategy (Vercel cron without custom headers
  is a footgun worth calling out), and trade-offs. Keeps Hostinger
  as the primary recommendation — this page is for people whose
  constraints rule it out.

docs/README.md
  - Reading order grouped as Setup / Deploy / Reference, adding the
    two new pages.
  - Stack-at-a-glance updated: encryption is AES-256-GCM, not CBC
    (the PR #71 change that earlier docs hadn't caught up to).

src/lib/docs/content.ts
  Registers the two new markdown files in the ORDER map so they
  appear in the in-app /docs sidebar under the Reference section,
  with prev/next pagination wiring.

Verified: /docs, /docs/architecture, /docs/deployment-alternatives,
and /docs/getting-started all return 200 from the dev server.
Sidebar shows 9 pages grouped Setup / Deploy / Reference with the
new entries. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:43:42 +03:00
Arnas Donauskas
e570e1a8f2
security: switch WhatsApp token encryption from AES-CBC to AES-GCM
CBC without a MAC is unauthenticated — anyone who can write rows to
whatsapp_config can flip bits in the stored access_token or
verify_token without the decrypt throwing. You'd silently get garbled
bytes; worst case, if the mutation happens to produce a valid token,
messages go out under a spoofed account. GCM appends a 16-byte
authentication tag; any tampering fails the decrypt.

## What changed

src/lib/whatsapp/encryption.ts
  - encrypt() now emits AES-256-GCM ciphertexts with a 12-byte IV,
    formatted as `<iv>:<ciphertext>:<authTag>` (three colons).
  - decrypt() auto-detects format by splitting on `:`:
      3 parts → GCM, validated via auth tag.
      2 parts → legacy CBC, read-only compatibility path.
    Callers are unchanged; same signature, same return type.
  - isLegacyFormat() exposes the format check so hot paths can
    opportunistically upgrade old rows.
  - IV length is validated on decrypt (12 for GCM, 16 for CBC) to
    fail fast on corrupted input.

## Self-healing existing rows

Two call sites opportunistically upgrade a legacy row to GCM after a
successful decrypt — fire-and-forget, no latency impact on the request
path, idempotent under concurrent writes.

  - src/app/api/whatsapp/send/route.ts (hot path)
      After decrypting access_token, if it was CBC, we schedule an
      UPDATE that writes a fresh GCM ciphertext back to the same row.
      The first send after deploy upgrades the token; subsequent
      sends are no-ops because isLegacyFormat returns false.

  - src/app/api/whatsapp/webhook/route.ts (subscribe handshake)
      When a config's verify_token matches the inbound
      hub.verify_token, we upgrade it in place if it was legacy.
      Runs during Meta's webhook re-subscription events, which
      happen on config changes and periodic refreshes.

No schema migration required — the DB column holds whatever string
encrypt() produces. Operators who want to force an upgrade for a
particular user can clear and re-save the WhatsApp settings form.

## Verified (tsx smoke test)
  - GCM roundtrip works (plaintext in → same plaintext out).
  - Legacy CBC decrypt works on 2-part format.
  - isLegacyFormat distinguishes new/legacy correctly.
  - Bit-flipped GCM ciphertext throws on decrypt.
  - Forged auth tag throws on decrypt.
  - Same plaintext produces different ciphertexts across calls
    (IV randomness).

## Rollback

Revert this file. Existing GCM rows would then fail to decrypt
because the previous encrypt.ts has no branch for the 3-part format.
Safer rollback: keep the decrypt branches, revert only the encrypt()
body to CBC — but since GCM roundtrips cleanly and the PR's bug
surface is small (pure crypto utility), a straight revert should only
matter if a forker had time between merging this PR and reverting to
accumulate GCM rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:25:48 +03:00
Arnas Donauskas
1f295a831a
security: rate-limit send and broadcast per user
A fork running WaCRM publicly had no server-side guard on the
WhatsApp send endpoints — a compromised session or an aggressive UI
loop could call /api/whatsapp/send or /broadcast as fast as the
network allowed and blow through Meta's per-number quota (or worse,
get the number suspended).

This PR adds a minimal in-memory fixed-window rate limiter keyed by
user id.

Budgets (tuned for a template default — forkers can edit):
  - /api/whatsapp/send      60 per minute per user  (1/s sustained)
  - /api/whatsapp/broadcast  5 per minute per user  (campaign dispatch
                                                    rate, not fan-out)

Behaviour:
  - Check runs right after the Supabase auth check; unauthenticated
    calls 401 as before and never consume a bucket entry.
  - When a user exceeds the budget the route returns 429 with
    Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining,
    X-RateLimit-Reset headers.
  - Send and broadcast buckets are independent; two users are
    independent.

Design trade-off:
  The limiter lives in a process-local Map. On a single Hostinger
  Managed Node.js instance — the template's default deployment —
  that's the whole limit. On a horizontally-scaled deploy (multiple
  regions, serverless fan-out) each instance has its own Map and the
  effective rate is N * limit. Swap src/lib/rate-limit.ts for a
  Redis/Upstash/Durable-Objects backend if you scale past one
  instance; keep the checkRateLimit return shape and the call sites
  don't move.

Memory is self-cleaning: a 1-in-1000 opportunistic sweep drops
expired keys without a background timer, so the Map stays bounded
without running anything outside the request path.

Smoke test (tsx):
  - 7 broadcasts → 5 ok, 2 denied.
  - Different user → independent bucket.
  - Same user, different route → independent bucket.
  - Retry-after = 60s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:48:30 +03:00
Arnas Donauskas
be7edfd180
fix(webhook): require META_APP_SECRET, fail closed
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>
2026-04-20 18:54:52 +03:00
Arnas Donauskas
45fd2a9845
feat(docs): in-app docs viewer at /docs
Renders every file in docs/*.md as a proper documentation site styled
like Stripe / Vercel / Linear docs: sticky header, grouped left sidebar
(Setup / Deploy / Operate), dark prose with inline code pills, syntax
-ready code blocks, responsive table styling, and prev/next pagination.

- src/lib/docs/content.ts — parses md with marked and decorates the
  HTML with Tailwind utility classes via post-processing regex. Also
  rewrites inter-doc `./other.md` links to `/docs/other` and extracts
  an h2/h3 TOC for future use.
- src/app/docs/layout.tsx + src/components/docs/shell.tsx — shared
  frame with docs-scoped header (GitHub, Home, Get started), collapsing
  sidebar on mobile.
- src/app/docs/page.tsx — grouped index cards per section.
- src/app/docs/[slug]/page.tsx — individual doc + prev/next nav.
- Adds "Docs" to landing nav, footer, and a "Read the full
  documentation" CTA under the open-source cards.
- Adds `marked` dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:28:36 +03:00
Arnas Donauskas
15e4b5366d
feat(seo): full SEO pass (reapply with OG-image build fix)
Re-applies the original SEO work from PR #47 that was reverted in
PR #54 because its opengraph-image.tsx crashed `next build` during
static prerender of /opengraph-image. Satori (next/og's renderer)
errored with:

  Error: Expected <div> to have explicit "display: flex",
         "display: contents", or "display: none" if it has more
         than one child node.
  Failed to load dynamic font for ✓ . Status: 400

Root cause in the OG image JSX:
- Several <div>s held text + a sibling element (e.g. "Run your
  WhatsApp business from <span>one inbox.</span>") without an
  explicit display value. Satori requires it whenever there is
  more than one child.
- The ✓ Unicode glyph isn't covered by the default font Satori
  loads, so next/og tried to download a dynamic font and failed.

Fix:
- Every <div> with more than one child now has display: flex.
- Multi-span headline sits in a flex-wrap row of two <span>s.
- ✓ replaced with plain text dot separators in the feature row.

Verified end-to-end: `npm run build` now completes with 27/27
pages prerendered, /opengraph-image among them.

All the other SEO-pass content is identical to the originally
merged-and-reverted work: metadataBase + title template, OpenGraph
+ Twitter Cards defaults, JSON-LD (WebSite + Organization +
SoftwareApplication + FAQPage), sitemap.ts, robots.ts, noindex on
auth + dashboard route-group layouts, shared faq-data + site-config
modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:25:59 +03:00
Arnas Donauskas
adf78bb2ee
Revert "feat(seo): full SEO pass — metadata, structured data, sitemap, robots" 2026-04-19 12:15:42 +03:00
Arnas Donauskas
c2587f7466
feat(seo): full SEO pass — metadata, structured data, sitemap, robots
Brings the site from bare-bones metadata to production-ready SEO.
Covers technical crawlability, rich-result eligibility, social
sharing, and duplicate-content prevention.

Added:
- src/lib/seo/site-config.ts — single source of truth for base URL,
  brand, description, keywords. Referenced by every consumer so a
  domain / rename is a one-file edit.
- src/lib/seo/faq-data.ts — FAQ Q&A extracted from the component so
  the UI and the FAQPage JSON-LD render the same content.
- src/lib/seo/structured-data.ts — builders for WebSite,
  Organization, SoftwareApplication, and FAQPage JSON-LD nodes.
  FAQPage is eligible for Google's FAQ rich result; SoftwareApplication
  with a free Offer can earn a "Free" badge on SERP.
- src/components/seo/json-ld.tsx — emits one <script
  type="application/ld+json"> per node (Google's preferred shape).
- src/app/opengraph-image.tsx — Next auto-generates a 1200×630 PNG
  for the landing page's OG / Twitter previews.
- src/app/sitemap.ts — lists only public, indexable pages.
- src/app/robots.ts — Allow /, Disallow every app / auth route,
  references the sitemap.

Updated:
- src/app/layout.tsx — metadataBase, title template (%s — WaCRM),
  default OpenGraph + Twitter Cards (inherited everywhere), canonical,
  icons config, viewport (themeColor + colorScheme=dark for browser
  chrome on mobile).
- src/app/page.tsx — injects the 4 JSON-LD blocks, sets explicit
  canonical for /, overrides the title.
- src/app/(auth)/layout.tsx — noindex+nofollow for login / signup /
  forgot-password (no SEO value; would compete with landing page).
- src/app/(dashboard)/layout.tsx — split into thin server layout
  (exports noindex metadata) + DashboardShell client component. App
  routes won't be indexed even if a URL leaks.
- src/components/landing/faq.tsx — consumes the shared FAQ data
  module (no more drift between UI and JSON-LD).

Verified end-to-end against the running dev server:
- GET / HTML contains: canonical, description, keywords, robots,
  og:title/url/image/type, twitter:card/title, theme-color, + 4
  JSON-LD blocks (WebSite, Organization, SoftwareApplication,
  FAQPage).
- GET /opengraph-image → 200 OK, content-type image/png.
- GET /login HTML contains <meta name="robots" content="noindex,
  nofollow, nocache">.
- GET /sitemap.xml serves valid XML with /.
- GET /robots.txt serves Allow /, Disallow all app + auth paths,
  sitemap reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:17:48 +03:00
Arnas Donauskas
ed669424b6
feat(dashboard): replace placeholder with real analytics
Rewrites the dashboard page as a data-driven analytics surface using
existing Supabase tables only — no schema changes, no other pages
touched.

Widgets (all render independent skeletons while their data fetches,
and show a shared empty state when there's not enough data to be
useful):

- Metric cards (Active Conversations, New Contacts Today, Open Deals
  Value, Messages Sent Today) with up/down/no-change delta indicator.
- Quick actions row (New Contact / Deal / Broadcast / Automation).
- Conversations Over Time: SVG line chart, 7/30/90-day tabs, per-day
  hover crosshair + tooltip. Cached per range so tab switches are
  instant.
- Pipeline Value: SVG donut with per-stage arc segments, total in the
  center, legend with deal count + value.
- Average First Response Time: SVG bar chart by weekday with a
  configurable dashed target line and this-week / last-week summary.
- Activity feed: latest 20 items merged across messages, contacts,
  deals, broadcasts, and automation logs — click-through deep-links
  where applicable.

Deviations from spec, noted up-front:
- Strictly dark theme — spec's #f9fafb alternating rows translate to
  slate-900 / slate-900/40 stripes.
- Kept Inter (tabular-nums, bold 28px) for metric values instead of
  Space Grotesk to avoid touching root layout.
- Quick actions link to the relevant list pages (/contacts, etc.)
  rather than auto-opening their create modals — that would require
  modifying those pages.

All aggregation is client-side via RLS-scoped queries. If a tenant's
dataset outgrows a few thousand messages per range window, the
response-time and line-chart queries should migrate to SQL RPCs —
noted as follow-up, not shipped here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:16:00 +03:00
Arnas Donauskas
5be2bad51d
Merge pull request #41 from ArnasDon/fix/automations-validate-step-config-on-activate
fix(automations): reject activation of invalid step/trigger config
2026-04-17 21:58:46 +03:00
Arnas Donauskas
348dbb7d6c
Merge pull request #40 from ArnasDon/fix/automations-atomic-execution-counter
fix(automations): atomic execution_count via SQL function (migration 007)
2026-04-17 21:56:37 +03:00
Arnas Donauskas
98751b8fe6
Merge pull request #39 from ArnasDon/fix/automations-welcome-template-uses-first-inbound
fix(automations): Welcome Message template uses first_inbound_message
2026-04-17 21:54:28 +03:00
Arnas Donauskas
3758711f5a
Merge pull request #38 from ArnasDon/fix/automations-meta-send-contact-ownership
fix(automations): scope meta-send contact lookup by user_id
2026-04-17 21:53:05 +03:00
Arnas Donauskas
570fc1234c
fix(automations): reject activation of invalid step/trigger config
Activating an automation with obviously-broken config (add_tag step
with empty tag_id, send_message with empty text, keyword_match
trigger with no keywords, etc.) used to succeed — and then every
dispatch produced a cryptic failed log row that users often missed.

- New validate.ts centralises the checks (mirrors engine.ts's
  runtime guards one layer earlier so failures surface at save time).
  Returns { path, message } issues so the UI can point users at the
  exact problem.
- POST /api/automations rejects with 400 when is_active === true and
  the merged config fails validation.
- PATCH /api/automations/[id] computes the post-patch "effective"
  state (merging body fields over the existing row, loading current
  steps if body.steps isn't provided) and applies the same gate.
  Drafts (is_active=false) remain free to be incomplete so users can
  still save progress mid-build.
- Builder surfaces the first issue in a toast with path as the
  description, so users don't need DevTools to find what to fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:50:13 +03:00
Arnas Donauskas
635b5c1df6
fix(automations): atomic execution_count via SQL function (migration 007)
The engine did a client-side read-modify-write on
automations.execution_count, so two concurrent dispatches of the
same automation (e.g. fired for two different contacts within the
same second) could both read N and both write N+1, permanently
losing one count.

- Migration 007 adds increment_automation_execution_count(p_automation_id)
  as a SECURITY DEFINER sql function. Only service_role can EXECUTE
  it — an authenticated user can't juice someone else's counter by
  calling the RPC directly.
- Engine now calls this RPC instead of reading automation.execution_count
  and adding 1. Atomic in one statement; concurrent dispatches both
  land correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:45:27 +03:00
Arnas Donauskas
a3d59d8ea3
fix(automations): Welcome Message template uses first_inbound_message
The template seeded trigger_type as new_contact_created, which only
fires when the webhook auto-creates a contact row. A user who added
or imported the contact manually before the contact first messaged
would not hit the welcome flow — that's almost certainly not what the
user setting up "Welcome Message" wants.

first_inbound_message (added in PR #33) is the superset: fires the
first time any contact — auto-created or pre-existing — sends us a
customer message. Correct semantic for a welcome/greeting template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:42:20 +03:00
Arnas Donauskas
4afa9bea32
fix(automations): scope meta-send contact lookup by user_id
The engine runs with the service-role client (bypassing RLS) and the
public POST /api/automations/engine endpoint accepts contact_id from
the request body. Without a user_id filter on the contacts lookup,
an authenticated user could fire their own automations against
another tenant's contact UUID and send via their own WhatsApp config
to that contact's phone. UUIDs are unguessable so practical risk is
low, but this is cheap defense-in-depth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:39:33 +03:00
Arnas Donauskas
f25f20ed63
fix(automations): order send_template params numerically, not lexicographically
Meta's template placeholders are positional ({{1}}, {{2}}, …). We
previously sorted the keys of step_config.variables lexicographically,
so a template with ≥10 variables got params in the order
"1", "10", "2", …, silently scrambling every render past the 9th
placeholder. Fix: sort numeric-looking keys by numeric value, fall back
to locale compare for non-numeric keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:36:40 +03:00
Arnas Donauskas
5243188d0c
feat(automations): add 'First Message from Contact' trigger
new_contact_created only fires when the webhook auto-creates the
contact row, so it misses the case of manually-added / imported
contacts sending their first-ever message.

first_inbound_message fires whenever the incoming message is the
contact's first customer-sent message (checked by counting prior
customer-sender messages in the conversation before insert), which
covers both auto-created and pre-existing contacts.

- Added first_inbound_message to AutomationTriggerType.
- New pill color + dropdown entry labeled "First Message from Contact".
- Webhook now counts prior customer messages before inserting the
  incoming one; dispatches first_inbound_message when the count is 0.
- new_contact_created still fires for brand-new contacts; they're
  independent triggers with overlapping coverage, so users pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:47:13 +03:00
Arnas Donauskas
0e75eb8d11
fix(automations): actually send messages via Meta
send_message and send_template steps were inserting a row into
messages with status='sending' but never calling Meta's WhatsApp API,
so automation replies appeared in the inbox but never reached the
phone. send_template was a plain noop ("template send recorded").

- New helper src/lib/automations/meta-send.ts mirrors the logic in
  /api/whatsapp/send (decrypt config, phone-variant retry, persist
  messages row with status='sent' and the real whatsapp message id).
  Kept as a separate helper to avoid touching the working manual-send
  route; the two can converge in a later refactor.
- Engine's send_message / send_template now await the helper and
  surface Meta errors through the existing per-step failure path,
  so a bad token / invalid template shows up in automation_logs with
  status='failed' instead of silently "sending" forever.
- Consolidated automation dispatches in the webhook to fire after the
  conversation is created. Previously new_contact_created raced the
  conversation insert, which could have made send_message fail to
  resolve a target conversation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:34:52 +03:00
Arnas Donauskas
b7b362ae42
feat(automations): visual flow builder, engine, and logs viewer
Replaces the /automations placeholder with a full feature:
- Migration 006 adds automations, automation_steps, automation_logs,
  and automation_pending_executions with RLS matching existing tables.
- List page with active/inactive switch, quick-start templates,
  duplicate, delete, and View Logs actions.
- Full-screen vertical flow builder with Trigger + Action + Condition
  (Yes/No branches) + Wait cards. Inline edit forms, up/down reorder,
  dotted-grid canvas.
- Execution engine (src/lib/automations/engine.ts) dispatched from the
  WhatsApp webhook for new_message_received, new_contact_created, and
  keyword_match. Wait steps enqueue to automation_pending_executions;
  /api/automations/cron drains them behind AUTOMATION_CRON_SECRET.
- Logs viewer with per-step checkmarks and expandable detail.

Existing files touched only in webhook/route.ts (fire-and-forget
trigger dispatch) and types/index.ts (appended automation types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:39:02 +03:00
Arnas Donauskas
66dd4ef97e
fix(broadcasts): senior-review hardening pass
Fixes 8 real issues found during a thorough review of the broadcast
feature (PRs #21-#29).

Security
  * Webhook POSTs had zero spoof protection. Anyone could forge a
    status update and drift broadcast counts arbitrarily. Added
    HMAC-SHA256 signature verification via the X-Hub-Signature-256
    header (Meta's standard). New helper src/lib/whatsapp/webhook-
    signature.ts with timing-safe comparison. Rollout fails open
    with a warning when META_APP_SECRET is unset so existing
    deploys don't break at rollout; set the env var to enforce.

Correctness
  * CSV audience was completely broken — synthesized "csv-N" strings
    as contact_id, which failed the UUID cast on insert. Every CSV
    broadcast silently created zero recipients. Replaced with a
    real find-or-create against the contacts table (batched lookup
    by phone, single INSERT for missing rows, de-duplicates CSV
    inputs). CSV contacts now end up as real contacts.rows with
    real UUIDs flowing through the rest of the pipeline.

  * Recipient insert errors were logged with console.error and the
    loop marched on — broadcasts then ran with incomplete recipient
    sets, webhook updates couldn't find rows, and aggregate counts
    drifted. Now: on batch insert failure, flip the broadcast to
    failed + failed_count=total + throw so handleSend's toast fires.

  * Webhook status guard ordered "failed" above "replied", which
    meant a webhook replay delivering "failed" for an already-
    replied recipient would upgrade replied → failed. Split the
    ladder: forward-only ordering across pending → sent →
    delivered → read → replied; failed is a terminal side branch
    that can only be reached from pending or sent (never from any
    success state). Net effect: a message that was read can no
    longer regress to failed on a replay.

  * Template sync only pulled page 1 (ignored paging.next). Users
    with >100 templates silently missed some, and broadcasts with
    those names failed with Meta's "template not found". Now
    follows paging.next with a 20-page safety cap, reports
    truncated=true in the response if hit.

  * Template sync reported success:true even when individual
    upserts failed. Now accumulates per-template errors and
    surfaces them via a separate toast.error in the UI — green
    toast no longer hides silent drift.

Performance
  * Migration 005: replace the broadcast_recipients aggregate
    trigger installed in migration 003. Old version ran
    COUNT(*) FILTER on every row change — O(n²) across a send
    loop (10k recipients × 10k index scans). New version
    increments / decrements by ±1 per status transition: O(1)
    per change. Kept the old recompute_broadcast_counts() as a
    callable safety-net in case of drift.

  * List page poll kept running while the tab was hidden. Added
    visibilitychange handler — polling pauses when hidden,
    resumes (and does an immediate fetch) on refocus.

UX
  * Step 3 (Personalize) let users skip past unmapped placeholders
    → broadcasts then sent to Meta with empty-string params.
    Next is now disabled until every {{N}} has a value; an amber
    warning lists the missing placeholders.

Documented accepted risks in the PR body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:38:52 +03:00
Arnas Donauskas
5d5c5f0a20
fix(broadcasts): per-recipient variables, webhook status sync, rate limit
Three correctness bugs were making the existing broadcast flow wrong,
not just incomplete.

1. Per-recipient variables were broken
   use-broadcast-sending.ts computed templateParams per contact but shipped
   only templateParams[0] to the API, so every contact in a batch received
   contact 0's personalization (name/email/etc).

   Fix: extend POST /api/whatsapp/broadcast to accept a per-recipient
   shape `{ recipients: [{ phone, params }], ... }`. Legacy
   `{ phone_numbers, template_params }` still accepted. Hook sends per-
   recipient payloads; API returns whatsapp_message_id per phone.

2. Webhook never updated broadcast_recipients
   handleStatusUpdate in webhook/route.ts only mirrored Meta status onto
   the `messages` table. broadcast_recipients had no correlation column,
   so delivered_count / read_count / replied_count never advanced for
   real broadcasts.

   Fix (migration 003):
     - broadcast_recipients.whatsapp_message_id (+ UNIQUE index) for
       Meta-message-id correlation
     - composite index (broadcast_id, status) for fast aggregate scans
     - AFTER INSERT/UPDATE/DELETE trigger that recomputes parent
       broadcasts counts via COUNT(*) FILTER — sender + webhook only
       touch the recipient row, aggregates stay consistent

   Webhook:
     - handleStatusUpdate also updates broadcast_recipients with forward-
       only status guard (read cannot regress to sent on a replay)
     - processMessage flags the replied recipient row when an inbound
       message arrives from a recent broadcast contact

3. Rate limiting did not match spec
   Batch 50 / no delay → Batch 10 / 1s between batches, matching the spec
   and Meta's per-phone-number rate ceiling.

Also: extract statusConfig from both page components into
src/lib/broadcast-status.ts (single source of truth for PR B). Add
whatsapp_message_id to BroadcastRecipient type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:22:38 +03:00
Arnas Donauskas
f3c68967f1
refactor: Meta API helpers use named params (prevents swapped-args bugs)
Over the course of the project the same swapped-args bug was found
four separate times against the Meta API helpers (config in PR #8,
send in PR #12, broadcast in PR #13, webhook media in PR #14). Each
time the compiler accepted the call because all the args were
strings, so the bug only surfaced at runtime as an opaque Meta error.

This refactor eliminates the entire bug class by converting every
helper to a single options object. A typo like
  sendTextMessage({ accessToken: id, phoneNumberId: tok, ... })
is now a TypeScript error at the call site rather than a 400 from
Meta two layers deep.

Helpers updated in src/lib/whatsapp/meta-api.ts:
- verifyPhoneNumber({ phoneNumberId, accessToken })
- sendTextMessage({ phoneNumberId, accessToken, to, text })
- sendTemplateMessage({ phoneNumberId, accessToken, to, templateName, language?, params? })
- getMediaUrl({ mediaId, accessToken })
- downloadMedia({ downloadUrl, accessToken })

Each exported Args interface so routes can type local variables
holding partial argument sets (useful for the broadcast retry loop).

Also factored the duplicated "parse Meta error JSON" block into a
shared throwMetaError() helper — was repeated in 5 places.

Call sites updated (all were already correct after prior fixes, but
now the types enforce it):
- src/app/api/whatsapp/config/route.ts (GET + POST)
- src/app/api/whatsapp/send/route.ts (attempt() closure)
- src/app/api/whatsapp/broadcast/route.ts (retry loop)
- src/app/api/whatsapp/webhook/route.ts (verifyAndBuildUrl)
- src/app/api/whatsapp/media/[mediaId]/route.ts (proxy pipeline)

No behavior change. Build passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:52:25 +03:00
Arnas Donauskas
e97d5b5064
feat: auto-retry with phone variants on Meta "not in allowed list"
Meta WhatsApp returns error #131030 ("Recipient phone number not in
allowed list") in two situations:
  1. Sandbox: the recipient isn't registered as a test number.
  2. The registered number format differs from what we sent, usually
     because of trunk-prefix 0 quirks (e.g. Lithuanian numbers stored
     as +370063949836 vs international +37063949836).

Today that error bubbles up to the UI and the user has to manually
edit the contact. This PR makes the send pipeline self-heal: on 131030
it retries with alternate phone-number formats, and if an alternate
works it persists the corrected format back to the contact so future
sends are a single API call.

src/lib/whatsapp/phone-utils.ts
- Added phoneVariants(sanitized) — yields original, then variants with
  a trunk 0 inserted/removed after each plausible country-code length
  (1-3 digits). Deduplicated. Original is always first so the fast
  path for correctly-formatted numbers is one attempt.
- Added isRecipientNotAllowedError(message) helper — matches
  code 131030 or the English error text, so we only retry when the
  failure is actually about the recipient (other errors fail fast).

src/app/api/whatsapp/send/route.ts
- Reply send now iterates phoneVariants() and stops at the first that
  succeeds. If a non-original variant works, contacts.phone is updated
  to the working format.

src/app/api/whatsapp/broadcast/route.ts
- Same retry applied per recipient in the broadcast loop.
- Also fixed a separate bug: broadcast was calling
  sendTemplateMessage(accessToken, phone_number_id, ...) — wrong order.
  Correct signature is (phoneNumberId, accessToken, to, templateName,
  language?, params?). This was silently rejecting every broadcast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:34:53 +03:00
Arnas Donauskas
4de6b7719e
fix: make Supabase browser client a singleton
Fixes two runtime errors:

1. "Lock 'lock:sb-xxx-auth-token' was released because another request
   stole it" — caused by multiple Supabase client instances competing
   for the same auth storage lock.

2. "Failed to fetch conversations: {}" — related to #1. When the auth
   lock is stolen mid-request, the session becomes temporarily
   unavailable and queries fail.

The codebase had 65 call sites invoking createClient() — each creating
a new browser client with its own auth listener. This change makes
createClient() cache and return a single shared instance.

Also improved error logging in conversation-list.tsx to show Supabase
error fields (message/details/hint/code) instead of an empty {}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:39:24 +03:00
Arnas Donauskas
658d070811
Initial scaffold: WhatsApp CRM with Next.js + Supabase
Complete WhatsApp CRM application built with Next.js 14 (App Router),
Supabase (PostgreSQL + Auth + Realtime), and shadcn/ui.

Features:
- Auth: Login, signup, forgot password with Supabase Auth
- Dashboard: Analytics overview with stats, recent activity
- Inbox: Real-time WhatsApp conversations with message threading
- Contacts: CRUD with tags, custom fields, notes, CSV import
- Broadcasts: 4-step wizard with audience selection, template sending
- Pipelines: Kanban board with deal management
- Settings: WhatsApp API config, templates, tags
- WhatsApp API: Webhook (verify + receive), send, broadcast, media proxy
- Database: 16 tables with RLS policies, indexes, realtime subscriptions
- Dark theme throughout with emerald accents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:17:31 +03:00
Arnas Donauskas
de3deb32da
feat: initial commit 2026-04-16 17:53:27 +03:00