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/
13
wacrm/.editorconfig
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
76
wacrm/.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
@@ -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.
|
||||
68
wacrm/.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
@@ -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.
|
||||
BIN
wacrm/.github/assets/hostinger-deploy.png
vendored
Normal file
|
After Width: | Height: | Size: 122 KiB |
53
wacrm/.github/dependabot.yml
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
124
wacrm/CONTRIBUTING.md
Normal 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
@@ -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
@@ -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
@@ -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)
|
||||
[](https://github.com/ArnasDon/wacrm/actions/workflows/ci.yml)
|
||||
[](https://nextjs.org)
|
||||
[](https://supabase.com)
|
||||
[](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
@@ -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
@@ -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
@@ -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
@@ -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
85
wacrm/package.json
Normal 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
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
wacrm/public/file.svg
Normal 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
@@ -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 |
139
wacrm/public/inbox-doodle.svg
Normal 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
@@ -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 |
1
wacrm/public/opus/encoderWorker.min.js
vendored
Normal file
1
wacrm/public/vercel.svg
Normal 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
@@ -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 |
131
wacrm/src/app/(auth)/forgot-password/page.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
24
wacrm/src/app/(auth)/layout.tsx
Normal 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;
|
||||
}
|
||||
161
wacrm/src/app/(auth)/login/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
244
wacrm/src/app/(auth)/signup/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
73
wacrm/src/app/(dashboard)/agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
wacrm/src/app/(dashboard)/automations/[id]/edit/page.tsx
Normal 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} />
|
||||
}
|
||||
205
wacrm/src/app/(dashboard)/automations/[id]/logs/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
wacrm/src/app/(dashboard)/automations/new/page.tsx
Normal 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
|
||||
}
|
||||
363
wacrm/src/app/(dashboard)/automations/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
528
wacrm/src/app/(dashboard)/broadcasts/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
wacrm/src/app/(dashboard)/broadcasts/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
288
wacrm/src/app/(dashboard)/broadcasts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
836
wacrm/src/app/(dashboard)/contacts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
wacrm/src/app/(dashboard)/dashboard-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
wacrm/src/app/(dashboard)/dashboard/page.tsx
Normal 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}`
|
||||
}
|
||||
88
wacrm/src/app/(dashboard)/flows/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
340
wacrm/src/app/(dashboard)/flows/[id]/runs/page.tsx
Normal 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'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 "";
|
||||
}
|
||||
437
wacrm/src/app/(dashboard)/flows/page.tsx
Normal 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";
|
||||
}
|
||||
628
wacrm/src/app/(dashboard)/inbox/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
wacrm/src/app/(dashboard)/layout.tsx
Normal 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>;
|
||||
}
|
||||
268
wacrm/src/app/(dashboard)/notifications/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
492
wacrm/src/app/(dashboard)/pipelines/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
wacrm/src/app/(dashboard)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
wacrm/src/app/api/account/api-keys/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
159
wacrm/src/app/api/account/api-keys/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
wacrm/src/app/api/account/invitations/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
253
wacrm/src/app/api/account/invitations/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
122
wacrm/src/app/api/account/members/[userId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
wacrm/src/app/api/account/members/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
103
wacrm/src/app/api/account/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
94
wacrm/src/app/api/account/transfer-ownership/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
253
wacrm/src/app/api/ai/config/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
116
wacrm/src/app/api/ai/draft/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
132
wacrm/src/app/api/ai/knowledge/[id]/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
79
wacrm/src/app/api/ai/knowledge/reindex/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
110
wacrm/src/app/api/ai/knowledge/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
98
wacrm/src/app/api/ai/playground/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
94
wacrm/src/app/api/ai/test/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
75
wacrm/src/app/api/automations/[id]/duplicate/route.ts
Normal 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 })
|
||||
}
|
||||
138
wacrm/src/app/api/automations/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
68
wacrm/src/app/api/automations/cron/route.ts
Normal 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 })
|
||||
}
|
||||
33
wacrm/src/app/api/automations/engine/route.ts
Normal 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 })
|
||||
}
|
||||
125
wacrm/src/app/api/automations/route.ts
Normal 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 })
|
||||
}
|
||||
108
wacrm/src/app/api/flows/[id]/activate/route.ts
Normal 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 })
|
||||
}
|
||||
194
wacrm/src/app/api/flows/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
|
||||
86
wacrm/src/app/api/flows/[id]/runs/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
112
wacrm/src/app/api/flows/cron/route.ts
Normal 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 })
|
||||
}
|
||||
169
wacrm/src/app/api/flows/route.ts
Normal 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 })
|
||||
}
|
||||
34
wacrm/src/app/api/flows/templates/route.ts
Normal 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 })
|
||||
}
|
||||
87
wacrm/src/app/api/invitations/[token]/peek/route.ts
Normal 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);
|
||||
}
|
||||
91
wacrm/src/app/api/invitations/[token]/redeem/route.ts
Normal 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 });
|
||||
}
|
||||
52
wacrm/src/app/api/v1/account/members/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
41
wacrm/src/app/api/v1/broadcasts/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
105
wacrm/src/app/api/v1/broadcasts/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
102
wacrm/src/app/api/v1/contacts/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
149
wacrm/src/app/api/v1/contacts/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
78
wacrm/src/app/api/v1/conversations/[id]/assign/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
65
wacrm/src/app/api/v1/conversations/[id]/messages/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
40
wacrm/src/app/api/v1/conversations/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
66
wacrm/src/app/api/v1/conversations/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
wacrm/src/app/api/v1/me/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
136
wacrm/src/app/api/v1/messages/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
114
wacrm/src/app/api/v1/messages/simulate/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
146
wacrm/src/app/api/v1/webhooks/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||