diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index 21143a2..16ee965 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -132,12 +132,27 @@ export default function InboxPage() { if (!user) return; - // Table is `whatsapp_config` (singular) — the previous "whatsapp_configs" - // query always returned no rows, so the banner always showed "not connected". + // whatsapp_config is one-row-per-account post-multi-user, so + // the previous `.eq('user_id', user.id)` would miss the row + // for any teammate who didn't personally save the config — + // the "WhatsApp not connected" banner would show in the + // shared inbox even though the admin had it configured. + // Resolve account_id via the profile and query by that. + const { data: profile } = await supabase + .from("profiles") + .select("account_id") + .eq("user_id", user.id) + .maybeSingle(); + const accountId = profile?.account_id as string | undefined; + if (!accountId) { + setWhatsappConnected(false); + return; + } + const { data } = await supabase .from("whatsapp_config") .select("status") - .eq("user_id", user.id) + .eq("account_id", accountId) .maybeSingle(); setWhatsappConnected(data?.status === "connected"); diff --git a/src/app/api/whatsapp/broadcast/route.ts b/src/app/api/whatsapp/broadcast/route.ts index 78b640c..e4205a3 100644 --- a/src/app/api/whatsapp/broadcast/route.ts +++ b/src/app/api/whatsapp/broadcast/route.ts @@ -79,6 +79,23 @@ export async function POST(request: Request) { return rateLimitResponse(limit) } + // Resolve the caller's account_id. whatsapp_config + templates + // + broadcasts are all account-scoped post-multi-user, so the + // old `.eq('user_id', user.id)` filters miss every row created + // by a teammate. + const { data: profile } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .maybeSingle() + const accountId = profile?.account_id as string | undefined + if (!accountId) { + return NextResponse.json( + { error: 'Your profile is not linked to an account.' }, + { status: 403 }, + ) + } + const body = await request.json() const { recipients: newRecipients, @@ -120,7 +137,7 @@ export async function POST(request: Request) { const { data: config, error: configError } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', user.id) + .eq('account_id', accountId) .single() if (configError || !config) { @@ -143,7 +160,7 @@ export async function POST(request: Request) { const { data: rawTemplateRow } = await supabase .from('message_templates') .select('*') - .eq('user_id', user.id) + .eq('account_id', accountId) .eq('name', template_name) .eq('language', template_language || 'en_US') .maybeSingle() diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts index 3480bd4..a216930 100644 --- a/src/app/api/whatsapp/config/route.ts +++ b/src/app/api/whatsapp/config/route.ts @@ -8,6 +8,29 @@ import { } from '@/lib/whatsapp/meta-api' import { encrypt, decrypt } from '@/lib/whatsapp/encryption' +/** + * Resolve the caller's account_id from their profile. Inlined here + * (rather than going through `@/lib/auth/account.getCurrentAccount`) + * because the GET handler wants to return shaped 200s for every + * non-auth failure mode, not throw — keeping the helper minimal lets + * the existing response branches stay as-is. + * + * Returns null if the user has no profile or no account; callers + * should treat that the same as "not connected". + */ +async function resolveAccountId( + supabase: Awaited>, + userId: string, +): Promise { + const { data, error } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', userId) + .maybeSingle() + if (error || !data?.account_id) return null + return data.account_id as string +} + // 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 @@ -50,10 +73,22 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const accountId = await resolveAccountId(supabase, user.id) + if (!accountId) { + return NextResponse.json( + { + connected: false, + reason: 'no_account', + message: 'Your profile is not linked to an account.', + }, + { status: 200 }, + ) + } + const { data: config, error: configError } = await supabase .from('whatsapp_config') .select('phone_number_id, access_token, status') - .eq('user_id', user.id) + .eq('account_id', accountId) .maybeSingle() if (configError) { @@ -141,6 +176,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const accountId = await resolveAccountId(supabase, user.id) + if (!accountId) { + return NextResponse.json( + { error: 'Your profile is not linked to an account.' }, + { status: 403 }, + ) + } + const body = await request.json() const { phone_number_id, waba_id, access_token, verify_token, pin } = body @@ -160,16 +203,18 @@ 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 + // Reject if another account has already claimed this phone_number_id. + // wacrm is single-tenant-per-WhatsApp-number — letting two accounts // bind the same number causes the webhook's `.single()` lookup to // throw PGRST116 ("multiple rows"), silently dropping every - // inbound message. See issue #136. + // inbound message. See issue #136. Post-multi-user we key on + // account_id (not user_id) since teammates inside the same account + // all share one config; the conflict is between accounts. const { data: claimed, error: claimedError } = await supabaseAdmin() .from('whatsapp_config') - .select('user_id') + .select('account_id') .eq('phone_number_id', phone_number_id) - .neq('user_id', user.id) + .neq('account_id', accountId) .maybeSingle() if (claimedError) { @@ -224,13 +269,13 @@ export async function POST(request: Request) { ) } - // Look up any pre-existing row so we know whether this number is - // already registered with Meta — if so we can skip /register when - // the user didn't provide a PIN this time around. + // Look up any pre-existing row for this account so we know whether + // this number is already registered with Meta — if so we can skip + // /register when the user didn't provide a PIN this time around. const { data: existing } = await supabase .from('whatsapp_config') .select('id, registered_at, phone_number_id') - .eq('user_id', user.id) + .eq('account_id', accountId) .maybeSingle() const sameNumber = @@ -318,7 +363,7 @@ export async function POST(request: Request) { const { error: updateError } = await supabase .from('whatsapp_config') .update(baseRow) - .eq('user_id', user.id) + .eq('account_id', accountId) if (updateError) { console.error('Error updating whatsapp_config:', updateError) @@ -328,9 +373,17 @@ export async function POST(request: Request) { ) } } else { + // Insert with both columns: `account_id` is the tenancy key + // (NOT NULL post-017, UNIQUE so duplicates trip the constraint + // up-front), `user_id` is the audit column identifying which + // member of the account saved the config. const { error: insertError } = await supabase .from('whatsapp_config') - .insert({ user_id: user.id, ...baseRow }) + .insert({ + account_id: accountId, + user_id: user.id, + ...baseRow, + }) if (insertError) { console.error('Error inserting whatsapp_config:', insertError) @@ -386,10 +439,18 @@ export async function DELETE() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const accountId = await resolveAccountId(supabase, user.id) + if (!accountId) { + return NextResponse.json( + { error: 'Your profile is not linked to an account.' }, + { status: 403 }, + ) + } + const { error: deleteError } = await supabase .from('whatsapp_config') .delete() - .eq('user_id', user.id) + .eq('account_id', accountId) if (deleteError) { console.error('Error deleting whatsapp_config:', deleteError) diff --git a/src/app/api/whatsapp/config/verify-registration/route.ts b/src/app/api/whatsapp/config/verify-registration/route.ts index adcdc5d..3dea721 100644 --- a/src/app/api/whatsapp/config/verify-registration/route.ts +++ b/src/app/api/whatsapp/config/verify-registration/route.ts @@ -38,10 +38,27 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + // whatsapp_config is one-row-per-account post-017. Resolve the + // caller's account_id so a teammate who joined an existing account + // sees the same registration state as the admin who set it up. + const { data: profile } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .maybeSingle() + const accountId = profile?.account_id as string | undefined + if (!accountId) { + return NextResponse.json({ + live: false, + checks: { config_exists: false }, + message: 'Your profile is not linked to an account.', + }) + } + const { data: config } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', user.id) + .eq('account_id', accountId) .maybeSingle() if (!config) { diff --git a/src/app/api/whatsapp/media/[mediaId]/route.ts b/src/app/api/whatsapp/media/[mediaId]/route.ts index f5a0a3c..b4b9cf5 100644 --- a/src/app/api/whatsapp/media/[mediaId]/route.ts +++ b/src/app/api/whatsapp/media/[mediaId]/route.ts @@ -31,11 +31,28 @@ export async function GET( ) } + // Resolve the caller's account_id — whatsapp_config is one-per- + // account post-multi-user, so a teammate fetching media for a + // conversation in the shared inbox needs the account's config, + // not their personal (non-existent) row. + const { data: profile } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .maybeSingle() + const accountId = profile?.account_id as string | undefined + if (!accountId) { + return NextResponse.json( + { error: 'Your profile is not linked to an account.' }, + { status: 403 }, + ) + } + // Fetch and decrypt WhatsApp config const { data: config, error: configError } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', user.id) + .eq('account_id', accountId) .single() if (configError || !config) { diff --git a/src/app/api/whatsapp/react/route.ts b/src/app/api/whatsapp/react/route.ts index e8e1dc5..a912c3f 100644 --- a/src/app/api/whatsapp/react/route.ts +++ b/src/app/api/whatsapp/react/route.ts @@ -36,6 +36,21 @@ export async function POST(request: Request) { return rateLimitResponse(limit); } + // Resolve the caller's account_id so conversation + whatsapp_config + // lookups work for teammates who didn't author the rows directly. + const { data: profile } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .maybeSingle(); + const accountId = profile?.account_id as string | undefined; + if (!accountId) { + return NextResponse.json( + { error: 'Your profile is not linked to an account.' }, + { status: 403 }, + ); + } + const body = await request.json(); const { message_id, emoji } = body as { message_id?: string; @@ -71,9 +86,9 @@ export async function POST(request: Request) { const { data: conversation, error: convError } = await supabase .from('conversations') - .select('id, user_id, contact:contacts(phone)') + .select('id, account_id, contact:contacts(phone)') .eq('id', targetMessage.conversation_id) - .eq('user_id', user.id) + .eq('account_id', accountId) .maybeSingle(); if (convError || !conversation) { @@ -93,11 +108,11 @@ export async function POST(request: Request) { ); } - // WhatsApp config + access token + // WhatsApp config + access token. Account-scoped post-multi-user. const { data: config, error: configError } = await supabase .from('whatsapp_config') .select('phone_number_id, access_token') - .eq('user_id', user.id) + .eq('account_id', accountId) .single(); if (configError || !config) { diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index d5ea7f2..5aff6b9 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -40,6 +40,23 @@ export async function POST(request: Request) { return rateLimitResponse(limit) } + // Resolve the caller's account_id. Every downstream lookup + // (conversation, whatsapp_config, message_templates) is account- + // scoped post-multi-user, so the previous `user_id` filters + // returned nothing for teammates who didn't author the row. + const { data: profile } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .maybeSingle() + const accountId = profile?.account_id as string | undefined + if (!accountId) { + return NextResponse.json( + { error: 'Your profile is not linked to an account.' }, + { status: 403 }, + ) + } + const body = await request.json() const { conversation_id, @@ -79,7 +96,7 @@ export async function POST(request: Request) { .from('conversations') .select('*, contact:contacts(*)') .eq('id', conversation_id) - .eq('user_id', user.id) + .eq('account_id', accountId) .single() if (convError || !conversation) { @@ -110,7 +127,7 @@ export async function POST(request: Request) { const { data: config, error: configError } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', user.id) + .eq('account_id', accountId) .single() if (configError || !config) { @@ -196,7 +213,7 @@ export async function POST(request: Request) { const { data } = await supabase .from('message_templates') .select('*') - .eq('user_id', user.id) + .eq('account_id', accountId) .eq('name', template_name) .eq('language', template_language || 'en_US') .maybeSingle() @@ -337,7 +354,7 @@ export async function POST(request: Request) { ended_at: new Date().toISOString(), end_reason: 'agent_replied', }) - .eq('user_id', user.id) + .eq('account_id', accountId) .eq('contact_id', contact.id) .eq('status', 'active') if (pauseErr) { diff --git a/src/app/api/whatsapp/templates/[id]/route.ts b/src/app/api/whatsapp/templates/[id]/route.ts index abf6b87..f259ded 100644 --- a/src/app/api/whatsapp/templates/[id]/route.ts +++ b/src/app/api/whatsapp/templates/[id]/route.ts @@ -64,6 +64,21 @@ export async function PATCH( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + // Resolve the caller's account_id so template + whatsapp_config + // lookups work for teammates who didn't author the row. + const { data: profile } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .maybeSingle() + const accountId = profile?.account_id as string | undefined + if (!accountId) { + return NextResponse.json( + { error: 'Your profile is not linked to an account.' }, + { status: 403 }, + ) + } + let payload: TemplatePayload try { payload = (await request.json()) as TemplatePayload @@ -77,7 +92,7 @@ export async function PATCH( .from('message_templates') .select('id, name, status, meta_template_id, language') .eq('id', id) - .eq('user_id', user.id) + .eq('account_id', accountId) .maybeSingle() if (lookupErr || !existing) { return NextResponse.json({ error: 'Template not found.' }, { status: 404 }) @@ -127,7 +142,7 @@ export async function PATCH( const { data: config, error: configError } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', user.id) + .eq('account_id', accountId) .single() if (configError || !config) { return NextResponse.json( @@ -224,11 +239,27 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + // Same account-scoping rationale as the PATCH handler above — + // teammates need to be able to operate on shared templates + + // the shared whatsapp_config. + const { data: profile } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .maybeSingle() + const accountId = profile?.account_id as string | undefined + if (!accountId) { + return NextResponse.json( + { error: 'Your profile is not linked to an account.' }, + { status: 403 }, + ) + } + const { data: existing, error: lookupErr } = await supabase .from('message_templates') .select('id, name, meta_template_id') .eq('id', id) - .eq('user_id', user.id) + .eq('account_id', accountId) .maybeSingle() if (lookupErr || !existing) { return NextResponse.json({ error: 'Template not found.' }, { status: 404 }) @@ -238,7 +269,7 @@ export async function DELETE( const { data: config, error: configError } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', user.id) + .eq('account_id', accountId) .single() if (configError || !config || !config.waba_id) { return NextResponse.json( diff --git a/src/app/api/whatsapp/templates/submit/route.ts b/src/app/api/whatsapp/templates/submit/route.ts index aafafd6..8056223 100644 --- a/src/app/api/whatsapp/templates/submit/route.ts +++ b/src/app/api/whatsapp/templates/submit/route.ts @@ -16,6 +16,7 @@ import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize' * fields here means adding a column later only touches one spot. */ function buildUpsertRow( + accountId: string, userId: string, payload: TemplatePayload, extras: { @@ -25,6 +26,13 @@ function buildUpsertRow( }, ) { return { + // Account tenancy — required NOT NULL on message_templates as + // of migration 017. Without this an INSERT throws on the + // not-null constraint. + account_id: accountId, + // Original author — kept as audit only. The unique index is + // still on (user_id, name, language) — see the upsert helper + // for the cross-teammate dedup follow-up. user_id: userId, name: payload.name, category: payload.category, @@ -51,6 +59,11 @@ async function upsertTemplateRow( supabase: SupabaseClient, row: ReturnType, ) { + // TODO(account-sharing): conflict target is still scoped to + // user_id. Once a follow-up migration drops the legacy unique + // index on (user_id, name, language) and adds (account_id, + // name, language), switch `onConflict` here so two teammates + // can't shadow each other's same-named template. return supabase .from('message_templates') .upsert(row, { onConflict: 'user_id,name,language' }) @@ -83,6 +96,21 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + // Resolve the caller's account_id — whatsapp_config + the + // message_templates row are account-scoped post-multi-user. + const { data: profile } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .maybeSingle() + const accountId = profile?.account_id as string | undefined + if (!accountId) { + return NextResponse.json( + { error: 'Your profile is not linked to an account.' }, + { status: 403 }, + ) + } + let payload: TemplatePayload try { payload = (await request.json()) as TemplatePayload @@ -125,7 +153,7 @@ export async function POST(request: Request) { const { data: config, error: configError } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', user.id) + .eq('account_id', accountId) .single() if (configError || !config) { return NextResponse.json( @@ -161,7 +189,7 @@ export async function POST(request: Request) { // until they fix and re-submit. await upsertTemplateRow( supabase, - buildUpsertRow(user.id, payload, { + buildUpsertRow(accountId, user.id, payload, { status: 'DRAFT', metaTemplateId: null, submissionError: message, @@ -181,7 +209,7 @@ export async function POST(request: Request) { const { data: row, error: upsertErr } = await upsertTemplateRow( supabase, - buildUpsertRow(user.id, payload, { + buildUpsertRow(accountId, user.id, payload, { status: normalizeStatus(metaStatus), metaTemplateId, submissionError: null, diff --git a/src/app/api/whatsapp/templates/sync/route.ts b/src/app/api/whatsapp/templates/sync/route.ts index cc17dce..dfe676f 100644 --- a/src/app/api/whatsapp/templates/sync/route.ts +++ b/src/app/api/whatsapp/templates/sync/route.ts @@ -135,10 +135,25 @@ export async function POST() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + // Resolve the caller's account_id — both whatsapp_config and + // the message_templates we sync into are account-scoped. + const { data: profile } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .maybeSingle() + const accountId = profile?.account_id as string | undefined + if (!accountId) { + return NextResponse.json( + { error: 'Your profile is not linked to an account.' }, + { status: 403 }, + ) + } + const { data: config, error: configError } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', user.id) + .eq('account_id', accountId) .single() if (configError || !config) { @@ -218,6 +233,10 @@ export async function POST() { : null const row = { + // Account tenancy + user audit, same split as the submit + // route. account_id is NOT NULL on message_templates + // post-017, so an INSERT without it errors. + account_id: accountId, user_id: user.id, name: t.name, category: normalizeCategory(t.category), @@ -238,7 +257,7 @@ export async function POST() { const { data: existing, error: lookupErr } = await supabase .from('message_templates') .select('id') - .eq('user_id', user.id) + .eq('account_id', accountId) .eq('name', t.name) .eq('language', t.language) .maybeSingle() diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx index 05edb1a..43a0be6 100644 --- a/src/components/settings/whatsapp-config.tsx +++ b/src/components/settings/whatsapp-config.tsx @@ -36,7 +36,12 @@ type ResetReason = 'token_corrupted' | 'meta_api_error' | null; export function WhatsAppConfig() { const supabase = createClient(); - const { user, loading: authLoading } = useAuth(); + // After multi-user, whatsapp_config is one-row-per-account, not + // one-row-per-user. We pull `accountId` straight off the auth + // context and key every read off it — so a teammate who just + // joined an account sees the inviter's saved config without + // having to re-enter anything. + const { user, accountId, loading: authLoading, profileLoading } = useAuth(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -79,14 +84,19 @@ export function WhatsAppConfig() { ? `${window.location.origin}/api/whatsapp/webhook` : ''; - const fetchConfig = useCallback(async (userId: string) => { + const fetchConfig = useCallback(async (acctId: string) => { setLoading(true); try { - // Load form values from Supabase (shows what's in DB) + // Load form values from Supabase (shows what's in DB). + // Switched from `user_id` (which would only match the row's + // original author) to `account_id` so every member of the + // account sees the same saved configuration. UNIQUE(account_id) + // on the table guarantees the .maybeSingle() return type + // remains accurate. const { data, error } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', userId) + .eq('account_id', acctId) .maybeSingle(); if (error) { @@ -146,13 +156,18 @@ export function WhatsAppConfig() { }, [supabase]); useEffect(() => { - if (authLoading) return; - if (!user) { + // Need both the auth session (`!authLoading`) AND the profile + // (`!profileLoading`, which carries `accountId`). Without the + // second guard, the effect would fire with `accountId === null` + // for the first render window and bail without ever retrying + // once the profile arrives. + if (authLoading || profileLoading) return; + if (!user || !accountId) { setLoading(false); return; } - fetchConfig(user.id); - }, [authLoading, user, fetchConfig]); + fetchConfig(accountId); + }, [authLoading, profileLoading, user, accountId, fetchConfig]); async function handleSave() { if (!phoneNumberId.trim()) { @@ -230,7 +245,7 @@ export function WhatsAppConfig() { setPin(''); } - if (user) await fetchConfig(user.id); + if (accountId) await fetchConfig(accountId); } catch (err) { console.error('Save error:', err); toast.error('Failed to save configuration'); @@ -286,7 +301,7 @@ export function WhatsAppConfig() { { duration: 8000 }, ); } - if (user) await fetchConfig(user.id); + if (accountId) await fetchConfig(accountId); } catch (err) { console.error('verify-registration failed:', err); toast.error('Could not reach the verification endpoint.');