diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts index e0c3375..92738d1 100644 --- a/src/app/api/whatsapp/config/route.ts +++ b/src/app/api/whatsapp/config/route.ts @@ -1,8 +1,25 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' +import { createClient as createAdminClient } from '@supabase/supabase-js' import { verifyPhoneNumber } from '@/lib/whatsapp/meta-api' import { encrypt, decrypt } from '@/lib/whatsapp/encryption' +// Lazy-initialised service-role client. We need it to detect a +// phone_number_id already claimed by a *different* user — under RLS, +// the user's own session can't see other users' rows, so the conflict +// would be invisible without the service role. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let _adminClient: any = null +function supabaseAdmin() { + if (!_adminClient) { + _adminClient = createAdminClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) + } + return _adminClient +} + /** * GET /api/whatsapp/config * @@ -130,6 +147,36 @@ export async function POST(request: Request) { ) } + // Reject if another user has already claimed this phone_number_id. + // wacrm is single-tenant-per-WhatsApp-number — letting two users + // bind the same number causes the webhook's `.single()` lookup to + // throw PGRST116 ("multiple rows"), silently dropping every + // inbound message. See issue #136. + const { data: claimed, error: claimedError } = await supabaseAdmin() + .from('whatsapp_config') + .select('user_id') + .eq('phone_number_id', phone_number_id) + .neq('user_id', user.id) + .maybeSingle() + + if (claimedError) { + console.error('Error checking phone_number_id ownership:', claimedError) + return NextResponse.json( + { error: 'Failed to validate configuration' }, + { status: 500 } + ) + } + + if (claimed) { + return NextResponse.json( + { + error: + 'This WhatsApp phone number is already linked to another account on this instance. Each phone number can only be connected to one wacrm user.', + }, + { status: 409 } + ) + } + // Verify credentials with Meta BEFORE saving let phoneInfo try { diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index b4cdcd8..2c4c7d5 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -204,18 +204,43 @@ async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) { const phoneNumberId = value.metadata.phone_number_id - // Find user's config by phone_number_id - const { data: config, error: configError } = await supabaseAdmin() + // Find user's config by phone_number_id. `.single()` returns + // PGRST116 for both 0 rows AND ≥2 rows — distinguish them so + // operators see the real cause in logs. ≥2 rows shouldn't happen + // post-migration 013 (UNIQUE constraint), but a row created + // before the constraint, or a race, would still surface here. + const { data: configRows, error: configError } = await supabaseAdmin() .from('whatsapp_config') .select('*') .eq('phone_number_id', phoneNumberId) - .single() - if (configError || !config) { + if (configError) { + console.error( + 'Error fetching whatsapp_config for phone_number_id:', + phoneNumberId, + configError + ) + continue + } + + if (!configRows || configRows.length === 0) { console.error('No config found for phone_number_id:', phoneNumberId) continue } + if (configRows.length > 1) { + console.error( + `Multiple configs (${configRows.length}) found for phone_number_id:`, + phoneNumberId, + '— inbound message dropped. Resolve duplicates so each number maps to a single user.', + 'Owners:', + configRows.map((r: { user_id: string }) => r.user_id) + ) + continue + } + + const config = configRows[0] + const decryptedAccessToken = decrypt(config.access_token) for (let i = 0; i < value.messages.length; i++) { diff --git a/supabase/migrations/013_whatsapp_config_phone_number_id_unique.sql b/supabase/migrations/013_whatsapp_config_phone_number_id_unique.sql new file mode 100644 index 0000000..abfddd0 --- /dev/null +++ b/supabase/migrations/013_whatsapp_config_phone_number_id_unique.sql @@ -0,0 +1,84 @@ +-- ============================================================ +-- 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 $$;