Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

13
wacrm/.editorconfig Normal file
View File

@@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
# Markdown line breaks come from trailing spaces; don't strip them.
trim_trailing_whitespace = false

115
wacrm/.env.local.example Normal file
View File

@@ -0,0 +1,115 @@
# ============================================================
# REQUIRED — the app won't start without these.
# ============================================================
# Supabase (Project Settings → API)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# Supabase service-role key. Bypasses RLS; used only by server-side
# routes (the webhook, automation engine, and the public API key
# auth path — see docs/public-api.md). Keep this secret — never
# paste it into client code.
#
# Note: the public API (/api/v1) needs no new env var. API keys are
# created in the dashboard (Settings → API keys) and stored hashed
# in the database; this service-role key is what lets the auth path
# look a presented key up without a user session.
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# WhatsApp token encryption (64 hex chars = 32 bytes, AES-256-GCM).
# Generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Rotating this value orphans every token encrypted under the previous
# key — users have to re-save their WhatsApp settings to reconnect.
ENCRYPTION_KEY=your-64-char-hex-key-here
# Meta App Secret (Meta for Developers → App Settings → Basic).
# Verifies the HMAC-SHA256 signature on every inbound webhook POST.
# Required — without it the webhook rejects every request.
META_APP_SECRET=your-meta-app-secret
# ============================================================
# RECOMMENDED — safe defaults exist but you'll want to set these.
# ============================================================
# Canonical public URL of this deployment (scheme + host, no trailing
# slash). Used for the sitemap and OG images. Routes that need a
# self-referential URL (invite links from /api/account/invitations,
# any future email-bound link) derive the origin from the request
# itself, so this variable is *only* needed when the request-derived
# origin would be wrong — e.g. when generating links from a cron job
# or a background worker that has no incoming request.
NEXT_PUBLIC_SITE_URL=https://crm.example.com
# ============================================================
# OPTIONAL — only needed if you use the feature.
# ============================================================
# Defense-in-depth allow-list for the hostnames that
# /api/account/invitations is willing to publish in invite URLs.
# Comma-separated, no scheme, no port (just hostnames).
#
# Why this exists: when NEXT_PUBLIC_SITE_URL is unset, invite URLs
# are derived from the incoming request's `Host` / `X-Forwarded-
# Host` header. On a typical proxied deploy the proxy sets these
# to your canonical hostname and they're trustworthy. On a *bare*
# deployment exposed to the public internet, an attacker could
# POST to the API directly with a spoofed `Host: phishing.example`
# and receive an invite URL pointing at their site.
#
# When this var is set, hostnames not on the list are rejected
# (the request falls through to the wacrm.tech fallback with a
# console.warn). When unset, behavior is unchanged from earlier
# versions — the request-derived host is trusted.
#
# Most operators don't need this: setting NEXT_PUBLIC_SITE_URL to
# your canonical URL already pins invite links there. This is for
# operators who want belt-and-braces or run multi-tenant setups
# where one app instance serves several hostnames.
#
# Example:
# ALLOWED_INVITE_HOSTS=crm.example.com,crm-staging.example.com
# Shared secret protecting GET /api/automations/cron. Required if you
# use Wait steps in automations (a scheduled pinger drains pending
# executions). Generate any long random string:
# openssl rand -hex 32
# See docs/automations-and-cron.md.
# AUTOMATION_CRON_SECRET=generate-a-long-random-string
# Meta App ID (Meta for Developers → App Settings → Basic). Required to
# create/edit message templates with an IMAGE header: Meta only accepts a
# Resumable-Upload media handle (not a plain URL) as the header sample, and
# that upload is app-scoped. Without it, image-header template submission
# returns a clear error; text/body-only templates are unaffected. Pair
# with META_APP_SECRET.
# META_APP_ID=your-meta-app-id
# When "true", POST /api/whatsapp/templates/submit skips the Meta call
# and stores the row with a synthetic `dry-run-<uuid>` meta_template_id.
# Set this in CI and local development so you can exercise the full
# template UI without a real WABA. Leave unset (or "false") in prod.
# WHATSAPP_TEMPLATES_DRY_RUN=true
# ------------------------------------------------------------------
# AI reply assistant (optional)
# ------------------------------------------------------------------
# The AI assistant is bring-your-own-key: each account pastes its own
# OpenAI or Anthropic key under Settings → AI Assistant. The key is
# stored AES-256-GCM-encrypted with ENCRYPTION_KEY (above) — there is
# NO global provider key env var, and nothing here is required for the
# feature to work. The two vars below only tune behaviour.
#
# The AI knowledge base (migration 030) uses Postgres full-text search
# out of the box. Optional semantic search needs the `pgvector`
# extension — migration 030 runs `CREATE EXTENSION IF NOT EXISTS vector`
# (Supabase has it available) — plus a per-account embeddings key set in
# Settings → AI Assistant. Still no env var required.
# Per-call timeout for provider requests, in milliseconds. Default 30000.
# AI_REQUEST_TIMEOUT_MS=30000
# How many recent text messages of a conversation to send the model as
# context (draft + auto-reply). Default 20.
# AI_CONTEXT_MESSAGE_LIMIT=20

8
wacrm/.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,8 @@
# Everyone who opens a PR against this repo gets Arnas as a required
# reviewer. Paired with a branch-protection rule on `main` that
# requires Code Owner approval, this ensures every change is reviewed
# before merge.
#
# Syntax: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-security/customizing-your-repository/about-code-owners
* @ArnasDon

28
wacrm/.github/CODE_OF_CONDUCT.md vendored Normal file
View File

@@ -0,0 +1,28 @@
# Code of Conduct
This project adopts the **[Contributor Covenant, version 2.1][covenant]**.
By participating in this project — filing an issue, opening a PR, commenting
on one, or interacting in any other community space — you agree to uphold
that standard.
## Reporting
If you witness or experience behaviour that violates the Code, please
report it privately to the project maintainer:
- Email: **a.donauskas@hostinger.com** with `[CRM template conduct]` in
the subject.
Reports are handled confidentially. Expect an acknowledgement within
72 hours and a decision on next steps within a week.
## Enforcement
The maintainer is responsible for enforcement and will apply the community
impact guidelines described in the [Contributor Covenant][covenant-enforce]
— ranging from a private correction to a permanent ban, proportional to the
behaviour.
[covenant]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
[covenant-enforce]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/#enforcement-guidelines

View File

@@ -0,0 +1,76 @@
name: Bug report
description: Something in the template isn't working the way the docs say it should.
title: "[bug] "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting! The more specific you can be, the faster we
can land a fix.
**Not a bug in the code?** If this is a security issue, close this
form and follow [SECURITY.md](https://github.com/ArnasDon/wacrm/blob/main/.github/SECURITY.md)
instead.
- type: textarea
id: summary
attributes:
label: What happened?
description: One or two sentences describing the symptom.
placeholder: Clicking a conversation leaves the thread stuck on "No messages yet".
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: The minimum steps we need to trigger the bug on our side.
placeholder: |
1. Sign in, go to /inbox.
2. Click any conversation that has past messages.
3. Thread pane shows "No messages yet" until hard refresh.
validations:
required: true
- type: textarea
id: expected
attributes:
label: What did you expect?
placeholder: Messages load the first time, every time.
validations:
required: true
- type: input
id: version
attributes:
label: Commit / version
description: |
The commit SHA or release you're on. `git rev-parse --short HEAD`
in the fork works.
placeholder: "e.g. d6a4677 or v0.2.0"
validations:
required: false
- type: dropdown
id: runtime
attributes:
label: Where is it running?
options:
- Local dev (npm run dev)
- Hostinger Managed Node.js
- Hostinger VPS
- Vercel
- Other Node host
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs / screenshots
description: Server logs, browser console errors, network tab — anything that looks suspicious. Scrub tokens before pasting.
render: text
validations:
required: false

11
wacrm/.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability (private)
url: https://github.com/ArnasDon/wacrm/security/advisories/new
about: Do not file security issues in public. Follow the private disclosure flow.
- name: Setup / "how do I..." questions
url: https://github.com/ArnasDon/wacrm/blob/main/docs/README.md
about: Check the docs first — setup, deploy, troubleshooting are all covered.
- name: Using this as a template (forking)
url: https://github.com/ArnasDon/wacrm/blob/main/CONTRIBUTING.md
about: This is a template. Most changes belong in your fork, not an upstream issue — here's how that works.

View File

@@ -0,0 +1,68 @@
name: Feature request
description: Propose a new feature or a meaningful enhancement.
title: "[feat] "
labels: ["enhancement", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for the idea — but read this first.
This is a **template**, not a collaborative product. The
upstream scope is intentionally narrow, so most feature
requests end up as *"build this in your fork"* rather than
landing here. That's the point of a template.
When an upstream feature request *is* useful:
- It fixes a correctness problem in the template.
- It makes the template cleaner for the next forker (reducing
friction, removing footguns).
- It's in scope for "a generic WhatsApp CRM template" rather
than a bet on your specific workflow.
If the feature is really for your own deployment, fork and
build it there — see
[CONTRIBUTING.md](https://github.com/ArnasDon/wacrm/blob/main/CONTRIBUTING.md).
- type: textarea
id: problem
attributes:
label: What's the problem?
description: What are you trying to do today that the template makes harder than it should?
placeholder: |
When a broadcast ends I have no way to see which recipients
didn't reply so I can follow up manually.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What would you like to see?
description: Describe the feature from the user's perspective, not the implementation.
placeholder: |
On the broadcast detail page, a "No reply" filter that lists
recipients who received but didn't reply within 24 hours.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you considered
description: Things that kinda work today, workarounds, related features in other tools.
validations:
required: false
- type: dropdown
id: scope
attributes:
label: Scope
description: Your guess — we'll re-scope if needed.
options:
- Small (a couple of hours)
- Medium (a couple of days)
- Large (a couple of weeks, probably breaks into multiple PRs)
validations:
required: false

62
wacrm/.github/SECURITY.md vendored Normal file
View File

@@ -0,0 +1,62 @@
# Security Policy
Thanks for taking the time to look into the security of this template.
## Reporting a vulnerability
**Do not open a public GitHub issue for security bugs.** Public issues are
indexed by search engines and seen by every fork long before the upstream fix
lands.
Instead, please report privately via one of:
- [GitHub Security Advisories](https://github.com/ArnasDon/wacrm/security/advisories/new)
(preferred — keeps the disclosure, fix, and CVE all in one place).
- Email: `a.donauskas@hostinger.com` with `[CRM template security]` in the subject.
Include, if you can:
- A description of the issue and the impact.
- Reproduction steps or a proof-of-concept.
- The commit or release you're testing against.
- Whether you'd like credit in the eventual disclosure (we default to
crediting by the name or handle you give us, unless you prefer anonymous).
## What to expect
- **Acknowledgement** within 72 hours.
- **Initial assessment** (severity, affected versions, whether a workaround
exists) within one week.
- **Fix + coordinated disclosure** on a timeline proportional to severity.
Critical issues ship a patch as soon as one's ready; medium issues bundle
with the next release.
## Scope
In scope:
- Anything in this repository (`ArnasDon/wacrm`), including webhook and auth
flows, token encryption, RLS policies, and the built-in cron endpoints.
- Default configurations shipped in `docs/` — e.g. if the setup guide leaves
an unsafe default.
Out of scope:
- Vulnerabilities in Supabase, Next.js, Node.js, or other upstream
dependencies — please report those to their maintainers. We'll happily
bump versions on request.
- Issues that require a pre-compromised deployment (e.g. a leaked
service-role key) unless they widen the blast radius beyond the initial
compromise.
- Social engineering, physical attacks, or third-party services your fork
adds after deploy.
## Safe harbor
Research conducted under this policy is authorized. We won't pursue legal
action against anyone who:
- Makes a good-faith effort to avoid data destruction, privacy violations,
or service disruption.
- Gives us reasonable time to respond before any public disclosure.
- Doesn't exploit the issue beyond what's necessary to demonstrate it.
Thanks for helping keep this template (and its forks) safe.

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

53
wacrm/.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
version: 2
updates:
# Runtime + dev dependencies. Weekly on Mondays — keeps the noise
# predictable and aligns with a normal review cycle.
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
reviewers:
- ArnasDon
labels:
- dependencies
groups:
# Batch related upgrades into one PR so we're not closing six
# independent Supabase-bumps per week.
supabase:
patterns:
- "@supabase/*"
types:
patterns:
- "@types/*"
update-types:
- minor
- patch
dev-dependencies:
dependency-type: development
update-types:
- minor
- patch
ignore:
# Next + React + Tailwind are pinned exactly on purpose. Let
# them flow through manual major bumps when we're ready.
- dependency-name: next
- dependency-name: react
- dependency-name: react-dom
- dependency-name: tailwindcss
- dependency-name: eslint-config-next
# GitHub Actions in .github/workflows/. Catches setup-node /
# checkout version bumps that keep the CI matrix healthy.
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
reviewers:
- ArnasDon
labels:
- dependencies
- github-actions

42
wacrm/.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,42 @@
<!--
Heads up: this is a template, not a collaborative product. Most
changes belong in your fork. See CONTRIBUTING.md for which kinds of
upstream PRs tend to land (security, correctness, docs) vs. which
belong in a fork (new features, stack swaps, opinionated refactors).
If you haven't opened an issue yet for a non-trivial change, consider
doing that first to check alignment.
Keep this short and specific. The commit message is where the "why"
lives; this is where the reviewer gets the "what" and "how to try it".
-->
## Summary
<!-- One or two sentences. What does this PR do? -->
## What changed
<!-- Bullet list of the actual changes. Link file paths when useful. -->
## Test plan
<!--
How did you verify this works? How should the reviewer verify it?
Tick the boxes as you go.
-->
- [ ] `npm run typecheck` clean.
- [ ] `npm run lint` — no new errors beyond the pre-existing backlog.
- [ ] `npm run build` succeeds.
- [ ] Feature / fix manually exercised in the browser (or the reason it can't be).
## Related
<!-- Link the issue this closes, or "Part of #N" for multi-PR work. -->
<!--
Heads up:
- Security issues: do not disclose here; see .github/SECURITY.md.
- New deps: please justify briefly in the commit message or PR body.
- Runtime behaviour changes affecting forkers: update docs/*.
-->

53
wacrm/.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
# Cancel older CI runs for the same branch when a new commit arrives —
# saves minutes when someone pushes a stack of fixes to one PR.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Lint, typecheck, test, build
runs-on: ubuntu-latest
# Dummy env vars so `next build` doesn't fail when it reads the
# public Supabase config at build time. These never leave CI and
# never hit a real service — they just satisfy the `!` non-null
# assertions in the client factories. ENCRYPTION_KEY and
# META_APP_SECRET are read at module-load by lib/whatsapp/*; the
# test suite asserts behaviour around these values, so any non-
# empty placeholder works as long as it stays consistent with
# vitest.config.ts.
env:
NEXT_PUBLIC_SUPABASE_URL: https://ci.example.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-dummy-anon-key
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
META_APP_SECRET: 'ci-dummy-meta-secret'
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm test
- name: Build
run: npm run build

44
wacrm/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# keep example files so forkers have a template to copy
!.env.local.example
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

10
wacrm/.prettierignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
.next
out
build
coverage
next-env.d.ts
package-lock.json
# Generated / vendored
supabase/migrations
public/opus

10
wacrm/.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 80,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}

5
wacrm/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

613
wacrm/CHANGELOG.md Normal file
View File

@@ -0,0 +1,613 @@
# Changelog
User-visible changes in `wacrm`. Self-hosters: when pulling an update,
check this file for any **migration required** notes and apply the
matching SQL files from `supabase/migrations/` against your Supabase
project before restarting the app.
Versions follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Pre-1.0, `MINOR` bumps cover new modules; `PATCH` bumps cover bug fixes
and polish.
## [0.7.0] — 2026-07-02
Promotes the AI assistant to a first-class **AI Agents** section in the
sidebar — it's no longer tucked inside Settings.
### Added
- **AI Agents (sidebar).** A dedicated `/agents` area with two tabs:
- **Playground** — a test chat to message your agent and see its
grounded, multi-turn replies (and where it would hand off to a human)
*before* it ever answers a real customer. Runs the exact same path as
the auto-reply bot (knowledge-base retrieval + your provider), and
works even before you flip the master switch on, so you can try, then
enable. Backed by `POST /api/ai/playground`.
- **Setup** — the provider/key, business context, knowledge base, and
auto-reply controls (moved here from Settings → AI Assistant).
### Changed
- The AI configuration moved out of **Settings → AI Assistant** into the
new **AI Agents** section. No data change — same account config, new
home. No migration required.
## [0.6.0] — 2026-07-02
Adds an **AI knowledge base** so the assistant (0.5.0) can answer from
your own content instead of handing off. Paste FAQs, policies, or
product details under **Settings → AI Assistant → Knowledge base**; the
relevant excerpts are retrieved into every draft and auto-reply.
### Added
- **Knowledge base with hybrid retrieval.** Lexical Postgres full-text
search works for every account with no extra credentials. Optional
**semantic search** (pgvector, OpenAI `text-embedding-3-small`) turns
on when you add an **embeddings key** — semantic-primary, topped up
with lexical to fill the result set. Anthropic-only accounts (Anthropic
has no embeddings API) keep the lexical path with zero extra setup.
- **Knowledge base manager** in Settings — add/edit/delete documents and
a **Reindex** action to backfill embeddings after adding a key. Both
drafts and the auto-reply bot are grounded in the retrieved excerpts,
and the prompt still instructs the model to hand off (auto-reply) or
say it will follow up (draft) when the KB doesn't cover the question.
**Migration required:** apply `supabase/migrations/030_ai_knowledge.sql`
(enables `pgvector`; adds `ai_knowledge_documents` + `ai_knowledge_chunks`
and an `embeddings_api_key` column on `ai_configs`).
## [0.5.0] — 2026-07-02
Adds the **AI reply assistant** — bring-your-own-key. Each account
pastes its own OpenAI or Anthropic key under **Settings → AI
Assistant**; wacrm calls the provider directly with that key, so
there's no per-seat AI fee and your conversation data never leaves
your own infrastructure for a wacrm-run service. The key is stored
AES-256-GCM-encrypted at rest (same as WhatsApp tokens) and never
returned to the client after saving.
### Added
- **AI-drafted replies in the inbox.** A ✨ button in the composer
(agent+) reads the recent conversation and drops a suggested reply
into the box for the agent to edit and send. Read-only server-side —
`POST /api/ai/draft` never sends or stores anything. Respects your
business context / persona from the settings prompt.
- **AI auto-reply bot.** When enabled, inbound messages that no
deterministic Flow consumed and that have no agent assigned get an
automatic LLM reply. Bounded by a per-conversation cap
(`auto_reply_max_per_conversation`, default 3) and a clean human
handoff: when the model can't confidently help — or the customer
asks for a person — it stays silent and leaves the message for a
human, and won't auto-reply on that thread again until re-enabled.
Flows always win over the bot.
- **Settings → AI Assistant** (admin+ to edit): pick provider + model,
paste your key, add business context/tone, toggle the assistant and
auto-reply, set the per-conversation cap, and **Test key** against
the provider before saving.
- Providers: OpenAI (Chat Completions) and Anthropic (Messages) behind
one interface; model is a free-text field with sensible defaults, so
you can point it at any current model your key can access.
**Migration required:** apply
`supabase/migrations/029_ai_reply.sql` (adds `ai_configs` +
per-conversation auto-reply columns on `conversations`).
## [0.4.0] — 2026-07-01
Completes the public API (#245): **outbound event webhooks** so
automations can *react* to activity instead of polling.
### Added
- **Outbound event webhooks (`/api/v1/webhooks`).** Register an HTTPS
endpoint (scope `webhooks:manage`) to be POSTed to when an event
happens in your account — `message.received`, `message.status_updated`,
or `conversation.created`. Manage endpoints with
`GET/POST /api/v1/webhooks` and `GET/PATCH/DELETE /api/v1/webhooks/{id}`.
Each delivery is signed with an `X-Wacrm-Signature`
(HMAC-SHA256 over `timestamp.body`) so receivers can verify
authenticity and reject replays; the signing secret is returned once
at creation and stored encrypted. Delivery is best-effort — an
endpoint that fails repeatedly is auto-disabled after a threshold of
consecutive failures. See `docs/public-api.md`.
**Migration required:** apply
`supabase/migrations/028_webhook_endpoints.sql`.
([#245](https://github.com/ArnasDon/wacrm/issues/245))
## [0.3.0] — 2026-07-01
Multi-user accounts ship. Every wacrm install is multi-tenant on the
database side: a single user's signup creates a fresh "account", and
every row is scoped to that account rather than to the user directly.
This release also opens the user-visible **Members** surface — invite
teammates by link, manage their roles, transfer ownership — to all
users. The `'account_sharing'` beta gate that hid it during
development is removed (mirrors the Flows soft-GA in 0.2.0). Existing
self-hosted instances keep working: every existing user is backfilled
as the sole owner of their own account and sees identical data, and a
solo owner who never invites anyone sees the same single-user app they
always did.
### Added
- **Public REST API (`/api/v1`) — groundwork.** A scoped, revocable
**API key** system so you can drive wacrm from your own scripts and
automations. Create keys under **Settings → API keys** (admin+),
grant only the scopes each integration needs, and authenticate with
`Authorization: Bearer <key>`. Keys are account-scoped and stored
hashed (plaintext shown once). This release ships the auth layer,
scopes, per-key rate limiting, the management UI, and a
`GET /api/v1/me` probe to verify a key. See
`docs/public-api.md`. **Migration required:** apply
`supabase/migrations/026_api_keys.sql`. ([#245](https://github.com/ArnasDon/wacrm/issues/245))
- **Public REST API — data endpoints.** Built on the key auth above,
so external automations can read and drive the CRM:
- `POST /api/v1/messages` — send a text / template / media message to
a phone number; finds-or-creates the contact + conversation
(`messages:send`).
- `GET/POST /api/v1/contacts`, `GET/PATCH /api/v1/contacts/{id}`
list (search + tag filter), create (find-or-create by phone), read,
and update contacts, including tags (`contacts:read` /
`contacts:write`).
- `GET /api/v1/conversations`, `GET /api/v1/conversations/{id}`, and
`GET /api/v1/conversations/{id}/messages` — browse conversations and
their message history with delivery status (`conversations:read` /
`messages:read`).
- `POST /api/v1/broadcasts` + `GET /api/v1/broadcasts/{id}` — launch a
template broadcast to a recipient list and poll its progress
(`broadcasts:send`).
All list endpoints share one cursor-pagination contract
(`{ data, meta: { next_cursor } }`). No migration required — the
scopes already existed and the tables are unchanged. Outbound event
webhooks (react to inbound messages) are the remaining roadmap item.
See `docs/public-api.md`. ([#245](https://github.com/ArnasDon/wacrm/issues/245))
### Changed
- **Tenancy moves from per-user to per-account.** RLS on every
domain table (contacts, conversations, messages, broadcasts,
automations, flows, pipelines, templates, tags, …) now checks
account membership via a new SECURITY DEFINER helper
`is_account_member(account_id, min_role)` instead of
`auth.uid() = user_id`. The `user_id` columns stay on every row
for assignment / audit but no longer enforce isolation.
- **WhatsApp config is one-per-account, not one-per-user.** The
`whatsapp_config.UNIQUE(user_id)` constraint is replaced by
`UNIQUE(account_id)`.
- **`flow_runs` idempotency key swaps to `(account_id, contact_id)`**
so two accounts sharing a contact phone number can each run their
own flows independently.
- **The signup trigger (`handle_new_user`) now also creates a
personal account** and links the new profile to it as `owner`.
### Changed
- **Flow-media storage is now account-scoped.** Migration 016
pathed uploaded files under `auth.uid()/...`, which orphaned
flow media when a teammate left a shared account. New uploads
go under `account-<account_id>/...` and any account member
with the right role can edit them. Legacy paths remain
writable by the original uploader for backward compatibility.
- **Webhook contact lookup now pre-filters in SQL.** Previously
pulled every contact in an account just to JS-filter to one
row by phone — fine when account = one user, painful when
account = team. Pre-filter by phone suffix on the database
side; re-apply `phonesMatch` on the (typically 0-2 row)
candidate set.
### Migration required
- `supabase/migrations/020_account_sharing_followups.sql`
composite partial indexes on `automations(account_id,
trigger_type) WHERE is_active` and `flows(account_id) WHERE
status='active'` for the engine dispatch hot path; updated
`flow-media` storage RLS to allow account-member writes under
the new path convention. Idempotent.
- **Role-aware UI gating across the app.** The inbox composer's
send button + textarea, the "New broadcast / automation / flow"
buttons, the "Add pipeline / deal" buttons, and the "Add /
Import contact" buttons are now disabled-with-tooltip for
viewers (and for agents on settings-class actions). Choice:
show-but-disable rather than hide, so the UI never feels
silently broken to a teammate looking at a feature they don't
yet have permission for.
- **Sidebar surfaces the active account** above the user info
whenever the account name differs from your own — i.e. once
you've renamed the account or joined a shared one. A default
solo account is named after you, so the strip stays hidden to
avoid duplicating your name in the footer.
- **Members is open to all users.** The `account_sharing` beta
flag that hid the Settings → Members tab and the sidebar
account strip during development is gone; the multi-user
surface is now part of the standard app. (Same soft-GA move as
Flows in 0.2.0.)
### Fixed
- **Inbound WhatsApp messages now land in the shared inbox.** The
webhook + automations + flows engines used to route inbound
events by `user_id`, which after the 017 migration only matched
the WhatsApp config owner's automations / flows — teammates'
rules never fired. PR 8 of the multi-user series flips every
lookup to `account_id` so any member of the account sees the
inbound message and any teammate's automation or flow can react
to it. Also fixes incipient NOT NULL violations on
`automation_logs`, `automation_pending_executions`, `flow_runs`,
and `deals` — those tables gained `account_id NOT NULL` in 017
but the engines hadn't yet been updated to populate it.
### Added
- **Duplicate phone numbers are now prevented across contacts.** A
phone number can no longer become more than one contact in the same
account. Adding a contact whose number already exists is blocked
with a link to the existing record (and a softer warning for
near-matches that share their last 8 digits); CSV import de-dupes
within the file and against existing contacts, reporting
"X imported, Y duplicates skipped". The rule is enforced by a
database unique index on the normalized number, so the WhatsApp
webhook, the form, import, and any future path all agree. Existing
duplicates are merged into the oldest contact on upgrade (their
conversations, deals, notes, and tags are re-pointed, nothing is
lost). Closes #212.
- **Configurable default deal currency.** Each account can now pick
its default currency under **Settings → Deals** (admin+); the app
previously hardcoded USD throughout. New deals default to it, and
pipeline-stage totals, the dashboard "Open Deals Value" card, the
pipeline-value donut, and automation-created deals all use it.
Existing deals keep the currency they were saved with — totals are
shown in the account default with no exchange-rate conversion (one
currency per account). Full guide:
[Default currency](https://wacrm.tech/docs/settings#deals).
- **Members tab in Settings.** The user-facing surface for the
multi-user APIs below, available to everyone (no beta flag). From
Settings → **Members** an admin or owner can: see who's on the
account with their role and join date, invite teammates by
generating a one-time share link (pick the role + optional
expiry), revoke pending invites, change a member's role, remove a
member, and — as owner — transfer ownership. Recipients accept via
a public `/join/[token]` page. Full guide:
[Members docs](https://wacrm.tech/docs/members).
- **Account & member management API** — server-side endpoints
backing the Members tab. All routes are role-gated and
return Supabase-RLS-scoped data.
- `GET /api/account` — caller's account + role. Any member.
- `PATCH /api/account` — rename the account. Admin+.
- `GET /api/account/members` — list members. Email visible to
admin+ only; agents/viewers see name + avatar + role +
joined date.
- `PATCH /api/account/members/[userId]` — change a member's
role. Admin+. Owner promotion/demotion goes through the
transfer endpoint instead.
- `DELETE /api/account/members/[userId]` — remove a member.
Admin+. The removed user keeps their login and is moved to a
freshly-created personal account (mirror of the signup flow).
- `POST /api/account/transfer-ownership` — owner only. Atomic
swap with the named member.
- **Invitation API + redeem flow** — the no-email, link-only
invite path that powers the Members tab's "Invite member" button
and the `/join/[token]` accept page.
- `GET /api/account/invitations` — list outstanding (admin+).
- `POST /api/account/invitations` — create an invite, returns
the plaintext token + share URL **exactly once** (we store
only the SHA-256 hash on the row). Body
`{ role, expiresInDays?, label? }`. Admin+.
- `DELETE /api/account/invitations/[id]` — revoke (admin+).
- `GET /api/invitations/[token]/peek` — public, per-IP
rate-limited. Returns `{ ok, account_name, role, expires_at }`
or `{ ok: false, reason }` so the join page can render
"You're being invited to <Account> as <Role>".
- `POST /api/invitations/[token]/redeem` — authenticated.
Atomically moves the caller's profile to the inviter's
account and cleans up the orphan personal account. Refuses
with 409 if the caller's current account already contains
domain data (no silent data loss).
### Migration required
Apply against your Supabase project before deploying this version:
- `supabase/migrations/017_account_sharing.sql` — introduces the
`accounts` and `account_invitations` tables plus an
`account_role_enum` type; adds `account_id` to every
user-scoped table and backfills it; rewrites every RLS policy;
replaces the new-user trigger. Idempotent. **No data loss**
every existing user is mapped to a freshly-created account
with role `owner` and every existing row of theirs is linked
to that account.
- `supabase/migrations/018_account_member_rpcs.sql` — adds three
`SECURITY DEFINER` RPCs (`set_member_role`,
`remove_account_member`, `transfer_account_ownership`) that
back the member-management API. They self-check the caller's
role and raise SQLSTATE `42501` / `22023` on forbidden / bad
input so the API layer can map cleanly to 403 / 400.
Idempotent.
- `supabase/migrations/019_invitation_rpcs.sql` — adds two
`SECURITY DEFINER` RPCs: `peek_invitation` (anonymous read by
token hash, returns a fixed-shape JSON envelope) and
`redeem_invitation` (authenticated atomic move + orphan
cleanup, with a domain-data safety check). Both bypass the
RLS that would otherwise block their reads/writes. Idempotent.
- `supabase/migrations/021_account_default_currency.sql` — adds
`accounts.default_currency` (`TEXT NOT NULL DEFAULT 'USD'`, with a
3-letter-code `CHECK`) backing the configurable default currency.
Idempotent; existing accounts backfill to `USD`. **Apply before
deploying** — the app now reads this column when loading the
account, so an un-migrated database breaks account loading.
- `supabase/migrations/022_contact_phone_dedup.sql` — adds the
generated `contacts.phone_normalized` column, **merges existing
duplicate contacts into the oldest** (re-pointing conversations,
deals, notes, tags, custom values, and broadcast recipients — no
data loss), then adds a `UNIQUE (account_id, phone_normalized)`
index. Idempotent. **Apply before deploying** — CSV import reads
`phone_normalized`, and the index is what enforces de-duplication
for every write path. The one-shot merge runs inside the migration.
## [0.2.2] — 2026-05-29
Flow nodes can now send media. Closes the most-requested gap from user
feedback after the v0.2.0 Flows launch — flows were text-only and
couldn't deliver an invoice, receipt, product photo, or short demo
video mid-conversation.
### Added
- **`send_media` flow node.** Send an image (PNG / JPEG / WebP), video
(MP4 / 3GP), or document (PDF, Word, Excel, PowerPoint, TXT) to the
customer from any point in a flow. Pick a file in the builder, it
uploads to the new `flow-media` Supabase Storage bucket, and Meta
fetches the public URL at send time. Optional caption (1024 char cap,
supports `{{vars.X}}` interpolation); documents also take an optional
filename shown in the recipient's chat. Auto-advances after send —
same suspend semantics as `send_message`.
([#156](https://github.com/ArnasDon/wacrm/pull/156))
### Migration required
Apply against your Supabase project before deploying this version:
- `supabase/migrations/016_flow_media.sql` — does two things:
1. Adds `'send_media'` to the `flow_nodes.node_type` CHECK
constraint. Without this the `send_media` node fails to save with
a constraint violation.
2. Creates the public `flow-media` Supabase Storage bucket (16 MB
file-size cap, image / video / document MIME allowlist) plus
per-user RLS policies (path prefix = `auth.uid()`). Without this
the builder's file picker fails on upload. Same shape as the
`avatars` bucket from migration 008 — the bucket is **public** so
Meta can fetch the URL without credentials.
The migration is idempotent and safe to re-run.
## [0.2.1] — 2026-05-26
Bug-fix release. Plugs a silent inbound-message drop that triggered
when two users on the same instance saved the same WhatsApp
`phone_number_id`.
### Fixed
- **Inbound WhatsApp messages no longer silently disappear** when two
users have claimed the same `phone_number_id`. Previously the
webhook used `.single()` to look up the owning config, which errors
`PGRST116` for both 0 rows *and* ≥2 rows — the second user's save
put the DB into the ≥2-row state and every inbound message was
dropped while the log misleadingly reported *"No config found for
phone_number_id"*. Three layers of fix: `POST /api/whatsapp/config`
now returns **409** when another user has already claimed the
number, the webhook lookup distinguishes 0 rows from ≥2 rows and
logs the conflicting `user_id`s, and a new DB constraint
(`UNIQUE(phone_number_id)`) prevents the bad state at the storage
layer. Reported in
[#136](https://github.com/ArnasDon/wacrm/issues/136), fixed in
[#143](https://github.com/ArnasDon/wacrm/pull/143).
### Migration required
Apply against your Supabase project before deploying this version:
- `supabase/migrations/013_whatsapp_config_phone_number_id_unique.sql`
— adds `UNIQUE(phone_number_id)` to `whatsapp_config`. **Fails
loudly with a copy-pasteable resolution hint** if duplicate rows
already exist; auto-deduping would destroy encrypted tokens, so
the operator picks which row keeps the number. To check first:
```sql
SELECT phone_number_id, array_agg(user_id) AS owners, count(*) AS n
FROM whatsapp_config
GROUP BY phone_number_id
HAVING count(*) > 1;
```
If that returns rows, `DELETE` the duplicate row(s) you want to
drop, then re-run the migration.
### Note on multi-user setups
wacrm is intentionally **single-tenant per WhatsApp number**. RLS on
`conversations`/`messages` is `auth.uid() = user_id`, so a second
user physically cannot read messages routed to a different owner —
two users sharing one number was never supported. If you need
multiple humans handling the same inbox, run them under one shared
account.
## [0.2.0] — 2026-05-22
The **Flows** release. Adds a no-code, branching, button-driven WhatsApp
conversation engine that runs alongside Automations. Also ships a
5-theme color picker in Settings and opens Flows to all users.
### Added
#### Flows — branching chatbot conversations
- **Module + schema.** New `flows`, `flow_nodes`, `flow_runs`,
`flow_run_events` tables with partial unique indexes that enforce
one active run per contact. Widened `messages.content_type` CHECK
to accept `'interactive'`; added `interactive_reply_id` column so
the inbox can render button/list taps.
([#112](https://github.com/ArnasDon/wacrm/pull/112))
- **Runner engine.** `dispatchInboundToFlows` parses every inbound
webhook, decides whether the message is a reply on an active run
or a fresh trigger, advances the state machine, and reports back
to the webhook so consumed messages don't also fire automations.
Idempotent on Meta's `message_id`.
([#114](https://github.com/ArnasDon/wacrm/pull/114))
- **No-code builder UI** at `/flows`. Linear-list editor with
per-node config forms, live validator, draft/active/archived
status, and a 5-route REST API (`GET/POST /api/flows`,
`GET/PUT/DELETE /api/flows/[id]`, `POST /api/flows/[id]/activate`,
`GET /api/flows/[id]/runs`, `GET /api/flows/templates`).
([#115](https://github.com/ArnasDon/wacrm/pull/115))
- **Templates + v1.5 node types.** Three starter templates
(Welcome menu, FAQ bot, Lead capture) cloneable from the New-flow
dialog. Three new node types: `collect_input` (capture customer
text into a variable), `condition` (branch on var / tag / contact
field), `set_tag` (add or remove a tag). `{{vars.X}}` interpolation
in send_message + collect_input prompts. Per-flow run-history
viewer at `/flows/[id]/runs`.
([#117](https://github.com/ArnasDon/wacrm/pull/117))
- **Stale-run sweep cron** at `GET /api/flows/cron` — marks runs
past their configured timeout (default 24h) as `timed_out` so
abandoned conversations free up the contact for new triggers.
Reuses `AUTOMATION_CRON_SECRET`.
([#114](https://github.com/ArnasDon/wacrm/pull/114))
#### Color themes
- **5 color themes** (Violet default, Emerald, Cobalt, Amber, Rose)
selectable from a new **Appearance** tab in Settings. CSS variables
scoped under `html[data-theme="..."]`, applied at runtime via
`dataset.theme`, persisted to `localStorage`. Inline boot script in
`layout.tsx` replays the choice before first paint so there's no
flash of the default.
([#132](https://github.com/ArnasDon/wacrm/pull/132))
- **Theme tokenization sweep** — every previously hard-coded
`violet-*` Tailwind class replaced with `primary` tokens across
~49 files. Picking a non-violet theme now themes the whole app,
not just the chrome.
([#133](https://github.com/ArnasDon/wacrm/pull/133))
### Changed
#### Flows — soft-GA
- **Flows is now available to every authenticated user.** The
per-account beta gate is gone; the sidebar entry + page header
carry a small "Beta" chip as the only remaining signal.
([#134](https://github.com/ArnasDon/wacrm/pull/134))
- **Editor UX**:
- Internal `node_key` + per-button/row `reply_id` identifiers
hidden behind a per-node "Show advanced" disclosure.
([#118](https://github.com/ArnasDon/wacrm/pull/118))
- `send_list` nodes can have multiple sections.
([#119](https://github.com/ArnasDon/wacrm/pull/119))
- Collapsed node cards show a 1-line content preview per node
type (text excerpt, button titles, condition summary, etc.).
([#120](https://github.com/ArnasDon/wacrm/pull/120))
- Validation issues are clickable: jump to + flash the offending
node.
([#121](https://github.com/ArnasDon/wacrm/pull/121))
- Unsaved-changes "● Edited" indicator + `beforeunload` reload
guard.
([#122](https://github.com/ArnasDon/wacrm/pull/122))
- New-flow dialog actually widens to fit the 3 template cards
(was capped at 384px by a baked-in `sm:max-w-sm` from shadcn).
([#129](https://github.com/ArnasDon/wacrm/pull/129),
[#131](https://github.com/ArnasDon/wacrm/pull/131))
- Validation panel pinned to the viewport bottom so
activate-readiness follows the user as they scroll through nodes.
([#130](https://github.com/ArnasDon/wacrm/pull/130))
#### Engine reliability
- **Atomic `execution_count` increment** via SECURITY DEFINER RPC —
prevents lost counts when two webhooks start runs concurrently.
Mirrors the automations engine pattern.
([#124](https://github.com/ArnasDon/wacrm/pull/124))
- **Preload all flow_nodes once per dispatch** — one SELECT per
inbound instead of one per advance-loop iteration. A 5-node
auto-advance chain now costs 1 round trip, not 5.
([#125](https://github.com/ArnasDon/wacrm/pull/125))
- **Wasted re-read dropped** after reprompt reset; `loadActiveRun`
switched to defensive `.limit(1)` so a migration glitch producing
duplicates can't crash dispatch.
([#126](https://github.com/ArnasDon/wacrm/pull/126))
### Security
- **PII redacted from `reply_received` event payload** — customer
text is no longer persisted to `flow_run_events.payload`; only
the length is. A `collect_input` prompt asking "what's your card
number?" used to leave the PAN sitting in the events table.
([#123](https://github.com/ArnasDon/wacrm/pull/123))
- **Constant-time cron-secret compare** on `/api/flows/cron`
(`crypto.timingSafeEqual`) to close a theoretical
timing-side-channel on the `x-cron-secret` header check.
([#127](https://github.com/ArnasDon/wacrm/pull/127))
### Fixed
- **`/flows` no longer spuriously redirects to `/dashboard`** when
navigating in. Root cause: `useAuth` flipped `loading: false`
before the profile fetch resolved. `use-auth` now exposes a
separate `profileLoading` boolean.
([#128](https://github.com/ArnasDon/wacrm/pull/128))
### Migration required
Apply, in order, against your Supabase project:
1. `supabase/migrations/010_flows.sql` — Flows core tables, indexes,
RLS policies, and the `messages` schema widening.
2. `supabase/migrations/011_profile_beta_features.sql` — adds the
`profiles.beta_features` column. Surviving for future betas;
Flows no longer reads it.
3. `supabase/migrations/012_flows_increment_counter.sql` — atomic
counter RPC. Without this the engine still runs but
`flows.execution_count` is racy.
Each migration is idempotent — safe to re-run if you're not sure
whether you applied a previous one.
### Removed
- **`src/lib/flows/feature-flag.ts`** + its tests. Flows is open to
all users; the `profiles.beta_features` column itself survives
for future beta gates.
([#134](https://github.com/ArnasDon/wacrm/pull/134))
---
## [0.1.1] — 2026-05-19
### Added
- Chat actions in the inbox: emoji reactions, reply-with-quote, and
copy-text on individual messages. Hover on desktop, long-press on
touch. Outbound reactions and replies forward to WhatsApp via the
Cloud API; inbound reactions and swipe-replies from customers
arrive through the webhook and appear in real time.
### Migration required
- Apply `supabase/migrations/009_message_actions.sql` to your
Supabase project. It adds `messages.reply_to_message_id` and the
new `message_reactions` table (with RLS and realtime). The
migration is idempotent — safe to re-run.
### Changed
- The webhook no longer stores inbound customer reactions as fake
text messages. They are written to `message_reactions` instead,
so any custom queries that counted reactions as messages will
need updating.
---
## [0.1.0]
Initial template release. Core CRM: inbox, contacts, pipelines,
broadcasts, automations (with a Wait-step cron drain), WhatsApp
Cloud API integration, Supabase auth + RLS.

1
wacrm/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

124
wacrm/CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,124 @@
# Using this template
This is a **template repository**, not a collaborative product. The
expected flow is:
1. **Fork** it to your own GitHub account or organisation.
2. **Deploy** the fork — see [`docs/`](./docs/README.md).
3. **Customise** your fork. Rebrand, add the features you need, remove
the ones you don't, swap hosting, change the schema.
You **don't** need to send changes back upstream. The fact that your
fork diverges is the whole point — the upstream is deliberately
opinionated about stack, UX, and scope, and your fork is where those
opinions become yours.
## Fork and run
```bash
# 1. Fork on GitHub: https://github.com/ArnasDon/wacrm → Fork
# 2. Clone your fork
git clone https://github.com/<your-username>/wacrm.git
cd wacrm
cp .env.local.example .env.local # fill in Supabase + Meta creds
npm install
npm run dev
```
Full setup (Supabase migrations, WhatsApp Business API, deploy) lives in
[`docs/`](./docs/README.md).
## Keeping your fork up to date
Pull in upstream bug fixes and security patches periodically:
```bash
git remote add upstream https://github.com/ArnasDon/wacrm.git # once
git fetch upstream
git checkout main
git merge upstream/main # or: git rebase upstream/main
# Resolve any conflicts (likely in areas you've customised), then push
git push origin main
```
If you've made heavy local customisations, rebasing can surface
conflicts every time you pull. Pinning to a specific upstream tag and
updating on your schedule is a valid alternative.
## Reporting bugs in the upstream template
If you find a bug in the upstream code — not one you introduced in your
fork — please file it using the
[bug report](https://github.com/ArnasDon/wacrm/issues/new?template=bug_report.yml)
template. Including the commit SHA, the runtime (Hostinger / Vercel /
local / other), and logs will get to a fix fastest.
## Reporting security issues
**Do not file security issues publicly.** Follow the private flow in
[SECURITY.md](./.github/SECURITY.md).
## Upstream pull requests
Not the primary flow, but welcome in specific cases:
- **Security fixes** — always welcome, please follow SECURITY.md first
for disclosure.
- **Bug fixes** that match upstream intent (crash, correctness,
documentation errors, typos) — land quickly.
- **Small improvements** (accessibility, obvious UX nits) — usually
welcome, open an issue first to check alignment.
Less likely to land:
- **New features.** The template's scope is intentionally narrow. A
"great idea for a CRM" is often a great idea for *your* CRM — i.e.
your fork — but would dilute the template for the next forker.
- **Stack changes** (different ORM, different UI kit, different auth
provider). These belong in a fork, not upstream.
- **Opinionated refactors** without a concrete correctness or
performance motivation.
If you do send a PR, the usual rules apply:
- Branch off the latest `main` (don't push to a merged branch — commits
end up orphaned).
- Run `npm run typecheck` and `npm run format` locally first.
- Fill in the PR template, especially the **Test plan**.
- One logical change per PR.
- Commit-message first line is imperative + terse; the body explains
the *why*, the diff shows the *what*.
Expect a review within a few days. PRs opened without an issue may be
closed — open the issue first to align.
## If you maintain a public fork
- Rebrand. The "CRM Template for WhatsApp" name, favicon, and
`wacrm.tech` URL belong to the upstream project; please swap them
for your own before putting your deployment in front of users.
- Keep the MIT [`LICENSE`](./LICENSE) file — that's how the template's
permissions travel with the code. Attribution in a `README` section
is appreciated but not required.
- You are free to re-license additions to your fork however you like.
## Dev-loop reference
Even if you never send a PR upstream, these are the scripts you'll use
in your fork:
| Command | What it does |
| --- | --- |
| `npm run dev` | Turbopack dev server on port 3000. |
| `npm run build` | Production build. Next also runs its own typecheck here. |
| `npm run typecheck` | `tsc --noEmit`. Fast TS-only pass. |
| `npm run lint` | ESLint. |
| `npm run format` | Prettier write. |
| `npm run format:check` | Prettier in check-only mode. Useful in CI. |
## Licensing
This template is MIT ([`LICENSE`](./LICENSE)). Anything you contribute
upstream is assumed to be MIT too. Your fork's additions are yours to
license however you like.

23
wacrm/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# Dockerfile para WACRM (Next.js)
FROM node:22-alpine
WORKDIR /app
# Instalar dependencias
COPY package*.json ./
RUN npm ci
# Copiar código fuente
COPY . .
# Variables de entorno para build
ENV NEXT_TELEMETRY_DISABLED=1
# Build de la aplicación
RUN npm run build
# Exponer puerto
EXPOSE 3000
# Comando de inicio
CMD ["npm", "start"]

21
wacrm/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Arnas Donauskas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

168
wacrm/README.md Normal file
View File

@@ -0,0 +1,168 @@
# wacrm — CRM Template for WhatsApp
> Self-hostable CRM template for WhatsApp® — shared inbox, contacts,
> sales pipelines, broadcasts, and no-code automations. Fork it, brand
> it, host it.
<p align="center">
<a href="https://www.hostinger.com/web-apps-hosting">
<img src="./.github/assets/hostinger-deploy.png" alt="Ship your Node.js app in one click — Deploy to Hostinger" width="900">
</a>
</p>
[![License: MIT](https://img.shields.io/badge/License-MIT-violet.svg)](./LICENSE)
[![CI](https://github.com/ArnasDon/wacrm/actions/workflows/ci.yml/badge.svg)](https://github.com/ArnasDon/wacrm/actions/workflows/ci.yml)
[![Next.js 16](https://img.shields.io/badge/Next.js-16-black?logo=nextdotjs)](https://nextjs.org)
[![Supabase](https://img.shields.io/badge/Supabase-Postgres%20%2B%20Auth-3ecf8e?logo=supabase)](https://supabase.com)
[![Stars](https://img.shields.io/github/stars/ArnasDon/wacrm?style=social)](https://github.com/ArnasDon/wacrm/stargazers)
The marketing site and self-host docs live in a separate repo:
[ArnasDon/wacrm-site](https://github.com/ArnasDon/wacrm-site)
([wacrm.tech](https://wacrm.tech)). This repo is the product —
clone or fork it to run your own CRM.
## What you get out of the box
- **Shared inbox** on the official WhatsApp Business API — multiple
agents working one number, per-conversation assignment, status, and
notes.
- **Contacts + tags + custom fields**, CSV import, deduplication.
- **Sales pipelines** (Kanban) with deals linked to conversations.
- **Broadcasts** with Meta-approved templates, delivery + read
tracking, per-recipient variable substitution.
- **No-code automations** — triggers on inbound messages, new
contacts, keywords, or schedule; conditional branches, waits,
tags, webhooks. Visual builder.
- **AI reply assistant** — bring your own OpenAI or Anthropic key
(stored encrypted; no per-seat AI fee, your data stays yours).
One-click AI-drafted replies in the inbox, plus an optional
auto-reply bot with a per-conversation cap and clean human handoff.
Add a **knowledge base** (FAQs, policies, product docs) and it
answers from your own content — hybrid retrieval (Postgres full-text,
or semantic pgvector when an embeddings key is set).
- **Real-time dashboard** — response times, daily volume, pipeline
value, cross-module activity feed.
- **Team accounts** — invite teammates by link, role-based access
(owner / admin / agent / viewer), ownership transfer. Every install
is account-scoped, so one shared inbox can be staffed by a whole
team. Solo use stays single-user with zero setup.
- **Account management** — email, password, avatar, global sign-out.
- **Public REST API** (`/api/v1`) with scoped, revocable API keys —
build your own automations on top of your CRM. See
[docs/public-api.md](./docs/public-api.md).
## Why fork this?
This is a **template**, not a product. Forking means you get:
- **Full ownership** — your code, your Supabase project, your domain,
your data. No SaaS lock-in, no seat pricing, no trust dance.
- **Full customisation** — add the fields your team needs, remove the
modules you don't, redesign anything. The stack is boring on
purpose (Next.js + Supabase + Tailwind) so the learning curve is
short.
- **Zero ops to start** — [Hostinger](https://www.hostinger.com/web-apps-hosting)
Managed Node.js deploys a fork in a few clicks. No Docker, no
Kubernetes, no infra team needed.
([See below ↓](#-deploy-on-hostinger-recommended))
- **Real security primitives** — token encryption (AES-256-GCM), RLS
on every table, HMAC-verified webhooks, CSP, rate limiting, CI
typecheck/build on every PR.
Not a framework. Not an SDK. A concrete, working CRM you can stand up
in an afternoon and make yours.
## Quick start
```bash
# Fork on GitHub first: https://github.com/ArnasDon/wacrm → Fork
git clone https://github.com/<your-username>/wacrm.git
cd wacrm
npm install
cp .env.local.example .env.local # fill in Supabase + Meta creds
npm run dev
```
Open <http://localhost:3000>. You'll be redirected to `/login` (or
`/dashboard` if already signed in).
## 🚀 Deploy on Hostinger (recommended)
<p align="center">
<a href="https://www.hostinger.com/web-apps-hosting">
<img src="./.github/assets/hostinger-deploy.png" alt="Ship your Node.js app in one click — Deploy to Hostinger" width="1000">
</a>
</p>
<p align="center">
<a href="https://wacrm.tech/docs/deployment-hostinger">
<img src="https://img.shields.io/badge/Step--by--step_guide-wacrm.tech%2Fdocs-111?style=for-the-badge" alt="Step-by-step guide" height="44">
</a>
</p>
**wacrm is built to run on [Hostinger](https://www.hostinger.com/web-apps-hosting).**
It's the path we test, document, and recommend — and the fastest way
to get a production-grade CRM live without owning a VPS or a
Kubernetes cluster.
### Why Hostinger?
| | |
|---|---|
| **One-click Git deploy** | Connect your fork, push to `main`, Hostinger builds and ships it. No SSH, no Docker, no CI to wire up — this repo's own `main` deploys this way. |
| **Managed Node.js** | Next.js 16 (App Router, server actions, ISR) runs out of the box on [Premium, Business, and Cloud](https://www.hostinger.com/web-apps-hosting) shared plans. You don't manage Node versions, processes, or reverse proxies. |
| **Free SSL + free domain** | Automatic Let's Encrypt on your custom domain (or a free one included with annual plans). HTTPS is on by default — required for the WhatsApp Business webhook. |
| **Global CDN + LiteSpeed** | Static assets cached at the edge, dynamic routes served from LiteSpeed. Snappy dashboards out of the box, no Cloudflare setup required. |
| **Env vars + logs in hPanel** | Set `SUPABASE_*`, `WHATSAPP_*`, and `ENCRYPTION_KEY` from the panel — no `.env` on the server. Live application logs in the same UI. |
| **DDoS protection + daily backups** | Built-in, no add-ons. The webhook endpoint is a public target — having protection at the edge matters. |
| **Cheaper than a VPS** | Plans start at a few dollars a month — order-of-magnitude less than a comparable managed Node.js host, and you don't pay extra for the database (that's Supabase). |
| **24/7 human support** | Live chat support in 20+ languages — useful when your CRM is the thing your team relies on to talk to customers. |
### The 60-second version
1. **Fork** this repo on GitHub.
2. In **hPanel → Websites → Create**, pick **Node.js** and connect
your fork.
3. Paste your Supabase + Meta env vars into hPanel.
4. Push to `main`. Hostinger builds and serves it. Done.
Full walkthrough with screenshots:
**[wacrm.tech/docs/deployment-hostinger](https://wacrm.tech/docs/deployment-hostinger)**.
> _Note: wacrm is MIT-licensed and runs anywhere Node.js does
> (Vercel, Railway, your own VPS). Hostinger is recommended, not
> required._
## Documentation
Full self-host documentation — Supabase migrations, WhatsApp Business
API config, and production deploy — lives at
**[wacrm.tech/docs](https://wacrm.tech/docs)**
(source: [ArnasDon/wacrm-site](https://github.com/ArnasDon/wacrm-site)).
Key pages:
- [Getting started](https://wacrm.tech/docs/getting-started)
- [Supabase setup](https://wacrm.tech/docs/supabase-setup)
- [WhatsApp setup](https://wacrm.tech/docs/whatsapp-setup)
- [Environment variables](https://wacrm.tech/docs/environment-variables)
- [Deploy on Hostinger](https://wacrm.tech/docs/deployment-hostinger)
- [Architecture](https://wacrm.tech/docs/architecture)
- [Troubleshooting](https://wacrm.tech/docs/troubleshooting)
## Stack
- **App** — Next.js 16 (App Router), React 19, TypeScript, Tailwind v4.
- **Data** — Supabase (Postgres + Auth + Storage + RLS).
- **WhatsApp** — Meta Cloud API (official WhatsApp Business API).
## Contributing
This is a template, not a collaborative product — the expected flow is
fork → customise → deploy, **not** upstream contribution. Bug reports
and security issues are welcome; feature PRs often belong in your fork
rather than here. Details in
[`CONTRIBUTING.md`](./CONTRIBUTING.md) and
[`.github/SECURITY.md`](./.github/SECURITY.md).
## License
[MIT](./LICENSE). Fork it, brand it, host it.

25
wacrm/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

383
wacrm/docs/public-api.md Normal file
View File

@@ -0,0 +1,383 @@
# Public API (`/api/v1`)
The public API lets you drive your wacrm instance from your own
scripts and automations — send messages, manage contacts, launch
broadcasts — without going through the dashboard UI.
> **Status:** stable. Authentication, scopes, rate limiting, the
> messages / contacts / conversations / broadcasts endpoints, and
> outbound event [webhooks](#webhooks) all ship now.
## Authentication
Every request authenticates with an **API key**, sent as a bearer
token:
```
Authorization: Bearer wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Keys are **account-scoped**: a key acts on exactly one account, the
one it was created in. There is no cross-account access.
### Creating a key
In the dashboard: **Settings → API keys → New API key**. Only
**admins and owners** can create keys.
1. Give the key a name (after the integration that will use it).
2. Grant the **scopes** it needs — nothing more (see below).
3. Copy the key. **The full key is shown exactly once.** wacrm
stores only a SHA-256 hash, so it can never be shown again. If you
lose it, revoke it and create a new one.
### Revoking a key
**Settings → API keys → Revoke.** Revocation is effective on the
key's next request. Revoked keys stay in the list as an audit trail.
## Scopes
A key can do only what its scopes allow — independent of who created
it. Grant the minimum.
| Scope | Allows |
| -------------------- | ---------------------------------------- |
| `messages:send` | Send WhatsApp messages |
| `messages:read` | Read messages and delivery status |
| `contacts:read` | List and read contacts |
| `contacts:write` | Create and update contacts |
| `conversations:read` | List and read conversations |
| `broadcasts:send` | Launch broadcast campaigns |
| `webhooks:manage` | Register and manage outbound webhooks |
A key with **no scopes** still authenticates and can call
`GET /api/v1/me` — useful for verifying a key works.
## Response envelope
Every response uses one of two shapes:
```jsonc
// success
{ "data": { /* ... */ } }
// failure
{ "error": { "code": "forbidden", "message": "This API key is missing the 'messages:send' scope" } }
```
Branch on `error.code` (stable); `error.message` is for humans and
may be reworded.
| Status | `code` | Meaning |
| ------ | -------------- | ------------------------------------------------ |
| 401 | `unauthorized` | Missing / malformed / unknown / revoked / expired key |
| 403 | `forbidden` | Valid key, but missing the required scope |
| 429 | `rate_limited` | Per-key rate limit exceeded |
| 400 | `bad_request` | Malformed input |
| 404 | `not_found` | No such resource |
| 500 | `internal` | Server error |
## Rate limits
Requests are limited **per key**: **120 requests per minute**. On a
`429`, these headers tell you when to retry:
- `Retry-After` — seconds until the window resets
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
> The limiter is in-memory and **per process**. A single-instance
> deploy (the common case for a self-hosted fork) is fine as-is. If
> you scale to multiple instances, swap the limiter for a shared
> store (Redis/Upstash) — see the note at the top of
> `src/lib/rate-limit.ts`. The limit is otherwise unenforced across
> instances.
## Endpoints
### `GET /api/v1/me`
Returns the account a key is bound to and the scopes it carries.
Requires only a valid key (no scope). Use it to verify a key works
and to discover its scopes.
```bash
curl https://your-crm.example.com/api/v1/me \
-H "Authorization: Bearer wacrm_live_xxx"
```
```json
{
"data": {
"account": { "id": "…", "name": "Acme Inc" },
"key": { "id": "…", "scopes": ["messages:send"] }
}
}
```
### `POST /api/v1/messages`
Send a WhatsApp message to a phone number. Scope: `messages:send`. You
pass an **E.164 number**, not an internal id — the endpoint
finds-or-creates the contact + conversation, then sends.
```bash
curl -X POST https://your-crm.example.com/api/v1/messages \
-H "Authorization: Bearer wacrm_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "to": "+14155550123", "type": "text", "text": "Hi 👋" }'
```
`type` is `text` (default), `template`, or a media kind (`image` /
`video` / `document` / `audio`). Media needs `media_url` (and optional
`filename`); `text` doubles as the caption. `template` needs a
`template` object:
```jsonc
{
"to": "+14155550123",
"type": "template",
"template": {
"name": "order_update",
"language": "en_US",
"params": ["A123"] // positional body vars, or a structured object
},
"reply_to_message_id": "<uuid>" // optional; must be in the same conversation
}
```
Response (201):
```json
{
"data": {
"message_id": "…",
"whatsapp_message_id": "wamid.…",
"conversation_id": "…",
"contact_id": "…",
"contact_created": true
}
}
```
Domain error codes beyond the table above: `whatsapp_not_configured`
(400), `meta_error` (502 — the request reached Meta and it rejected the
send), `template_malformed` (500).
### `GET /api/v1/contacts`
List contacts, newest first. Scope: `contacts:read`. Paginated (see
[Pagination](#pagination)). Optional filters: `?search=` (matches name
or phone) and `?tag=<tagId>`.
```json
{
"data": [
{
"id": "…", "phone": "+14155550123", "name": "Jane Doe",
"email": null, "company": "Acme", "avatar_url": null,
"tags": [{ "id": "…", "name": "vip", "color": "#3b82f6" }],
"created_at": "…", "updated_at": "…"
}
],
"meta": { "next_cursor": "…" }
}
```
### `POST /api/v1/contacts`
Create a contact. Scope: `contacts:write`. `phone` (E.164) is required;
`name`, `email`, `company`, and `tags` (an array of tag names, created
if missing) are optional. **Find-or-create by phone:** an existing
match returns `200` with the existing contact; a new contact returns
`201`. The response body is the serialized contact (same shape as the
list rows above).
### `GET` / `PATCH /api/v1/contacts/{id}`
Read or update one contact. Scopes: `contacts:read` / `contacts:write`.
`PATCH` updates only the fields you send (`name`, `email`, `company`);
pass `tags` (an array of tag names) to replace the contact's tags. A
contact in another account returns `404`.
### `GET /api/v1/conversations`
List conversations, newest first. Scope: `conversations:read`.
Paginated. Optional filters: `?status=` (`open` / `pending` / `closed`)
and `?contact_id=`. Each conversation embeds its contact + tags.
### `GET /api/v1/conversations/{id}`
Read one conversation. Scope: `conversations:read`. `404` if it belongs
to another account.
### `GET /api/v1/conversations/{id}/messages`
List a conversation's messages, newest first. Scope: `messages:read`.
Paginated. Each message includes its `direction` (`inbound` /
`outbound`), `status` (delivery state), `whatsapp_message_id`, and
`content_*`. The conversation is verified to belong to your account
first (`404` otherwise).
### `POST /api/v1/broadcasts`
Launch a template broadcast to a list of recipients. Scope:
`broadcasts:send`. The broadcast + its recipient rows are persisted
immediately and the sends fan out in the background, so the call
returns fast — poll `GET /api/v1/broadcasts/{id}` for progress.
```bash
curl -X POST https://your-crm.example.com/api/v1/broadcasts \
-H "Authorization: Bearer wacrm_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "July promo",
"template_name": "promo_july",
"template_language": "en_US",
"recipients": [
{ "to": "+14155550123", "params": ["Jane"] },
{ "to": "+14155550124" }
]
}'
```
Recipients are capped at **1000 per request** — split larger sends.
Invalid phone numbers are dropped and counted as `rejected`. Response
(202):
```json
{
"data": {
"broadcast_id": "…",
"status": "sending",
"total_recipients": 2,
"accepted": 2,
"rejected": 0
}
}
```
### `GET /api/v1/broadcasts/{id}`
Broadcast status + counts. Scope: `broadcasts:send`. `status` moves
`sending``sent`; `delivered_count` / `read_count` keep climbing as
Meta delivery webhooks arrive. `404` for another account's broadcast.
## Pagination
Every list endpoint pages the same way. Request a page size with
`?limit=` (default 50, max 100) and read the next page with the opaque
`meta.next_cursor` from the previous response:
```
GET /api/v1/contacts?limit=50
→ { "data": [ … ], "meta": { "next_cursor": "eyJ…" } }
GET /api/v1/contacts?limit=50&cursor=eyJ…
→ { "data": [ … ], "meta": { "next_cursor": null } } // last page
```
Cursors are keyset-based (stable under concurrent inserts). Pass the
cursor back verbatim — don't parse it. `next_cursor: null` means the
last page.
## Webhooks
Rather than polling, register an endpoint and wacrm will POST to it when
things happen in your account. **Migration required:** apply
`supabase/migrations/028_webhook_endpoints.sql`.
### Events
| Event | Fires when |
| ------------------------ | ------------------------------------------------- |
| `message.received` | An inbound message arrives from a contact |
| `message.status_updated` | A message you sent changed delivery status |
| `conversation.created` | A new conversation is opened for a contact |
### Managing endpoints
All under scope `webhooks:manage`.
- `POST /api/v1/webhooks` — register `{ "url": "https://…", "events": ["message.received"] }`. `url` must be `https://`. **The response includes `secret` exactly once** — store it to verify signatures; wacrm keeps only an encrypted copy.
- `GET /api/v1/webhooks` — list your endpoints (never returns the secret).
- `GET /api/v1/webhooks/{id}` — read one.
- `PATCH /api/v1/webhooks/{id}` — update `url`, `events`, or `is_active` (re-enabling clears the failure counter).
- `DELETE /api/v1/webhooks/{id}` — remove one.
```bash
curl -X POST https://your-crm.example.com/api/v1/webhooks \
-H "Authorization: Bearer wacrm_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/wacrm", "events": ["message.received"] }'
# → 201 { "data": { "id": "…", "url": "…", "events": [...], "secret": "whsec_…" } }
```
### Delivery payload
Every delivery is a POST with this envelope; `id` is a unique per-
delivery uuid you can dedupe on, and `data` varies by `event`:
```json
{
"id": "8f3c…",
"event": "message.received",
"occurred_at": "2026-07-01T12:00:00.000Z",
"account_id": "…",
"data": { /* per-event, see below */ }
}
```
`data` by event:
```jsonc
// message.received
{ "conversation_id": "…", "contact_id": "…", "whatsapp_message_id": "wamid.…", "content_type": "text", "text": "Hi 👋" }
// conversation.created
{ "conversation_id": "…", "contact_id": "…" }
// message.status_updated
{ "whatsapp_message_id": "wamid.…", "conversation_id": "…", "status": "delivered" }
```
Headers: `X-Wacrm-Event`, `X-Wacrm-Webhook-Id`, and `X-Wacrm-Signature`.
### Verifying the signature
`X-Wacrm-Signature: t=<unix_seconds>,v1=<hex>` where `v1 =
HMAC-SHA256(secret, "${t}.${rawBody}")`. Recompute it over the **raw
request body** and compare in constant time; reject if `t` is more than
a few minutes old (replay protection).
```js
const [, t, v1] = header.match(/t=(\d+),v1=([0-9a-f]+)/);
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.${rawBody}`).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
```
### Delivery semantics
Delivery is **best-effort**: a single attempt per event with a short
timeout, and **redirects are not followed**. `message.status_updated`
covers messages wacrm stores (inbox + API sends), not broadcast-only
sends, and — because providers re-send and re-order status callbacks —
the same status may arrive more than once or out of order; **dedupe on
`id` and don't assume ordering**. Each consecutive failure increments
`failure_count`; after enough consecutive failures the endpoint is
auto-disabled (`is_active: false`) — re-enable it with `PATCH` (which
resets the counter). Durable retry-with-backoff (a delivery queue) is a
future enhancement; today, treat missed deliveries as possible and
reconcile with the read endpoints when it matters.
**Target restrictions (SSRF).** The `url` must be `https://` and must
resolve to a public address — requests to `localhost`, private/RFC1918
ranges, link-local (incl. cloud metadata `169.254.169.254`), and similar
internal targets are refused at delivery time.
## Roadmap
The public API now covers messaging, contacts, conversations,
broadcasts, and outbound webhooks — the full scope of
[#245](https://github.com/ArnasDon/wacrm/issues/245). Future ideas
(deals/pipelines, templates, flows, a delivery queue for webhooks) are
not yet scheduled.

20
wacrm/eslint.config.mjs Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Vendored minified opus-recorder encoder worker (served statically).
"public/opus/**",
]),
]);
export default eslintConfig;

128
wacrm/next.config.ts Normal file
View File

@@ -0,0 +1,128 @@
import type { NextConfig } from "next";
/**
* Baseline security headers applied to every response.
*
* CSP ships as `Content-Security-Policy-Report-Only` so the browser
* surfaces violations in the console without blocking anything — once
* we have confidence nothing legit trips it (two deploys, a pass on
* every route), flip the key to `Content-Security-Policy` to enforce.
*
* The rest of the headers are straight blocks, safe to enforce today:
* - HSTS: only meaningful on HTTPS (no-op on http://localhost).
* - X-Content-Type-Options / X-Frame-Options / Referrer-Policy:
* baseline OWASP hardening, no behavioural cost.
* - Permissions-Policy: we don't use camera / microphone / etc, so
* deny them. A supply-chain compromise or a forgotten plugin
* can't silently opt back in.
*/
const SECURITY_HEADERS = [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
// Microphone is allowed for same-origin (`self`) so the inbox
// composer can record voice notes via MediaRecorder. Everything
// else stays denied — a compromised dependency can't silently grab
// the camera / geolocation / etc.
key: "Permissions-Policy",
value: "camera=(), microphone=(self), geolocation=(), payment=(), usb=()",
},
{
key: "Content-Security-Policy-Report-Only",
value: [
"default-src 'self'",
// Next.js needs 'unsafe-inline' for its inline hydration script
// and 'unsafe-eval' in dev + some production optimisations.
// Nonce-based CSP is a later project.
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
// Tailwind + inline style attributes on lots of components.
"style-src 'self' 'unsafe-inline'",
// Supabase public-bucket avatars, contact avatars (arbitrary
// https URLs paste-able from the UI), OG images, data URLs for
// tiny inline assets.
"img-src 'self' data: blob: https:",
// Outbound media previews (blob: from MediaRecorder + file picker)
// and Supabase public-bucket audio/video the inbox renders.
"media-src 'self' blob: https://*.supabase.co",
"font-src 'self' data:",
// Supabase REST + realtime (WSS). All Meta API calls happen
// server-side, so graph.facebook.com does not belong here.
"connect-src 'self' http://192.168.10.114:8000 http://localhost:8000 https://*.supabase.co wss://*.supabase.co",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "),
},
] as const;
const nextConfig: NextConfig = {
/**
* Cache-Control policy.
*
* Why this exists:
* Hostinger's CDN was applying `s-maxage=31536000` (1 year) to
* prerendered HTML pages by default. When a new deploy shipped
* fresh Turbopack chunk hashes, the edge kept serving year-old
* HTML referencing chunk filenames that no longer existed on
* disk — result: HTML 200, every /_next/static/*.js and .css
* came back 404, the page rendered unstyled. Private/incognito
* did nothing because the cache is server-side.
*
* Strategy:
* - /_next/static/* — leave to Next. Turbopack dev chunks can go
* stale if we force immutable caching here; Next already emits
* the correct production headers for hashed assets.
* - /api/* — no-store. API responses are per-user and
* must never be shared across requests at the edge.
* - Everything else — public, brief s-maxage + generous
* stale-while-revalidate. The edge serves instantly from cache
* for the first 5 min, then returns cached content while
* refreshing in the background for up to 24 h. A deploy's
* chunk-hash drift self-heals within ~5 min with no user-
* visible latency.
*
* Note: dynamic dashboard routes (/inbox, /contacts, /pipelines,
* /broadcasts, etc.) are server-rendered per request — Next.js
* and Supabase auth already prevent them from being served
* from a shared cache. The s-maxage here is a ceiling; Next.js
* and auth middleware still set `private` / `no-store` for
* per-user responses.
*
* Security headers are appended via a separate catch-all rule
* below — Next.js merges headers from every matching rule, so
* they apply to every response regardless of which cache rule
* matched.
*/
async headers() {
return [
{
source: "/api/:path*",
headers: [{ key: "Cache-Control", value: "no-store" }],
},
{
source: "/:path((?!_next/static|_next/image|api).*)",
headers: [
{
key: "Cache-Control",
value:
"public, max-age=0, s-maxage=300, stale-while-revalidate=86400",
},
],
},
{
// Security headers on every response, including /_next/static
// assets (nosniff matters there) and /api/* (HSTS + referrer-
// policy don't hurt).
source: "/:path*",
headers: [...SECURITY_HEADERS],
},
];
},
};
export default nextConfig;

10938
wacrm/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

85
wacrm/package.json Normal file
View File

@@ -0,0 +1,85 @@
{
"name": "wacrm",
"version": "0.7.0",
"private": true,
"description": "Self-hostable CRM template for WhatsApp built on Next.js and Supabase — shared inbox, contacts, sales pipelines, broadcasts, and no-code automations.",
"license": "MIT",
"author": "Arnas Donauskas",
"homepage": "https://github.com/ArnasDon/wacrm",
"repository": {
"type": "git",
"url": "git+https://github.com/ArnasDon/wacrm.git"
},
"bugs": {
"url": "https://github.com/ArnasDon/wacrm/issues"
},
"keywords": [
"crm",
"whatsapp",
"whatsapp-business-api",
"nextjs",
"supabase",
"automation",
"broadcast",
"self-hosted",
"template"
],
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@dagrejs/dagre": "^3.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.107.0",
"@xyflow/react": "^12.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.22.0",
"next": "16.2.6",
"opus-recorder": "^8.0.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"shadcn": "^4.11.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^26",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"prettier": "^3.9.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^4",
"typescript": "^6",
"vitest": "^4.1.9"
},
"overrides": {
"postcss": "^8.5.10",
"ip-address": "^10.1.1",
"fast-uri": "^3.1.2",
"hono": "^4.12.25",
"js-yaml": "^4.2.0",
"@babel/core": "^7.29.6"
}
}

7
wacrm/postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
wacrm/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
wacrm/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
WhatsApp-style doodle background for the inbox message area.
Design notes:
- 240x240 tile, repeats seamlessly via CSS background-repeat. Icons stay
inside a (10, 10) → (217, 217) safe zone so the tile boundary always
falls in empty space; no icon is ever cut in half by a seam.
- Icon shapes are taken verbatim from lucide-react (which the app already
ships) so they sit visually consistent with every other icon in the UI.
- Stroke color is slate-500 (#64748b) at 22% opacity — visible against
bg-slate-950 (#020617) without competing with message text.
- All shapes use stroke + fill:none + round caps to match the lucide
line-art style.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240" fill="none" stroke="#64748b" stroke-opacity="0.22" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<!-- 1. MessageSquare @ (30,22) s=0.95 -->
<g transform="translate(30 22) scale(0.95)">
<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/>
</g>
<!-- 2. Phone @ (95,18) s=0.85 r=-12 -->
<g transform="translate(95 18) scale(0.85) rotate(-12 12 12)">
<path d="M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384"/>
</g>
<!-- 3. Star @ (160,28) s=0.75 -->
<g transform="translate(160 28) scale(0.75)">
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>
</g>
<!-- 4. Heart @ (200,28) s=0.7 r=8 -->
<g transform="translate(200 28) scale(0.7) rotate(8 12 12)">
<path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/>
</g>
<!-- 5. Calendar @ (50,70) s=0.9 r=5 -->
<g transform="translate(50 70) scale(0.9) rotate(5 12 12)">
<path d="M8 2v4"/>
<path d="M16 2v4"/>
<rect width="18" height="18" x="3" y="4" rx="2"/>
<path d="M3 10h18"/>
</g>
<!-- 6. Lightbulb @ (130,60) s=0.85 r=-8 -->
<g transform="translate(130 60) scale(0.85) rotate(-8 12 12)">
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/>
<path d="M9 18h6"/>
<path d="M10 22h4"/>
</g>
<!-- 7. BarChart @ (198,70) s=0.85 -->
<g transform="translate(198 70) scale(0.85)">
<path d="M5 21v-6"/>
<path d="M12 21V9"/>
<path d="M19 21V3"/>
</g>
<!-- 8. Users @ (25,115) s=0.85 -->
<g transform="translate(25 115) scale(0.85)">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<path d="M16 3.128a4 4 0 0 1 0 7.744"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<circle cx="9" cy="7" r="4"/>
</g>
<!-- 9. DollarSign @ (90,110) s=0.8 r=-5 -->
<g transform="translate(90 110) scale(0.8) rotate(-5 12 12)">
<line x1="12" x2="12" y1="2" y2="22"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</g>
<!-- 10. Trophy @ (152,118) s=0.85 -->
<g transform="translate(152 118) scale(0.85)">
<path d="M10 14.66v1.626a2 2 0 0 1-.976 1.696A5 5 0 0 0 7 21.978"/>
<path d="M14 14.66v1.626a2 2 0 0 0 .976 1.696A5 5 0 0 1 17 21.978"/>
<path d="M18 9h1.5a1 1 0 0 0 0-5H18"/>
<path d="M4 22h16"/>
<path d="M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z"/>
<path d="M6 9H4.5a1 1 0 0 1 0-5H6"/>
</g>
<!-- 11. Smile @ (205,108) s=0.7 r=10 -->
<g transform="translate(205 108) scale(0.7) rotate(10 12 12)">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<line x1="9" x2="9.01" y1="9" y2="9"/>
<line x1="15" x2="15.01" y1="9" y2="9"/>
</g>
<!-- 12. Globe @ (45,158) s=0.85 -->
<g transform="translate(45 158) scale(0.85)">
<circle cx="12" cy="12" r="10"/>
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/>
<path d="M2 12h20"/>
</g>
<!-- 13. Mail @ (115,152) s=0.9 r=8 -->
<g transform="translate(115 152) scale(0.9) rotate(8 12 12)">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/>
</g>
<!-- 14. Briefcase @ (178,165) s=0.85 r=-4 -->
<g transform="translate(178 165) scale(0.85) rotate(-4 12 12)">
<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
<rect width="20" height="14" x="2" y="6" rx="2"/>
</g>
<!-- 15. CheckCircle @ (25,200) s=0.8 r=-10 -->
<g transform="translate(25 200) scale(0.8) rotate(-10 12 12)">
<path d="M21.801 10A10 10 0 1 1 17 3.335"/>
<path d="m9 11 3 3L22 4"/>
</g>
<!-- 16. Percent @ (90,208) s=0.75 -->
<g transform="translate(90 208) scale(0.75)">
<line x1="19" x2="5" y1="5" y2="19"/>
<circle cx="6.5" cy="6.5" r="2.5"/>
<circle cx="17.5" cy="17.5" r="2.5"/>
</g>
<!-- 17. ShoppingCart @ (152,205) s=0.8 r=5 -->
<g transform="translate(152 205) scale(0.8) rotate(5 12 12)">
<circle cx="8" cy="21" r="1"/>
<circle cx="19" cy="21" r="1"/>
<path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>
</g>
<!-- 18. Rocket @ (208,198) s=0.7 r=15 -->
<g transform="translate(208 198) scale(0.7) rotate(15 12 12)">
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09"/>
<path d="M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

1
wacrm/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

1
wacrm/public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
wacrm/public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { MessageSquare, CheckCircle, ArrowLeft } from "lucide-react";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const supabase = createClient();
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`,
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
setSuccess(true);
setLoading(false);
};
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<CheckCircle className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-foreground">
Check your email
</CardTitle>
<CardDescription className="text-muted-foreground">
We&apos;ve sent a password reset link to{" "}
<span className="text-foreground">{email}</span>. Please check your
inbox.
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/login">
<Button
variant="outline"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Back to sign in
</Button>
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<MessageSquare className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-foreground">Reset password</CardTitle>
<CardDescription className="text-muted-foreground">
Enter your email and we&apos;ll send you a reset link
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleReset} className="flex flex-col gap-4">
{error && (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<Button
type="submit"
disabled={loading}
className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Sending..." : "Send reset link"}
</Button>
</form>
<Link
href="/login"
className="mt-6 flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to sign in
</Link>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
// Shared metadata for auth pages (login / signup / forgot-password).
// None of these should be indexed — they'd compete with the marketing
// landing in SERPs and offer nothing to a searcher who hasn't already
// signed up. Each page still gets its own <title> via its own
// metadata.title override below the route group layout.
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
},
},
};
export default function AuthLayout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -0,0 +1,161 @@
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { MessageSquare, UsersRound } from "lucide-react";
// `useSearchParams` opts the component out of static prerendering
// unless it sits under a Suspense boundary. We split the form into
// a child component so the outer page can prerender the chrome
// (background, card frame) while the form hydrates with the query
// string on the client.
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginPageInner />
</Suspense>
);
}
function LoginPageInner() {
const searchParams = useSearchParams();
// Forwarded from `/join/<token>` when the visitor already has an
// account. After a successful sign-in we send them to the join
// page to accept rather than to /dashboard.
const inviteToken = searchParams.get("invite");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const router = useRouter();
const supabase = createClient();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
if (inviteToken) {
router.push(`/join/${encodeURIComponent(inviteToken)}`);
} else {
router.push("/dashboard");
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
{inviteToken ? (
<UsersRound className="h-6 w-6 text-primary" />
) : (
<MessageSquare className="h-6 w-6 text-primary" />
)}
</div>
<CardTitle className="text-xl text-foreground">
{inviteToken ? "Sign in to accept" : "Welcome back"}
</CardTitle>
<CardDescription className="text-muted-foreground">
{inviteToken
? "Sign in and we'll take you to the invitation."
: "Sign in to your account"}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="flex flex-col gap-4">
{error && (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<Link
href="/forgot-password"
className="text-sm text-primary hover:text-primary/80"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<Button
type="submit"
disabled={loading}
className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
<p className="mt-6 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link
href={
inviteToken
? `/signup?invite=${encodeURIComponent(inviteToken)}`
: "/signup"
}
className="text-primary hover:text-primary/80"
>
Create account
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,244 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { MessageSquare, CheckCircle, UsersRound } from "lucide-react";
// `useSearchParams` opts the component out of static prerendering
// unless wrapped in Suspense — same pattern as /login.
export default function SignupPage() {
return (
<Suspense fallback={null}>
<SignupPageInner />
</Suspense>
);
}
function SignupPageInner() {
const searchParams = useSearchParams();
// When the user lands here from `/join/<token>` we carry the
// invite token in the query so it survives the signup → email
// verification → redirect round-trip. `emailRedirectTo` below
// points back at /join/<token> so the user lands on the redeem
// step after verifying instead of being dropped on /dashboard.
const inviteToken = searchParams.get("invite");
const [fullName, setFullName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const supabase = createClient();
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
setLoading(true);
// If we have an invite token, point Supabase's verification
// email back at the join page so the user can accept after
// verifying. Without a token, Supabase uses its default
// redirect (the app root).
const emailRedirectTo = inviteToken
? `${window.location.origin}/join/${encodeURIComponent(inviteToken)}`
: undefined;
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullName,
},
...(emailRedirectTo ? { emailRedirectTo } : {}),
},
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
setSuccess(true);
setLoading(false);
};
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<CheckCircle className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-foreground">
Check your email
</CardTitle>
<CardDescription className="text-muted-foreground">
We&apos;ve sent a confirmation link to{" "}
<span className="text-foreground">{email}</span>. Please check your
inbox and click the link to verify your account.
</CardDescription>
</CardHeader>
<CardContent>
<Link
href={
inviteToken
? `/login?invite=${encodeURIComponent(inviteToken)}`
: "/login"
}
>
<Button
variant="outline"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Back to sign in
</Button>
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
{inviteToken ? (
<UsersRound className="h-6 w-6 text-primary" />
) : (
<MessageSquare className="h-6 w-6 text-primary" />
)}
</div>
<CardTitle className="text-xl text-foreground">
{inviteToken ? "Create account & join" : "Create account"}
</CardTitle>
<CardDescription className="text-muted-foreground">
{inviteToken
? "Verify your email, then accept the invitation to join your team."
: "Get started with CRM Template for WhatsApp"}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSignup} className="flex flex-col gap-4">
{error && (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="fullName" className="text-muted-foreground">
Full name
</Label>
<Input
id="fullName"
type="text"
placeholder="John Doe"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<Input
id="password"
type="password"
placeholder="At least 6 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirmPassword" className="text-muted-foreground">
Confirm password
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Repeat your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<Button
type="submit"
disabled={loading}
className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Creating account..." : "Create account"}
</Button>
</form>
<p className="mt-6 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href={
inviteToken
? `/login?invite=${encodeURIComponent(inviteToken)}`
: "/login"
}
className="text-primary hover:text-primary/80"
>
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { useEffect, useState } from 'react';
import { Bot, Sparkles, Settings2 } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { AiPlayground } from '@/components/agents/ai-playground';
import { AiConfig } from '@/components/settings/ai-config';
type Tab = 'playground' | 'setup';
export default function AgentsPage() {
const [tab, setTab] = useState<Tab>('playground');
const [decided, setDecided] = useState(false);
// Land first-time users on Setup, returning users on the Playground.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch('/api/ai/config');
const data = await res.json().catch(() => ({}));
if (!cancelled) setTab(data?.configured ? 'playground' : 'setup');
} catch {
if (!cancelled) setTab('setup');
} finally {
if (!cancelled) setDecided(true);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div>
<div className="flex items-center gap-2">
<Bot className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight text-foreground">
AI Agents
</h1>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Your bring-your-own-key AI agent set it up, then test it in the
playground before it replies to customers in the inbox.
</p>
{decided && (
<Tabs
value={tab}
onValueChange={(v) => setTab(v as Tab)}
className="mt-6"
>
<TabsList>
<TabsTrigger value="playground">
<Sparkles className="mr-1.5 h-4 w-4" /> Playground
</TabsTrigger>
<TabsTrigger value="setup">
<Settings2 className="mr-1.5 h-4 w-4" /> Setup
</TabsTrigger>
</TabsList>
<TabsContent value="playground" className="mt-4">
<AiPlayground onGoToSetup={() => setTab('setup')} />
</TabsContent>
<TabsContent value="setup" className="mt-4">
<AiConfig />
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client"
import { use, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react"
import {
AutomationBuilder,
fromServerSteps,
type BuilderInitial,
type ServerStepNode,
} from "@/components/automations/automation-builder"
import type { AutomationTriggerType } from "@/types"
export default function EditAutomationPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = use(params)
const router = useRouter()
const [initial, setInitial] = useState<BuilderInitial | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
const res = await fetch(`/api/automations/${id}`)
if (!res.ok) {
if (!cancelled) setError(`Failed to load (${res.status})`)
return
}
const body = await res.json()
if (cancelled) return
setInitial({
id: body.automation.id,
name: body.automation.name ?? "",
description: body.automation.description ?? "",
trigger_type: body.automation.trigger_type as AutomationTriggerType,
trigger_config: body.automation.trigger_config ?? {},
is_active: !!body.automation.is_active,
steps: fromServerSteps((body.steps ?? []) as ServerStepNode[]),
})
}
load()
return () => {
cancelled = true
}
}, [id])
if (error) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-3">
<p className="text-sm text-red-400">{error}</p>
<button
onClick={() => router.push("/automations")}
className="text-sm text-primary hover:text-primary/80"
>
Back to Automations
</button>
</div>
)
}
if (!initial) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)
}
return <AutomationBuilder initial={initial} />
}

View File

@@ -0,0 +1,205 @@
"use client"
import { use, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
Check,
Loader2,
X,
ChevronDown,
ChevronRight,
} from "lucide-react"
import { createClient } from "@/lib/supabase/client"
import type {
Automation,
AutomationLog,
AutomationLogStepResult,
} from "@/types"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { formatRelative } from "@/lib/automations/trigger-meta"
export default function AutomationLogsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = use(params)
const router = useRouter()
const [automation, setAutomation] = useState<Automation | null>(null)
const [logs, setLogs] = useState<AutomationLog[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [openLogId, setOpenLogId] = useState<string | null>(null)
useEffect(() => {
async function load() {
try {
const supabase = createClient()
const [autRes, logRes] = await Promise.all([
supabase
.from("automations")
.select("*")
.eq("id", id)
.maybeSingle(),
supabase
.from("automation_logs")
.select("*, contact:contacts(id, name, phone)")
.eq("automation_id", id)
.order("created_at", { ascending: false })
.limit(100),
])
if (autRes.error) throw autRes.error
if (logRes.error) throw logRes.error
setAutomation(autRes.data as Automation | null)
setLogs((logRes.data ?? []) as AutomationLog[])
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load logs")
}
}
load()
}, [id])
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => router.push("/automations")}>
Back
</Button>
</div>
)
}
if (!automation || logs === null) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => router.push("/automations")}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div>
<h1 className="text-2xl font-bold text-foreground">{automation.name}</h1>
<p className="mt-0.5 text-sm text-muted-foreground">Execution logs</p>
</div>
</div>
{logs.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40">
<p className="text-sm text-foreground">No executions yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Trigger this automation to see runs here.
</p>
</div>
) : (
<ul className="space-y-2">
{logs.map((log) => {
const isOpen = openLogId === log.id
return (
<li
key={log.id}
className="rounded-xl border border-border bg-card"
>
<button
type="button"
onClick={() => setOpenLogId(isOpen ? null : log.id)}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<StatusBadge status={log.status} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{log.contact?.name ?? log.contact?.phone ?? "Unknown contact"}
</div>
<div className="truncate text-xs text-muted-foreground">
{log.trigger_event} · {log.steps_executed?.length ?? 0} step
{log.steps_executed?.length === 1 ? "" : "s"}
</div>
</div>
<div className="text-xs text-muted-foreground">
{formatRelative(log.created_at)}
</div>
</button>
{isOpen && (
<div className="border-t border-border px-4 py-3">
{log.error_message && (
<p className="mb-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{log.error_message}
</p>
)}
<ul className="space-y-1.5">
{(log.steps_executed ?? []).map((r, i) => (
<StepRow key={i} result={r} />
))}
{(log.steps_executed ?? []).length === 0 && (
<li className="text-xs text-muted-foreground">No steps recorded.</li>
)}
</ul>
</div>
)}
</li>
)
})}
</ul>
)}
</div>
)
}
function StatusBadge({ status }: { status: AutomationLog["status"] }) {
const classes =
status === "success"
? "border-primary/30 bg-primary/10 text-primary"
: status === "partial"
? "border-amber-500/30 bg-amber-500/10 text-amber-300"
: "border-red-500/30 bg-red-500/10 text-red-300"
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
classes,
)}
>
{status}
</span>
)
}
function StepRow({ result }: { result: AutomationLogStepResult }) {
const ok = result.status === "success"
return (
<li className="flex items-start gap-2 text-xs">
<span
className={cn(
"mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full",
ok ? "bg-primary/20 text-primary" : "bg-red-500/20 text-red-400",
)}
aria-hidden
>
{ok ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
</span>
<span className="text-muted-foreground">{result.step_type}</span>
{result.detail && (
<span className="truncate text-muted-foreground"> {result.detail}</span>
)}
</li>
)
}

View File

@@ -0,0 +1,90 @@
"use client"
import { useMemo } from "react"
import { useSearchParams } from "next/navigation"
import {
AutomationBuilder,
type BuilderInitial,
type BuilderStep,
} from "@/components/automations/automation-builder"
import { AUTOMATION_TEMPLATES, type TemplateSlug } from "@/lib/automations/templates"
import type { AutomationStepType, AutomationTriggerType } from "@/types"
export default function NewAutomationPage() {
const params = useSearchParams()
const template = params.get("template") as TemplateSlug | null
const initial: BuilderInitial = useMemo(() => {
if (template && AUTOMATION_TEMPLATES[template]) {
const t = AUTOMATION_TEMPLATES[template]
const steps = expandFromSeeds(
t.steps.map((seed, idx) => ({
index: idx,
step_type: seed.step_type,
step_config: seed.step_config as Record<string, unknown>,
branch: seed.branch ?? null,
parent_index: seed.parent_index ?? null,
})),
)
return {
name: t.name,
description: t.description,
trigger_type: t.trigger_type,
trigger_config: t.trigger_config as Record<string, unknown>,
is_active: false,
steps,
}
}
return {
name: "",
description: "",
trigger_type: "new_message_received" as AutomationTriggerType,
trigger_config: {},
is_active: false,
steps: [],
}
}, [template])
return <AutomationBuilder initial={initial} />
}
interface SeedRow {
index: number
step_type: AutomationStepType
step_config: Record<string, unknown>
branch: "yes" | "no" | null
parent_index: number | null
}
function uid(): string {
return (
"c_" +
(typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2) + Date.now().toString(36))
)
}
/** Template seeds are flat with parent_index references. Expand into the
* builder's nested tree, preserving order within each scope. */
function expandFromSeeds(rows: SeedRow[]): BuilderStep[] {
const nodes: BuilderStep[] = rows.map((r) => ({
cid: uid(),
step_type: r.step_type,
step_config: r.step_config,
branches:
r.step_type === "condition" ? { yes: [], no: [] } : undefined,
}))
const roots: BuilderStep[] = []
rows.forEach((r, i) => {
if (r.parent_index == null) {
roots.push(nodes[i])
return
}
const parent = nodes[r.parent_index]
if (!parent.branches) parent.branches = { yes: [], no: [] }
parent.branches[r.branch ?? "yes"].push(nodes[i])
})
return roots
}

View File

@@ -0,0 +1,363 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
Zap,
Plus,
MoreVertical,
Copy,
Pencil,
Trash2,
FileText,
MessageCircle,
Clock,
Users,
PhoneCall,
Loader2,
} from "lucide-react"
import { createClient } from "@/lib/supabase/client"
import { useCan } from "@/hooks/use-can"
import type { Automation } from "@/types"
import { Button } from "@/components/ui/button"
import { GatedButton } from "@/components/ui/gated-button"
import { Switch } from "@/components/ui/switch"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { AUTOMATION_TEMPLATES, type TemplateSlug } from "@/lib/automations/templates"
import { triggerMeta, formatRelative } from "@/lib/automations/trigger-meta"
import { cn } from "@/lib/utils"
const TEMPLATE_ORDER: TemplateSlug[] = [
"welcome_message",
"out_of_office",
"lead_qualifier",
"follow_up_reminder",
]
const TEMPLATE_ICON: Record<TemplateSlug, typeof Zap> = {
welcome_message: MessageCircle,
out_of_office: Clock,
lead_qualifier: Users,
follow_up_reminder: PhoneCall,
}
export default function AutomationsPage() {
const router = useRouter()
const canCreate = useCan("send-messages")
const [automations, setAutomations] = useState<Automation[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<Automation | null>(null)
const [deleting, setDeleting] = useState(false)
async function load() {
try {
const supabase = createClient()
const { data, error: fetchErr } = await supabase
.from("automations")
.select("*")
.order("created_at", { ascending: false })
if (fetchErr) throw fetchErr
setAutomations((data ?? []) as Automation[])
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load automations")
}
}
useEffect(() => {
load()
}, [])
async function toggleActive(a: Automation, next: boolean) {
// Optimistic flip so the switch feels instant.
setAutomations((prev) =>
prev?.map((x) => (x.id === a.id ? { ...x, is_active: next } : x)) ?? prev,
)
const res = await fetch(`/api/automations/${a.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ is_active: next }),
})
if (!res.ok) {
// Roll back on error.
setAutomations((prev) =>
prev?.map((x) => (x.id === a.id ? { ...x, is_active: !next } : x)) ?? prev,
)
const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to update")
return
}
toast.success(next ? "Automation activated" : "Automation paused")
}
async function duplicate(a: Automation) {
const res = await fetch(`/api/automations/${a.id}/duplicate`, { method: "POST" })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to duplicate")
return
}
toast.success("Automation duplicated")
load()
}
async function confirmDelete() {
if (!pendingDelete) return
setDeleting(true)
const res = await fetch(`/api/automations/${pendingDelete.id}`, { method: "DELETE" })
setDeleting(false)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to delete")
return
}
toast.success("Automation deleted")
setPendingDelete(null)
load()
}
async function startFromTemplate(slug: TemplateSlug) {
router.push(`/automations/new?template=${slug}`)
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
)
}
if (automations === null) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)
}
const showTemplates = automations.length < 3
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Automations</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build workflows that react to WhatsApp® events automatically.
</p>
</div>
<GatedButton
canAct={canCreate}
gateReason="create automations"
onClick={() => router.push("/automations/new")}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
Create Automation
</GatedButton>
</div>
{showTemplates && (
<section>
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">Quick-start templates</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{TEMPLATE_ORDER.map((slug) => {
const t = AUTOMATION_TEMPLATES[slug]
const Icon = TEMPLATE_ICON[slug]
return (
<button
key={slug}
onClick={() => startFromTemplate(slug)}
className="group flex flex-col items-start rounded-xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
>
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary group-hover:bg-primary/15">
<Icon className="h-5 w-5" />
</div>
<div className="text-sm font-semibold text-foreground">{t.name}</div>
<p className="mt-1 text-xs text-muted-foreground">{t.description}</p>
</button>
)
})}
</div>
</section>
)}
{automations.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Zap className="h-6 w-6 text-primary" />
</div>
<p className="mt-3 text-sm font-medium text-foreground">No automations yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Pick a template above or create one from scratch.
</p>
</div>
) : (
<ul className="space-y-3">
{automations.map((a) => (
<AutomationCard
key={a.id}
automation={a}
onToggle={(next) => toggleActive(a, next)}
onEdit={() => router.push(`/automations/${a.id}/edit`)}
onDuplicate={() => duplicate(a)}
onLogs={() => router.push(`/automations/${a.id}/logs`)}
onDelete={() => setPendingDelete(a)}
/>
))}
</ul>
)}
<Dialog open={!!pendingDelete} onOpenChange={(v) => !v && setPendingDelete(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete automation</DialogTitle>
<DialogDescription>
This permanently removes{" "}
<span className="text-foreground">{pendingDelete?.name}</span> and its execution
history. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setPendingDelete(null)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleting}
>
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function AutomationCard({
automation,
onToggle,
onEdit,
onDuplicate,
onLogs,
onDelete,
}: {
automation: Automation
onToggle: (next: boolean) => void
onEdit: () => void
onDuplicate: () => void
onLogs: () => void
onDelete: () => void
}) {
const meta = triggerMeta(automation.trigger_type)
return (
<li className="rounded-xl border border-border bg-card transition-colors hover:border-border">
<div className="flex items-center gap-4 p-4">
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary/10"
aria-hidden
>
<Zap className="h-5 w-5 text-primary" />
</div>
<button
type="button"
onClick={onEdit}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-foreground">
{automation.name}
</span>
{automation.is_active && (
<span className="relative flex h-2 w-2" aria-label="active">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
</span>
)}
</div>
{automation.description && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{automation.description}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
meta.pillClass,
)}
>
{meta.label}
</span>
<span className="tabular-nums">
{automation.execution_count} run{automation.execution_count === 1 ? "" : "s"}
</span>
<span aria-hidden>·</span>
<span>last {formatRelative(automation.last_executed_at)}</span>
</div>
</button>
<div className="flex items-center gap-3">
<Switch
checked={automation.is_active}
onCheckedChange={(v) => onToggle(!!v)}
aria-label={automation.is_active ? "Deactivate" : "Activate"}
/>
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open menu"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[popup-open]:bg-muted"
>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}>
<Copy className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={onLogs}>
<FileText className="h-4 w-4" />
View Logs
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</li>
)
}

View File

@@ -0,0 +1,528 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Broadcast, BroadcastRecipient, RecipientStatus } from '@/types';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ArrowLeft,
Loader2,
Users,
Send,
CheckCheck,
Eye,
AlertCircle,
MessageCircle,
Filter,
Download,
ChevronDown,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import {
getBroadcastStatus,
getRecipientStatus,
} from '@/lib/broadcast-status';
interface StatCardProps {
label: string;
value: number;
total: number;
icon: React.ReactNode;
color: string;
}
function StatCard({ label, value, total, icon, color }: StatCardProps) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${color}`}>
{icon}
</div>
<span className="text-xs text-muted-foreground">{pct}%</span>
</div>
<p className="mt-3 text-2xl font-bold text-foreground">{value.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
);
}
interface FunnelStep {
label: string;
value: number;
color: string;
}
/**
* Pure-CSS funnel chart: decreasing-width rounded bars.
* Width is relative to the largest step (typically Sent) so we
* always render a full bar at the top and proportional tails.
*/
function FunnelChart({ steps }: { steps: FunnelStep[] }) {
const max = Math.max(...steps.map((s) => s.value), 1);
return (
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="mb-4 text-sm font-medium text-foreground">Funnel</h3>
<div className="space-y-2">
{steps.map((step) => {
const pctOfMax = Math.max(5, Math.round((step.value / max) * 100));
const pctOfSent =
steps[0].value > 0
? Math.round((step.value / steps[0].value) * 100)
: 0;
return (
<div key={step.label} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs text-muted-foreground">
{step.label}
</span>
<div className="relative h-7 flex-1 rounded-full bg-muted">
<div
className={`h-7 rounded-full ${step.color} transition-[width] duration-500`}
style={{ width: `${pctOfMax}%` }}
/>
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-foreground">
{step.value.toLocaleString()}
<span className="ml-2 text-muted-foreground/80">
({pctOfSent}%)
</span>
</span>
</div>
</div>
);
})}
</div>
</div>
);
}
const RECIPIENT_STATUSES: readonly RecipientStatus[] = [
'pending',
'sent',
'delivered',
'read',
'replied',
'failed',
];
/**
* CSV export helper — RFC 4180 quoting. Quote every field so
* commas/newlines/quotes round-trip cleanly.
*/
function toCsv(rows: string[][]): string {
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
return rows.map((r) => r.map(escape).join(',')).join('\n');
}
function downloadBlob(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export default function BroadcastDetailPage() {
const params = useParams();
const router = useRouter();
const broadcastId = params.id as string;
const [broadcast, setBroadcast] = useState<Broadcast | null>(null);
const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<RecipientStatus | 'all'>(
'all',
);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
async function fetchData() {
try {
const supabase = createClient();
const { data: bc, error: bcError } = await supabase
.from('broadcasts')
.select('*')
.eq('id', broadcastId)
.single();
if (bcError) throw bcError;
setBroadcast(bc);
const { data: recs, error: recsError } = await supabase
.from('broadcast_recipients')
.select('*, contact:contacts(*)')
.eq('broadcast_id', broadcastId)
.order('created_at', { ascending: false });
if (recsError) throw recsError;
setRecipients(recs ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load broadcast');
} finally {
setLoading(false);
}
}
fetchData();
}, [broadcastId]);
const filteredRecipients = useMemo(
() =>
statusFilter === 'all'
? recipients
: recipients.filter((r) => r.status === statusFilter),
[recipients, statusFilter],
);
function handleExport() {
if (!broadcast) return;
const header = [
'Contact',
'Phone',
'Status',
'Sent At',
'Delivered At',
'Read At',
'Replied At',
'Error',
];
const rows = recipients.map((r) => [
r.contact?.name ?? '',
r.contact?.phone ?? '',
r.status,
r.sent_at ?? '',
r.delivered_at ?? '',
r.read_at ?? '',
r.replied_at ?? '',
r.error_message ?? '',
]);
const csv = toCsv([header, ...rows]);
const safeName = broadcast.name.replace(/[^a-z0-9-_]+/gi, '-').toLowerCase();
downloadBlob(`broadcast-${safeName}-${broadcastId.slice(0, 8)}.csv`, csv);
}
async function handleDelete() {
setDeleting(true);
const supabase = createClient();
// broadcast_recipients cascades on broadcasts.id (migration 001), so a
// single delete is sufficient — the aggregate trigger in migration 003
// is defined on broadcast_recipients but fires only on its own row
// changes, not on a cascaded drop of the parent row.
const { error: delErr } = await supabase
.from('broadcasts')
.delete()
.eq('id', broadcastId);
setDeleting(false);
if (delErr) {
toast.error(`Failed to delete: ${delErr.message}`);
return;
}
toast.success('Broadcast deleted');
router.push('/broadcasts');
}
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
if (error || !broadcast) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error ?? 'Broadcast not found'}</p>
<Button variant="outline" onClick={() => router.push('/broadcasts')}>
Back to Broadcasts
</Button>
</div>
);
}
const status = getBroadcastStatus(broadcast.status);
const funnelSteps: FunnelStep[] = [
{ label: 'Sent', value: broadcast.sent_count, color: 'bg-primary' },
{ label: 'Delivered', value: broadcast.delivered_count, color: 'bg-teal-500' },
{ label: 'Read', value: broadcast.read_count, color: 'bg-blue-500' },
{ label: 'Replied', value: broadcast.replied_count, color: 'bg-indigo-500' },
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="icon"
onClick={() => router.push('/broadcasts')}
className="border-border"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{broadcast.name}</h1>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
>
{status.label}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>Template: {broadcast.template_name}</span>
<span>-</span>
<span>
Created {new Date(broadcast.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Delete — inline-confirm pattern matches the pipeline-settings
"Delete Pipeline" flow. Mid-send broadcasts can't be deleted
because orphaning in-flight Meta messages would leave the
funnel inconsistent. */}
{confirmDelete ? (
<div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm">
<span className="text-red-300">Delete this broadcast?</span>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleDelete}
disabled={deleting}
className="h-7 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? 'Deleting…' : 'Confirm'}
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
disabled={broadcast.status === 'sending'}
onClick={() => setConfirmDelete(true)}
title={
broadcast.status === 'sending'
? 'Cannot delete while a broadcast is actively sending'
: 'Delete this broadcast'
}
className="border-red-500/30 bg-transparent text-red-400 hover:bg-red-500/10 disabled:opacity-40"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatCard
label="Total Recipients"
value={broadcast.total_recipients}
total={broadcast.total_recipients}
icon={<Users className="h-4 w-4" />}
color="bg-muted text-muted-foreground"
/>
<StatCard
label="Sent"
value={broadcast.sent_count}
total={broadcast.total_recipients}
icon={<Send className="h-4 w-4" />}
color="bg-primary/10 text-primary"
/>
<StatCard
label="Delivered"
value={broadcast.delivered_count}
total={broadcast.total_recipients}
icon={<CheckCheck className="h-4 w-4" />}
color="bg-teal-500/10 text-teal-400"
/>
<StatCard
label="Read"
value={broadcast.read_count}
total={broadcast.total_recipients}
icon={<Eye className="h-4 w-4" />}
color="bg-blue-500/10 text-blue-400"
/>
<StatCard
label="Replied"
value={broadcast.replied_count}
total={broadcast.total_recipients}
icon={<MessageCircle className="h-4 w-4" />}
color="bg-indigo-500/10 text-indigo-400"
/>
<StatCard
label="Failed"
value={broadcast.failed_count}
total={broadcast.total_recipients}
icon={<AlertCircle className="h-4 w-4" />}
color="bg-red-500/10 text-red-400"
/>
</div>
<FunnelChart steps={funnelSteps} />
{/* Recipients Table */}
<div className="rounded-xl border border-border bg-card">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
<h2 className="text-sm font-medium text-foreground">
Recipients ({filteredRecipients.length}
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
</h2>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="border-border text-muted-foreground hover:bg-muted"
/>
}
>
<Filter className="h-3.5 w-3.5" />
{statusFilter === 'all'
? 'All statuses'
: getRecipientStatus(statusFilter).label}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent className="border-border bg-popover">
<DropdownMenuItem
onClick={() => setStatusFilter('all')}
className={
statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground'
}
>
All statuses
</DropdownMenuItem>
{RECIPIENT_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onClick={() => setStatusFilter(s)}
className={
statusFilter === s
? 'text-primary'
: 'text-popover-foreground'
}
>
{getRecipientStatus(s).label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={recipients.length === 0}
className="border-border text-muted-foreground hover:bg-muted"
>
<Download className="h-3.5 w-3.5" />
Export CSV
</Button>
</div>
</div>
{filteredRecipients.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{recipients.length === 0
? 'No recipients found.'
: 'No recipients match this filter.'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="text-muted-foreground">Contact</TableHead>
<TableHead className="text-muted-foreground">Phone</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead>
<TableHead className="text-muted-foreground">Sent</TableHead>
<TableHead className="text-muted-foreground">Delivered</TableHead>
<TableHead className="text-muted-foreground">Read</TableHead>
<TableHead className="text-muted-foreground">Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRecipients.map((recipient) => {
const rStatus = getRecipientStatus(recipient.status);
return (
<TableRow key={recipient.id} className="border-border">
<TableCell className="font-medium text-foreground">
{recipient.contact?.name ?? 'Unknown'}
</TableCell>
<TableCell className="text-muted-foreground">
{recipient.contact?.phone ?? '-'}
</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
>
{rStatus.label}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{recipient.sent_at
? new Date(recipient.sent_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="text-muted-foreground">
{recipient.delivered_at
? new Date(recipient.delivered_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="text-muted-foreground">
{recipient.read_at
? new Date(recipient.read_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="max-w-xs truncate text-xs text-red-400">
{recipient.error_message ?? '-'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,233 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { toast } from 'sonner';
import { MessageTemplate } from '@/types';
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience';
import { Step3Personalize } from '@/components/broadcasts/step3-personalize';
import { Step4ScheduleSend } from '@/components/broadcasts/step4-schedule-send';
import { useBroadcastSending } from '@/hooks/use-broadcast-sending';
import { Check } from 'lucide-react';
const steps = [
{ label: 'Template', key: 'template' },
{ label: 'Audience', key: 'audience' },
{ label: 'Personalize', key: 'personalize' },
{ label: 'Send', key: 'send' },
] as const;
export default function NewBroadcastPage() {
const router = useRouter();
const { accountId } = useAuth();
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
const [currentStep, setCurrentStep] = useState(0);
const [template, setTemplate] = useState<MessageTemplate | null>(null);
const [audience, setAudience] = useState<{
type: 'all' | 'tags' | 'custom_field' | 'csv';
tagIds?: string[];
customField?: {
fieldId: string;
operator: 'is' | 'is_not' | 'contains';
value: string;
};
csvContacts?: { phone: string; name?: string }[];
excludeTagIds?: string[];
}>({ type: 'all' });
const [variables, setVariables] = useState<
Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }>
>({});
const [headerMediaUrl, setHeaderMediaUrl] = useState('');
const [name, setName] = useState('');
async function handleSend() {
if (!template) return;
try {
const broadcastId = await createAndSendBroadcast({
name,
template,
audience: {
type: audience.type,
tagIds: audience.tagIds,
customField: audience.customField,
csvContacts: audience.csvContacts,
excludeTagIds: audience.excludeTagIds,
},
variables,
headerMediaUrl,
});
router.push(`/broadcasts/${broadcastId}`);
} catch (err) {
// Previously swallowed with console.error — the wizard would
// just no-op, leaving the user confused. Surface the reason.
const message = err instanceof Error ? err.message : 'Broadcast failed';
console.error('Broadcast failed:', err);
toast.error(message);
}
}
/**
* Writes a draft broadcast row — no recipients, no sending. The user
* can revisit it via the list page to finish the flow later. We
* don't persist the in-progress audience/variable config here
* because the current schema doesn't carry it past `audience_filter`
* and `template_variables`; those are enough for the user to
* recognize the draft but not to exactly round-trip into the wizard.
* A full resume-draft UX is a future polish.
*/
async function handleSaveDraft() {
if (!template || !name.trim()) {
toast.error('Give the broadcast a name before saving a draft.');
return;
}
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) {
toast.error('Not signed in.');
return;
}
if (!accountId) {
toast.error('Your profile is not linked to an account.');
return;
}
const { error } = await supabase.from('broadcasts').insert({
user_id: user.id,
account_id: accountId,
name: name.trim(),
template_name: template.name,
template_language: template.language ?? 'en_US',
template_variables: variables,
audience_filter: {
type: audience.type,
tagIds: audience.tagIds,
},
status: 'draft',
total_recipients: 0,
sent_count: 0,
delivered_count: 0,
read_count: 0,
replied_count: 0,
failed_count: 0,
});
if (error) {
toast.error(`Failed to save draft: ${error.message}`);
return;
}
toast.success('Draft saved');
router.push('/broadcasts');
}
return (
<div className="mx-auto max-w-3xl space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground">New Broadcast</h1>
<p className="mt-1 text-sm text-muted-foreground">
Create and send a broadcast message to your contacts.
</p>
</div>
{/* Step Indicator */}
<div className="flex items-center justify-between">
{steps.map((step, index) => {
const isActive = index === currentStep;
const isCompleted = index < currentStep;
return (
<div key={step.key} className="flex flex-1 items-center">
<div className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium transition-all ${
isCompleted
? 'bg-primary text-primary-foreground'
: isActive
? 'border-2 border-primary bg-primary/10 text-primary'
: 'border border-border bg-muted text-muted-foreground'
}`}
>
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
</div>
<span
className={`hidden text-sm font-medium sm:block ${
isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground'
}`}
>
{step.label}
</span>
</div>
{index < steps.length - 1 && (
<div
className={`mx-3 h-px flex-1 ${
index < currentStep ? 'bg-primary' : 'bg-muted'
}`}
/>
)}
</div>
);
})}
</div>
{/* Step Content */}
<div className="relative min-h-[400px]">
<div
className="transition-all duration-300 ease-in-out"
style={{
opacity: isProcessing ? 0.6 : 1,
pointerEvents: isProcessing ? 'none' : 'auto',
}}
>
{currentStep === 0 && (
<Step1ChooseTemplate
selectedTemplate={template}
onSelect={setTemplate}
onNext={() => setCurrentStep(1)}
onBack={() => router.push('/broadcasts')}
/>
)}
{currentStep === 1 && (
<Step2SelectAudience
audience={audience}
onUpdate={setAudience}
onNext={() => setCurrentStep(2)}
onBack={() => setCurrentStep(0)}
/>
)}
{currentStep === 2 && template && (
<Step3Personalize
template={template}
variables={variables}
onUpdate={setVariables}
headerMediaUrl={headerMediaUrl}
onHeaderMediaUrlChange={setHeaderMediaUrl}
onNext={() => setCurrentStep(3)}
onBack={() => setCurrentStep(1)}
/>
)}
{currentStep === 3 && template && (
<Step4ScheduleSend
name={name}
onNameChange={setName}
template={template}
audience={audience}
onSend={handleSend}
onSaveDraft={handleSaveDraft}
onBack={() => setCurrentStep(2)}
isProcessing={isProcessing}
progress={progress}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,288 @@
'use client';
import { useEffect, useState, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Broadcast } from '@/types';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Radio, Plus, Loader2 } from 'lucide-react';
import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button';
import { getBroadcastStatus } from '@/lib/broadcast-status';
/**
* Poll cadence while any broadcast is sending. Kept modest so we don't
* beat on Supabase — the aggregate trigger in migration 003 keeps
* counts consistent; we just need to surface the freshest snapshot.
*/
const POLL_INTERVAL_MS = 5_000;
function percent(numerator: number, denominator: number): number {
if (!denominator) return 0;
return Math.round((numerator / denominator) * 100);
}
function RateCell({
value,
total,
color,
}: {
value: number;
total: number;
/** Tailwind bg class for the fill, e.g. "bg-primary" */
color: string;
}) {
const pct = percent(value, total);
return (
<div className="flex items-center gap-2">
<span className="w-10 text-right text-xs tabular-nums text-muted-foreground">
{pct}%
</span>
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
<div
className={`h-1.5 rounded-full ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
export default function BroadcastsPage() {
const router = useRouter();
const canCreate = useCan('send-messages');
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Used to kick off polling only while something is actively sending.
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
async function fetchBroadcasts() {
try {
const supabase = createClient();
const { data, error: fetchError } = await supabase
.from('broadcasts')
.select('*')
.order('created_at', { ascending: false });
if (fetchError) throw fetchError;
setBroadcasts(data ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load broadcasts');
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchBroadcasts();
}, []);
const anySending = useMemo(
() => broadcasts.some((b) => b.status === 'sending'),
[broadcasts],
);
useEffect(() => {
function startPolling() {
if (pollTimer.current) return;
pollTimer.current = setInterval(fetchBroadcasts, POLL_INTERVAL_MS);
}
function stopPolling() {
if (!pollTimer.current) return;
clearInterval(pollTimer.current);
pollTimer.current = null;
}
// Pause polling while the tab is hidden — keeps Supabase cold when
// the user is away, and ensures a fresh fetch the moment they
// refocus so they don't see stale data on return.
function handleVisibilityChange() {
if (!anySending) return;
if (document.visibilityState === 'hidden') {
stopPolling();
} else {
fetchBroadcasts();
startPolling();
}
}
if (anySending && document.visibilityState === 'visible') {
startPolling();
} else {
stopPolling();
}
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [anySending]);
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Top indeterminate progress bar: only visible while a broadcast
is mid-send. Pure CSS animation so no extra deps. */}
{anySending && (
<div
role="progressbar"
aria-label="Broadcast in progress"
className="broadcast-indeterminate fixed inset-x-0 top-0 z-40 h-0.5 overflow-hidden bg-muted"
>
<div className="broadcast-indeterminate-bar h-0.5 bg-primary" />
<style jsx>{`
.broadcast-indeterminate-bar {
width: 33%;
transform: translateX(-100%);
animation: broadcast-slide 1.6s cubic-bezier(0.4, 0, 0.2, 1)
infinite;
}
@keyframes broadcast-slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
`}</style>
</div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Broadcasts</h1>
<p className="mt-1 text-sm text-muted-foreground">
Send bulk messages to your contacts using approved templates.
</p>
</div>
<GatedButton
canAct={canCreate}
gateReason="create broadcasts"
onClick={() => router.push('/broadcasts/new')}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
New Broadcast
</GatedButton>
</div>
{broadcasts.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-border bg-card">
<Radio className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">No broadcasts yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Create your first broadcast to reach your contacts at scale.
</p>
<GatedButton
canAct={canCreate}
gateReason="create broadcasts"
onClick={() => router.push('/broadcasts/new')}
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
New Broadcast
</GatedButton>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-border bg-card">
<Table>
<TableHeader>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="text-muted-foreground">Name</TableHead>
<TableHead className="hidden text-muted-foreground md:table-cell">Template</TableHead>
<TableHead className="hidden text-right text-muted-foreground sm:table-cell">
Recipients
</TableHead>
<TableHead className="hidden text-muted-foreground lg:table-cell">Delivery</TableHead>
<TableHead className="hidden text-muted-foreground lg:table-cell">Read</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead>
<TableHead className="hidden text-muted-foreground sm:table-cell">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{broadcasts.map((broadcast) => {
const status = getBroadcastStatus(broadcast.status);
return (
<TableRow
key={broadcast.id}
className="cursor-pointer border-border hover:bg-muted/50"
onClick={() => router.push(`/broadcasts/${broadcast.id}`)}
>
<TableCell className="font-medium text-foreground">
{broadcast.name}
</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">
{broadcast.template_name}
</TableCell>
<TableCell className="hidden text-right text-muted-foreground tabular-nums sm:table-cell">
{broadcast.total_recipients}
</TableCell>
<TableCell className="hidden lg:table-cell">
<RateCell
value={broadcast.delivered_count}
total={broadcast.total_recipients}
color="bg-primary"
/>
</TableCell>
<TableCell className="hidden lg:table-cell">
<RateCell
value={broadcast.read_count}
total={broadcast.total_recipients}
color="bg-blue-500"
/>
</TableCell>
<TableCell>
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
>
{status.pulse && (
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-yellow-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-yellow-400" />
</span>
)}
{status.label}
</span>
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">
{new Date(broadcast.created_at).toLocaleDateString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,836 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { createClient } from '@/lib/supabase/client';
import { toast } from 'sonner';
import type { Contact, Tag, ContactTag } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Search,
Plus,
Upload,
MoreHorizontal,
Pencil,
Trash2,
Loader2,
Users,
ChevronLeft,
ChevronRight,
SlidersHorizontal,
Filter,
X,
} from 'lucide-react';
import { ContactForm } from '@/components/contacts/contact-form';
import { ContactDetailView } from '@/components/contacts/contact-detail-view';
import { ImportModal } from '@/components/contacts/import-modal';
import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager';
import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button';
import { Checkbox } from '@/components/ui/checkbox';
const PAGE_SIZE = 25;
interface ContactWithTags extends Contact {
tags?: Tag[];
}
export default function ContactsPage() {
const supabase = createClient();
const canEdit = useCan('send-messages');
const canEditSettings = useCan('edit-settings');
const [contacts, setContacts] = useState<ContactWithTags[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const [totalCount, setTotalCount] = useState(0);
// Tag filter — contacts shown must have ANY of these tags (OR).
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
// Modals
const [formOpen, setFormOpen] = useState(false);
const [editContact, setEditContact] = useState<Contact | null>(null);
const [editContactTags, setEditContactTags] = useState<ContactTag[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detailContactId, setDetailContactId] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [customFieldsOpen, setCustomFieldsOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
const [deleting, setDeleting] = useState(false);
// Bulk selection (page-scoped — only the loaded rows are selectable)
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
// All tags for display
const [tagsMap, setTagsMap] = useState<Record<string, Tag>>({});
// Guards against out-of-order fetch responses: each fetchContacts run
// claims a sequence number and only the latest is allowed to commit its
// results. Without this, rapidly toggling tag filters could let a slower
// earlier request resolve last and render stale rows.
const fetchSeq = useRef(0);
const fetchTags = useCallback(async () => {
const { data } = await supabase.from('tags').select('*');
if (data) {
const map: Record<string, Tag> = {};
data.forEach((t) => (map[t.id] = t));
setTagsMap(map);
// Drop any filter selections whose tag no longer exists (e.g. a tag
// deleted elsewhere) so it can't linger invisibly in the query.
setSelectedTagIds((prev) => {
const pruned = prev.filter((id) => map[id]);
return pruned.length === prev.length ? prev : pruned;
});
}
}, [supabase]);
const fetchContacts = useCallback(async () => {
const seq = ++fetchSeq.current;
setLoading(true);
// The visible rows are about to change — drop any selection that
// referred to the old page/search results so the bulk bar can't
// act on rows the user can no longer see.
setSelected(new Set());
const from = page * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const term = search.trim();
let contactRows: Contact[];
let count: number;
if (selectedTagIds.length > 0) {
// Tag filter active — resolve it server-side (join + distinct +
// windowed total count + pagination) so a tag covering many
// contacts can't silently truncate the result or overflow an IN
// clause. See migration 025_filter_contacts_by_tags.
const { data, error } = await supabase.rpc('filter_contacts_by_tags', {
p_tag_ids: selectedTagIds,
p_search: term || null,
p_limit: PAGE_SIZE,
p_offset: from,
});
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
if (error) {
toast.error('Failed to load contacts');
setLoading(false);
return;
}
const rows = (data ?? []) as { contact: Contact; total_count: number }[];
contactRows = rows.map((r) => r.contact);
count = rows.length > 0 ? Number(rows[0].total_count) : 0;
} else {
let query = supabase
.from('contacts')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(from, to);
if (term) {
const like = `%${term}%`;
query = query.or(`name.ilike.${like},phone.ilike.${like},email.ilike.${like}`);
}
const { data, count: exactCount, error } = await query;
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
if (error) {
toast.error('Failed to load contacts');
setLoading(false);
return;
}
contactRows = data ?? [];
count = exactCount ?? 0;
}
setTotalCount(count);
if (contactRows.length === 0) {
setContacts([]);
setLoading(false);
return;
}
// Fetch tags for these contacts
const contactIds = contactRows.map((c) => c.id);
const { data: contactTags } = await supabase
.from('contact_tags')
.select('contact_id, tag_id')
.in('contact_id', contactIds);
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
const tagsByContact: Record<string, string[]> = {};
contactTags?.forEach((ct) => {
if (!tagsByContact[ct.contact_id]) tagsByContact[ct.contact_id] = [];
tagsByContact[ct.contact_id].push(ct.tag_id);
});
const enriched: ContactWithTags[] = contactRows.map((c) => ({
...c,
tags: (tagsByContact[c.id] ?? [])
.map((tid) => tagsMap[tid])
.filter(Boolean),
}));
setContacts(enriched);
setLoading(false);
}, [supabase, page, search, selectedTagIds, tagsMap]);
// Load-once-on-mount-ish data fetches. Each setter inside runs
// inside an async promise completion (Supabase await), not
// synchronously in the effect body, so the cascade the lint rule
// warns about doesn't apply here.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchTags();
}, [fetchTags]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchContacts();
}, [fetchContacts]);
function openAddForm() {
setEditContact(null);
setEditContactTags([]);
setFormOpen(true);
}
async function openEditForm(contact: Contact) {
const { data } = await supabase
.from('contact_tags')
.select('*')
.eq('contact_id', contact.id);
setEditContact(contact);
setEditContactTags(data ?? []);
setFormOpen(true);
}
function openDetail(contactId: string) {
setDetailContactId(contactId);
setDetailOpen(true);
}
function confirmDelete(contact: Contact) {
setDeleteTarget(contact);
setDeleteConfirmOpen(true);
}
async function handleDelete() {
if (!deleteTarget) return;
setDeleting(true);
const { error } = await supabase
.from('contacts')
.delete()
.eq('id', deleteTarget.id);
if (error) {
toast.error('Failed to delete contact');
} else {
toast.success('Contact deleted');
fetchContacts();
}
setDeleting(false);
setDeleteConfirmOpen(false);
setDeleteTarget(null);
}
const allOnPageSelected =
contacts.length > 0 && contacts.every((c) => selected.has(c.id));
const someOnPageSelected = contacts.some((c) => selected.has(c.id));
function toggleSelectAll() {
setSelected((prev) => {
const next = new Set(prev);
if (allOnPageSelected) {
contacts.forEach((c) => next.delete(c.id));
} else {
contacts.forEach((c) => next.add(c.id));
}
return next;
});
}
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleBulkDelete() {
const ids = [...selected];
if (ids.length === 0) return;
setDeleting(true);
const { error } = await supabase.from('contacts').delete().in('id', ids);
if (error) {
toast.error('Failed to delete contacts');
} else {
toast.success(`${ids.length} contact${ids.length === 1 ? '' : 's'} deleted`);
setSelected(new Set());
fetchContacts();
}
setDeleting(false);
setBulkDeleteOpen(false);
}
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const hasNext = page < totalPages - 1;
const hasPrev = page > 0;
// Tag filter helpers. Every change resets to page 0 — the result set
// shrinks/grows so page N may no longer be valid (mirrors the search box).
const allTags = Object.values(tagsMap).sort((a, b) =>
a.name.localeCompare(b.name)
);
const hasActiveFilters = search.trim().length > 0 || selectedTagIds.length > 0;
function toggleTagFilter(tagId: string) {
setSelectedTagIds((prev) =>
prev.includes(tagId)
? prev.filter((id) => id !== tagId)
: [...prev, tagId]
);
setPage(0);
}
function clearTagFilters() {
setSelectedTagIds([]);
setPage(0);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground">Contacts</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`}
</p>
</div>
<div className="flex items-center gap-2">
{canEditSettings && (
<Button
variant="outline"
onClick={() => setCustomFieldsOpen(true)}
className="border-border text-muted-foreground hover:bg-muted"
>
<SlidersHorizontal className="size-4" />
Custom fields
</Button>
)}
<GatedButton
variant="outline"
canAct={canEdit}
gateReason="add or import contacts"
onClick={() => setImportOpen(true)}
className="border-border text-muted-foreground hover:bg-muted"
>
<Upload className="size-4" />
Import
</GatedButton>
<GatedButton
canAct={canEdit}
gateReason="add or import contacts"
onClick={openAddForm}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Plus className="size-4" />
Add Contact
</GatedButton>
</div>
</div>
{/* Search + tag filter */}
<div className="space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative w-full max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
// Reset pagination when the query changes — the result
// set shrinks/grows, page N may no longer be valid.
setPage(0);
}}
placeholder="Search by name, phone, or email..."
className="pl-8 bg-card border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
className="border-border text-muted-foreground hover:bg-muted shrink-0"
/>
}
>
<Filter className="size-4" />
Filter by tags
{selectedTagIds.length > 0 && (
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground">
{selectedTagIds.length}
</span>
)}
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-sm font-medium text-popover-foreground">
Filter by tags
</span>
{selectedTagIds.length > 0 && (
<button
onClick={clearTagFilters}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear all
</button>
)}
</div>
{allTags.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground text-center">
No tags yet.
</p>
) : (
<div className="max-h-64 overflow-y-auto py-1">
{allTags.map((tag) => (
<label
key={tag.id}
className="flex items-center gap-2.5 px-3 py-1.5 cursor-pointer hover:bg-muted/50"
>
<Checkbox
checked={selectedTagIds.includes(tag.id)}
onCheckedChange={() => toggleTagFilter(tag.id)}
aria-label={`Filter by ${tag.name}`}
/>
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: tag.color }}
/>
<span className="text-sm text-popover-foreground truncate">
{tag.name}
</span>
</label>
))}
</div>
)}
</PopoverContent>
</Popover>
</div>
{/* Active tag-filter chips */}
{selectedTagIds.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{selectedTagIds.map((id) => {
const tag = tagsMap[id];
if (!tag) return null;
return (
<span
key={id}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium"
style={{
backgroundColor: tag.color + '20',
color: tag.color,
}}
>
{tag.name}
<button
onClick={() => toggleTagFilter(id)}
aria-label={`Remove ${tag.name} filter`}
className="hover:opacity-70"
>
<X className="size-3" />
</button>
</span>
);
})}
<button
onClick={clearTagFilters}
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Clear all
</button>
</div>
)}
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/40 px-4 py-2">
<p className="text-sm text-foreground">
<span className="font-medium">{selected.size}</span>{' '}
{selected.size === 1 ? 'contact' : 'contacts'} selected
</p>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(new Set())}
className="text-muted-foreground hover:text-foreground"
>
Clear
</Button>
<GatedButton
variant="destructive"
size="sm"
canAct={canEdit}
gateReason="delete contacts"
onClick={() => setBulkDeleteOpen(true)}
>
<Trash2 className="size-4" />
Delete selected
</GatedButton>
</div>
</div>
)}
{/* Table */}
<div className="rounded-lg border border-border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="w-10">
<Checkbox
checked={allOnPageSelected}
indeterminate={!allOnPageSelected && someOnPageSelected}
onCheckedChange={toggleSelectAll}
disabled={contacts.length === 0}
aria-label="Select all contacts on this page"
/>
</TableHead>
<TableHead className="text-muted-foreground">Name</TableHead>
<TableHead className="text-muted-foreground">Phone</TableHead>
<TableHead className="text-muted-foreground hidden md:table-cell">Email</TableHead>
<TableHead className="text-muted-foreground hidden lg:table-cell">Company</TableHead>
<TableHead className="text-muted-foreground hidden md:table-cell">Tags</TableHead>
<TableHead className="text-muted-foreground hidden lg:table-cell">Created</TableHead>
<TableHead className="text-muted-foreground w-12" />
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="border-border">
<TableCell colSpan={8} className="text-center py-12">
<div className="flex flex-col items-center gap-2">
<Loader2 className="size-6 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading contacts...</p>
</div>
</TableCell>
</TableRow>
) : contacts.length === 0 ? (
<TableRow className="border-border">
<TableCell colSpan={8} className="text-center py-12">
<div className="flex flex-col items-center gap-2">
<Users className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{hasActiveFilters
? 'No contacts match your filters.'
: 'No contacts yet.'}
</p>
{!hasActiveFilters && (
<GatedButton
canAct={canEdit}
gateReason="add or import contacts"
variant="outline"
size="sm"
onClick={openAddForm}
className="mt-2 border-border text-muted-foreground hover:bg-muted"
>
<Plus className="size-3.5" />
Add your first contact
</GatedButton>
)}
</div>
</TableCell>
</TableRow>
) : (
contacts.map((contact) => (
<TableRow
key={contact.id}
className="border-border hover:bg-muted/50 cursor-pointer"
onClick={() => openDetail(contact.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.has(contact.id)}
onCheckedChange={() => toggleSelect(contact.id)}
aria-label={`Select ${contact.name || contact.phone}`}
/>
</TableCell>
<TableCell className="text-foreground font-medium">
{contact.name || <span className="text-muted-foreground italic">Unnamed</span>}
</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">
{contact.phone}
</TableCell>
<TableCell className="text-muted-foreground hidden md:table-cell text-sm">
{contact.email || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="text-muted-foreground hidden lg:table-cell text-sm">
{contact.company || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{contact.tags && contact.tags.length > 0 ? (
contact.tags.slice(0, 3).map((tag) => (
<span
key={tag.id}
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: tag.color + '20',
color: tag.color,
}}
>
{tag.name}
</span>
))
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
{contact.tags && contact.tags.length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{contact.tags.length - 3}
</span>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground text-xs hidden lg:table-cell">
{new Date(contact.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
/>
}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-popover border-border"
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
openEditForm(contact);
}}
className="text-popover-foreground focus:bg-muted focus:text-foreground"
>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
e.stopPropagation();
confirmDelete(contact);
}}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '}
{totalCount}
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
disabled={!hasPrev}
onClick={() => setPage((p) => p - 1)}
className="border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs text-muted-foreground px-2">
Page {page + 1} of {totalPages}
</span>
<Button
variant="outline"
size="icon-sm"
disabled={!hasNext}
onClick={() => setPage((p) => p + 1)}
className="border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
{/* Contact Form Dialog */}
<ContactForm
open={formOpen}
onOpenChange={setFormOpen}
contact={editContact}
contactTags={editContactTags}
onSaved={() => {
fetchContacts();
fetchTags();
}}
onViewExisting={(id) => {
setFormOpen(false);
openDetail(id);
}}
/>
{/* Contact Detail Sheet */}
<ContactDetailView
open={detailOpen}
onOpenChange={setDetailOpen}
contactId={detailContactId}
onUpdated={fetchContacts}
/>
{/* Import Modal */}
<ImportModal
open={importOpen}
onOpenChange={setImportOpen}
onImported={fetchContacts}
/>
{/* Custom Fields Manager (admin+) */}
{canEditSettings && (
<CustomFieldsManager
open={customFieldsOpen}
onOpenChange={setCustomFieldsOpen}
/>
)}
{/* Delete Confirmation */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Delete Contact</DialogTitle>
<DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '}
<span className="text-popover-foreground font-medium">
{deleteTarget?.name || deleteTarget?.phone}
</span>
? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setDeleteConfirmOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting && <Loader2 className="size-4 animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk Delete Confirmation */}
<Dialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-popover-foreground">
Delete {selected.size} {selected.size === 1 ? 'Contact' : 'Contacts'}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '}
<span className="text-popover-foreground font-medium">
{selected.size} {selected.size === 1 ? 'contact' : 'contacts'}
</span>
? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setBulkDeleteOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleBulkDelete}
disabled={deleting}
>
{deleting && <Loader2 className="size-4 animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { AuthProvider, useAuth } from "@/hooks/use-auth";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { PresenceHeartbeat } from "@/components/presence/presence-heartbeat";
// Auth-gated dashboard shell. Extracted from the layout so the layout
// itself can stay a server component and export metadata (noindex) —
// client components can't export Next's metadata object.
function DashboardShellInner({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
// Sidebar drawer state — only used on mobile. On lg+ the sidebar is
// always visible and this stays at `false` (ignored by the component).
const [sidebarOpen, setSidebarOpen] = useState(false);
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
useEffect(() => {
if (!loading && !user) {
router.push("/login");
}
}, [user, loading, router]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
if (!user) return null;
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Reports this tab's online/away presence once we know a user is
signed in. Headless — renders nothing. */}
<PresenceHeartbeat />
<Sidebar open={sidebarOpen} onClose={closeSidebar} />
<div className="flex flex-1 flex-col overflow-hidden">
<Header onOpenSidebar={() => setSidebarOpen(true)} />
{/* Thinner horizontal padding on mobile so cards have room to breathe. */}
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div>
</div>
);
}
export function DashboardShell({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<DashboardShellInner>{children}</DashboardShellInner>
</AuthProvider>
);
}

View File

@@ -0,0 +1,225 @@
"use client"
import { useCallback, useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useAuth } from '@/hooks/use-auth'
import { formatCurrency } from '@/lib/currency'
import {
MessageSquare,
UserPlus,
DollarSign,
Send,
} from 'lucide-react'
import {
loadActivity,
loadConversationsSeries,
loadMetrics,
loadPipelineDonut,
loadResponseTime,
} from '@/lib/dashboard/queries'
import type {
ActivityItem,
ConversationsSeriesPoint,
MetricsBundle,
PipelineDonutData,
ResponseTimeSummary,
} from '@/lib/dashboard/types'
import { MetricCard } from '@/components/dashboard/metric-card'
import { SkeletonCard } from '@/components/dashboard/skeleton'
import { QuickActions } from '@/components/dashboard/quick-actions'
import { ConversationsChart } from '@/components/dashboard/conversations-chart'
import { PipelineDonut } from '@/components/dashboard/pipeline-donut'
import { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
import { ActivityFeed } from '@/components/dashboard/activity-feed'
type RangeDays = 7 | 30 | 90
export default function DashboardPage() {
const { defaultCurrency } = useAuth()
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
const [metricsLoading, setMetricsLoading] = useState(true)
const [range, setRange] = useState<RangeDays>(30)
// Keep a cache per range so switching tabs doesn't re-fetch what we
// already have. Ranges the user hasn't opened yet stay null and
// trigger a fetch on first view.
const [series, setSeries] = useState<Record<RangeDays, ConversationsSeriesPoint[] | null>>({
7: null,
30: null,
90: null,
})
const [seriesLoading, setSeriesLoading] = useState(true)
const [pipeline, setPipeline] = useState<PipelineDonutData | null>(null)
const [pipelineLoading, setPipelineLoading] = useState(true)
const [responseTime, setResponseTime] = useState<ResponseTimeSummary | null>(null)
const [responseTimeLoading, setResponseTimeLoading] = useState(true)
const [activity, setActivity] = useState<ActivityItem[] | null>(null)
const [activityLoading, setActivityLoading] = useState(true)
const loadAll = useCallback(() => {
const db = createClient()
// Kick everything off in parallel. Each block has its own
// setState + finally so a slow query doesn't hold up faster
// sections — each widget shows its own skeleton independently.
void loadMetrics(db)
.then((m) => setMetrics(m))
.catch((err) => console.error('[dashboard] metrics failed:', err))
.finally(() => setMetricsLoading(false))
void loadConversationsSeries(db, 30)
.then((s) => setSeries((prev) => ({ ...prev, 30: s })))
.catch((err) => console.error('[dashboard] series failed:', err))
.finally(() => setSeriesLoading(false))
void loadPipelineDonut(db)
.then((p) => setPipeline(p))
.catch((err) => console.error('[dashboard] pipeline failed:', err))
.finally(() => setPipelineLoading(false))
void loadResponseTime(db)
.then((r) => setResponseTime(r))
.catch((err) => console.error('[dashboard] response time failed:', err))
.finally(() => setResponseTimeLoading(false))
// Fetch up to 50 so the biggest page-size option in the feed
// (50 rows) is already in memory — switching sizes then becomes
// a pure client-side slice with no extra round trip.
void loadActivity(db, 50)
.then((a) => setActivity(a))
.catch((err) => console.error('[dashboard] activity failed:', err))
.finally(() => setActivityLoading(false))
}, [])
useEffect(() => {
loadAll()
}, [loadAll])
// Range switch handler — kept in an event callback (not an effect)
// so the setState calls stay out of the react-hooks/set-state-in-effect
// rule's way. The cached bucket check means switching back to a
// previously-viewed range is instant and doesn't re-fetch.
const handleRangeChange = useCallback(
(r: RangeDays) => {
setRange(r)
if (series[r] !== null) return
setSeriesLoading(true)
const db = createClient()
loadConversationsSeries(db, r)
.then((s) => setSeries((prev) => ({ ...prev, [r]: s })))
.catch((err) => console.error('[dashboard] series failed:', err))
.finally(() => setSeriesLoading(false))
},
[series],
)
return (
<div className="space-y-5">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
<p className="mt-1 text-sm text-muted-foreground">
Live analytics across conversations, contacts, deals, broadcasts, and automations.
</p>
</div>
{/* Metric cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{metricsLoading || !metrics ? (
Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
) : (
<>
<MetricCard
title="Active Conversations"
value={metrics.activeConversations.current.toLocaleString()}
icon={MessageSquare}
delta={{
sign: metrics.activeConversations.previous,
label: deltaLabel(metrics.activeConversations.previous, 'new today vs yesterday'),
}}
/>
<MetricCard
title="New Contacts Today"
value={metrics.newContactsToday.current.toLocaleString()}
icon={UserPlus}
delta={{
sign:
metrics.newContactsToday.current - metrics.newContactsToday.previous,
label: deltaLabel(
metrics.newContactsToday.current - metrics.newContactsToday.previous,
'vs yesterday',
),
}}
/>
<MetricCard
title="Open Deals Value"
value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
icon={DollarSign}
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
/>
<MetricCard
title="Messages Sent Today"
value={metrics.messagesSentToday.current.toLocaleString()}
icon={Send}
delta={{
sign:
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
label: deltaLabel(
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
'vs yesterday',
),
}}
/>
</>
)}
</div>
{/* Quick actions */}
<QuickActions />
{/* Charts row */}
{/* items-stretch (the grid default) stretches the two columns to
match the tallest sibling; adding h-full on each wrapper and
on the inner panels makes both cards actually fill that
stretched height so their rounded borders line up. Without
this, the pipeline card rendered at its natural (shorter)
height while the line chart drove the row height. */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div className="h-full lg:col-span-3">
<ConversationsChart
series={series}
loading={seriesLoading}
range={range}
onRangeChange={handleRangeChange}
/>
</div>
<div className="h-full lg:col-span-2">
<PipelineDonut
data={pipeline}
loading={pipelineLoading}
currency={defaultCurrency}
/>
</div>
</div>
{/* Response time */}
<ResponseTimeChart data={responseTime} loading={responseTimeLoading} />
{/* Activity feed */}
<ActivityFeed items={activity} loading={activityLoading} />
</div>
)
}
// ------------------------------------------------------------
function deltaLabel(delta: number, suffix: string): string {
if (delta === 0) return `No change ${suffix}`
const sign = delta > 0 ? '+' : ''
return `${sign}${delta.toLocaleString()} ${suffix}`
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { FlowEditorShell } from "@/components/flows/flow-editor-shell";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
/**
* Flow editor shell.
*
* Loads `{flow, nodes}` from `/api/flows/[id]` and hands it to
* `<FlowBuilder>`. Owns the loading/error state so the builder can
* focus purely on editing.
*
* Open to every authenticated user — the beta gate that previously
* 404'd non-beta accounts was removed in PR #134. The API still
* 404s on a flow id the caller doesn't own (RLS), which becomes the
* "Flow not found" state below.
*/
export default function FlowEditorPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const [flow, setFlow] = useState<FlowRow | null>(null);
const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!params.id) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/flows/${params.id}`);
if (res.status === 404) {
if (!cancelled) setNotFound(true);
return;
}
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json = (await res.json()) as {
flow: FlowRow;
nodes: FlowNodeRow[];
};
if (!cancelled) {
setFlow(json.flow);
setNodes(json.nodes ?? []);
}
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load flow.");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [params.id]);
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (notFound || !flow) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-muted-foreground">Flow not found.</p>
<button
type="button"
onClick={() => router.push("/flows")}
className="text-sm text-primary hover:opacity-80"
>
Back to flows
</button>
</div>
);
}
return <FlowEditorShell initialFlow={flow} initialNodes={nodes} />;
}

View File

@@ -0,0 +1,340 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import {
ArrowLeft,
Loader2,
CircleCheck,
CircleAlert,
Clock,
UserPlus,
PlayCircle,
PauseCircle,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { toast } from "sonner";
import { format, formatDistanceToNow } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/**
* Run history viewer.
*
* Lists the 50 most recent runs for a flow, newest first. Each row
* collapses to a one-liner (contact + status + time); expanding shows
* the full `flow_run_events` timeline for that run — useful for
* debugging "why didn't my flow advance?" by surfacing the engine's
* own log.
*/
interface RunRow {
id: string;
status:
| "active"
| "completed"
| "handed_off"
| "timed_out"
| "paused_by_agent"
| "failed";
current_node_key: string | null;
started_at: string;
last_advanced_at: string;
ended_at: string | null;
end_reason: string | null;
vars: Record<string, unknown>;
reprompt_count: number;
contact: { id: string; name: string | null; phone: string } | null;
}
interface EventRow {
flow_run_id: string;
event_type: string;
node_key: string | null;
payload: Record<string, unknown>;
created_at: string;
}
const STATUS_META: Record<
RunRow["status"],
{ label: string; classes: string; icon: typeof Clock }
> = {
active: {
label: "Active",
classes: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
icon: PlayCircle,
},
completed: {
label: "Completed",
classes: "border-border bg-muted text-muted-foreground",
icon: CircleCheck,
},
handed_off: {
label: "Handed off",
classes: "border-amber-600/40 bg-amber-500/10 text-amber-300",
icon: UserPlus,
},
timed_out: {
label: "Timed out",
classes: "border-border bg-muted/60 text-muted-foreground",
icon: Clock,
},
paused_by_agent: {
label: "Paused by agent",
classes: "border-border bg-muted text-muted-foreground",
icon: PauseCircle,
},
failed: {
label: "Failed",
classes: "border-red-600/40 bg-red-500/10 text-red-300",
icon: CircleAlert,
},
};
export default function FlowRunsPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
const [runs, setRuns] = useState<RunRow[]>([]);
const [events, setEvents] = useState<EventRow[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!params.id) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/flows/${params.id}/runs`);
if (res.status === 404) {
if (!cancelled) setNotFound(true);
return;
}
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json = (await res.json()) as {
flow: { id: string; name: string };
runs: RunRow[];
events: EventRow[];
};
if (!cancelled) {
setFlow(json.flow);
setRuns(json.runs ?? []);
setEvents(json.events ?? []);
}
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load runs.");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [params.id]);
function toggle(runId: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(runId)) next.delete(runId);
else next.add(runId);
return next;
});
}
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (notFound || !flow) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-muted-foreground">Flow not found.</p>
<button
type="button"
onClick={() => router.push("/flows")}
className="text-sm text-primary hover:opacity-80"
>
Back to flows
</button>
</div>
);
}
return (
<div className="mx-auto max-w-4xl p-6">
<button
type="button"
onClick={() => router.push(`/flows/${flow.id}`)}
className="mb-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
{flow.name}
</button>
<h1 className="text-xl font-semibold text-foreground">Runs</h1>
<p className="mt-1 text-sm text-muted-foreground">
The 50 most recent times this flow ran. Expand a row to see the engine&apos;s
per-step log.
</p>
{runs.length === 0 ? (
<div className="mt-6 rounded-lg border border-dashed border-border bg-card/50 px-6 py-12 text-center text-sm text-muted-foreground">
No runs yet. Trigger the flow from a personal WhatsApp number to see
it appear here.
</div>
) : (
<div className="mt-6 flex flex-col gap-2">
{runs.map((run) => (
<RunCard
key={run.id}
run={run}
events={events.filter((e) => e.flow_run_id === run.id)}
expanded={expanded.has(run.id)}
onToggle={() => toggle(run.id)}
/>
))}
</div>
)}
</div>
);
}
function RunCard({
run,
events,
expanded,
onToggle,
}: {
run: RunRow;
events: EventRow[];
expanded: boolean;
onToggle: () => void;
}) {
const meta = STATUS_META[run.status];
const StatusIcon = meta.icon;
const contactLabel =
run.contact?.name?.trim() || run.contact?.phone || "Unknown contact";
const duration = run.ended_at
? formatDistanceToNow(new Date(run.ended_at), {
addSuffix: false,
})
: null;
return (
<div className="rounded-lg border border-border bg-card">
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
{expanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{contactLabel}
</span>
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
<StatusIcon className="h-3 w-3" />
{meta.label}
</Badge>
{run.status === "active" && run.current_node_key && (
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
at {run.current_node_key}
</code>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span>Started {format(new Date(run.started_at), "PP p")}</span>
{run.reprompt_count > 0 && (
<span>· {run.reprompt_count} re-prompts</span>
)}
{duration && <span>· ran for {duration}</span>}
</div>
</div>
</button>
{expanded && (
<div className="border-t border-border px-4 py-3">
{Object.keys(run.vars).length > 0 && (
<details className="mb-3">
<summary className="cursor-pointer text-xs text-muted-foreground">
Captured vars ({Object.keys(run.vars).length})
</summary>
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
{JSON.stringify(run.vars, null, 2)}
</pre>
</details>
)}
<div className="flex flex-col gap-1">
{events.length === 0 ? (
<p className="text-xs text-muted-foreground">
No events recorded for this run.
</p>
) : (
events.map((ev, ix) => <EventLine key={ix} ev={ev} />)
)}
</div>
</div>
)}
</div>
);
}
const EVENT_COLOR: Record<string, string> = {
started: "text-emerald-300",
node_entered: "text-muted-foreground",
message_sent: "text-sky-300",
reply_received: "text-primary",
fallback_fired: "text-amber-300",
handoff: "text-amber-300",
timeout: "text-muted-foreground",
error: "text-red-300",
completed: "text-emerald-300",
};
function EventLine({ ev }: { ev: EventRow }) {
const cls = EVENT_COLOR[ev.event_type] ?? "text-muted-foreground";
return (
<div className="flex items-start gap-2 rounded-md px-2 py-1 text-xs">
<span className="w-32 shrink-0 text-[10px] text-muted-foreground">
{format(new Date(ev.created_at), "HH:mm:ss")}
</span>
<span className={cn("w-32 shrink-0 font-mono text-[10px]", cls)}>
{ev.event_type}
</span>
{ev.node_key && (
<code className="shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
{ev.node_key}
</code>
)}
{Object.keys(ev.payload).length > 0 && (
<span className="min-w-0 truncate text-[10px] text-muted-foreground">
{summarizePayload(ev.payload)}
</span>
)}
</div>
);
}
function summarizePayload(payload: Record<string, unknown>): string {
// Show the keys that matter most to a human debugger; full JSON is
// available via the "Captured vars" details panel for the run.
const keys = ["reply_id", "captured_key", "reason", "advancing_to"];
for (const k of keys) {
if (k in payload && payload[k] !== null && payload[k] !== undefined) {
return `${k}=${String(payload[k]).slice(0, 80)}`;
}
}
return "";
}

View File

@@ -0,0 +1,437 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
Workflow,
Plus,
Trash2,
Pencil,
Loader2,
MessageSquare,
PlayCircle,
PauseCircle,
Archive,
HelpCircle,
UserPlus,
FileText,
} from "lucide-react";
import { useCan } from "@/hooks/use-can";
import { Button } from "@/components/ui/button";
import { GatedButton } from "@/components/ui/gated-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/**
* Flows list page.
*
* Open to every authenticated user. Flows is in soft-GA — the "Beta"
* chip in the header is the only remaining signal that the surface
* is new. The previous per-account beta gate was removed in PR #134.
*/
interface FlowRow {
id: string;
name: string;
description: string | null;
status: "draft" | "active" | "archived";
trigger_type: "keyword" | "first_inbound_message" | "manual";
trigger_config: { keywords?: string[] } | Record<string, unknown>;
execution_count: number;
last_executed_at: string | null;
created_at: string;
updated_at: string;
}
const STATUS_LABELS: Record<FlowRow["status"], string> = {
draft: "Draft",
active: "Active",
archived: "Archived",
};
const STATUS_COLORS: Record<FlowRow["status"], string> = {
draft: "border-border bg-muted text-muted-foreground",
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
archived: "border-border bg-muted/50 text-muted-foreground",
};
interface TemplateSummary {
slug: string;
name: string;
description: string;
icon: "MessageSquare" | "HelpCircle" | "UserPlus";
trigger_type: string;
node_count: number;
}
const TEMPLATE_ICONS = {
MessageSquare,
HelpCircle,
UserPlus,
} as const;
export default function FlowsPage() {
const router = useRouter();
const canCreate = useCan("send-messages");
const [flows, setFlows] = useState<FlowRow[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [templates, setTemplates] = useState<TemplateSummary[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [flowsRes, tmplRes] = await Promise.all([
fetch("/api/flows"),
fetch("/api/flows/templates"),
]);
if (!flowsRes.ok) {
throw new Error(`Failed to load flows: ${flowsRes.status}`);
}
const flowsJson = (await flowsRes.json()) as { flows: FlowRow[] };
if (!cancelled) setFlows(flowsJson.flows ?? []);
// Templates endpoint is forward-looking — if it 404s on an
// older deployment, gracefully fall through.
if (tmplRes.ok) {
const tmplJson = (await tmplRes.json()) as {
templates: TemplateSummary[];
};
if (!cancelled) setTemplates(tmplJson.templates ?? []);
}
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load flows.");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
async function handleCreate() {
if (!newName.trim()) return;
setCreating(true);
try {
const res = await fetch("/api/flows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: newName.trim(),
trigger_type: "keyword",
trigger_config: { keywords: [] },
}),
});
if (!res.ok) throw new Error(`Create failed: ${res.status}`);
const json = (await res.json()) as { flow: FlowRow };
setCreateOpen(false);
setNewName("");
router.push(`/flows/${json.flow.id}`);
} catch (err) {
console.error(err);
toast.error("Couldn't create flow.");
} finally {
setCreating(false);
}
}
async function handleUseTemplate(slug: string) {
setCreating(true);
try {
const res = await fetch("/api/flows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template_slug: slug }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error ?? `Clone failed: ${res.status}`);
}
const json = (await res.json()) as { flow: FlowRow };
setCreateOpen(false);
router.push(`/flows/${json.flow.id}`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Clone failed";
toast.error(msg);
} finally {
setCreating(false);
}
}
async function handleDelete(flow: FlowRow) {
const yes = window.confirm(
`Delete "${flow.name}"? Any active runs will end immediately.`,
);
if (!yes) return;
try {
const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
setFlows((prev) => prev.filter((f) => f.id !== flow.id));
toast.success("Flow deleted.");
} catch (err) {
console.error(err);
toast.error("Couldn't delete flow.");
}
}
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6 p-6">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">Flows</h1>
<span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300">
Beta
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Build branching, button-driven WhatsApp conversations. Useful for
menus, FAQs, and triage before a human steps in.
</p>
</div>
<GatedButton
canAct={canCreate}
gateReason="create flows"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
New flow
</GatedButton>
</header>
{flows.length === 0 ? (
<EmptyState
onCreate={() => setCreateOpen(true)}
canCreate={canCreate}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{flows.map((flow) => (
<FlowCard
key={flow.id}
flow={flow}
onEdit={() => router.push(`/flows/${flow.id}`)}
onDelete={() => handleDelete(flow)}
/>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
{/* `sm:max-w-4xl` not `max-w-4xl` — shadcn's DialogContent has
`sm:max-w-sm` baked into its default classes. Without the
sm: prefix our override applies at base only and the
sm-scoped 384px wins at every real desktop breakpoint. */}
<DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground">
<DialogHeader>
<DialogTitle>Create a new flow</DialogTitle>
<DialogDescription className="text-muted-foreground">
Start from a template or build from scratch.
</DialogDescription>
</DialogHeader>
{templates.length > 0 && (
<div className="space-y-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Start from a template
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((t) => {
const Icon = TEMPLATE_ICONS[t.icon] ?? FileText;
return (
<button
key={t.slug}
type="button"
onClick={() => handleUseTemplate(t.slug)}
disabled={creating}
className="flex flex-col gap-2.5 rounded-lg border border-border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:opacity-50"
>
<Icon className="h-5 w-5 text-primary" />
<span className="text-sm font-semibold text-popover-foreground">
{t.name}
</span>
<span className="text-xs leading-relaxed text-muted-foreground">
{t.description}
</span>
<span className="mt-auto border-t border-border pt-2 text-[11px] text-muted-foreground">
{t.node_count} {t.node_count === 1 ? "node" : "nodes"}
</span>
</button>
);
})}
</div>
</div>
)}
<div className="space-y-2 border-t border-border pt-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Or start blank
</p>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Welcome menu"
className="bg-muted"
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setCreateOpen(false)}
disabled={creating}
>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!newName.trim() || creating}>
{creating && <Loader2 className="h-4 w-4 animate-spin" />}
Create blank flow
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function EmptyState({
onCreate,
canCreate,
}: {
onCreate: () => void;
canCreate: boolean;
}) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card/50 px-6 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<Workflow className="h-6 w-6 text-muted-foreground" />
</div>
<h2 className="mt-4 text-base font-medium text-foreground">
No flows yet
</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Build your first conversation a welcome menu, an order lookup, an FAQ
bot. Customers tap buttons; the bot routes them to the right answer (or
the right agent).
</p>
<GatedButton
canAct={canCreate}
gateReason="create flows"
onClick={onCreate}
className="mt-5"
>
<Plus className="h-4 w-4" />
Create your first flow
</GatedButton>
</div>
);
}
function FlowCard({
flow,
onEdit,
onDelete,
}: {
flow: FlowRow;
onEdit: () => void;
onDelete: () => void;
}) {
const triggerSummary = describeTrigger(flow);
const StatusIcon =
flow.status === "active"
? PlayCircle
: flow.status === "archived"
? Archive
: PauseCircle;
return (
<div className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:border-border">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Workflow className="h-4 w-4 shrink-0 text-primary" />
<h3 className="truncate text-sm font-semibold text-foreground">
{flow.name}
</h3>
</div>
<Badge
variant="outline"
className={cn(
"shrink-0 gap-1 text-[10px]",
STATUS_COLORS[flow.status],
)}
>
<StatusIcon className="h-3 w-3" />
{STATUS_LABELS[flow.status]}
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{flow.description || triggerSummary}
</p>
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"}
</span>
</div>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
);
}
function describeTrigger(flow: FlowRow): string {
if (flow.trigger_type === "keyword") {
const keywords = Array.isArray(flow.trigger_config.keywords)
? (flow.trigger_config.keywords as string[])
: [];
if (keywords.length === 0) return "Triggers on keyword (none set)";
return `Triggers on: ${keywords.join(", ")}`;
}
if (flow.trigger_type === "first_inbound_message") {
return "Triggers on a contact's first-ever inbound message";
}
return "Manual trigger";
}

View File

@@ -0,0 +1,628 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import {
CONVERSATION_SELECT,
normalizeConversation,
} from "@/lib/inbox/conversations";
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
import { useRealtime } from "@/hooks/use-realtime";
import { ConversationList } from "@/components/inbox/conversation-list";
import { MessageThread } from "@/components/inbox/message-thread";
import { ContactSidebar } from "@/components/inbox/contact-sidebar";
import { toast } from "sonner";
import { WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
// Remembers the agent's show/hide choice for the desktop contact panel
// across reloads and sessions (device-scoped, like the theme prefs).
const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open";
export default function InboxPage() {
const router = useRouter();
const searchParams = useSearchParams();
/**
* `?c=<id>` deep-link support. Used when landing here from the
* dashboard's recent-conversations list so the right thread opens
* automatically instead of showing the empty center panel.
*/
const deepLinkConvId = searchParams.get("c");
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConversation, setActiveConversation] =
useState<Conversation | null>(null);
const [activeContact, setActiveContact] = useState<Contact | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [whatsappConnected, setWhatsappConnected] = useState<boolean | null>(
null
);
/**
* Bumped whenever we want children (ConversationList, MessageThread)
* to refetch from the DB — used as a safety net against missed
* realtime events. Bumped on WS reconnect and on tab visibility →
* visible. The initial mount fetches don't depend on this; they fire
* once on conversationId-change as usual.
*/
const [resyncToken, setResyncToken] = useState(0);
/**
* Whether the desktop contact sidebar (tags / deals / notes) is shown.
* Defaults to `true` (the historical behaviour) and is restored from
* localStorage after mount. We deliberately do NOT read localStorage in
* the initializer: the server renders with `true`, so reading a stored
* `false` synchronously would produce a hydration mismatch. The effect
* below reconciles to the stored value right after mount instead.
*/
const [contactPanelOpen, setContactPanelOpen] = useState(true);
useEffect(() => {
try {
const stored = localStorage.getItem(CONTACT_PANEL_STORAGE_KEY);
if (stored !== null) setContactPanelOpen(stored === "true");
} catch {
// localStorage can throw in private-browsing / sandboxed contexts.
}
}, []);
const handleToggleContactPanel = useCallback(() => {
setContactPanelOpen((prev) => {
const next = !prev;
try {
localStorage.setItem(CONTACT_PANEL_STORAGE_KEY, String(next));
} catch {
// Persistence is best-effort; ignore storage failures.
}
return next;
});
}, []);
// Fire the deep-link auto-select exactly once per URL — subsequent
// list refreshes (realtime, manual refetch) must not snap the user
// back to the deep-linked conversation if they've already clicked
// elsewhere.
const autoSelectedForDeepLinkRef = useRef<string | null>(null);
// Tracks conversations whose hydrate fetch is currently in flight. The
// conv-INSERT and the first-message-INSERT events both call into
// hydrateConversation; the dedupe here keeps it at one refetch per
// new conversation even when both events arrive within milliseconds.
const hydratingConvIdsRef = useRef<Set<string>>(new Set());
/**
* Synchronous mirror of the conversation ids currently in `conversations`
* state. Event handlers need to know "do we already have this conv?"
* without waiting for a setState updater to run — updaters fire during
* reconciliation, *after* the synchronous handler code returns, so a
* `let foundInList = false; setState(p => { foundInList = ...; return ... })`
* flag reads as `false` in the same tick (this exact bug shipped in #105
* and caused #106: every incoming message and every status flip fired a
* redundant DB hydrate, swamping the supabase client and starving the
* realtime channel). The ref is kept in sync via the effect below.
*/
const knownConvIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
const next = new Set<string>();
for (const c of conversations) next.add(c.id);
knownConvIdsRef.current = next;
}, [conversations]);
// Pull the conversation row with its `contact` joined and merge it
// into state. Needed because Supabase Realtime payloads only carry the
// row's own columns — a brand-new conversation arrives without a
// contact, which surfaced as "Unknown" names, empty avatars, and
// (when the conv-INSERT event was delayed past the message-INSERT)
// conversations stuck on "No messages yet" until the user reloaded.
// Also self-heals if a realtime event was missed: callers can invoke
// this whenever they reference a conversation id they don't recognise.
const hydrateConversation = useCallback(async (convId: string) => {
if (hydratingConvIdsRef.current.has(convId)) return;
hydratingConvIdsRef.current.add(convId);
try {
const supabase = createClient();
const { data, error } = await supabase
.from("conversations")
.select(CONVERSATION_SELECT)
.eq("id", convId)
.maybeSingle();
if (error) {
// Supabase errors have non-enumerable properties — log fields
// explicitly so the console message isn't just `{}`.
console.error("Failed to hydrate conversation:", {
message: error.message,
details: error.details,
hint: error.hint,
code: error.code,
});
return;
}
if (!data) return;
const fetched = normalizeConversation(data);
setConversations((prev) => {
const existing = prev.find((c) => c.id === fetched.id);
if (existing) {
// Already in state — keep its fields (a realtime UPDATE may
// have landed while the fetch was in flight and patched
// last_message_text / unread_count to fresher values than
// the row we just read). Only backfill `contact`, which the
// realtime payloads never carry.
return prev.map((c) =>
c.id === fetched.id
? { ...c, contact: c.contact ?? fetched.contact }
: c,
);
}
return [fetched, ...prev];
});
} finally {
hydratingConvIdsRef.current.delete(convId);
}
}, []);
// Check WhatsApp connection status on mount
useEffect(() => {
const checkConnection = async () => {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) return;
// whatsapp_config is one-row-per-account post-multi-user, so
// the previous `.eq('user_id', user.id)` would miss the row
// for any teammate who didn't personally save the config —
// the "WhatsApp not connected" banner would show in the
// shared inbox even though the admin had it configured.
// Resolve account_id via the profile and query by that.
const { data: profile } = await supabase
.from("profiles")
.select("account_id")
.eq("user_id", user.id)
.maybeSingle();
const accountId = profile?.account_id as string | undefined;
if (!accountId) {
setWhatsappConnected(false);
return;
}
const { data } = await supabase
.from("whatsapp_config")
.select("status")
.eq("account_id", accountId)
.maybeSingle();
setWhatsappConnected(data?.status === "connected");
};
checkConnection();
}, []);
// Handle realtime message events
const handleMessageEvent = useCallback(
(event: { eventType: string; new: Message; old: Partial<Message> }) => {
const newMsg = event.new;
if (event.eventType === "INSERT") {
// Add to messages if it belongs to active conversation
if (
activeConversation &&
newMsg.conversation_id === activeConversation.id
) {
setMessages((prev) => {
// Avoid duplicates
if (prev.some((m) => m.id === newMsg.id)) return prev;
// Replace optimistic message if it exists
const withoutOptimistic = prev.filter(
(m) => !m.id.startsWith("temp-")
);
return [...withoutOptimistic, newMsg];
});
}
// Update conversation list preview. We need to know *synchronously*
// whether the conv is already in state to decide between patching
// the preview and triggering a hydrate — see the comment on
// knownConvIdsRef for why a closure flag inside the updater would
// always read false here.
if (knownConvIdsRef.current.has(newMsg.conversation_id)) {
setConversations((prev) =>
prev.map((c) =>
c.id === newMsg.conversation_id
? {
...c,
last_message_text: newMsg.content_text ?? "",
last_message_at: newMsg.created_at,
unread_count:
activeConversation?.id === newMsg.conversation_id
? 0
: c.unread_count + 1,
}
: c,
),
);
} else {
// First time we're seeing this conv: the conv-INSERT event
// hasn't landed yet, or was missed. Hydrate from the DB so
// the row surfaces with its `contact` joined; the conv-UPDATE
// event the webhook emits right after the message INSERT will
// converge state when it arrives.
hydrateConversation(newMsg.conversation_id);
}
}
if (event.eventType === "UPDATE") {
// Update message status
setMessages((prev) =>
prev.map((m) => (m.id === newMsg.id ? { ...m, ...newMsg } : m))
);
}
},
[activeConversation, hydrateConversation]
);
// Handle realtime conversation events
const handleConversationEvent = useCallback(
(event: {
eventType: string;
new: Conversation;
old: Partial<Conversation>;
}) => {
const conv = event.new;
if (event.eventType === "INSERT") {
// Prepend immediately for snappy UX so the new conv shows in the
// list right away, then hydrate to fill in the `contact` join
// (realtime payloads never include joins). Skip both if we
// already have the row — that shouldn't happen normally, but
// out-of-order delivery would have us prepending a duplicate.
if (!knownConvIdsRef.current.has(conv.id)) {
setConversations((prev) => {
if (prev.some((c) => c.id === conv.id)) return prev;
return [conv, ...prev];
});
hydrateConversation(conv.id);
}
}
if (event.eventType === "UPDATE") {
if (knownConvIdsRef.current.has(conv.id)) {
// If this UPDATE is for the conv the user is currently viewing,
// suppress the incoming unread_count — the user is reading it
// RIGHT NOW, so any positive value would just flicker the badge
// back on for the ~100ms it takes for the reset effect's server
// UPDATE to round-trip. Non-active convs take the value as-is.
const isActive = activeConversation?.id === conv.id;
setConversations((prev) =>
prev.map((c) =>
c.id === conv.id
? {
...c,
...conv,
unread_count: isActive ? 0 : conv.unread_count,
}
: c,
),
);
} else {
// UPDATE arrived before the INSERT (or after a missed INSERT)
// — fetch the row so it surfaces with its contact joined. The
// patch contained in `conv` will already be reflected in what
// the hydrate fetch returns.
hydrateConversation(conv.id);
}
// Update active conversation if it changed
if (activeConversation && conv.id === activeConversation.id) {
setActiveConversation((prev) =>
prev ? { ...prev, ...conv } : prev
);
}
}
},
[activeConversation, hydrateConversation]
);
// Subscribe to realtime. The `isConnected` flag below feeds the
// reconnect resync: realtime is best-effort and events sent while the
// WS was disconnected (laptop sleep, network blip, background-tab
// throttle) are simply lost. We need a way to catch up.
const { isConnected } = useRealtime({
channelName: "inbox-realtime",
onMessageEvent: handleMessageEvent,
onConversationEvent: handleConversationEvent,
enabled: true,
});
/**
* Bump `resyncToken` whenever the realtime channel transitions from
* disconnected → connected *after* the initial connect. The initial
* connect is covered by the children's on-mount fetches; only later
* reconnects need a manual refetch to fill the gap.
*
* Tracked via a `was-connected` ref rather than a count so that React
* strict-mode's dev-only effect double-fire doesn't read as a
* reconnect.
*/
const wasConnectedRef = useRef(false);
const initialConnectDoneRef = useRef(false);
useEffect(() => {
if (isConnected && !wasConnectedRef.current) {
// false → true transition
if (initialConnectDoneRef.current) {
setResyncToken((n) => n + 1);
} else {
initialConnectDoneRef.current = true;
}
}
wasConnectedRef.current = isConnected;
}, [isConnected]);
/**
* Refetch when the tab regains focus. Background tabs may have their
* WS throttled by the browser even without a full disconnect, so a
* visibilitychange → visible is a reliable signal that we may have
* missed events. Cheap to fire; the children dedupe on their own.
*/
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === "visible") {
setResyncToken((n) => n + 1);
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
/**
* Manual refresh trigger for the thread-header refresh button.
* Bumps the same resyncToken the reconnect / visibility paths use,
* so it goes through the existing dedupe & refetch plumbing — no
* separate code path to keep in sync.
*/
const handleManualRefresh = useCallback(() => {
setResyncToken((n) => n + 1);
}, []);
const handleConversationsLoaded = useCallback(
(loaded: Conversation[]) => {
setConversations(loaded);
// Resolve a pending deep-link here rather than in an effect — this
// is an event handler, so the setState calls below are allowed by
// react-hooks/set-state-in-effect. Runs once per ?c=<id> URL value
// via the ref, so realtime refreshes of the list can't snap the
// user back to the deep-linked thread after they've navigated.
if (
deepLinkConvId &&
autoSelectedForDeepLinkRef.current !== deepLinkConvId &&
loaded.length > 0
) {
autoSelectedForDeepLinkRef.current = deepLinkConvId;
// If the deep-linked conversation is already the active one
// (e.g. because the user clicked it in the list and we
// router.replace()'d the URL, which made the ConversationList
// refetch and land us back here), do NOT re-apply it. Doing so
// would setMessages([]) on a thread whose messages have
// already been loaded by MessageThread — and because
// conversationId didn't change, MessageThread wouldn't
// refetch. The thread would read "No messages yet" until a
// full page reload rehydrated state from scratch.
if (activeConversation?.id === deepLinkConvId) return;
const match = loaded.find((c) => c.id === deepLinkConvId);
if (match) {
setActiveConversation(match);
setActiveContact(match.contact ?? null);
setMessages([]);
// Mirror the optimistic unread reset that handleSelectConversation
// does — the user just deep-linked into this conv, treat that the
// same as a click. Leaves activeConversation.unread_count alone so
// the MessageThread reset effect still fires the server UPDATE.
if (match.unread_count > 0) {
setConversations((prev) =>
prev.map((c) =>
c.id === match.id ? { ...c, unread_count: 0 } : c,
),
);
}
}
}
},
[deepLinkConvId, activeConversation?.id]
);
const handleSelectConversation = useCallback(
(conv: Conversation) => {
// Re-clicking the already-active conversation would clear the
// messages array, but the fetch effect in MessageThread only re-runs
// when conversationId changes — so messages would stay empty until
// the user navigated away and back. Bail out early instead.
if (activeConversation?.id === conv.id) return;
setActiveConversation(conv);
setActiveContact(conv.contact ?? null);
setMessages([]);
// Optimistically clear the unread badge for this conv. The
// server-side reset is fired by the unread-reset effect inside
// MessageThread (which reads activeConversation.unread_count, not
// the list copy — so we deliberately leave that intact below to
// keep the effect firing), and the realtime UPDATE that comes
// back will sync to 0 again as a no-op. Zeroing the list copy
// here means the user sees the badge disappear the instant they
// click instead of waiting for the round-trip — and it persists
// even if the realtime UPDATE is dropped.
setConversations((prev) =>
prev.map((c) =>
c.id === conv.id && c.unread_count > 0
? { ...c, unread_count: 0 }
: c,
),
);
// Record the selection on the deep-link ref BEFORE we change the
// URL. The router.replace below flips `deepLinkConvId`, which can
// in turn cause ConversationList to refetch and eventually call
// handleConversationsLoaded again. Without this line, the ref
// still points at the previous value, the auto-select block
// sees `ref !== deepLinkConvId`, fires a second time, and
// clobbers the messages MessageThread just fetched.
autoSelectedForDeepLinkRef.current = conv.id;
// Reflect the selection in the URL so a refresh lands the user
// back in the same thread, and so copy-paste links work. Use
// replace() to avoid polluting browser history with every click.
router.replace(`/inbox?c=${conv.id}`, { scroll: false });
},
[activeConversation?.id, router]
);
// Mobile "back" — deselect the conversation so the list pane comes
// back. Also clears the ?c= param so a refresh lands on the list
// instead of re-opening the thread the user just backed out of.
const handleCloseConversation = useCallback(() => {
setActiveConversation(null);
setActiveContact(null);
setMessages([]);
// Clearing the ref lets the deep-link auto-selector fire again if
// the user later visits /inbox?c=<same-id> — desirable UX.
autoSelectedForDeepLinkRef.current = null;
router.replace("/inbox", { scroll: false });
}, [router]);
const handleMessagesLoaded = useCallback((loaded: Message[]) => {
setMessages(loaded);
}, []);
const handleNewMessage = useCallback((msg: Message) => {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
}, []);
const handleUpdateMessage = useCallback(
(id: string, updates: Partial<Message>) => {
setMessages((prev) =>
prev.map((m) => (m.id === id ? { ...m, ...updates } : m))
);
},
[]
);
const handleStatusChange = useCallback(
(conversationId: string, status: ConversationStatus) => {
setConversations((prev) =>
prev.map((c) => (c.id === conversationId ? { ...c, status } : c))
);
if (activeConversation?.id === conversationId) {
setActiveConversation((prev) => (prev ? { ...prev, status } : prev));
}
},
[activeConversation]
);
const handleAssignChange = useCallback(
(conversationId: string, assignedAgentId: string | null) => {
setConversations((prev) =>
prev.map((c) =>
c.id === conversationId
? { ...c, assigned_agent_id: assignedAgentId ?? undefined }
: c
)
);
if (activeConversation?.id === conversationId) {
setActiveConversation((prev) =>
prev
? { ...prev, assigned_agent_id: assignedAgentId ?? undefined }
: prev
);
}
},
[activeConversation]
);
// On mobile (<lg) we show a SINGLE pane — either the list or the
// thread — rather than cramming both side-by-side. Selecting a
// conversation slides the thread in; the thread's back button pops
// it back to the list. On lg+ both panes render side-by-side as
// before, unchanged.
const hasActiveConv = !!activeConversation;
return (
<div className="-m-4 flex h-[calc(100vh-3.5rem)] flex-col overflow-hidden sm:-m-6">
{/* WhatsApp connection banner — in the flex column, not absolute,
so it pushes the panels down instead of overlapping them. */}
{whatsappConnected === false && (
<div className="flex shrink-0 items-center justify-center gap-2 border-b border-amber-500/20 bg-amber-500/10 px-4 py-2">
<WifiOff className="h-4 w-4 text-amber-400" />
<p className="text-xs text-amber-400">
WhatsApp® is not connected. Go to Settings to connect your account.
</p>
</div>
)}
<div className="flex flex-1 overflow-hidden">
{/* Left panel: Conversation list.
Hidden on mobile when a conversation is selected so the
thread can occupy the full width. Always visible on lg+. */}
<div
className={cn(
"flex h-full flex-1 lg:flex-none",
hasActiveConv ? "hidden lg:flex" : "flex",
)}
>
<ConversationList
activeConversationId={activeConversation?.id ?? null}
onSelect={handleSelectConversation}
conversations={conversations}
onConversationsLoaded={handleConversationsLoaded}
resyncToken={resyncToken}
/>
</div>
{/* Center panel: Message thread.
Hidden on mobile when no conversation is selected so the
list can occupy the full width. Always visible on lg+
(shows its own empty-state if no thread is picked yet).
`min-w-0` is load-bearing: without it, a single wide piece
of content inside the thread (long quote preview, very
long URL in a message body) forces the flex child past
its share and pushes the contact-sidebar panel off-screen
on the right. Issue #165. */}
<div
className={cn(
"flex h-full min-w-0 flex-1 lg:flex",
hasActiveConv ? "flex" : "hidden lg:flex",
)}
>
<MessageThread
conversation={activeConversation}
contact={activeContact}
messages={messages}
onMessagesLoaded={handleMessagesLoaded}
onNewMessage={handleNewMessage}
onUpdateMessage={handleUpdateMessage}
onStatusChange={handleStatusChange}
onAssignChange={handleAssignChange}
onBack={handleCloseConversation}
resyncToken={resyncToken}
onRefresh={handleManualRefresh}
contactPanelOpen={contactPanelOpen}
onToggleContactPanel={handleToggleContactPanel}
/>
</div>
{/* Right panel: Contact sidebar — desktop only, and only when the
agent hasn't collapsed it via the thread-header toggle (#258).
On mobile it's always hidden (the `lg:block` below), so the
toggle — which is itself desktop-only — never affects it. */}
{contactPanelOpen && (
<div className="hidden lg:block">
<ContactSidebar contact={activeContact} />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { DashboardShell } from "./dashboard-shell";
// Server layout whose only job is to declare "do not index" metadata
// for the authed app. robots.ts already disallows these paths at the
// crawler-level and middleware redirects unauthenticated visitors, so
// this is belt-and-suspenders — but SEO-critical if a URL ever leaks
// via a link shared externally.
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
},
},
};
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return <DashboardShell>{children}</DashboardShell>;
}

View File

@@ -0,0 +1,268 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import type { Notification } from "@/types";
import { Bell, CheckCheck, Loader2, UserPlus } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
// Icon per notification type. Only one type exists today
// (conversation_assigned) but this keeps future types a one-line add.
const TYPE_ICON: Record<Notification["type"], typeof Bell> = {
conversation_assigned: UserPlus,
};
export default function NotificationsPage() {
const router = useRouter();
const { accountId } = useAuth();
const [notifications, setNotifications] = useState<Notification[] | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const [markingAll, setMarkingAll] = useState(false);
const load = useCallback(async () => {
if (!accountId) return;
const supabase = createClient();
const { data, error: fetchErr } = await supabase
.from("notifications")
.select("*")
.eq("account_id", accountId)
.order("created_at", { ascending: false })
.limit(100);
if (fetchErr) {
setError(fetchErr.message);
return;
}
setNotifications((data ?? []) as Notification[]);
}, [accountId]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [load]);
// Realtime — new assignments appear without a refresh, and a
// "mark all read" fired from another tab/device stays in sync here.
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("notifications-page")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "notifications" },
(payload) => {
if (payload.eventType === "INSERT") {
const row = payload.new as Notification;
setNotifications((prev) => {
if (!prev) return [row];
if (prev.some((n) => n.id === row.id)) return prev;
return [row, ...prev];
});
} else if (payload.eventType === "UPDATE") {
const row = payload.new as Notification;
setNotifications((prev) =>
prev?.map((n) => (n.id === row.id ? { ...n, ...row } : n)) ??
prev,
);
} else if (payload.eventType === "DELETE") {
const oldRow = payload.old as Partial<Notification>;
setNotifications(
(prev) => prev?.filter((n) => n.id !== oldRow.id) ?? prev,
);
}
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
const markRead = useCallback(
async (id: string) => {
// Optimistic — the row is already visually "read" by the time the
// request lands, so the UI doesn't wait on the round-trip.
setNotifications(
(prev) =>
prev?.map((n) =>
n.id === id && !n.read_at
? { ...n, read_at: new Date().toISOString() }
: n,
) ?? prev,
);
const supabase = createClient();
const { error: updateErr } = await supabase
.from("notifications")
.update({ read_at: new Date().toISOString() })
.eq("id", id)
.is("read_at", null);
if (updateErr) {
toast.error("Failed to mark notification as read");
load();
}
},
[load],
);
const handleClick = useCallback(
(n: Notification) => {
if (!n.read_at) markRead(n.id);
if (n.conversation_id) {
router.push(`/inbox?c=${n.conversation_id}`);
}
},
[markRead, router],
);
const unreadIds = notifications?.filter((n) => !n.read_at).map((n) => n.id) ?? [];
const markAllRead = useCallback(async () => {
if (unreadIds.length === 0) return;
setMarkingAll(true);
const now = new Date().toISOString();
setNotifications(
(prev) => prev?.map((n) => (n.read_at ? n : { ...n, read_at: now })) ?? prev,
);
const supabase = createClient();
const { error: updateErr } = await supabase
.from("notifications")
.update({ read_at: now })
.is("read_at", null);
setMarkingAll(false);
if (updateErr) {
toast.error("Failed to mark all as read");
load();
}
}, [unreadIds.length, load]);
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
);
}
if (notifications === null) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Notifications</h1>
<p className="mt-1 text-sm text-muted-foreground">
Conversations other teammates assign to you show up here.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={unreadIds.length === 0 || markingAll}
onClick={markAllRead}
>
{markingAll ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCheck className="h-4 w-4" />
)}
Mark all as read
</Button>
</div>
{notifications.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/40">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Bell className="h-6 w-6 text-primary" />
</div>
<p className="mt-3 text-sm font-medium text-foreground">
No notifications yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
You&apos;ll see an alert here when someone assigns you a
conversation.
</p>
</div>
) : (
<ul className="space-y-2">
{notifications.map((n) => {
const Icon = TYPE_ICON[n.type] ?? Bell;
const isUnread = !n.read_at;
return (
<li key={n.id}>
<button
type="button"
onClick={() => handleClick(n)}
className={cn(
"flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-colors",
isUnread
? "border-primary/30 bg-primary/5 hover:border-primary/50"
: "border-border bg-card hover:border-border/70",
)}
>
<div
className={cn(
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
isUnread ? "bg-primary/15" : "bg-muted",
)}
aria-hidden
>
<Icon
className={cn(
"h-5 w-5",
isUnread ? "text-primary" : "text-muted-foreground",
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn(
"truncate text-sm font-semibold",
isUnread ? "text-foreground" : "text-muted-foreground",
)}
>
{n.title}
</span>
{isUnread && (
<span
aria-label="Unread"
className="h-2 w-2 flex-shrink-0 rounded-full bg-primary"
/>
)}
</div>
{n.body && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{n.body}
</p>
)}
<p className="mt-1 text-[11px] text-muted-foreground/70">
{formatDistanceToNow(new Date(n.created_at), {
addSuffix: true,
})}
</p>
</div>
</button>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,492 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { createClient } from "@/lib/supabase/client";
import type { Pipeline, PipelineStage, Deal } from "@/types";
import { PipelineBoard } from "@/components/pipelines/pipeline-board";
import { PipelineSettings } from "@/components/pipelines/pipeline-settings";
import { DealForm } from "@/components/pipelines/deal-form";
import { PipelineAnalytics } from "@/components/pipelines/pipeline-analytics";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react";
import { toast } from "sonner";
import { useCan } from "@/hooks/use-can";
import { useAuth } from "@/hooks/use-auth";
import { GatedButton } from "@/components/ui/gated-button";
// Pipeline creation is admin-class (settings-tier write under
// the new RLS); deal creation is operational and only requires
// agent+. The two CTAs gate on different `useCan` capabilities,
// not on different copy.
// Spec-defined seed — name and color per the product spec.
const SPEC_DEFAULT_STAGES = [
{ name: "New Lead", color: "#3b82f6", position: 0 }, // blue
{ name: "Qualified", color: "#eab308", position: 1 }, // yellow
{ name: "Proposal Sent", color: "#f97316", position: 2 }, // orange
{ name: "Negotiation", color: "#8b5cf6", position: 3 }, // purple
{ name: "Won", color: "#22c55e", position: 4 }, // green
];
export default function PipelinesPage() {
const supabase = createClient();
const canEditSettings = useCan("edit-settings");
const canCreateDeals = useCan("send-messages");
const { accountId } = useAuth();
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
const [stages, setStages] = useState<PipelineStage[]>([]);
const [deals, setDeals] = useState<Deal[]>([]);
const [loading, setLoading] = useState(true);
// Dialog / sheet state
const [newPipelineOpen, setNewPipelineOpen] = useState(false);
const [newPipelineName, setNewPipelineName] = useState("");
const [creating, setCreating] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
// Deal form state is lifted here so both the top-bar "Add Deal" and
// the per-column "+" trigger the same Sheet.
const [dealFormOpen, setDealFormOpen] = useState(false);
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
const [defaultStageId, setDefaultStageId] = useState<string>("");
// Guard against double-seeding (React StrictMode double-effect in dev).
const seedAttempted = useRef(false);
const loadPipelines = useCallback(async () => {
const { data, error } = await supabase
.from("pipelines")
.select("*")
.order("created_at");
if (error) {
console.error("Failed to load pipelines:", error.message);
return [];
}
return data ?? [];
}, [supabase]);
const loadStages = useCallback(
async (pipelineId: string) => {
const { data } = await supabase
.from("pipeline_stages")
.select("*")
.eq("pipeline_id", pipelineId)
.order("position");
return data ?? [];
},
[supabase],
);
const loadDeals = useCallback(
async (pipelineId: string) => {
const { data } = await supabase
.from("deals")
.select("*, contact:contacts(*), assignee:profiles!deals_assigned_to_fkey(*)")
.eq("pipeline_id", pipelineId)
.order("created_at", { ascending: false });
return (data ?? []) as Deal[];
},
[supabase],
);
const seedDefaultPipeline = useCallback(async (): Promise<Pipeline | null> => {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) return null;
// pipelines.account_id is NOT NULL post-017 with no DB default.
if (!accountId) return null;
const { data: pipeline, error } = await supabase
.from("pipelines")
.insert({ user_id: user.id, account_id: accountId, name: "Sales Pipeline" })
.select()
.single();
if (error || !pipeline) {
console.error("Failed to seed pipeline:", error?.message);
return null;
}
const stagesPayload = SPEC_DEFAULT_STAGES.map((s) => ({
pipeline_id: pipeline.id,
name: s.name,
color: s.color,
position: s.position,
}));
await supabase.from("pipeline_stages").insert(stagesPayload);
return pipeline as Pipeline;
}, [supabase, accountId]);
// Initial load + seed-if-empty
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
let list = await loadPipelines();
if (list.length === 0 && !seedAttempted.current) {
seedAttempted.current = true;
const seeded = await seedDefaultPipeline();
if (seeded) list = await loadPipelines();
}
if (cancelled) return;
setPipelines(list);
if (list.length > 0) {
setSelectedPipelineId((prev) =>
prev && list.some((p) => p.id === prev) ? prev : list[0].id,
);
} else {
setSelectedPipelineId("");
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [loadPipelines, seedDefaultPipeline]);
// Load stages + deals whenever selected pipeline changes.
// Clearing on no-selection is a legitimate sync with URL/prop
// state; the load completion uses async setters inside promise
// callbacks (not synchronous in the effect body).
useEffect(() => {
if (!selectedPipelineId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setStages([]);
// eslint-disable-next-line react-hooks/set-state-in-effect
setDeals([]);
return;
}
let cancelled = false;
(async () => {
const [s, d] = await Promise.all([
loadStages(selectedPipelineId),
loadDeals(selectedPipelineId),
]);
if (cancelled) return;
setStages(s);
setDeals(d);
})();
return () => {
cancelled = true;
};
}, [selectedPipelineId, loadStages, loadDeals]);
const refreshPipelines = useCallback(async () => {
const list = await loadPipelines();
setPipelines(list);
if (list.length === 0) setSelectedPipelineId("");
else if (!list.some((p) => p.id === selectedPipelineId))
setSelectedPipelineId(list[0].id);
}, [loadPipelines, selectedPipelineId]);
const refreshStages = useCallback(async () => {
if (!selectedPipelineId) return;
setStages(await loadStages(selectedPipelineId));
}, [loadStages, selectedPipelineId]);
const refreshDeals = useCallback(async () => {
if (!selectedPipelineId) return;
setDeals(await loadDeals(selectedPipelineId));
}, [loadDeals, selectedPipelineId]);
const handleDealMoved = useCallback(
async (dealId: string, newStageId: string) => {
// Optimistic update — board already animated; just persist.
setDeals((prev) =>
prev.map((d) => (d.id === dealId ? { ...d, stage_id: newStageId } : d)),
);
const { error } = await supabase
.from("deals")
.update({ stage_id: newStageId })
.eq("id", dealId);
if (error) {
toast.error("Failed to move deal");
refreshDeals();
}
},
[supabase, refreshDeals],
);
const handleAddDeal = useCallback(
(stageId?: string) => {
setEditingDeal(null);
setDefaultStageId(stageId ?? stages[0]?.id ?? "");
setDealFormOpen(true);
},
[stages],
);
const handleEditDeal = useCallback((deal: Deal) => {
setEditingDeal(deal);
setDefaultStageId(deal.stage_id);
setDealFormOpen(true);
}, []);
async function handleCreatePipeline() {
const name = newPipelineName.trim();
if (!name) return;
setCreating(true);
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) {
setCreating(false);
return;
}
// pipelines.account_id is NOT NULL post-017 with no DB default.
if (!accountId) {
toast.error("Your profile is not linked to an account.");
setCreating(false);
return;
}
const { data: pipeline, error } = await supabase
.from("pipelines")
.insert({ user_id: user.id, account_id: accountId, name })
.select()
.single();
if (error || !pipeline) {
toast.error("Failed to create pipeline");
setCreating(false);
return;
}
const stagesPayload = SPEC_DEFAULT_STAGES.map((s) => ({
pipeline_id: pipeline.id,
name: s.name,
color: s.color,
position: s.position,
}));
await supabase.from("pipeline_stages").insert(stagesPayload);
setNewPipelineName("");
setNewPipelineOpen(false);
setSelectedPipelineId(pipeline.id);
await refreshPipelines();
setCreating(false);
toast.success("Pipeline created");
}
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="h-9 w-28 animate-pulse rounded-lg bg-muted" />
</div>
<div className="flex gap-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-96 w-72 animate-pulse rounded-xl bg-muted/50" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
{/* Pipeline selector dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors data-[popup-open]:bg-muted"
>
<GitBranch className="h-4 w-4 text-primary" />
<span className="font-semibold">
{selectedPipeline?.name ?? "Select Pipeline"}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-64 border-border bg-popover text-popover-foreground"
>
{pipelines.length === 0 && (
<DropdownMenuItem disabled className="text-muted-foreground">
No pipelines yet
</DropdownMenuItem>
)}
{pipelines.map((p) => (
<DropdownMenuItem
key={p.id}
onClick={() => setSelectedPipelineId(p.id)}
className={
p.id === selectedPipelineId
? "text-primary"
: "text-popover-foreground"
}
>
<GitBranch className="mr-2 h-3.5 w-3.5" />
{p.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="bg-border" />
{selectedPipeline && (
<DropdownMenuItem
onClick={() => setSettingsOpen(true)}
className="text-popover-foreground"
>
<Settings className="mr-2 h-3.5 w-3.5" />
Manage Pipelines
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-2">
<GatedButton
variant="outline"
canAct={canEditSettings}
gateReason="create pipelines"
onClick={() => setNewPipelineOpen(true)}
className="border-border bg-card text-foreground hover:bg-muted"
>
<Plus className="mr-1 h-4 w-4" />
Add Pipeline
</GatedButton>
<GatedButton
canAct={canCreateDeals}
gateReason="create deals"
disabled={!selectedPipelineId || stages.length === 0}
onClick={() => handleAddDeal()}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="mr-1 h-4 w-4" />
Add Deal
</GatedButton>
</div>
</div>
{/* Board */}
{pipelines.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20">
<GitBranch className="h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-medium text-foreground">
No pipelines yet
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Create a pipeline to start tracking deals
</p>
<GatedButton
canAct={canEditSettings}
gateReason="create pipelines"
onClick={() => setNewPipelineOpen(true)}
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="mr-1 h-4 w-4" />
Create Pipeline
</GatedButton>
</div>
) : (
<>
<PipelineAnalytics stages={stages} deals={deals} />
<PipelineBoard
stages={stages}
deals={deals}
onDealMoved={handleDealMoved}
onAddDeal={handleAddDeal}
onEditDeal={handleEditDeal}
/>
</>
)}
{/* New Pipeline Dialog */}
<Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}>
<DialogContent className="sm:max-w-sm bg-popover border-border">
<DialogHeader>
<DialogTitle className="text-popover-foreground">New Pipeline</DialogTitle>
</DialogHeader>
<div className="py-2">
<Label className="text-muted-foreground">Pipeline Name</Label>
<Input
value={newPipelineName}
onChange={(e) => setNewPipelineName(e.target.value)}
placeholder="e.g., Enterprise Sales"
className="mt-2 bg-muted border-border text-foreground"
onKeyDown={(e) => {
if (e.key === "Enter") handleCreatePipeline();
}}
/>
<p className="mt-2 text-xs text-muted-foreground">
Default stages (New Lead Won) will be created automatically.
</p>
</div>
<DialogFooter className="bg-popover/50 border-border">
<Button
variant="outline"
onClick={() => setNewPipelineOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleCreatePipeline}
disabled={creating || !newPipelineName.trim()}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{creating ? "Creating..." : "Create Pipeline"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Pipeline Settings */}
{selectedPipeline && (
<PipelineSettings
open={settingsOpen}
onOpenChange={setSettingsOpen}
pipeline={selectedPipeline}
stages={stages}
onPipelinesChanged={refreshPipelines}
onStagesChanged={refreshStages}
onCreateNewPipeline={() => {
setSettingsOpen(false);
setNewPipelineOpen(true);
}}
/>
)}
{/* Deal Form (Sheet) */}
<DealForm
open={dealFormOpen}
onOpenChange={setDealFormOpen}
deal={editingDeal}
pipelineId={selectedPipelineId}
stages={stages}
defaultStageId={defaultStageId}
onSaved={refreshDeals}
/>
</div>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useMemo, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/hooks/use-auth';
import { useTheme } from '@/hooks/use-theme';
import { SettingsRail } from '@/components/settings/settings-rail';
import { SettingsOverview } from '@/components/settings/settings-overview';
import { ProfileForm } from '@/components/settings/profile-form';
import { SecurityPanel } from '@/components/settings/security-panel';
import { AppearancePanel } from '@/components/settings/appearance-panel';
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
import { TemplateManager } from '@/components/settings/template-manager';
import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel';
import { DealsSettings } from '@/components/settings/deals-settings';
import { MembersTab } from '@/components/settings/members-tab';
import { ApiKeysSettings } from '@/components/settings/api-keys-settings';
import {
resolveSection,
type SettingsSection,
} from '@/components/settings/settings-sections';
export default function SettingsPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { defaultCurrency } = useAuth();
const { mode } = useTheme();
// The URL (`?tab=`) is the single source of truth for the active
// section — deep-linkable, and it keeps the existing links in the
// app sidebar/header working. Legacy tab values (tags, custom-fields)
// resolve onto their new home; unknown/empty → the Overview landing.
const section = resolveSection(searchParams.get('tab'));
const go = (next: SettingsSection) => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', next);
router.replace(`/settings?${params.toString()}`, { scroll: false });
};
// Cheap, fetch-free rail hints. The Overview landing carries the
// full live status/counts; the rail just surfaces the two that are
// already in context.
const hints: Partial<Record<SettingsSection, ReactNode>> = useMemo(
() => ({
appearance: mode.charAt(0).toUpperCase() + mode.slice(1),
deals: defaultCurrency,
}),
[mode, defaultCurrency],
);
const panel: Record<SettingsSection, ReactNode> = {
overview: <SettingsOverview onSelect={go} />,
profile: <ProfileForm />,
security: <SecurityPanel />,
appearance: <AppearancePanel />,
whatsapp: <WhatsAppConfig />,
templates: <TemplateManager />,
fields: <FieldsAndTagsPanel />,
deals: <DealsSettings />,
members: <MembersTab />,
api: <ApiKeysSettings />,
};
return (
<div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Settings
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Everything in one place your account and your workspace. Pick a
section to manage it.
</p>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[236px_minmax(0,1fr)] lg:items-start">
<SettingsRail active={section} onSelect={go} hints={hints} />
<div className="min-w-0">{panel[section]}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
// ============================================================
// DELETE /api/account/api-keys/[id] — revoke a key.
//
// Soft revoke: sets `revoked_at` rather than deleting the row, so
// the key's name/prefix stay visible in the roster as an audit
// trail ("this key existed and was turned off") and so the auth
// path's liveness check (`findActiveKeyByHash` filters revoked
// rows) starts rejecting it immediately. Admin+, enforced here and
// by the `api_keys_update` RLS policy.
//
// Revocation is effective on the next request: once `revoked_at` is
// set, `findActiveKeyByHash` returns null and the key 401s.
// ============================================================
import { NextResponse } from 'next/server';
import { requireRole, toErrorResponse } from '@/lib/auth/account';
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit';
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireRole('admin');
const limit = checkRateLimit(
`admin:apiKeyRevoke:${ctx.userId}`,
RATE_LIMITS.adminAction
);
if (!limit.success) return rateLimitResponse(limit);
const { id } = await params;
// Scope the update by account_id as well as id so an admin can
// never revoke another account's key by guessing a UUID. (RLS
// already enforces this; the explicit filter is belt-and-braces
// and makes the "0 rows updated → 404" path precise.)
const { data, error } = await ctx.supabase
.from('api_keys')
.update({ revoked_at: new Date().toISOString() })
.eq('id', id)
.eq('account_id', ctx.accountId)
.is('revoked_at', null)
.select('id')
.maybeSingle();
if (error) {
console.error('[DELETE /api/account/api-keys/[id]] error:', error);
return NextResponse.json(
{ error: 'Failed to revoke API key' },
{ status: 500 }
);
}
if (!data) {
// Either no such key in this account, or it was already revoked.
return NextResponse.json(
{ error: 'API key not found or already revoked' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,159 @@
// ============================================================
// /api/account/api-keys
//
// GET — list this account's API keys (safe columns only).
// POST — mint a new key.
//
// These are the *dashboard* endpoints for managing keys, so they
// authenticate the normal way (cookie session) and go through the
// RLS client. Listing is open to any member (viewer+) — the roster
// is not secret; the secret (the key itself) is never in it. Minting
// is admin+ (a key hands out capabilities), enforced by both
// `requireRole('admin')` here and the `api_keys_insert` RLS policy.
//
// IMPORTANT: the plaintext key is returned exactly ONCE, in the POST
// response. We persist only its SHA-256 hash, so neither GET nor any
// future endpoint can resurface it — same one-time-reveal contract
// as invite links. If the admin loses it, they revoke and re-issue.
// ============================================================
import { NextResponse } from 'next/server';
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account';
import { generateApiKey } from '@/lib/api-keys/keys';
import { normalizeScopes } from '@/lib/api-keys/scopes';
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit';
const MAX_NAME_LEN = 80;
// Hard ceiling on caller-supplied expiry (1 year), mirroring the
// invite-link clamp. NULL/absent = never expires.
const MAX_EXPIRY_DAYS = 365;
// Columns safe to expose. `key_hash` is deliberately excluded — it
// never leaves the server.
const SAFE_COLUMNS =
'id, name, key_prefix, scopes, last_used_at, expires_at, revoked_at, created_at';
export async function GET() {
try {
// Any member can view the roster (RLS allows it); we just need a
// resolved account context.
const ctx = await getCurrentAccount();
const { data, error } = await ctx.supabase
.from('api_keys')
.select(SAFE_COLUMNS)
.eq('account_id', ctx.accountId)
.order('created_at', { ascending: false });
if (error) {
console.error('[GET /api/account/api-keys] fetch error:', error);
return NextResponse.json(
{ error: 'Failed to load API keys' },
{ status: 500 }
);
}
return NextResponse.json({ keys: data ?? [] });
} catch (err) {
return toErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireRole('admin');
const limit = checkRateLimit(
`admin:apiKeyCreate:${ctx.userId}`,
RATE_LIMITS.adminAction
);
if (!limit.success) return rateLimitResponse(limit);
const body = (await request.json().catch(() => null)) as {
name?: unknown;
scopes?: unknown;
expiresInDays?: unknown;
} | null;
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
if (!rawName) {
return NextResponse.json(
{ error: "'name' is required" },
{ status: 400 }
);
}
if (rawName.length > MAX_NAME_LEN) {
return NextResponse.json(
{ error: `Name must be ${MAX_NAME_LEN} characters or fewer` },
{ status: 400 }
);
}
// Scopes default to none if omitted — that yields a key that can
// only call the scope-free endpoints (e.g. GET /api/v1/me).
const scopes = normalizeScopes(body?.scopes ?? []);
if (scopes === null) {
return NextResponse.json(
{ error: "'scopes' must be an array of known scope strings" },
{ status: 400 }
);
}
let expiresAt: string | null = null;
const rawExpiry = body?.expiresInDays;
if (
typeof rawExpiry === 'number' &&
Number.isFinite(rawExpiry) &&
rawExpiry > 0
) {
const days = Math.min(Math.floor(rawExpiry), MAX_EXPIRY_DAYS);
expiresAt = new Date(
Date.now() + days * 24 * 60 * 60 * 1000
).toISOString();
}
const { plaintext, hash, prefix } = generateApiKey();
const { data, error } = await ctx.supabase
.from('api_keys')
.insert({
account_id: ctx.accountId,
created_by: ctx.userId,
name: rawName,
key_prefix: prefix,
key_hash: hash,
scopes,
expires_at: expiresAt,
})
.select(SAFE_COLUMNS)
.single();
if (error || !data) {
console.error('[POST /api/account/api-keys] insert error:', error);
return NextResponse.json(
{ error: 'Failed to create API key' },
{ status: 500 }
);
}
return NextResponse.json(
{
key: data,
// Plaintext — shown to the admin exactly once.
plaintext,
},
{ status: 201 }
);
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,73 @@
// ============================================================
// DELETE /api/account/invitations/[id]
//
// Admin+. Revokes a pending invitation by id. RLS on
// `account_invitations` already restricts the DELETE to admins
// of the inviting account; we lean on it and skip the explicit
// ownership check.
//
// We intentionally delete the row outright rather than soft-
// deleting (a "revoked_at" flag). Once revoked, an invite is
// dead forever — there's no UX where a former invite should be
// listed; the plaintext token is gone too. Hard delete keeps
// the table small.
// ============================================================
import { NextResponse } from "next/server";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const ctx = await requireRole("admin");
const limit = checkRateLimit(
`admin:inviteRevoke:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const { id } = await params;
// No `eq('account_id', ctx.accountId)` — the RLS policy
// (`is_account_member(account_id, 'admin')`) already scopes
// the DELETE to invites in the caller's account. Adding the
// filter would be redundant; omitting it surfaces a
// cross-account attempt as a silent 0-row delete (which is
// exactly what we want for a revocation endpoint).
const { error, count } = await ctx.supabase
.from("account_invitations")
.delete({ count: "exact" })
.eq("id", id);
if (error) {
console.error("[DELETE /api/account/invitations/[id]] error:", error);
return NextResponse.json(
{ error: "Failed to revoke invitation" },
{ status: 500 },
);
}
if (count === 0) {
// Either the id doesn't exist or RLS hid it (different
// account). 404 either way — surfacing "exists but not
// yours" would leak existence.
return NextResponse.json(
{ error: "Invitation not found" },
{ status: 404 },
);
}
return NextResponse.json({ ok: true });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,253 @@
// ============================================================
// /api/account/invitations
//
// GET — list outstanding (un-redeemed, non-expired) invites.
// POST — create a new invite link.
//
// Both admin+. The list endpoint is what the Members tab uses to
// populate the "Pending invitations" section; create is what the
// "Invite member" dialog calls.
//
// IMPORTANT: the plaintext token is returned exactly ONCE — in
// the POST response. We store only the SHA-256 hash on the row,
// so neither GET nor a future PATCH can ever resurface the
// link. The admin sees it in the creation modal, copies it, and
// shares it via WhatsApp/Slack/whatever they like. If they
// dismiss the modal without copying, the only recourse is to
// revoke and re-issue.
// ============================================================
import { NextResponse } from "next/server";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import {
clampExpiryDays,
generateInviteToken,
inviteExpiresAt,
inviteUrl,
} from "@/lib/auth/invitations";
import { isAccountRole } from "@/lib/auth/roles";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
// Resolve the base URL we publish invite links under.
//
// Resolution order, first match wins:
//
// 1. `NEXT_PUBLIC_SITE_URL` — admin's explicit config. Trumps
// everything; if you set this, that's where links point.
// 2. `X-Forwarded-Host` (+ `X-Forwarded-Proto`) — set by every
// reverse proxy in front of the app: Hostinger Managed
// Node.js, Vercel, Cloudflare, nginx. This is what makes
// invite links Just Work in production without forcing the
// operator to set an env var.
// 3. `Host` header + the protocol the request arrived on —
// bare deployments without a proxy.
// 4. Last-resort marketing-site fallback. Only hit if the
// request has no Host header at all, which is essentially
// impossible from a real browser. Logs a warning so the
// operator can spot the misconfig.
//
// Defense-in-depth: `ALLOWED_INVITE_HOSTS`
//
// The request-header path (#2 and #3 above) trusts whatever
// hostname the client (or proxy) puts in the header. On a
// typical proxied deploy (Vercel / Hostinger / Cloudflare) the
// proxy overwrites these so they're trustworthy. On a bare
// deployment exposed to the public internet, an attacker could
// POST directly with a crafted `Host: phishing.example` and
// receive an invite URL pointing at their site.
//
// When `ALLOWED_INVITE_HOSTS` is set (comma-separated hostnames),
// we validate the derived host against the list. Anything not
// on the list falls through to the wacrm.tech fallback with a
// loud console.warn. Operators who care about this attack
// surface should set this to their canonical hostnames; everyone
// else gets today's permissive behavior.
//
// Previous implementation hard-defaulted to `https://wacrm.tech`
// (the docs/marketing site, a different repo). Forks that didn't
// set `NEXT_PUBLIC_SITE_URL` got invite links pointing at the
// marketing site, which 404s on `/join/<token>`. This resolution
// chain removes the foot-gun.
function parseAllowedHosts(): readonly string[] | null {
const raw = process.env.ALLOWED_INVITE_HOSTS?.trim();
if (!raw) return null;
const list = raw
.split(",")
.map((h) => h.trim().toLowerCase())
.filter(Boolean);
return list.length > 0 ? list : null;
}
function isHostAllowed(
hostname: string,
allowList: readonly string[] | null,
): boolean {
if (!allowList) return true; // No allow-list → permissive (legacy behavior).
return allowList.includes(hostname.toLowerCase());
}
function getBaseUrl(request: Request): string {
const explicit = process.env.NEXT_PUBLIC_SITE_URL?.trim();
if (explicit) return explicit.replace(/\/+$/, "");
const allowList = parseAllowedHosts();
const forwardedHost = request.headers
.get("x-forwarded-host")
?.split(",")[0]
?.trim();
const forwardedProto = request.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim();
if (forwardedHost && isHostAllowed(forwardedHost, allowList)) {
return `${forwardedProto || "https"}://${forwardedHost}`;
}
const host = request.headers.get("host")?.trim();
if (host && isHostAllowed(host, allowList)) {
// The protocol on `request.url` is whatever the framework saw —
// reliable for bare deployments where no proxy is rewriting it.
const reqProto = new URL(request.url).protocol.replace(":", "");
return `${reqProto}://${host}`;
}
// We fall through here when EITHER no Host header was present at
// all (essentially impossible from a real browser) OR an
// ALLOWED_INVITE_HOSTS list was set and neither candidate matched
// it. The warning is the operator's signal that someone is
// probing the API with a spoofed Host header.
if (allowList && (forwardedHost || host)) {
console.warn(
"[POST /api/account/invitations] rejected non-allow-listed host:",
{ forwardedHost, host, allowList },
);
} else {
console.warn(
"[POST /api/account/invitations] could not derive base URL from request; falling back to marketing domain",
);
}
return "https://wacrm.tech";
}
const MAX_LABEL_LEN = 80;
export async function GET() {
try {
const ctx = await requireRole("admin");
const { data, error } = await ctx.supabase
.from("account_invitations")
.select(
"id, role, label, created_by_user_id, created_at, expires_at, accepted_at, accepted_by_user_id",
)
.eq("account_id", ctx.accountId)
.is("accepted_at", null)
.gt("expires_at", new Date().toISOString())
.order("created_at", { ascending: false });
if (error) {
console.error("[GET /api/account/invitations] fetch error:", error);
return NextResponse.json(
{ error: "Failed to load invitations" },
{ status: 500 },
);
}
return NextResponse.json({ invitations: data ?? [] });
} catch (err) {
return toErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireRole("admin");
// 30/min per user. The Members tab is a clicks-only UI so any
// legitimate admin is far below this; the cap exists to keep
// a script run in a loop or a compromised admin session from
// flooding `account_invitations` with rows.
const limit = checkRateLimit(
`admin:inviteCreate:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const body = (await request.json().catch(() => null)) as
| { role?: unknown; expiresInDays?: unknown; label?: unknown }
| null;
const role = body?.role;
if (!isAccountRole(role) || role === "owner") {
// The DB CHECK already rejects 'owner', but failing fast
// here gives a clearer 400 than the eventual constraint
// violation surfaced as a 500.
return NextResponse.json(
{ error: "'role' must be one of admin, agent, viewer" },
{ status: 400 },
);
}
const expiresInDaysRaw = body?.expiresInDays;
// `clampExpiryDays` tolerates undefined / NaN / negatives by
// collapsing to the safe default, so we just pass the raw
// value through after a type narrow.
const expiresInDays =
typeof expiresInDaysRaw === "number" ? expiresInDaysRaw : undefined;
const expiryDays = clampExpiryDays(expiresInDays);
const expiresAt = inviteExpiresAt(expiryDays);
let label: string | null = null;
if (typeof body?.label === "string") {
const trimmed = body.label.trim();
if (trimmed.length > MAX_LABEL_LEN) {
return NextResponse.json(
{ error: `Label must be ${MAX_LABEL_LEN} characters or fewer` },
{ status: 400 },
);
}
label = trimmed === "" ? null : trimmed;
}
const { token, hash } = generateInviteToken();
const { data, error } = await ctx.supabase
.from("account_invitations")
.insert({
account_id: ctx.accountId,
token_hash: hash,
role,
created_by_user_id: ctx.userId,
label,
expires_at: expiresAt.toISOString(),
})
.select("id, role, label, expires_at, created_at")
.single();
if (error || !data) {
console.error("[POST /api/account/invitations] insert error:", error);
return NextResponse.json(
{ error: "Failed to create invitation" },
{ status: 500 },
);
}
return NextResponse.json(
{
invitation: data,
// Plaintext payload — visible to the admin exactly once.
token,
url: inviteUrl(token, getBaseUrl(request)),
expiresInDays: expiryDays,
},
{ status: 201 },
);
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,122 @@
// ============================================================
// /api/account/members/[userId]
//
// PATCH — change a member's role. Admin+.
// DELETE — remove a member. Admin+.
//
// Both delegate to SECURITY DEFINER RPCs from migration 018:
// - set_member_role(p_user_id, p_new_role)
// - remove_account_member(p_user_id)
//
// The RPCs do the *real* authorisation work — caller must be
// admin+, target must be in caller's account, target can't be the
// owner, can't be self. The TS layer here only forwards the call
// and maps Postgres SQLSTATEs back to HTTP statuses.
// ============================================================
import { NextResponse } from "next/server";
import type { PostgrestError } from "@supabase/supabase-js";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import { isAccountRole } from "@/lib/auth/roles";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
// Map known SQLSTATEs from the RPCs (see migration 018) onto HTTP
// statuses. The `error.code` field is the SQLSTATE; the `message`
// is the human-readable RAISE message we put in the migration.
function rpcErrorToResponse(err: PostgrestError): NextResponse {
if (err.code === "42501") {
return NextResponse.json({ error: err.message }, { status: 403 });
}
if (err.code === "22023") {
return NextResponse.json({ error: err.message }, { status: 400 });
}
console.error("[members route] unexpected RPC error:", err);
return NextResponse.json(
{ error: "Failed to update member" },
{ status: 500 },
);
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ userId: string }> },
) {
try {
const ctx = await requireRole("admin");
const limit = checkRateLimit(
`admin:memberRole:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const { userId } = await params;
const body = (await request.json().catch(() => null)) as
| { role?: unknown }
| null;
const role = body?.role;
if (!isAccountRole(role)) {
return NextResponse.json(
{ error: "'role' must be one of owner, admin, agent, viewer" },
{ status: 400 },
);
}
// The RPC blocks promotion to / demotion from owner, but
// surface the friendlier 400 before crossing the wire too.
if (role === "owner") {
return NextResponse.json(
{
error:
"Use POST /api/account/transfer-ownership to promote a member to owner",
},
{ status: 400 },
);
}
const { error } = await ctx.supabase.rpc("set_member_role", {
p_user_id: userId,
p_new_role: role,
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true });
} catch (err) {
return toErrorResponse(err);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ userId: string }> },
) {
try {
const ctx = await requireRole("admin");
const limit = checkRateLimit(
`admin:memberRemove:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const { userId } = await params;
const { data, error } = await ctx.supabase.rpc("remove_account_member", {
p_user_id: userId,
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true, newPersonalAccountId: data });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,73 @@
// ============================================================
// GET /api/account/members
//
// Lists every member of the caller's account. Any member can call
// it (the Members tab is shown to admins+, but agents/viewers see
// a read-only roster too).
//
// Field visibility
// Sensitive fields (email) are returned only when the caller is
// admin+. Agents and viewers see name + avatar + role + joined
// date only. This mirrors the design decision from the planning
// phase: "agent/viewer sees names only".
// ============================================================
import { NextResponse } from "next/server";
import { getCurrentAccount, toErrorResponse } from "@/lib/auth/account";
import { canManageMembers, isAccountRole } from "@/lib/auth/roles";
import type { AccountMember } from "@/types";
interface ProfileRow {
user_id: string;
full_name: string | null;
email: string | null;
avatar_url: string | null;
account_role: string;
created_at: string;
}
export async function GET() {
try {
const ctx = await getCurrentAccount();
// RLS on profiles allows reading any row whose account matches
// the caller's, so this query is naturally account-scoped.
const { data, error } = await ctx.supabase
.from("profiles")
.select("user_id, full_name, email, avatar_url, account_role, created_at")
.eq("account_id", ctx.accountId)
.order("created_at", { ascending: true });
if (error) {
console.error("[GET /api/account/members] fetch error:", error);
return NextResponse.json(
{ error: "Failed to load members" },
{ status: 500 },
);
}
const canSeeEmails = canManageMembers(ctx.role);
const members: AccountMember[] = (data as ProfileRow[]).flatMap((row) => {
// Defensive: the DB enum should never let an unknown role
// through, but if a migration ever broadens the enum without
// updating TS, skip the row rather than crash the page.
if (!isAccountRole(row.account_role)) return [];
return [
{
user_id: row.user_id,
full_name: row.full_name ?? "",
email: canSeeEmails ? row.email : null,
avatar_url: row.avatar_url,
role: row.account_role,
joined_at: row.created_at,
},
];
});
return NextResponse.json({ members });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,103 @@
// ============================================================
// /api/account
//
// GET — current caller's account + role. Any member.
// PATCH — rename the account. Admin+.
//
// Why both verbs share a route file
// They speak about the same singular resource (the caller's
// account) and reuse the same `requireRole` plumbing. Splitting
// them across files would duplicate the `account_id` lookup
// without buying anything.
// ============================================================
import { NextResponse } from "next/server";
import {
requireRole,
getCurrentAccount,
toErrorResponse,
} from "@/lib/auth/account";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
export async function GET() {
try {
const ctx = await getCurrentAccount();
return NextResponse.json({
account: ctx.account,
role: ctx.role,
});
} catch (err) {
return toErrorResponse(err);
}
}
const MAX_NAME_LEN = 80;
export async function PATCH(request: Request) {
try {
const ctx = await requireRole("admin");
// Per-user limit on admin-class mutations. Bounds accidental
// abuse (script run in a loop) and a compromised admin session
// spamming renames. Each admin endpoint keys its own bucket so
// one route doesn't starve another.
const limit = checkRateLimit(
`admin:rename:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const body = (await request.json().catch(() => null)) as
| { name?: unknown }
| null;
const rawName = body?.name;
if (typeof rawName !== "string") {
return NextResponse.json(
{ error: "'name' must be a string" },
{ status: 400 },
);
}
const name = rawName.trim();
if (name.length === 0) {
return NextResponse.json(
{ error: "Account name cannot be empty" },
{ status: 400 },
);
}
if (name.length > MAX_NAME_LEN) {
return NextResponse.json(
{ error: `Account name must be ${MAX_NAME_LEN} characters or fewer` },
{ status: 400 },
);
}
// RLS allows this UPDATE because accounts_update requires
// `is_account_member(id, 'admin')`, and requireRole already
// guaranteed the caller is admin+.
const { data, error } = await ctx.supabase
.from("accounts")
.update({ name })
.eq("id", ctx.accountId)
.select("id, name")
.single();
if (error) {
console.error("[PATCH /api/account] update error:", error);
return NextResponse.json(
{ error: "Failed to update account" },
{ status: 500 },
);
}
return NextResponse.json({ account: data });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,94 @@
// ============================================================
// POST /api/account/transfer-ownership
//
// Owner only. Atomically:
// - demotes the current owner to 'admin'
// - promotes the target member to 'owner'
// - updates accounts.owner_user_id
//
// The atomic part lives in the `transfer_account_ownership`
// SECURITY DEFINER RPC (migration 018). This route just validates
// shape and forwards.
//
// Why a separate endpoint instead of PATCH /members/[userId]?
// The semantics differ: transfer demotes the current owner as
// a side-effect and changes the owner_user_id pointer on
// `accounts`. Making it explicit prevents the "I clicked the
// role dropdown by mistake" failure mode where an admin would
// silently hand their account away.
// ============================================================
import { NextResponse } from "next/server";
import type { PostgrestError } from "@supabase/supabase-js";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
function rpcErrorToResponse(err: PostgrestError): NextResponse {
if (err.code === "42501") {
return NextResponse.json({ error: err.message }, { status: 403 });
}
if (err.code === "22023") {
return NextResponse.json({ error: err.message }, { status: 400 });
}
console.error("[transfer-ownership] unexpected RPC error:", err);
return NextResponse.json(
{ error: "Failed to transfer ownership" },
{ status: 500 },
);
}
// Crude shape check — full UUID validation happens DB-side when
// the FK / lookup runs. This guards against obviously-wrong input
// (numbers, objects) before we round-trip.
function looksLikeUuid(v: unknown): v is string {
return (
typeof v === "string" &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
);
}
export async function POST(request: Request) {
try {
// `requireRole('owner')` is belt-and-braces — the RPC checks
// this too, but failing fast here saves a Supabase round trip
// on the obvious "admin trying to transfer" case.
const ctx = await requireRole("owner");
// Rate-limit owner-only transfers. Legitimate use is one click
// every few months at most; a script run in a loop would
// produce a noisy audit trail. 30/min is well above any human
// pace and bounds the noise.
const limit = checkRateLimit(
`admin:transferOwnership:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const body = (await request.json().catch(() => null)) as
| { newOwnerUserId?: unknown }
| null;
const newOwnerUserId = body?.newOwnerUserId;
if (!looksLikeUuid(newOwnerUserId)) {
return NextResponse.json(
{ error: "'newOwnerUserId' must be a valid UUID" },
{ status: 400 },
);
}
const { error } = await ctx.supabase.rpc("transfer_account_ownership", {
p_new_owner_user_id: newOwnerUserId,
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,253 @@
import { NextResponse } from 'next/server'
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { encrypt, decrypt } from '@/lib/whatsapp/encryption'
import { validateAiCredentials } from '@/lib/ai/validate'
import { embedTexts } from '@/lib/ai/embeddings'
import { AiError, type AiProvider } from '@/lib/ai/types'
function bad(message: string) {
return NextResponse.json({ error: message }, { status: 400 })
}
/**
* GET /api/ai/config
*
* Any member may read the config so the inbox/settings can reflect
* whether AI is set up. The encrypted key is NEVER returned — only a
* `has_key` flag; the settings form shows a masked placeholder.
*/
export async function GET() {
try {
const { supabase, accountId } = await getCurrentAccount()
const { data, error } = await supabase
.from('ai_configs')
// `api_key` is selected only to derive `has_key` — it is stripped
// out below and never returned to the client.
.select(
'provider, model, system_prompt, is_active, auto_reply_enabled, auto_reply_max_per_conversation, api_key, embeddings_api_key',
)
.eq('account_id', accountId)
.maybeSingle()
if (error) {
console.error('[ai/config GET] fetch error:', error)
return NextResponse.json(
{ error: 'Failed to load AI configuration' },
{ status: 500 },
)
}
if (!data) return NextResponse.json({ configured: false })
// The keys are selected only to derive the has_* flags; neither is
// returned to the client.
const { api_key, embeddings_api_key, ...safe } = data
return NextResponse.json({
configured: true,
has_key: !!api_key,
has_embeddings_key: !!embeddings_api_key,
...safe,
})
} catch (err) {
return toErrorResponse(err)
}
}
/**
* POST /api/ai/config (admin+)
*
* Upsert the account's AI config. Validates the key with the provider
* before persisting (mirrors the WhatsApp config verifying with Meta
* first), then stores the key AES-256-GCM-encrypted. When `api_key` is
* omitted the existing stored key is reused (the form sends it only
* when the user re-enters it).
*/
export async function POST(request: Request) {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-config:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const body = await request.json().catch(() => null)
if (!body || typeof body !== 'object') return bad('Invalid request body')
const provider = body.provider as AiProvider
if (provider !== 'openai' && provider !== 'anthropic') {
return bad('provider must be "openai" or "anthropic"')
}
const model = typeof body.model === 'string' ? body.model.trim() : ''
if (!model) return bad('model is required')
const systemPrompt =
typeof body.system_prompt === 'string' && body.system_prompt.trim()
? body.system_prompt.trim()
: null
const isActive = body.is_active === true
const autoReplyEnabled = body.auto_reply_enabled === true
let maxPer = Number(body.auto_reply_max_per_conversation)
if (!Number.isFinite(maxPer)) maxPer = 3
maxPer = Math.min(20, Math.max(1, Math.floor(maxPer)))
const rawKey = typeof body.api_key === 'string' ? body.api_key.trim() : ''
// Embeddings key (optional, for semantic KB search): a non-empty
// string sets/replaces it; an explicit null clears it; absent leaves
// it unchanged. The form only sends it when the admin edits it.
const rawEmbeddingsKey =
typeof body.embeddings_api_key === 'string'
? body.embeddings_api_key.trim()
: ''
const clearEmbeddingsKey = body.embeddings_api_key === null
// Reuse the stored key when the form didn't send a fresh one.
const { data: existing } = await supabase
.from('ai_configs')
.select('id, provider, model, api_key')
.eq('account_id', accountId)
.maybeSingle()
let apiKeyPlain: string
if (rawKey) {
apiKeyPlain = rawKey
} else if (existing?.api_key) {
try {
apiKeyPlain = decrypt(existing.api_key)
} catch {
return bad('Stored API key could not be decrypted — re-enter your key.')
}
} else {
return bad('api_key is required')
}
// Only spend a provider round-trip when the credentials that affect
// reachability actually changed. A save that just flips a toggle or
// edits the system prompt on an existing, already-validated config
// skips the call — no wasted token/latency on the account's key.
const credentialsChanged =
!existing ||
rawKey !== '' ||
provider !== existing.provider ||
model !== existing.model
if (credentialsChanged) {
try {
await validateAiCredentials({
provider,
model,
apiKey: apiKeyPlain,
systemPrompt,
isActive,
autoReplyEnabled,
autoReplyMaxPerConversation: maxPer,
embeddingsApiKey: null,
})
} catch (err) {
if (err instanceof AiError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: 400 },
)
}
console.error('[ai/config POST] validation error:', err)
return bad('Could not validate the API key with the provider.')
}
}
// Validate a new embeddings key before storing (a cheap 1-input
// embed), same "verify before save" discipline as the chat key.
if (rawEmbeddingsKey) {
try {
await embedTexts(rawEmbeddingsKey, ['ping'])
} catch (err) {
if (err instanceof AiError) {
return NextResponse.json(
{ error: `Embeddings key: ${err.message}`, code: err.code },
{ status: 400 },
)
}
console.error('[ai/config POST] embeddings validation error:', err)
return bad('Could not validate the embeddings key.')
}
}
const encryptedKey = rawKey ? encrypt(rawKey) : null
const shared: Record<string, unknown> = {
provider,
model,
system_prompt: systemPrompt,
is_active: isActive,
auto_reply_enabled: autoReplyEnabled,
auto_reply_max_per_conversation: maxPer,
}
if (rawEmbeddingsKey) {
shared.embeddings_api_key = encrypt(rawEmbeddingsKey)
} else if (clearEmbeddingsKey) {
shared.embeddings_api_key = null
}
if (existing) {
const { error: upErr } = await supabase
.from('ai_configs')
.update(encryptedKey ? { ...shared, api_key: encryptedKey } : shared)
.eq('account_id', accountId)
if (upErr) {
console.error('[ai/config POST] update error:', upErr)
return NextResponse.json(
{ error: 'Failed to save AI configuration' },
{ status: 500 },
)
}
} else {
const { error: insErr } = await supabase.from('ai_configs').insert({
account_id: accountId,
created_by: userId,
api_key: encryptedKey, // guaranteed non-null: rawKey required when no existing row
...shared,
})
if (insErr) {
console.error('[ai/config POST] insert error:', insErr)
return NextResponse.json(
{ error: 'Failed to save AI configuration' },
{ status: 500 },
)
}
}
return NextResponse.json({ success: true })
} catch (err) {
return toErrorResponse(err)
}
}
/**
* DELETE /api/ai/config (admin+)
*
* Removes the account's AI config (turns everything off and forgets the
* key). Also used to recover from a corrupted encrypted key.
*/
export async function DELETE() {
try {
const { supabase, accountId } = await requireRole('admin')
const { error } = await supabase
.from('ai_configs')
.delete()
.eq('account_id', accountId)
if (error) {
console.error('[ai/config DELETE] error:', error)
return NextResponse.json(
{ error: 'Failed to delete AI configuration' },
{ status: 500 },
)
}
return NextResponse.json({ success: true })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,116 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadAiConfig } from '@/lib/ai/config'
import { buildConversationContext } from '@/lib/ai/context'
import { retrieveKnowledge } from '@/lib/ai/knowledge'
import { generateReply } from '@/lib/ai/generate'
import { buildSystemPrompt } from '@/lib/ai/defaults'
import { latestUserMessage } from '@/lib/ai/query'
import { AiError } from '@/lib/ai/types'
/**
* POST /api/ai/draft (agent+)
*
* Body: { conversation_id }
* Returns: { draft } — a suggested reply for the agent to edit + send.
*
* Uses the account's configured provider/key (BYO). Read-only: it never
* sends or stores anything, just hands text back to the composer.
*/
export async function POST(request: Request) {
try {
const { supabase, accountId, userId } = await requireRole('agent')
const userLimit = checkRateLimit(`ai-draft:${userId}`, RATE_LIMITS.aiDraft)
if (!userLimit.success) return rateLimitResponse(userLimit)
// Also cap the whole team's draws on the shared BYO provider key.
const accountLimit = checkRateLimit(
`ai-draft-acct:${accountId}`,
RATE_LIMITS.aiDraftAccount,
)
if (!accountLimit.success) return rateLimitResponse(accountLimit)
const body = await request.json().catch(() => null)
const conversationId =
body && typeof body.conversation_id === 'string' ? body.conversation_id : ''
if (!conversationId) {
return NextResponse.json(
{ error: 'conversation_id is required' },
{ status: 400 },
)
}
// RLS scopes the SSR client to the caller's account, so a missing
// row means "not yours / not found" either way.
const { data: conversation, error: convErr } = await supabase
.from('conversations')
.select('id')
.eq('id', conversationId)
.maybeSingle()
if (convErr) {
console.error('[ai/draft] conversation lookup error:', convErr)
return NextResponse.json({ error: 'Failed to load conversation' }, { status: 500 })
}
if (!conversation) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
}
const config = await loadAiConfig(supabase, accountId).catch((err) => {
// Decrypt failure — surface distinctly from "not configured".
console.error('[ai/draft] loadAiConfig error:', err)
throw new AiError('Stored API key could not be decrypted.', {
code: 'key_decrypt_failed',
status: 400,
})
})
if (!config) {
return NextResponse.json(
{
error: 'AI assistant is not set up. Enable it in Settings → AI Assistant.',
code: 'ai_not_configured',
},
{ status: 400 },
)
}
const messages = await buildConversationContext(supabase, conversationId)
// Nothing to draft from — a brand-new thread with no customer text
// would otherwise produce a nonsensical reply-to-nothing.
if (messages.length === 0) {
return NextResponse.json(
{
error: 'No messages to draft from yet.',
code: 'no_messages',
},
{ status: 400 },
)
}
// Ground the draft in the account's knowledge base (best-effort —
// returns [] when there's no KB or retrieval fails).
const knowledge = await retrieveKnowledge(
supabase,
accountId,
config,
latestUserMessage(messages),
)
const systemPrompt = buildSystemPrompt({
userPrompt: config.systemPrompt,
mode: 'draft',
knowledge,
})
const { text } = await generateReply({ config, systemPrompt, messages })
return NextResponse.json({ draft: text })
} catch (err) {
if (err instanceof AiError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: err.status },
)
}
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server'
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadEmbeddingsKey } from '@/lib/ai/config'
import { ingestDocument } from '@/lib/ai/knowledge'
import { AiError } from '@/lib/ai/types'
type Params = { params: Promise<{ id: string }> }
/**
* GET /api/ai/knowledge/[id] — full document (any member).
*/
export async function GET(_request: Request, { params }: Params) {
try {
const { supabase, accountId } = await getCurrentAccount()
const { id } = await params
const { data, error } = await supabase
.from('ai_knowledge_documents')
.select('id, title, content, updated_at')
.eq('account_id', accountId)
.eq('id', id)
.maybeSingle()
if (error) {
console.error('[ai/knowledge/[id] GET] error:', error)
return NextResponse.json({ error: 'Failed to load document' }, { status: 500 })
}
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(data)
} catch (err) {
return toErrorResponse(err)
}
}
/**
* PATCH /api/ai/knowledge/[id] (admin+) — update title/content and
* re-index when the content changed.
*/
export async function PATCH(request: Request, { params }: Params) {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-kb:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const { id } = await params
const body = await request.json().catch(() => null)
const title = typeof body?.title === 'string' ? body.title.trim() : undefined
const content = typeof body?.content === 'string' ? body.content.trim() : undefined
if (title === undefined && content === undefined) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
if (title !== undefined && !title) {
return NextResponse.json({ error: 'title cannot be empty' }, { status: 400 })
}
if (content !== undefined && !content) {
return NextResponse.json({ error: 'content cannot be empty' }, { status: 400 })
}
const update: Record<string, string> = {}
if (title !== undefined) update.title = title
if (content !== undefined) update.content = content
const { data: updated, error } = await supabase
.from('ai_knowledge_documents')
.update(update)
.eq('account_id', accountId)
.eq('id', id)
.select('id')
.maybeSingle()
if (error) {
console.error('[ai/knowledge/[id] PATCH] error:', error)
return NextResponse.json({ error: 'Failed to update document' }, { status: 500 })
}
if (!updated) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (content !== undefined) {
const { key: embeddingsApiKey, corrupt } = await loadEmbeddingsKey(
supabase,
accountId,
)
try {
await ingestDocument(supabase, accountId, { embeddingsApiKey }, id, content)
} catch (err) {
const message = err instanceof AiError ? err.message : 'indexing failed'
console.error('[ai/knowledge/[id] PATCH] ingest error:', err)
return NextResponse.json(
{
success: true,
warning: `Updated, but semantic indexing failed (${message}). Lexical search still works; use Reindex to retry.`,
},
{ status: 200 },
)
}
if (corrupt) {
return NextResponse.json({
success: true,
warning:
'Updated with keyword search only — your embeddings key could not be decrypted (check ENCRYPTION_KEY, then re-enter the key).',
})
}
}
return NextResponse.json({ success: true })
} catch (err) {
return toErrorResponse(err)
}
}
/**
* DELETE /api/ai/knowledge/[id] (admin+) — chunks cascade.
*/
export async function DELETE(_request: Request, { params }: Params) {
try {
const { supabase, accountId } = await requireRole('admin')
const { id } = await params
const { error } = await supabase
.from('ai_knowledge_documents')
.delete()
.eq('account_id', accountId)
.eq('id', id)
if (error) {
console.error('[ai/knowledge/[id] DELETE] error:', error)
return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 })
}
return NextResponse.json({ success: true })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,79 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadEmbeddingsKey } from '@/lib/ai/config'
import { ingestDocument } from '@/lib/ai/knowledge'
import { AiError } from '@/lib/ai/types'
/**
* POST /api/ai/knowledge/reindex (admin+)
*
* Re-chunk and re-embed every document in the account. The main use is
* after adding an embeddings key: existing documents were stored
* lexical-only, and this backfills their vectors so semantic search
* turns on. Also recovers documents whose indexing failed earlier.
*/
export async function POST() {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-kb-reindex:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const { data: docs, error } = await supabase
.from('ai_knowledge_documents')
.select('id, content')
.eq('account_id', accountId)
if (error) {
console.error('[ai/knowledge/reindex] fetch error:', error)
return NextResponse.json(
{ error: 'Failed to load documents' },
{ status: 500 },
)
}
const { key: embeddingsApiKey, corrupt } = await loadEmbeddingsKey(
supabase,
accountId,
)
// The whole point of Reindex is usually to backfill embeddings — so
// if a key is configured but can't be decrypted, don't quietly do a
// lexical-only pass and report success. Stop and tell the admin.
if (corrupt) {
return NextResponse.json(
{
success: false,
reindexed: 0,
error:
'Your embeddings key could not be decrypted (check ENCRYPTION_KEY, then re-enter the key in Settings → AI Assistant). Nothing was reindexed.',
},
{ status: 200 },
)
}
let reindexed = 0
for (const doc of docs ?? []) {
try {
await ingestDocument(supabase, accountId, { embeddingsApiKey }, doc.id, doc.content)
reindexed += 1
} catch (err) {
// One bad document (e.g. a mid-run embeddings rate-limit) should
// not abort the whole batch.
const message = err instanceof AiError ? err.message : String(err)
console.error(`[ai/knowledge/reindex] doc ${doc.id} failed:`, message)
return NextResponse.json(
{
success: false,
reindexed,
total: (docs ?? []).length,
error: `Reindexed ${reindexed}, then hit an error: ${message}`,
},
{ status: 200 },
)
}
}
return NextResponse.json({ success: true, reindexed })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,110 @@
import { NextResponse } from 'next/server'
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadEmbeddingsKey } from '@/lib/ai/config'
import { ingestDocument } from '@/lib/ai/knowledge'
import { AiError } from '@/lib/ai/types'
/**
* GET /api/ai/knowledge
*
* List the account's knowledge-base documents (any member).
*/
export async function GET() {
try {
const { supabase, accountId } = await getCurrentAccount()
const { data, error } = await supabase
.from('ai_knowledge_documents')
.select('id, title, updated_at')
.eq('account_id', accountId)
.order('updated_at', { ascending: false })
if (error) {
console.error('[ai/knowledge GET] error:', error)
return NextResponse.json(
{ error: 'Failed to load knowledge base' },
{ status: 500 },
)
}
return NextResponse.json({ documents: data ?? [] })
} catch (err) {
return toErrorResponse(err)
}
}
/**
* POST /api/ai/knowledge (admin+)
*
* Create a document, then chunk + (optionally) embed it. If indexing
* fails the document is still saved so the admin can retry via reindex.
*/
export async function POST(request: Request) {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-kb:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const body = await request.json().catch(() => null)
const title = typeof body?.title === 'string' ? body.title.trim() : ''
const content = typeof body?.content === 'string' ? body.content.trim() : ''
if (!title || !content) {
return NextResponse.json(
{ error: 'title and content are required' },
{ status: 400 },
)
}
const { data: doc, error } = await supabase
.from('ai_knowledge_documents')
.insert({ account_id: accountId, created_by: userId, title, content })
.select('id')
.single()
if (error || !doc) {
console.error('[ai/knowledge POST] insert error:', error)
return NextResponse.json(
{ error: 'Failed to save document' },
{ status: 500 },
)
}
const { key: embeddingsApiKey, corrupt } = await loadEmbeddingsKey(
supabase,
accountId,
)
try {
await ingestDocument(
supabase,
accountId,
{ embeddingsApiKey },
doc.id,
content,
)
} catch (err) {
const message = err instanceof AiError ? err.message : 'indexing failed'
console.error('[ai/knowledge POST] ingest error:', err)
return NextResponse.json(
{
success: true,
id: doc.id,
warning: `Saved, but semantic indexing failed (${message}). Lexical search still works; use Reindex to retry.`,
},
{ status: 200 },
)
}
if (corrupt) {
return NextResponse.json({
success: true,
id: doc.id,
warning:
'Saved with keyword search only — your embeddings key could not be decrypted (check ENCRYPTION_KEY, then re-enter the key).',
})
}
return NextResponse.json({ success: true, id: doc.id })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,98 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadAiConfig } from '@/lib/ai/config'
import { retrieveKnowledge } from '@/lib/ai/knowledge'
import { generateReply } from '@/lib/ai/generate'
import { buildSystemPrompt } from '@/lib/ai/defaults'
import { latestUserMessage } from '@/lib/ai/query'
import { AiError, type ChatMessage } from '@/lib/ai/types'
// Keep the tested transcript bounded, mirroring the live context window.
const MAX_TURNS = 20
/**
* POST /api/ai/playground (agent+)
*
* Test-chat with the account's agent WITHOUT touching WhatsApp. Runs the
* exact same path the auto-reply bot uses — knowledge-base retrieval +
* `auto_reply` system prompt + the configured provider — so what you see
* here is what a real customer would get. Reads the config even when the
* master switch is off (requireActive:false) so you can try it before
* going live. Stateless: the client sends the running transcript each turn.
*/
export async function POST(request: Request) {
try {
const { supabase, accountId, userId } = await requireRole('agent')
const limit = checkRateLimit(`ai-playground:${userId}`, RATE_LIMITS.aiDraft)
if (!limit.success) return rateLimitResponse(limit)
const body = await request.json().catch(() => null)
const rawMessages = Array.isArray(body?.messages) ? body.messages : null
if (!rawMessages) {
return NextResponse.json({ error: 'messages is required' }, { status: 400 })
}
const messages: ChatMessage[] = rawMessages
.filter(
(m: unknown): m is ChatMessage =>
!!m &&
typeof m === 'object' &&
((m as ChatMessage).role === 'user' ||
(m as ChatMessage).role === 'assistant') &&
typeof (m as ChatMessage).content === 'string' &&
(m as ChatMessage).content.trim().length > 0,
)
.slice(-MAX_TURNS)
if (messages.length === 0) {
return NextResponse.json(
{ error: 'Send a message to test the agent.' },
{ status: 400 },
)
}
const config = await loadAiConfig(supabase, accountId, {
requireActive: false,
}).catch((err) => {
console.error('[ai/playground] loadAiConfig error:', err)
throw new AiError('Stored API key could not be decrypted.', {
code: 'key_decrypt_failed',
status: 400,
})
})
if (!config) {
return NextResponse.json(
{
error: 'No agent configured yet. Add your provider key in Setup.',
code: 'ai_not_configured',
},
{ status: 400 },
)
}
const knowledge = await retrieveKnowledge(
supabase,
accountId,
config,
latestUserMessage(messages),
)
const systemPrompt = buildSystemPrompt({
userPrompt: config.systemPrompt,
mode: 'auto_reply',
knowledge,
})
const { text, handoff } = await generateReply({ config, systemPrompt, messages })
return NextResponse.json({ reply: text, handoff })
} catch (err) {
if (err instanceof AiError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: err.status },
)
}
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,94 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { decrypt } from '@/lib/whatsapp/encryption'
import { validateAiCredentials } from '@/lib/ai/validate'
import { AiError, type AiProvider } from '@/lib/ai/types'
/**
* POST /api/ai/test (admin+)
*
* "Test key" button: validate a candidate provider/model/key against
* the provider WITHOUT saving. When `api_key` is omitted the stored
* key is used, so an admin can re-test an existing config (e.g. after
* changing the model). Returns `{ ok: true }` on success, 400 with the
* provider's message on failure.
*/
export async function POST(request: Request) {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-test:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const body = await request.json().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const provider = body.provider as AiProvider
if (provider !== 'openai' && provider !== 'anthropic') {
return NextResponse.json(
{ error: 'provider must be "openai" or "anthropic"' },
{ status: 400 },
)
}
const model = typeof body.model === 'string' ? body.model.trim() : ''
if (!model) {
return NextResponse.json({ error: 'model is required' }, { status: 400 })
}
const rawKey = typeof body.api_key === 'string' ? body.api_key.trim() : ''
let apiKeyPlain = rawKey
if (!apiKeyPlain) {
const { data: existing } = await supabase
.from('ai_configs')
.select('api_key')
.eq('account_id', accountId)
.maybeSingle()
if (!existing?.api_key) {
return NextResponse.json(
{ error: 'Enter an API key to test.' },
{ status: 400 },
)
}
try {
apiKeyPlain = decrypt(existing.api_key)
} catch {
return NextResponse.json(
{ error: 'Stored API key could not be decrypted — re-enter your key.' },
{ status: 400 },
)
}
}
try {
await validateAiCredentials({
provider,
model,
apiKey: apiKeyPlain,
systemPrompt: null,
isActive: true,
autoReplyEnabled: false,
autoReplyMaxPerConversation: 3,
embeddingsApiKey: null,
})
} catch (err) {
if (err instanceof AiError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: 400 },
)
}
console.error('[ai/test] validation error:', err)
return NextResponse.json(
{ error: 'Could not validate the API key.' },
{ status: 400 },
)
}
return NextResponse.json({ ok: true })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/automations/admin-client'
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const admin = supabaseAdmin()
const { data: original, error: origErr } = await admin
.from('automations')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (origErr) return NextResponse.json({ error: origErr.message }, { status: 500 })
if (!original) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const { data: copy, error: copyErr } = await admin
.from('automations')
.insert({
// Clone into the same account as the original. account_id is NOT
// NULL post-017, so the INSERT fails the constraint without it.
account_id: original.account_id,
user_id: user.id,
name: `${original.name} (Copy)`,
description: original.description,
trigger_type: original.trigger_type,
trigger_config: original.trigger_config,
is_active: false,
})
.select()
.single()
if (copyErr || !copy) {
return NextResponse.json({ error: copyErr?.message ?? 'copy failed' }, { status: 500 })
}
const { data: steps } = await admin
.from('automation_steps')
.select('id, parent_step_id, branch, step_type, step_config, position')
.eq('automation_id', id)
.order('position', { ascending: true })
if (steps && steps.length > 0) {
// Re-map parent_step_id: build old→new id map first so the second
// pass inserts rows with correct parent references.
const idMap = new Map<string, string>()
const uid = () =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2) + Date.now().toString(36)
for (const row of steps) idMap.set(row.id as string, uid())
const rows = steps.map((row) => ({
id: idMap.get(row.id as string)!,
automation_id: copy.id,
parent_step_id: row.parent_step_id ? idMap.get(row.parent_step_id as string) : null,
branch: row.branch,
step_type: row.step_type,
step_config: row.step_config,
position: row.position,
}))
const { error: insErr } = await admin.from('automation_steps').insert(rows)
if (insErr) return NextResponse.json({ error: insErr.message }, { status: 500 })
}
return NextResponse.json({ automation: copy }, { status: 201 })
}

View File

@@ -0,0 +1,138 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/automations/admin-client'
import {
loadStepsTree,
replaceSteps,
type BuilderStepInput,
} from '@/lib/automations/steps-tree'
import {
validateStepsForActivation,
validateTriggerForActivation,
} from '@/lib/automations/validate'
async function requireUser() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
return user
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const admin = supabaseAdmin()
const { data: automation, error } = await admin
.from('automations')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!automation) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const steps = await loadStepsTree(id)
return NextResponse.json({ automation, steps })
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json().catch(() => null)
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
const admin = supabaseAdmin()
// Ownership check before we touch anything. Load the fields we need
// to compute the post-patch "effective" state for validation.
const { data: existing } = await admin
.from('automations')
.select('id, user_id, is_active, trigger_type, trigger_config')
.eq('id', id)
.maybeSingle()
if (!existing || existing.user_id !== user.id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const update: Record<string, unknown> = {}
for (const k of [
'name',
'description',
'trigger_type',
'trigger_config',
'is_active',
] as const) {
if (k in body) update[k] = body[k]
}
// If this PATCH leaves the automation active (either explicitly
// activating it OR editing an already-active one), validate the
// merged configuration first. Activation is the natural gate — drafts
// are still allowed to be incomplete.
const willBeActive =
typeof update.is_active === 'boolean' ? update.is_active : existing.is_active
if (willBeActive) {
const mergedTriggerType = (update.trigger_type ?? existing.trigger_type) as string
const mergedTriggerConfig = update.trigger_config ?? existing.trigger_config
const mergedSteps = Array.isArray(body.steps)
? (body.steps as { step_type: string; step_config: Record<string, unknown> }[])
: await loadStepsTree(id)
const issues = [
...validateTriggerForActivation(mergedTriggerType, mergedTriggerConfig),
...validateStepsForActivation(mergedSteps),
]
if (issues.length > 0) {
return NextResponse.json(
{
error: 'Cannot keep automation active with invalid configuration',
issues,
},
{ status: 400 },
)
}
}
if (Object.keys(update).length > 0) {
const { error: updErr } = await admin
.from('automations')
.update(update)
.eq('id', id)
if (updErr) return NextResponse.json({ error: updErr.message }, { status: 500 })
}
if (Array.isArray(body.steps)) {
const err = await replaceSteps(id, body.steps as BuilderStepInput[])
if (err) return NextResponse.json({ error: err }, { status: 500 })
}
return NextResponse.json({ ok: true })
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { error } = await supabaseAdmin()
.from('automations')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ ok: true })
}

View File

@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/automations/admin-client'
import { resumePendingExecution } from '@/lib/automations/engine'
import type { AutomationContext } from '@/lib/automations/engine'
/**
* Drain due `automation_pending_executions` rows. Meant to be hit
* on a schedule (Vercel Cron / external pinger) — requires a shared
* secret via the `x-cron-secret` header to match
* `AUTOMATION_CRON_SECRET`.
*
* The claim step (status = 'running') serves as a simple lock so
* overlapping invocations don't double-process rows. Best-effort
* only; expensive SELECT ... FOR UPDATE is avoided in favor of a
* two-step UPDATE-by-id.
*/
export async function GET(request: Request) {
const expected = process.env.AUTOMATION_CRON_SECRET
if (!expected) {
return NextResponse.json({ error: 'cron not configured' }, { status: 503 })
}
const supplied = request.headers.get('x-cron-secret')
if (supplied !== expected) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const admin = supabaseAdmin()
const { data: due, error } = await admin
.from('automation_pending_executions')
.select('*')
.eq('status', 'pending')
.lte('run_at', new Date().toISOString())
.order('run_at', { ascending: true })
.limit(50)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!due || due.length === 0) return NextResponse.json({ processed: 0 })
let processed = 0
for (const row of due) {
const { data: claim } = await admin
.from('automation_pending_executions')
.update({ status: 'running' })
.eq('id', row.id)
.eq('status', 'pending')
.select('id')
.maybeSingle()
if (!claim) continue
await resumePendingExecution({
id: row.id as string,
automation_id: row.automation_id as string,
// account_id is NOT NULL on automation_pending_executions
// post-017; the engine uses it for tenant-scoped lookups.
account_id: row.account_id as string,
user_id: row.user_id as string,
contact_id: (row.contact_id as string | null) ?? null,
log_id: (row.log_id as string | null) ?? null,
parent_step_id: (row.parent_step_id as string | null) ?? null,
branch: (row.branch as 'yes' | 'no' | null) ?? null,
next_step_position: row.next_step_position as number,
context: (row.context as AutomationContext) ?? {},
})
processed++
}
return NextResponse.json({ processed })
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server'
import { getCurrentAccount, toErrorResponse } from '@/lib/auth/account'
import { runAutomationsForTrigger } from '@/lib/automations/engine'
import type { AutomationTriggerType } from '@/types'
/**
* Manual trigger for testing or for external integrations that want
* to fire automations. Auth is required — we resolve the caller's
* account_id and dispatch over the account's automations.
*/
export async function POST(request: Request) {
let accountId: string
try {
const ctx = await getCurrentAccount()
accountId = ctx.accountId
} catch (err) {
return toErrorResponse(err)
}
const body = await request.json().catch(() => null)
if (!body?.trigger_type) {
return NextResponse.json({ error: 'trigger_type required' }, { status: 400 })
}
await runAutomationsForTrigger({
accountId,
triggerType: body.trigger_type as AutomationTriggerType,
contactId: body.contact_id ?? null,
context: body.context ?? {},
})
return NextResponse.json({ ok: true })
}

View File

@@ -0,0 +1,125 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/automations/admin-client'
import { getTemplate } from '@/lib/automations/templates'
import { insertSteps, type BuilderStepInput } from '@/lib/automations/steps-tree'
import {
validateStepsForActivation,
validateTriggerForActivation,
} from '@/lib/automations/validate'
export async function GET() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data, error } = await supabase
.from('automations')
.select('*')
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ automations: data ?? [] })
}
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Resolve the caller's account_id — `automations.account_id` is NOT
// NULL post-017, so an INSERT without it trips the not-null constraint
// even though the admin client bypasses RLS.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.single()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const body = await request.json().catch(() => null)
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
const { name, description, trigger_type, trigger_config, is_active, steps, template } = body
let effectiveSteps: BuilderStepInput[] | undefined = steps
let effectiveName = name
let effectiveDescription = description
let effectiveTriggerType = trigger_type
let effectiveTriggerConfig = trigger_config
if (template && (!steps || steps.length === 0)) {
const t = getTemplate(template)
if (t) {
effectiveName = effectiveName ?? t.name
effectiveDescription = effectiveDescription ?? t.description
effectiveTriggerType = effectiveTriggerType ?? t.trigger_type
effectiveTriggerConfig = effectiveTriggerConfig ?? t.trigger_config
effectiveSteps = t.steps as unknown as BuilderStepInput[]
}
}
if (!effectiveName || !effectiveTriggerType) {
return NextResponse.json(
{ error: 'name and trigger_type are required' },
{ status: 400 },
)
}
// Block activation of a clearly broken automation up-front instead of
// letting every trigger silently produce a failed log row. Drafts
// (is_active=false) are allowed to be incomplete so users can save
// progress mid-build.
if (is_active) {
const issues = [
...validateTriggerForActivation(effectiveTriggerType, effectiveTriggerConfig ?? {}),
...validateStepsForActivation(
(effectiveSteps ?? []) as unknown as { step_type: string; step_config: Record<string, unknown> }[],
),
]
if (issues.length > 0) {
return NextResponse.json(
{ error: 'Cannot activate automation with invalid configuration', issues },
{ status: 400 },
)
}
}
const admin = supabaseAdmin()
const { data: automation, error: insertErr } = await admin
.from('automations')
.insert({
user_id: user.id,
account_id: accountId,
name: effectiveName,
description: effectiveDescription ?? null,
trigger_type: effectiveTriggerType,
trigger_config: effectiveTriggerConfig ?? {},
is_active: !!is_active,
})
.select()
.single()
if (insertErr || !automation) {
return NextResponse.json(
{ error: insertErr?.message ?? 'insert failed' },
{ status: 500 },
)
}
if (effectiveSteps && effectiveSteps.length > 0) {
const err = await insertSteps(automation.id, effectiveSteps)
if (err) return NextResponse.json({ error: err }, { status: 500 })
}
return NextResponse.json({ automation }, { status: 201 })
}

View File

@@ -0,0 +1,108 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/flows/admin-client'
import { validateFlowForActivation } from '@/lib/flows/validate'
/**
* POST /api/flows/[id]/activate
*
* Body: { status: 'draft' | 'active' | 'archived' }
*
* Activating runs the full validator and refuses on any 'error'
* severity issue. Drafts and archives are unconditional — users
* need to be able to save broken-work-in-progress and pause flows
* without first fixing them.
*
* Returns the updated flow on success; on validation failure returns
* the full issue list so the builder can highlight each problem.
*/
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = (await request.json().catch(() => null)) as
| { status?: 'draft' | 'active' | 'archived' }
| null
const status = body?.status
if (!status || !['draft', 'active', 'archived'].includes(status)) {
return NextResponse.json(
{ error: "status must be one of 'draft' | 'active' | 'archived'" },
{ status: 400 },
)
}
// Ownership via RLS — caller's client.
const { data: existing } = await supabase
.from('flows')
.select('id')
.eq('id', id)
.maybeSingle()
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const admin = supabaseAdmin()
if (status === 'active') {
// Re-load with the full payload the validator needs.
const [{ data: flow }, { data: nodes }] = await Promise.all([
admin
.from('flows')
.select('name, trigger_type, trigger_config, entry_node_id')
.eq('id', id)
.maybeSingle(),
admin
.from('flow_nodes')
.select('node_key, node_type, config')
.eq('flow_id', id),
])
if (!flow) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const issues = validateFlowForActivation(
flow as {
name: string
trigger_type: 'keyword' | 'first_inbound_message' | 'manual'
trigger_config: Record<string, unknown>
entry_node_id: string | null
},
(nodes ?? []) as Array<{
node_key: string
node_type: string
config: Record<string, unknown>
}>,
)
const blockers = issues.filter((i) => i.severity === 'error')
if (blockers.length > 0) {
return NextResponse.json(
{
error: 'Cannot activate flow — fix the issues below first.',
issues,
},
{ status: 422 },
)
}
}
const { data: updated, error } = await admin
.from('flows')
.update({ status, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.maybeSingle()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ flow: updated })
}

View File

@@ -0,0 +1,194 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/flows/admin-client'
/**
* GET /api/flows/[id] — fetch one flow with its nodes.
* PUT /api/flows/[id] — replace name/trigger/entry/fallback + the
* full node graph (delete-then-insert under
* the hood; not atomic, but the runner is
* resilient to mid-edit reads — node_not_found
* gracefully ends the run).
* DELETE /api/flows/[id] — hard delete (RLS+CASCADE clean up nodes,
* runs, events).
*
* All three require a signed-in caller who owns the flow. Flows is in
* soft-GA — the beta gate that previously 404'd non-beta accounts is
* gone; the "Beta" label in the UI is the only remaining signal.
*/
async function requireOwnership(
flowId: string,
): Promise<
| {
ok: true
userId: string
supabase: Awaited<ReturnType<typeof createClient>>
}
| { ok: false; status: number; body: { error: string } }
> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { ok: false, status: 401, body: { error: 'Unauthorized' } }
}
// RLS scopes this to the caller — a flow owned by another user
// returns null (404 below).
const { data: flow } = await supabase
.from('flows')
.select('id')
.eq('id', flowId)
.maybeSingle()
if (!flow) {
return { ok: false, status: 404, body: { error: 'Not found' } }
}
return { ok: true, userId: user.id, supabase }
}
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
const { supabase } = guard
const [{ data: flow }, { data: nodes }] = await Promise.all([
supabase.from('flows').select('*').eq('id', id).maybeSingle(),
supabase
.from('flow_nodes')
.select('*')
.eq('flow_id', id)
.order('created_at', { ascending: true }),
])
if (!flow) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ flow, nodes: nodes ?? [] })
}
interface PutBody {
name?: string
description?: string | null
trigger_type?: 'keyword' | 'first_inbound_message' | 'manual'
trigger_config?: Record<string, unknown>
entry_node_id?: string | null
fallback_policy?: Record<string, unknown>
nodes?: Array<{
node_key: string
node_type: string
config: Record<string, unknown>
position_x?: number
position_y?: number
}>
}
export async function PUT(
request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
const body = (await request.json().catch(() => null)) as PutBody | null
if (!body) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
if (body.name !== undefined && !body.name.trim()) {
return NextResponse.json(
{ error: 'name cannot be empty' },
{ status: 400 },
)
}
const admin = supabaseAdmin()
// Update the flow row first — the body may not include `nodes` (a
// header-only save for editing the trigger config without touching
// the graph). Skip node replacement in that case.
const flowPatch: Record<string, unknown> = {
updated_at: new Date().toISOString(),
}
if (body.name !== undefined) flowPatch.name = body.name.trim()
if (body.description !== undefined)
flowPatch.description = body.description
if (body.trigger_type !== undefined) flowPatch.trigger_type = body.trigger_type
if (body.trigger_config !== undefined)
flowPatch.trigger_config = body.trigger_config
if (body.entry_node_id !== undefined)
flowPatch.entry_node_id = body.entry_node_id
if (body.fallback_policy !== undefined)
flowPatch.fallback_policy = body.fallback_policy
const { error: updErr } = await admin
.from('flows')
.update(flowPatch)
.eq('id', id)
if (updErr) {
return NextResponse.json({ error: updErr.message }, { status: 500 })
}
if (body.nodes !== undefined) {
// Delete-then-insert. Not transactional but the runner handles
// mid-edit reads safely (a node_not_found ends the run cleanly).
const { error: delErr } = await admin
.from('flow_nodes')
.delete()
.eq('flow_id', id)
if (delErr) {
return NextResponse.json({ error: delErr.message }, { status: 500 })
}
if (body.nodes.length > 0) {
const { error: insErr } = await admin.from('flow_nodes').insert(
body.nodes.map((n) => ({
flow_id: id,
node_key: n.node_key,
node_type: n.node_type,
config: n.config,
position_x: n.position_x ?? 0,
position_y: n.position_y ?? 0,
})),
)
if (insErr) {
return NextResponse.json({ error: insErr.message }, { status: 500 })
}
}
}
// Re-fetch and return the new state — the editor uses the response
// to reconcile its local form state.
const [{ data: flow }, { data: nodes }] = await Promise.all([
admin.from('flows').select('*').eq('id', id).maybeSingle(),
admin
.from('flow_nodes')
.select('*')
.eq('flow_id', id)
.order('created_at', { ascending: true }),
])
return NextResponse.json({ flow, nodes: nodes ?? [] })
}
export async function DELETE(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
// CASCADE on flow_nodes / flow_runs / flow_run_events handles the
// children. Active runs end abruptly — there's no graceful "drain"
// mechanism in v1, but that's intentional: deleting a flow is a
// deliberate destructive action and the partial unique index will
// free up the contact for new triggers immediately.
const { error } = await supabaseAdmin().from('flows').delete().eq('id', id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ ok: true })
}

View File

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
/**
* GET /api/flows/[id]/runs
*
* Newest-first list of flow runs for a single flow, with the latest
* event timeline embedded for each. Used by the run-history viewer
* page (`/flows/[id]/runs`) to give the owner end-to-end visibility
* into what the bot did with each customer.
*
* RLS does the ownership check (flow_runs has a `user_id` policy);
* we also gate on the per-account beta flag so the route 404s for
* non-beta accounts matching the rest of /api/flows.
*
* Limited to the 50 most recent runs. Pagination can come later;
* the dashboard surface here is for debugging, not heavy querying.
*/
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Confirm flow exists + caller owns it (RLS does this) before doing
// the run query — gives us a clean 404 instead of empty array.
const { data: flow } = await supabase
.from('flows')
.select('id, name')
.eq('id', id)
.maybeSingle()
if (!flow) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Pull runs + each run's contact name + each run's events. Two
// joined selects keep the round-trip count to the runs query + one
// per-run events query.
const { data: runs, error: runsErr } = await supabase
.from('flow_runs')
.select(
'id, status, current_node_key, started_at, last_advanced_at, ended_at, end_reason, vars, reprompt_count, contact:contacts(id, name, phone)',
)
.eq('flow_id', id)
.order('started_at', { ascending: false })
.limit(50)
if (runsErr) {
return NextResponse.json({ error: runsErr.message }, { status: 500 })
}
const runIds = (runs ?? []).map((r) => (r as { id: string }).id)
let events: Array<{
flow_run_id: string
event_type: string
node_key: string | null
payload: Record<string, unknown>
created_at: string
}> = []
if (runIds.length > 0) {
const { data: evs, error: evsErr } = await supabase
.from('flow_run_events')
.select('flow_run_id, event_type, node_key, payload, created_at')
.in('flow_run_id', runIds)
.order('created_at', { ascending: true })
if (evsErr) {
// Non-fatal — the page can still show runs without timelines.
console.error('[flows-runs] events fetch failed:', evsErr.message)
} else if (evs) {
events = evs as typeof events
}
}
return NextResponse.json({
flow,
runs: runs ?? [],
events,
})
}

View File

@@ -0,0 +1,112 @@
import { timingSafeEqual } from 'node:crypto'
import { NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/flows/admin-client'
import { resolveFallbackPolicy } from '@/lib/flows/fallback'
/**
* Sweep abandoned active flow runs.
*
* Reads each active run's parent-flow `fallback_policy.on_timeout_hours`
* to compute the staleness cutoff (default 24h), then marks any run
* past its cutoff as `timed_out`. Writes a matching `flow_run_events`
* row for the audit trail.
*
* Without this sweep, a customer who abandons a flow mid-conversation
* keeps a row in `idx_one_active_run_per_contact` (the partial unique
* index on `flow_runs WHERE status='active'`) forever — blocking any
* new triggers for them. The cron is therefore not optional.
*
* Auth: re-uses `AUTOMATION_CRON_SECRET` so operators only have one
* secret to provision. The two endpoints (`/api/automations/cron`
* and this one) are independent operations; we keep them on separate
* URLs so one failing doesn't block the other.
*
* Hosting: hit on a schedule (Vercel Cron / GitHub Actions / external
* pinger). A 5-minute interval is more than enough for a 24h timeout
* default; once per hour would also be acceptable for low-volume
* tenants.
*/
export async function GET(request: Request) {
const expected = process.env.AUTOMATION_CRON_SECRET
if (!expected) {
return NextResponse.json({ error: 'cron not configured' }, { status: 503 })
}
// Constant-time compare so an attacker who can hit the endpoint
// can't recover the secret byte-by-byte from response-time deltas.
// Length pre-check is required by timingSafeEqual (throws otherwise)
// and leaks only the length itself, which isn't sensitive.
const supplied = request.headers.get('x-cron-secret') ?? ''
const suppliedBuf = Buffer.from(supplied)
const expectedBuf = Buffer.from(expected)
if (
suppliedBuf.length !== expectedBuf.length ||
!timingSafeEqual(suppliedBuf, expectedBuf)
) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const admin = supabaseAdmin()
const now = new Date()
// Pull all currently-active runs along with their parent flow's
// fallback_policy. Joined in one query — the small set of active
// runs per tenant keeps this cheap.
const { data: runs, error } = await admin
.from('flow_runs')
.select(
'id, flow_id, user_id, contact_id, last_advanced_at, flows ( fallback_policy )',
)
.eq('status', 'active')
if (error) {
console.error('[flows-cron] active-run scan failed:', error.message)
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (!runs?.length) return NextResponse.json({ swept: 0 })
type Row = {
id: string
flow_id: string
user_id: string
contact_id: string | null
last_advanced_at: string
flows: { fallback_policy: unknown } | { fallback_policy: unknown }[] | null
}
let swept = 0
for (const r of runs as Row[]) {
const flowsField = Array.isArray(r.flows) ? r.flows[0] : r.flows
const policy = resolveFallbackPolicy(flowsField?.fallback_policy ?? null)
const lastAdvanced = new Date(r.last_advanced_at)
const ageHours = (now.getTime() - lastAdvanced.getTime()) / (1000 * 60 * 60)
if (ageHours < policy.on_timeout_hours) continue
// Mark timed_out — guarded by the precondition `status='active'`
// so concurrent advance from a late inbound doesn't overwrite a
// legitimate update.
const { data: updated } = await admin
.from('flow_runs')
.update({
status: 'timed_out',
ended_at: now.toISOString(),
end_reason: 'stale_sweep',
})
.eq('id', r.id)
.eq('status', 'active')
.select('id')
if (Array.isArray(updated) && updated.length > 0) {
await admin.from('flow_run_events').insert({
flow_run_id: r.id,
event_type: 'timeout',
payload: {
age_hours: Math.round(ageHours * 10) / 10,
policy_hours: policy.on_timeout_hours,
},
})
swept += 1
}
}
return NextResponse.json({ swept })
}

View File

@@ -0,0 +1,169 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/flows/admin-client'
import { getFlowTemplate } from '@/lib/flows/templates'
/**
* GET /api/flows — list the caller's flows.
* POST /api/flows — create a new (draft) flow.
*
* Available to every authenticated user. The previous per-account
* beta gate was removed when Flows went to soft-GA; the UI still
* shows a "Beta" label so users know the surface is young, but the
* routes themselves are open.
*/
async function requireUser(): Promise<
| { ok: true; userId: string; supabase: Awaited<ReturnType<typeof createClient>> }
| { ok: false; status: number; body: { error: string } }
> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { ok: false, status: 401, body: { error: 'Unauthorized' } }
}
return { ok: true, userId: user.id, supabase }
}
export async function GET() {
const guard = await requireUser()
if (!guard.ok) {
return NextResponse.json(guard.body, { status: guard.status })
}
const { supabase } = guard
const { data, error } = await supabase
.from('flows')
.select('*')
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ flows: data ?? [] })
}
export async function POST(request: Request) {
const guard = await requireUser()
if (!guard.ok) {
return NextResponse.json(guard.body, { status: guard.status })
}
const { userId, supabase } = guard
// Resolve the caller's account_id — `flows.account_id` is NOT NULL
// post-017, so an INSERT without it trips the not-null constraint
// even though the admin client below bypasses RLS.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', userId)
.single()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const body = (await request.json().catch(() => null)) as
| {
name?: string
description?: string | null
trigger_type?: 'keyword' | 'first_inbound_message' | 'manual'
trigger_config?: Record<string, unknown>
/**
* If set, clone the matching template's name + trigger +
* entry_node_id + nodes[] into a fresh draft for this user.
* `name` from the body overrides the template default if
* provided.
*/
template_slug?: string
}
| null
if (!body) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const admin = supabaseAdmin()
// -------- Template clone path --------
if (body.template_slug) {
const template = getFlowTemplate(body.template_slug)
if (!template) {
return NextResponse.json(
{ error: `Unknown template_slug "${body.template_slug}"` },
{ status: 400 },
)
}
const { data: flow, error: flowErr } = await admin
.from('flows')
.insert({
user_id: userId,
account_id: accountId,
name: body.name?.trim() || template.name,
description: template.description,
status: 'draft',
trigger_type: template.trigger_type,
trigger_config: template.trigger_config,
entry_node_id: template.entry_node_id,
})
.select()
.single()
if (flowErr || !flow) {
return NextResponse.json(
{ error: flowErr?.message ?? 'flow insert failed' },
{ status: 500 },
)
}
if (template.nodes.length > 0) {
const { error: nodesErr } = await admin.from('flow_nodes').insert(
template.nodes.map((n) => ({
flow_id: flow.id,
node_key: n.node_key,
node_type: n.node_type,
config: n.config,
})),
)
if (nodesErr) {
// Roll back the parent flow so a half-cloned template doesn't
// sit as an empty draft. CASCADE on flow_id removes the
// (probably zero) nodes too.
await admin.from('flows').delete().eq('id', flow.id)
return NextResponse.json(
{ error: nodesErr.message },
{ status: 500 },
)
}
}
return NextResponse.json({ flow }, { status: 201 })
}
// -------- Plain (empty) create path --------
if (!body.name?.trim()) {
return NextResponse.json({ error: 'name is required' }, { status: 400 })
}
const trigger_type = body.trigger_type ?? 'keyword'
const { data, error } = await admin
.from('flows')
.insert({
user_id: userId,
account_id: accountId,
name: body.name.trim(),
description: body.description ?? null,
status: 'draft',
trigger_type,
trigger_config: body.trigger_config ?? {},
})
.select()
.single()
if (error || !data) {
return NextResponse.json(
{ error: error?.message ?? 'insert failed' },
{ status: 500 },
)
}
return NextResponse.json({ flow: data }, { status: 201 })
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { listFlowTemplates } from '@/lib/flows/templates'
/**
* GET /api/flows/templates
*
* Returns the static template gallery (slug + name + description +
* icon hint + node_count) so the New-flow dialog can render cards
* without bundling the full template payloads client-side. Bodies
* are fetched only on actual clone via POST /api/flows.
*
* Available to any signed-in user. Flows is in soft-GA.
*/
export async function GET() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Shallow shape so the client gallery doesn't have to know about
// the full node tree.
const templates = listFlowTemplates().map((t) => ({
slug: t.slug,
name: t.name,
description: t.description,
icon: t.icon,
trigger_type: t.trigger_type,
node_count: t.nodes.length,
}))
return NextResponse.json({ templates })
}

View File

@@ -0,0 +1,87 @@
// ============================================================
// GET /api/invitations/[token]/peek
//
// Public — no auth required. Lets the /join/<token> page render
// "You're being invited to <Account> as <Role>" before the
// visitor signs up or signs in.
//
// Security model
// - Token is in the URL path, not the query, so it doesn't
// show up in standard access-log "referer" fields the way a
// `?token=` would.
// - The plaintext token never crosses the DB boundary — we
// hash it in TS first and look up by `token_hash`.
// - The peek RPC is SECURITY DEFINER so it bypasses the RLS
// that would otherwise block an anonymous SELECT on
// `account_invitations`. It returns a fixed-shape JSON
// payload that never leaks columns beyond what the join
// page renders.
// - Per-IP rate limit pinches brute-force enumeration of
// tokens. With 256 bits of entropy the enumeration risk is
// theoretical, but rate limiting is cheap insurance.
// ============================================================
import { NextResponse } from "next/server";
import { hashInviteToken } from "@/lib/auth/invitations";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
import { createClient } from "@/lib/supabase/server";
/**
* Best-effort client IP. The `x-forwarded-for` header is what
* every reverse proxy (Vercel, Hostinger, Cloudflare) sets when
* forwarding a request; we take the leftmost entry, which is
* the original client.
*
* Falls back to a constant when no proxy is in front (e.g.
* `localhost` during development) so rate-limit keys still
* exist — the limit then effectively applies "globally," which
* is fine for dev.
*/
function getClientIp(request: Request): string {
const xff = request.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const xri = request.headers.get("x-real-ip");
if (xri) return xri.trim();
return "unknown";
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
// Rate-limit by IP first. Returns 429 to a serial bruteforcer
// before we ever touch the DB.
const ip = getClientIp(request);
const limit = checkRateLimit(`peek:${ip}`, RATE_LIMITS.invitationPeek);
if (!limit.success) return rateLimitResponse(limit);
const { token } = await params;
if (!token || typeof token !== "string") {
return NextResponse.json(
{ ok: false, reason: "not_found" },
{ status: 404 },
);
}
const supabase = await createClient();
const { data, error } = await supabase.rpc("peek_invitation", {
p_token_hash: hashInviteToken(token),
});
if (error) {
console.error("[peek] rpc error:", error);
return NextResponse.json(
{ ok: false, reason: "server_error" },
{ status: 500 },
);
}
// The RPC always returns a json object — either ok:true with
// metadata or ok:false with a reason. Forward verbatim.
return NextResponse.json(data);
}

View File

@@ -0,0 +1,91 @@
// ============================================================
// POST /api/invitations/[token]/redeem
//
// Authenticated. Caller atomically moves from their personal
// account (created at signup) to the inviter's account with the
// invite's role. Heavy lifting lives in the SECURITY DEFINER
// `redeem_invitation` RPC from migration 019.
//
// Refusal contract (from the RPC)
// - SQLSTATE 42501 → 401 (caller not authenticated)
// - SQLSTATE 22023 → 400 (invitation not_found / used / expired)
// - SQLSTATE 23505 → 409 (caller's account already has data /
// they're already in this or another shared account)
//
// Rate limit (per IP) is the same shape as peek but tighter —
// a successful redeem changes data, and the RPC's data-loss
// guard makes brute-force retries pointless past a few attempts.
// ============================================================
import { NextResponse } from "next/server";
import type { PostgrestError } from "@supabase/supabase-js";
import { hashInviteToken } from "@/lib/auth/invitations";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
import { createClient } from "@/lib/supabase/server";
function getClientIp(request: Request): string {
const xff = request.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const xri = request.headers.get("x-real-ip");
if (xri) return xri.trim();
return "unknown";
}
function rpcErrorToResponse(err: PostgrestError): NextResponse {
if (err.code === "42501") {
return NextResponse.json({ error: err.message }, { status: 401 });
}
if (err.code === "22023") {
return NextResponse.json({ error: err.message }, { status: 400 });
}
if (err.code === "23505") {
return NextResponse.json({ error: err.message }, { status: 409 });
}
console.error("[redeem] unexpected RPC error:", err);
return NextResponse.json(
{ error: "Failed to redeem invitation" },
{ status: 500 },
);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const ip = getClientIp(request);
const limit = checkRateLimit(`redeem:${ip}`, RATE_LIMITS.invitationRedeem);
if (!limit.success) return rateLimitResponse(limit);
const { token } = await params;
if (!token || typeof token !== "string") {
return NextResponse.json(
{ error: "Missing invitation token" },
{ status: 400 },
);
}
const supabase = await createClient();
// The RPC checks `auth.uid()` itself, but failing fast here
// gives a cleaner 401 without a Supabase round trip on the
// common "user clicked the link before logging in" path.
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { data: accountId, error } = await supabase.rpc("redeem_invitation", {
p_token_hash: hashInviteToken(token),
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true, accountId });
}

View File

@@ -0,0 +1,52 @@
// ============================================================
// GET /api/v1/account/members
// Lists every member of the account (API-key scoped).
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { isAccountRole } from '@/lib/auth/roles';
interface ProfileRow {
user_id: string;
full_name: string | null;
email: string | null;
avatar_url: string | null;
account_role: string;
created_at: string;
}
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request, 'conversations:read');
const { data, error } = await ctx.supabase
.from('profiles')
.select('user_id, full_name, email, avatar_url, account_role, created_at')
.eq('account_id', ctx.accountId)
.order('created_at', { ascending: true });
if (error) {
console.error('[api/v1/account/members] fetch error:', error);
return fail('internal', 'Failed to load members', 500);
}
const members = (data as ProfileRow[]).flatMap((row) => {
if (!isAccountRole(row.account_role)) return [];
return [
{
id: row.user_id,
name: row.full_name ?? row.email ?? row.user_id,
email: row.email,
avatar_url: row.avatar_url,
role: row.account_role,
joined_at: row.created_at,
},
];
});
return ok({ members });
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,41 @@
// ============================================================
// GET /api/v1/broadcasts/{id} — broadcast status + counts
// (scope: broadcasts:send).
//
// Poll this after POST /api/v1/broadcasts to watch the fan-out
// progress. `status` moves 'sending' → 'sent'; the delivered/read
// counts continue to climb as Meta delivery webhooks arrive.
// Account-scoped: a foreign id → 404.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'broadcasts:send');
const { id } = await params;
const { data, error } = await ctx.supabase
.from('broadcasts')
.select(
'id, name, template_name, template_language, status, total_recipients, sent_count, delivered_count, read_count, replied_count, failed_count, created_at, updated_at'
)
.eq('id', id)
.eq('account_id', ctx.accountId)
.maybeSingle();
if (error) {
console.error('[api/v1/broadcasts] read error:', error);
return fail('internal', 'Failed to read broadcast', 500);
}
if (!data) return fail('not_found', 'Broadcast not found', 404);
return ok(data);
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,105 @@
// ============================================================
// POST /api/v1/broadcasts — launch a template broadcast
// (scope: broadcasts:send).
//
// Body:
// {
// "name": "July promo", // optional label
// "template_name": "promo_july", // required, approved template
// "template_language": "en_US", // optional (default en_US)
// "recipients": [ // required, 1..1000
// { "to": "+14155550123", "params": ["Jane"] },
// { "to": "+14155550124" }
// ]
// }
//
// The broadcast + its recipient rows are persisted synchronously, then
// the Meta fan-out runs in `after()` so the request returns fast. Poll
// `GET /api/v1/broadcasts/{id}` for progress.
//
// Response (202):
// { "data": { "broadcast_id", "status": "sending",
// "total_recipients", "accepted", "rejected" } }
// ============================================================
import { after } from 'next/server';
import { requireApiKey } from '@/lib/auth/api-context';
// The `after()` fan-out below sends to every recipient sequentially and
// runs within this route's max duration (the same constraint the
// webhook route documents). Give it headroom beyond the platform
// default so a modest batch isn't cut off mid-send — which would leave
// recipient rows 'pending' and the broadcast stuck 'sending'. This is a
// bound, not a guarantee: a near-cap (MAX_RECIPIENTS) audience can
// still exceed 60s, so very large sends should be split across
// requests. A durable queue/cron drain is the complete fix (follow-up).
export const maxDuration = 60;
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { resolveAuditUserId, ContactError } from '@/lib/api/v1/contacts';
import {
createBroadcast,
deliverBroadcast,
BroadcastError,
} from '@/lib/whatsapp/broadcast-core';
export async function POST(request: Request) {
try {
const ctx = await requireApiKey(request, 'broadcasts:send');
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const templateName =
typeof body.template_name === 'string' ? body.template_name : '';
const recipients = Array.isArray(body.recipients) ? body.recipients : [];
const auditUserId = await resolveAuditUserId(ctx.supabase, ctx.accountId);
const plan = await createBroadcast(ctx.supabase, ctx.accountId, auditUserId, {
name: typeof body.name === 'string' ? body.name : null,
templateName,
templateLanguage:
typeof body.template_language === 'string'
? body.template_language
: null,
recipients: recipients.map((r) => ({
to: typeof r?.to === 'string' ? r.to : '',
params: Array.isArray(r?.params) ? r.params : undefined,
})),
});
// Fan out after the response is sent. Uses the same service-role
// client — no request-scoped auth needed for the Meta calls or
// the account-scoped row updates.
after(() => deliverBroadcast(ctx.supabase, plan));
return ok(
{
broadcast_id: plan.broadcastId,
status: 'sending',
total_recipients: plan.planned.length,
accepted: plan.planned.length,
rejected: plan.rejected,
},
202
);
} catch (err) {
if (err instanceof BroadcastError) {
return fail(err.code, err.message, err.status);
}
if (err instanceof ContactError) {
return fail(
err.status === 400 ? 'bad_request' : 'internal',
err.message,
err.status
);
}
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,102 @@
// ============================================================
// GET /api/v1/contacts/{id} — read a contact (scope: contacts:read)
// PATCH /api/v1/contacts/{id} — update a contact (scope: contacts:write)
//
// Both are account-scoped: a contact belonging to another account
// returns 404 (never 403 — don't reveal it exists elsewhere).
// PATCH updates only the fields present in the body; pass `tags` (an
// array of tag names) to replace the contact's tags.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import {
getContactById,
setContactTags,
resolveAuditUserId,
ContactError,
} from '@/lib/api/v1/contacts';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'contacts:read');
const { id } = await params;
const contact = await getContactById(ctx.supabase, ctx.accountId, id);
if (!contact) return fail('not_found', 'Contact not found', 404);
return ok(contact);
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'contacts:write');
const { id } = await params;
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
// Verify the contact is in this account before mutating anything.
const existing = await getContactById(ctx.supabase, ctx.accountId, id);
if (!existing) return fail('not_found', 'Contact not found', 404);
// Build a partial update from the provided scalar fields. A field
// is updated only when its key is PRESENT (so omitted fields are
// untouched); `null` clears it, a string sets it, and any other
// type is a 400 rather than a silently-ignored no-op.
const updates: Record<string, unknown> = {};
for (const field of ['name', 'email', 'company'] as const) {
if (!(field in body)) continue;
const value = body[field];
if (value === null || typeof value === 'string') {
updates[field] = value;
} else {
return fail('bad_request', `'${field}' must be a string or null`, 400);
}
}
if (Object.keys(updates).length > 0) {
updates.updated_at = new Date().toISOString();
const { error } = await ctx.supabase
.from('contacts')
.update(updates)
.eq('id', id)
.eq('account_id', ctx.accountId);
if (error) {
console.error('[api/v1/contacts] update error:', error);
return fail('internal', 'Failed to update contact', 500);
}
}
if (Array.isArray(body.tags)) {
const auditUserId = await resolveAuditUserId(ctx.supabase, ctx.accountId);
await setContactTags(
ctx.supabase,
ctx.accountId,
auditUserId,
id,
body.tags.filter((t): t is string => typeof t === 'string')
);
}
const contact = await getContactById(ctx.supabase, ctx.accountId, id);
return ok(contact);
} catch (err) {
if (err instanceof ContactError) {
return fail(err.status === 400 ? 'bad_request' : 'internal', err.message, err.status);
}
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,149 @@
// ============================================================
// GET /api/v1/contacts — list contacts (scope: contacts:read)
// POST /api/v1/contacts — create a contact (scope: contacts:write)
//
// List is keyset-paginated (see src/lib/api/v1/pagination.ts) and
// supports `?search=` (name/phone) and `?tag=<tagId>` filters. Create
// is find-or-create by phone: an existing match returns 200 with
// `created: false`; a new row returns 201 with `created: true`.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, okList, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import {
parseListParams,
keysetFilter,
buildPage,
} from '@/lib/api/v1/pagination';
import {
CONTACT_SELECT,
serializeContact,
findOrCreateContact,
setContactTags,
getContactById,
resolveAuditUserId,
ContactError,
} from '@/lib/api/v1/contacts';
// PostgREST filter values are comma/paren-delimited; strip anything
// that could break the `.or()` grammar before interpolating a search
// term. Leaves the characters a phone or name legitimately contains.
function sanitizeSearch(raw: string): string {
return raw.replace(/[^\p{L}\p{N} +@.\-_]/gu, '').trim();
}
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request, 'contacts:read');
const { limit, cursor } = parseListParams(request);
const url = new URL(request.url);
const search = sanitizeSearch(url.searchParams.get('search') ?? '');
const tag = url.searchParams.get('tag');
// When filtering by tag, add an aliased INNER join on contact_tags
// used purely for the WHERE — the parent is kept only if it has the
// tag. The main `contact_tags(tags(*))` embed still returns the
// contact's FULL tag set for serialization. This filters in one
// bounded query (paged by limit+1) instead of pre-fetching an
// unbounded id list into an `.in(...)`.
const selectClause = tag
? `${CONTACT_SELECT}, tag_filter:contact_tags!inner(tag_id)`
: CONTACT_SELECT;
let query = ctx.supabase
.from('contacts')
.select(selectClause)
.eq('account_id', ctx.accountId);
if (search) {
query = query.or(`name.ilike.*${search}*,phone.ilike.*${search}*`);
}
if (tag) {
query = query.eq('tag_filter.tag_id', tag);
}
query = query
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(limit + 1);
const kf = keysetFilter(cursor);
if (kf) query = query.or(kf);
const { data, error } = await query;
if (error) {
console.error('[api/v1/contacts] list error:', error);
return fail('internal', 'Failed to list contacts', 500);
}
// Cast via unknown: the conditional `selectClause` (with the
// tag_filter alias) is a runtime string, so supabase-js can't infer
// a row type from it.
const { items, nextCursor } = buildPage(
(data ?? []) as unknown as Array<{ created_at: string; id: string }>,
limit
);
return okList(
items.map((r) => serializeContact(r as Record<string, unknown>)),
nextCursor
);
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireApiKey(request, 'contacts:write');
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const phone = typeof body.phone === 'string' ? body.phone.trim() : '';
if (!phone) {
return fail('bad_request', "'phone' is required", 400);
}
const auditUserId = await resolveAuditUserId(ctx.supabase, ctx.accountId);
const { id, created } = await findOrCreateContact(
ctx.supabase,
ctx.accountId,
auditUserId,
{
phone,
name: typeof body.name === 'string' ? body.name : undefined,
email: typeof body.email === 'string' ? body.email : undefined,
company: typeof body.company === 'string' ? body.company : undefined,
}
);
if (Array.isArray(body.tags)) {
await setContactTags(
ctx.supabase,
ctx.accountId,
auditUserId,
id,
body.tags.filter((t): t is string => typeof t === 'string')
);
}
const contact = await getContactById(ctx.supabase, ctx.accountId, id);
return ok(contact, created ? 201 : 200);
} catch (err) {
if (err instanceof ContactError) {
return fail(
err.status === 400 ? 'bad_request' : 'internal',
err.message,
err.status
);
}
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,78 @@
// ============================================================
// PATCH /api/v1/conversations/{id}/assign
// Assigns or unassigns a conversation to an account member.
// Body: { assigned_agent_id: string | null }
// ============================================================
import { NextResponse } from 'next/server';
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'conversations:write');
const { id } = await params;
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const assignedAgentId = body.assigned_agent_id ?? null;
// Validate that the target user is a member of the account (if not null).
if (assignedAgentId && typeof assignedAgentId === 'string') {
const { data: memberRows, error: memberErr } = await ctx.supabase
.from('profiles')
.select('id')
.eq('account_id', ctx.accountId)
.eq('user_id', assignedAgentId)
.limit(1);
if (memberErr || !memberRows || memberRows.length === 0) {
return fail(
'bad_request',
'assigned_agent_id is not a member of this account',
400
);
}
} else if (assignedAgentId !== null) {
return fail(
'bad_request',
'assigned_agent_id must be a string or null',
400
);
}
const { data, error } = await ctx.supabase
.from('conversations')
.update({
assigned_agent_id: assignedAgentId,
updated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('account_id', ctx.accountId)
.select('id, assigned_agent_id')
.single();
if (error) {
console.error('[api/v1/conversations/assign] update error:', error);
return fail('internal', 'Failed to assign conversation', 500);
}
if (!data) {
return fail('not_found', 'Conversation not found', 404);
}
return ok({
id: data.id,
assigned_agent_id: data.assigned_agent_id,
});
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,65 @@
// ============================================================
// GET /api/v1/conversations/{id}/messages — list a conversation's
// messages (scope: messages:read), newest first, keyset-paginated.
//
// The conversation is verified to belong to the key's account before
// any message is returned — a foreign or unknown id → 404.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { okList, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import {
parseListParams,
keysetFilter,
buildPage,
} from '@/lib/api/v1/pagination';
import { serializeMessage } from '@/lib/api/v1/conversations';
import type { Message } from '@/types';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'messages:read');
const { id } = await params;
const { limit, cursor } = parseListParams(request);
// Gate on account ownership of the conversation first.
const { data: conv } = await ctx.supabase
.from('conversations')
.select('id')
.eq('id', id)
.eq('account_id', ctx.accountId)
.maybeSingle();
if (!conv) return fail('not_found', 'Conversation not found', 404);
let query = ctx.supabase
.from('messages')
.select('*')
.eq('conversation_id', id)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(limit + 1);
const kf = keysetFilter(cursor);
if (kf) query = query.or(kf);
const { data, error } = await query;
if (error) {
console.error('[api/v1/messages] list error:', error);
return fail('internal', 'Failed to list messages', 500);
}
const { items, nextCursor } = buildPage(
(data ?? []) as Array<{ created_at: string; id: string }>,
limit
);
return okList(
items.map((m) => serializeMessage(m as unknown as Message)),
nextCursor
);
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,40 @@
// ============================================================
// GET /api/v1/conversations/{id} — read one conversation
// (scope: conversations:read). Account-scoped: a foreign id → 404.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import {
CONVERSATION_SELECT,
normalizeConversation,
} from '@/lib/inbox/conversations';
import { serializeConversation } from '@/lib/api/v1/conversations';
import type { Conversation } from '@/types';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'conversations:read');
const { id } = await params;
const { data, error } = await ctx.supabase
.from('conversations')
.select(CONVERSATION_SELECT)
.eq('id', id)
.eq('account_id', ctx.accountId)
.maybeSingle();
if (error) {
console.error('[api/v1/conversations] read error:', error);
return fail('internal', 'Failed to read conversation', 500);
}
if (!data) return fail('not_found', 'Conversation not found', 404);
return ok(serializeConversation(normalizeConversation(data as Conversation)));
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,66 @@
// ============================================================
// GET /api/v1/conversations — list conversations (scope: conversations:read)
//
// Keyset-paginated (newest first). Filters: `?status=` (open/pending/
// closed) and `?contact_id=`. Each conversation embeds its contact +
// tags via the shared CONVERSATION_SELECT.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { okList, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import {
parseListParams,
keysetFilter,
buildPage,
} from '@/lib/api/v1/pagination';
import {
CONVERSATION_SELECT,
normalizeConversation,
} from '@/lib/inbox/conversations';
import { serializeConversation } from '@/lib/api/v1/conversations';
import type { Conversation } from '@/types';
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request, 'conversations:read');
const { limit, cursor } = parseListParams(request);
const url = new URL(request.url);
const status = url.searchParams.get('status');
const contactId = url.searchParams.get('contact_id');
let query = ctx.supabase
.from('conversations')
.select(CONVERSATION_SELECT)
.eq('account_id', ctx.accountId);
if (status) query = query.eq('status', status);
if (contactId) query = query.eq('contact_id', contactId);
query = query
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(limit + 1);
const kf = keysetFilter(cursor);
if (kf) query = query.or(kf);
const { data, error } = await query;
if (error) {
console.error('[api/v1/conversations] list error:', error);
return fail('internal', 'Failed to list conversations', 500);
}
const { items, nextCursor } = buildPage(
(data ?? []) as Array<{ created_at: string; id: string }>,
limit
);
return okList(
items.map((r) =>
serializeConversation(normalizeConversation(r as Conversation))
),
nextCursor
);
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,31 @@
// ============================================================
// GET /api/v1/me — public API identity probe.
//
// The reference endpoint for the public API: it requires nothing
// but a valid key (no scope), and returns the account the key is
// bound to plus the scopes it carries. Integrators use it to verify
// their key works and to discover what it's allowed to do before
// wiring up real calls.
//
// It also exercises the entire public-API stack end to end — bearer
// parse → hash lookup → liveness → rate limit → envelope — so a
// green response here means the plumbing every future endpoint
// depends on is sound.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { getAccountName } from '@/lib/api-keys/store';
import { ok, toApiErrorResponse } from '@/lib/api/v1/respond';
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request);
const name = await getAccountName(ctx.accountId);
return ok({
account: { id: ctx.accountId, name },
key: { id: ctx.keyId, scopes: ctx.scopes },
});
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,136 @@
// ============================================================
// POST /api/v1/messages — send a WhatsApp message via the public API.
//
// The headline public endpoint (issue #245). Unlike the dashboard's
// `/api/whatsapp/send` (which takes an internal `conversation_id`),
// this takes a phone number — what an external automation actually
// has — resolves-or-creates the contact + conversation, then runs the
// same shared send core.
//
// Auth: API key with the `messages:send` scope. Account context (and
// the service-role client) come from `requireApiKey`.
//
// Body:
// {
// "to": "+14155550123", // required, E.164
// "type": "text", // text|template|image|video|document|audio (default: text)
// "text": "Hello!", // text body, or media caption
// "media_url": "https://…/file.pdf", // required for image/video/document/audio
// "filename": "invoice.pdf", // optional, document filename
// "template": { // required when type=template
// "name": "order_update",
// "language": "en_US",
// "params": ["A123"] | { "body": [...] } // array = positional body; object = structured
// },
// "reply_to_message_id": "<uuid>", // optional, must be in the same conversation
// "name": "Jane Doe" // optional, names a newly-created contact
// }
//
// Response (201):
// { "data": { "message_id", "whatsapp_message_id", "conversation_id",
// "contact_id", "contact_created" } }
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { resolveConversationByPhone } from '@/lib/whatsapp/resolve-conversation';
import {
sendMessageToConversation,
validateSendMessageParams,
SendMessageError,
} from '@/lib/whatsapp/send-message';
export async function POST(request: Request) {
try {
const ctx = await requireApiKey(request, 'messages:send');
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const to = typeof body.to === 'string' ? body.to.trim() : '';
if (!to) {
return fail('bad_request', "'to' is required", 400);
}
const type = typeof body.type === 'string' ? body.type : 'text';
// Unpack the optional `template` object into the flat params the
// send core expects. `params` as an array → legacy positional body
// params; as an object → structured header/body/button params.
const template =
body.template && typeof body.template === 'object'
? (body.template as Record<string, unknown>)
: null;
const templateParams = Array.isArray(template?.params)
? (template.params as unknown[]).filter(
(p): p is string => typeof p === 'string'
)
: undefined;
const templateMessageParams =
template?.params && !Array.isArray(template.params)
? template.params
: undefined;
// Validate the message shape BEFORE resolveConversationByPhone
// finds-or-creates a contact + conversation, so a bad payload 400s
// without leaving an orphan contact/conversation behind.
validateSendMessageParams({
messageType: type,
contentText: typeof body.text === 'string' ? body.text : null,
mediaUrl: typeof body.media_url === 'string' ? body.media_url : null,
templateName: typeof template?.name === 'string' ? template.name : null,
});
// Find-or-create the conversation for this phone, then send. Both
// steps share `SendMessageError`, so one catch maps the whole
// pipeline to the envelope.
const resolved = await resolveConversationByPhone(
ctx.supabase,
ctx.accountId,
to,
typeof body.name === 'string' ? body.name : null
);
const result = await sendMessageToConversation(
ctx.supabase,
ctx.accountId,
{
conversationId: resolved.conversationId,
messageType: type,
contentText: typeof body.text === 'string' ? body.text : null,
mediaUrl: typeof body.media_url === 'string' ? body.media_url : null,
filename: typeof body.filename === 'string' ? body.filename : null,
templateName: typeof template?.name === 'string' ? template.name : null,
templateLanguage:
typeof template?.language === 'string' ? template.language : null,
templateParams,
templateMessageParams,
replyToMessageId:
typeof body.reply_to_message_id === 'string'
? body.reply_to_message_id
: null,
}
);
return ok(
{
message_id: result.messageId,
whatsapp_message_id: result.whatsappMessageId,
conversation_id: resolved.conversationId,
contact_id: resolved.contactId,
contact_created: resolved.contactCreated,
},
201
);
} catch (err) {
if (err instanceof SendMessageError) {
return fail(err.code, err.message, err.status);
}
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,114 @@
// ============================================================
// POST /api/v1/messages/simulate — inject a fake outbound message.
//
// Same shape as /api/v1/messages, but bypasses Meta and persists the
// message directly as if it had been sent by an agent. Useful for
// demos, local development, or fallback when WhatsApp is not yet
// connected to a real Meta Business account.
//
// Auth: API key with the `messages:send` scope.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { resolveConversationByPhone } from '@/lib/whatsapp/resolve-conversation';
import {
validateSendMessageParams,
SendMessageError,
} from '@/lib/whatsapp/send-message';
export async function POST(request: Request) {
try {
const ctx = await requireApiKey(request, 'messages:send');
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const to = typeof body.to === 'string' ? body.to.trim() : '';
if (!to) {
return fail('bad_request', "'to' is required", 400);
}
const type = typeof body.type === 'string' ? body.type : 'text';
const template =
body.template && typeof body.template === 'object'
? (body.template as Record<string, unknown>)
: null;
const templateName =
template && typeof template.name === 'string' ? template.name : null;
validateSendMessageParams({
messageType: type,
contentText: typeof body.text === 'string' ? body.text : null,
mediaUrl: typeof body.media_url === 'string' ? body.media_url : null,
templateName,
});
const resolved = await resolveConversationByPhone(
ctx.supabase,
ctx.accountId,
to,
typeof body.name === 'string' ? body.name : null
);
const fakeWaMessageId = `simulate_${Date.now()}_${Math.random()
.toString(36)
.slice(2, 10)}`;
const { data: messageRecord, error: msgError } = await ctx.supabase
.from('messages')
.insert({
conversation_id: resolved.conversationId,
sender_type: 'agent',
content_type: type,
content_text: typeof body.text === 'string' ? body.text : null,
media_url: typeof body.media_url === 'string' ? body.media_url : null,
template_name: templateName,
message_id: fakeWaMessageId,
status: 'sent',
})
.select()
.single();
if (msgError) {
console.error('[messages/simulate] insert error:', msgError);
throw new SendMessageError(
'db_error',
`Failed to persist simulated message: ${msgError.message}`,
500
);
}
await ctx.supabase
.from('conversations')
.update({
last_message_text: typeof body.text === 'string' ? body.text : `[${type}]`,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', resolved.conversationId);
return ok(
{
message_id: messageRecord.id,
whatsapp_message_id: fakeWaMessageId,
conversation_id: resolved.conversationId,
contact_id: resolved.contactId,
contact_created: resolved.contactCreated,
simulated: true,
},
201
);
} catch (err) {
if (err instanceof SendMessageError) {
return fail(err.code, err.message, err.status);
}
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,146 @@
// ============================================================
// GET /api/v1/webhooks/{id} — read an endpoint (webhooks:manage)
// PATCH /api/v1/webhooks/{id} — update url/events/is_active
// DELETE /api/v1/webhooks/{id} — remove an endpoint
//
// All account-scoped: a foreign id → 404 (never 403). The signing
// secret is never returned here — it's shown once at creation only.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { normalizeEvents } from '@/lib/webhooks/events';
import {
WEBHOOK_PUBLIC_COLUMNS,
serializeWebhookEndpoint,
normalizeWebhookUrl,
} from '@/lib/webhooks/endpoints';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { id } = await params;
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.select(WEBHOOK_PUBLIC_COLUMNS)
.eq('id', id)
.eq('account_id', ctx.accountId)
.maybeSingle();
if (error) {
console.error('[api/v1/webhooks] read error:', error);
return fail('internal', 'Failed to read webhook', 500);
}
if (!data) return fail('not_found', 'Webhook not found', 404);
return ok(serializeWebhookEndpoint(data as Record<string, unknown>));
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { id } = await params;
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const updates: Record<string, unknown> = {};
if ('url' in body) {
const url = normalizeWebhookUrl(body.url);
if (!url) {
return fail('bad_request', "'url' must be a valid https:// URL", 400);
}
updates.url = url;
}
if ('events' in body) {
const events = normalizeEvents(body.events);
if (!events) {
return fail(
'bad_request',
"'events' must be a non-empty array of known event names",
400
);
}
updates.events = events;
}
if ('is_active' in body) {
if (typeof body.is_active !== 'boolean') {
return fail('bad_request', "'is_active' must be a boolean", 400);
}
updates.is_active = body.is_active;
// Re-enabling a disabled endpoint clears its failure streak so it
// isn't instantly re-disabled by a single stale failure.
if (body.is_active === true) updates.failure_count = 0;
}
if (Object.keys(updates).length === 0) {
return fail('bad_request', 'No updatable fields provided', 400);
}
// Scope the update by account_id so a foreign id touches nothing;
// the returned row (null when unmatched) drives the 404.
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.update(updates)
.eq('id', id)
.eq('account_id', ctx.accountId)
.select(WEBHOOK_PUBLIC_COLUMNS)
.maybeSingle();
if (error) {
console.error('[api/v1/webhooks] update error:', error);
return fail('internal', 'Failed to update webhook', 500);
}
if (!data) return fail('not_found', 'Webhook not found', 404);
return ok(serializeWebhookEndpoint(data as Record<string, unknown>));
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { id } = await params;
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.delete()
.eq('id', id)
.eq('account_id', ctx.accountId)
.select('id')
.maybeSingle();
if (error) {
console.error('[api/v1/webhooks] delete error:', error);
return fail('internal', 'Failed to delete webhook', 500);
}
if (!data) return fail('not_found', 'Webhook not found', 404);
return ok({ id: data.id, deleted: true });
} catch (err) {
return toApiErrorResponse(err);
}
}

Some files were not shown because too many files have changed in this diff Show More