mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Adds an AI reply assistant powered by the account's own OpenAI or
Anthropic key (stored AES-256-GCM-encrypted; no per-seat AI fee).
- AI-drafted replies in the inbox composer (✨ button, agent+), via
read-only POST /api/ai/draft.
- Optional auto-reply bot on inbound messages that no Flow consumed and
no agent owns, with a per-conversation cap and clean [[HANDOFF]] to a
human. Stands down when a message-level automation would also reply.
- Settings → AI Assistant (admin+): provider/model, key, business
context, toggles, and a Test-key button that verifies before saving.
- Provider layer (src/lib/ai) with OpenAI + Anthropic adapters behind
one interface; migration 029 (ai_configs + per-conversation columns +
atomic claim_ai_reply_slot RPC).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
132 lines
6.0 KiB
PL/PgSQL
132 lines
6.0 KiB
PL/PgSQL
-- ============================================================
|
|
-- 029_ai_reply.sql — AI reply assistant (bring-your-own-key)
|
|
--
|
|
-- Adds the account-level config for the AI reply assistant plus the
|
|
-- two per-conversation columns the auto-reply bot needs to stay
|
|
-- bounded.
|
|
--
|
|
-- Design notes
|
|
-- - `ai_configs` is account-scoped and UNIQUE(account_id) — one AI
|
|
-- setup per workspace, exactly like `whatsapp_config`. Teammates
|
|
-- inside an account share it.
|
|
-- - `api_key` is the caller's own OpenAI / Anthropic key. We call
|
|
-- the provider *with* it on every draft/auto-reply, so we need the
|
|
-- plaintext at call time — stored AES-256-GCM-encrypted at rest
|
|
-- (same `encrypt()`/`decrypt()` as `whatsapp_config.access_token`
|
|
-- and `webhook_endpoints.secret`) and never returned to the client
|
|
-- after save (the settings UI shows a masked placeholder).
|
|
-- - `created_by` records who saved it (audit); ON DELETE SET NULL so
|
|
-- removing a teammate doesn't drop the workspace's AI config.
|
|
-- - `is_active` is the master switch (draft + auto-reply both off
|
|
-- when false). `auto_reply_enabled` gates only the inbound bot;
|
|
-- `auto_reply_max_per_conversation` caps how many times the bot
|
|
-- will answer one thread before going quiet (prevents runaway
|
|
-- loops / bill blowout on a chatty customer).
|
|
--
|
|
-- - `conversations.ai_autoreply_disabled` — set true when the model
|
|
-- signals a human handoff, or when someone turns the bot off for
|
|
-- that one thread. Sticky: once a conversation is handed off it
|
|
-- stays off until explicitly re-enabled.
|
|
-- - `conversations.ai_reply_count` — running count of bot auto-
|
|
-- replies in the thread, checked against
|
|
-- `auto_reply_max_per_conversation`.
|
|
--
|
|
-- RLS
|
|
-- Settings-class, mirroring `whatsapp_config` / `webhook_endpoints`:
|
|
-- any member (viewer+) may read the config — the inbox draft button
|
|
-- needs to know whether AI is on — but only admin+ may create /
|
|
-- update / delete it. The auto-reply path runs under the service-role
|
|
-- client (a webhook has no `auth.uid()`), so RLS guards dashboard
|
|
-- reads, not the engine.
|
|
--
|
|
-- Idempotent — safe to run multiple times.
|
|
-- ============================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS ai_configs (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
account_id uuid NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
|
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
provider text NOT NULL CHECK (provider IN ('openai', 'anthropic')),
|
|
model text NOT NULL,
|
|
api_key text NOT NULL, -- AES-256-GCM-encrypted BYO provider key
|
|
system_prompt text, -- business context / persona / tone
|
|
is_active boolean NOT NULL DEFAULT false,
|
|
auto_reply_enabled boolean NOT NULL DEFAULT false,
|
|
auto_reply_max_per_conversation integer NOT NULL DEFAULT 3
|
|
CHECK (auto_reply_max_per_conversation BETWEEN 1 AND 20),
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
ALTER TABLE ai_configs ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- SELECT: any member of the account (viewer+) can see the config so
|
|
-- the inbox knows whether the "Draft with AI" affordance is live.
|
|
DROP POLICY IF EXISTS ai_configs_select ON ai_configs;
|
|
CREATE POLICY ai_configs_select ON ai_configs FOR SELECT
|
|
USING (is_account_member(account_id));
|
|
|
|
-- INSERT / UPDATE / DELETE: admin+ only (settings-class).
|
|
DROP POLICY IF EXISTS ai_configs_insert ON ai_configs;
|
|
CREATE POLICY ai_configs_insert ON ai_configs FOR INSERT
|
|
WITH CHECK (is_account_member(account_id, 'admin'));
|
|
|
|
DROP POLICY IF EXISTS ai_configs_update ON ai_configs;
|
|
CREATE POLICY ai_configs_update ON ai_configs FOR UPDATE
|
|
USING (is_account_member(account_id, 'admin'));
|
|
|
|
DROP POLICY IF EXISTS ai_configs_delete ON ai_configs;
|
|
CREATE POLICY ai_configs_delete ON ai_configs FOR DELETE
|
|
USING (is_account_member(account_id, 'admin'));
|
|
|
|
-- Keep updated_at fresh on every write.
|
|
CREATE OR REPLACE FUNCTION public.update_ai_configs_updated_at()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = now();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS ai_configs_updated_at ON ai_configs;
|
|
CREATE TRIGGER ai_configs_updated_at
|
|
BEFORE UPDATE ON ai_configs
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION public.update_ai_configs_updated_at();
|
|
|
|
-- ============================================================
|
|
-- Per-conversation auto-reply control.
|
|
-- ============================================================
|
|
ALTER TABLE conversations
|
|
ADD COLUMN IF NOT EXISTS ai_autoreply_disabled boolean NOT NULL DEFAULT false;
|
|
|
|
ALTER TABLE conversations
|
|
ADD COLUMN IF NOT EXISTS ai_reply_count integer NOT NULL DEFAULT 0;
|
|
|
|
-- ============================================================
|
|
-- Atomic auto-reply slot claim.
|
|
--
|
|
-- The bot claims a reply slot through this function rather than a
|
|
-- read-then-write from the app: two inbound messages on one
|
|
-- conversation can be processed concurrently, and a client-side
|
|
-- "read count, check < cap, then increment" would let both pass the
|
|
-- check and overshoot the per-conversation cap. Here the cap check and
|
|
-- the `+ 1` happen in a single UPDATE, so exactly `max_replies` slots
|
|
-- can ever be claimed. Returns true when a slot was claimed (the caller
|
|
-- may send), false when the cap is already reached (skip).
|
|
-- ============================================================
|
|
CREATE OR REPLACE FUNCTION public.claim_ai_reply_slot(
|
|
conversation_id uuid,
|
|
max_replies integer
|
|
)
|
|
RETURNS boolean AS $$
|
|
WITH claimed AS (
|
|
UPDATE conversations
|
|
SET ai_reply_count = ai_reply_count + 1
|
|
WHERE id = conversation_id
|
|
AND ai_reply_count < max_replies
|
|
RETURNING 1
|
|
)
|
|
SELECT EXISTS (SELECT 1 FROM claimed);
|
|
$$ LANGUAGE sql SECURITY DEFINER SET search_path = public;
|