mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Migration 029 created claim_ai_reply_slot(uuid, integer) as SECURITY
DEFINER but never GRANTed EXECUTE — the only function in the schema
missing its grant (007/012/017/018/019/025/030 all grant theirs).
SECURITY DEFINER sets the privileges a function runs *with*, not who may
*call* it: the caller still needs EXECUTE. On instances where the default
PUBLIC execute privilege on public-schema functions has been revoked
(hardened / self-hosted Supabase), the service_role that PostgREST uses
cannot invoke it. The AI auto-reply path runs entirely under the
service-role client (the inbound webhook has no auth.uid()), so
db.rpc('claim_ai_reply_slot') fails permission-denied, the dispatch bails
before sending, and the bot silently never answers ANY inbound message.
The Playground works because it never claims a slot — matching the report.
- 031_ai_reply_slot_grant.sql: new migration granting EXECUTE to
service_role (029 is already applied on existing installs, so it needs
its own migration to reach them). service_role only — the sole caller —
matching the 007/012 increment-counter precedent.
- 029_ai_reply.sql: add the same grant next to the definition so fresh
installs are correct.
- auto-reply.ts: log a real claim RPC error instead of returning
silently, so this class of deploy failure is diagnosable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
6.7 KiB
PL/PgSQL
142 lines
6.7 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;
|
|
|
|
-- The auto-reply bot claims slots under the service-role client (the
|
|
-- inbound webhook has no auth.uid()), so it needs EXECUTE. SECURITY
|
|
-- DEFINER alone is not enough — it sets the privileges the function runs
|
|
-- *with*, not who may call it. Without this grant the RPC fails with
|
|
-- permission-denied on instances where the default PUBLIC execute
|
|
-- privilege has been revoked (hardened / self-hosted Supabase), and the
|
|
-- bot silently never replies. Only the service role claims slots, so we
|
|
-- grant to it alone (mirrors 007 / 012). See migration 031 / issue #345.
|
|
GRANT EXECUTE ON FUNCTION public.claim_ai_reply_slot(uuid, integer) TO service_role;
|