mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #143 from ArnasDon/fix/whatsapp-config-phone-number-collision
fix(whatsapp): prevent silent inbound-message drop on duplicate phone_number_id
This commit is contained in:
commit
352e1db985
@ -1,8 +1,25 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createClient as createAdminClient } from '@supabase/supabase-js'
|
||||||
import { verifyPhoneNumber } from '@/lib/whatsapp/meta-api'
|
import { verifyPhoneNumber } from '@/lib/whatsapp/meta-api'
|
||||||
import { encrypt, decrypt } from '@/lib/whatsapp/encryption'
|
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
|
* 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
|
// Verify credentials with Meta BEFORE saving
|
||||||
let phoneInfo
|
let phoneInfo
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -204,18 +204,43 @@ async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) {
|
|||||||
|
|
||||||
const phoneNumberId = value.metadata.phone_number_id
|
const phoneNumberId = value.metadata.phone_number_id
|
||||||
|
|
||||||
// Find user's config by phone_number_id
|
// Find user's config by phone_number_id. `.single()` returns
|
||||||
const { data: config, error: configError } = await supabaseAdmin()
|
// 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')
|
.from('whatsapp_config')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('phone_number_id', phoneNumberId)
|
.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)
|
console.error('No config found for phone_number_id:', phoneNumberId)
|
||||||
continue
|
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)
|
const decryptedAccessToken = decrypt(config.access_token)
|
||||||
|
|
||||||
for (let i = 0; i < value.messages.length; i++) {
|
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