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.
This commit is contained in:
Arnas Donauskas 2026-05-26 10:35:59 +02:00
parent 423995533c
commit 71ae95f441
No known key found for this signature in database
3 changed files with 160 additions and 4 deletions

View File

@ -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 {

View File

@ -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++) {

View File

@ -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 $$;