wacrm/supabase/migrations/013_whatsapp_config_phone_number_id_unique.sql
Arnas Donauskas 71ae95f441
fix(whatsapp): prevent silent inbound-message drop when two users share a phone_number_id
The webhook routes inbound messages by phone_number_id and uses
`.single()` to find the owning config. If two users save the same
phone_number_id, `.single()` errors PGRST116 ("multiple rows") and every
inbound message is silently dropped while the log misleadingly reports
"No config found".

Three layers of fix:

- POST /api/whatsapp/config now rejects with 409 when another user has
  already claimed the phone_number_id. The check uses a service-role
  client because RLS hides the conflicting row from the saving user.
- Webhook lookup no longer uses `.single()`. It distinguishes 0 rows
  (true "no config") from ≥2 rows (duplicate claim) so logs name the
  real cause and list conflicting user_ids.
- Migration 013 adds UNIQUE(phone_number_id) to whatsapp_config so the
  same can't happen via direct DB writes or a race. Fails loudly with a
  copy-pasteable resolution hint if duplicates already exist, rather
  than auto-deduping (which would destroy tokens).

Closes #136.
2026-05-26 10:35:59 +02:00

85 lines
3.0 KiB
SQL

-- ============================================================
-- whatsapp_config: enforce one user per phone_number_id
--
-- The webhook routes inbound messages by `phone_number_id` and uses
-- `.single()` to find the owning config row. If two users have saved
-- the same `phone_number_id`, `.single()` errors PGRST116 ("multiple
-- rows returned") and the webhook silently drops every inbound
-- message — see issue #136.
--
-- wacrm is single-tenant per WhatsApp number by design (RLS on
-- conversations / messages is `auth.uid() = user_id`, so another user
-- physically cannot read a conversation routed to a different owner).
-- A UNIQUE constraint at the DB level makes that intent enforceable
-- and stops races between the app-level check and the insert.
--
-- ─── On existing data ───────────────────────────────────────────
-- If duplicates already exist in production, this migration FAILS
-- LOUDLY rather than silently dropping rows. Auto-deduping would
-- destroy user data (encrypted tokens, connection state) — the
-- operator has to choose which user keeps the number. To resolve:
--
-- SELECT phone_number_id, array_agg(user_id) AS owners
-- FROM whatsapp_config
-- GROUP BY phone_number_id
-- HAVING count(*) > 1;
--
-- Then DELETE the duplicate rows you don't want to keep and re-run
-- migrations.
--
-- Idempotent — safe to run multiple times once the constraint is in
-- place.
-- ============================================================
-- 1. Fail loudly if duplicates exist. Spelling out the conflicting
-- phone_number_id and the user_ids that own it gives the operator
-- a copy-pasteable starting point.
DO $$
DECLARE
conflict_count INT;
sample TEXT;
BEGIN
SELECT count(*) INTO conflict_count
FROM (
SELECT phone_number_id
FROM whatsapp_config
GROUP BY phone_number_id
HAVING count(*) > 1
) dupes;
IF conflict_count > 0 THEN
SELECT string_agg(
phone_number_id || ' -> [' || array_to_string(owners, ', ') || ']',
E'\n '
)
INTO sample
FROM (
SELECT phone_number_id, array_agg(user_id::text) AS owners
FROM whatsapp_config
GROUP BY phone_number_id
HAVING count(*) > 1
) dupe_detail;
RAISE EXCEPTION
E'Cannot add UNIQUE(phone_number_id) on whatsapp_config — % phone_number_id value(s) are claimed by more than one user:\n %\nDelete the duplicate rows you do not want to keep (see migration comment), then re-run migrations.',
conflict_count,
sample;
END IF;
END $$;
-- 2. Add the UNIQUE constraint. PostgreSQL has no "ADD CONSTRAINT IF
-- NOT EXISTS", so guard via pg_constraint.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'whatsapp_config_phone_number_id_key'
AND conrelid = 'whatsapp_config'::regclass
) THEN
ALTER TABLE whatsapp_config
ADD CONSTRAINT whatsapp_config_phone_number_id_key
UNIQUE (phone_number_id);
END IF;
END $$;