mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
423995533c
commit
71ae95f441
@ -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 {
|
||||
|
||||
@ -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++) {
|
||||
|
||||
@ -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 $$;
|
||||
Loading…
Reference in New Issue
Block a user