fix(multi-user): share whatsapp_config + templates by account

Reported by @ArnasDon: a teammate accepting an invite landed in the
shared account but the WhatsApp Config tab still showed "not
connected." Inbox banner read "WhatsApp not connected" too.

Cause: PR 8 of the multi-user series converted the *webhook* and
the *automations/flows engines* to account-scoped lookups, but
left every user-facing API route + UI page still filtering by
`user_id`. Post-017 `whatsapp_config` and `message_templates` are
account-scoped (one row per account, with `account_id NOT NULL`),
so `.eq('user_id', user.id)` returned zero rows for any teammate
who didn't personally save the config — including everyone who
joined via invite.

This PR completes the conversion across the user-facing surface.

Surface (11 files)
- `src/components/settings/whatsapp-config.tsx` — read accountId
  from `useAuth()`; trigger fetch on profile-ready, not just
  user-ready; pass accountId through to the supabase query.
- `src/app/(dashboard)/inbox/page.tsx` — resolve account_id from
  profiles, query whatsapp_config by account_id for the
  connected-banner state.
- `src/app/api/whatsapp/config/route.ts` — GET/POST/DELETE all
  resolve account_id from profile. The phone_number_id collision
  check (service-role) now compares against `account_id`, not
  `user_id` — two accounts still can't share a Meta number, two
  teammates in one account share one row by design. INSERT
  populates `account_id` (tenancy) + `user_id` (audit).
- `src/app/api/whatsapp/config/verify-registration/route.ts` —
  same conversion.
- `src/app/api/whatsapp/send/route.ts` — conversation,
  whatsapp_config, message_templates lookups all switch to
  account_id; rate-limit key stays per-user.
- `src/app/api/whatsapp/react/route.ts` — conversation +
  whatsapp_config switch to account_id.
- `src/app/api/whatsapp/broadcast/route.ts` — whatsapp_config +
  message_templates + broadcasts switch to account_id.
- `src/app/api/whatsapp/media/[mediaId]/route.ts` — config
  lookup switches to account_id.
- `src/app/api/whatsapp/templates/submit/route.ts` — config
  lookup + every message_templates SELECT/upsert now uses
  account_id. The upsert ROW gains `account_id` so the
  NOT NULL constraint added by migration 017 stops rejecting
  new templates (this was an incidental bug — every new
  template post-017 would have been broken regardless of
  multi-user).
- `src/app/api/whatsapp/templates/sync/route.ts` — same.
- `src/app/api/whatsapp/templates/[id]/route.ts` PATCH +
  DELETE — same.

Pattern at every route:

  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 403

then every downstream `.eq('user_id', user.id)` → `.eq('account_id', accountId)`.

Known follow-up not in this PR
- `message_templates` still has its UNIQUE index on
  `(user_id, name, language)` from migration 014; the submit
  upsert's `onConflict` targets that same triple. As a result,
  two teammates can still INSERT same-named templates without
  conflicting. The fix needs a small migration (drop legacy
  index, add `(account_id, name, language)` index, switch the
  `onConflict` target) — TODO comment left in the route file so
  it's not forgotten.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-01 14:01:46 +03:00
parent 7aac97e3ef
commit 2e911d5e5a
No known key found for this signature in database
11 changed files with 299 additions and 47 deletions

View File

@ -132,12 +132,27 @@ export default function InboxPage() {
if (!user) return; if (!user) return;
// Table is `whatsapp_config` (singular) — the previous "whatsapp_configs" // whatsapp_config is one-row-per-account post-multi-user, so
// query always returned no rows, so the banner always showed "not connected". // 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 const { data } = await supabase
.from("whatsapp_config") .from("whatsapp_config")
.select("status") .select("status")
.eq("user_id", user.id) .eq("account_id", accountId)
.maybeSingle(); .maybeSingle();
setWhatsappConnected(data?.status === "connected"); setWhatsappConnected(data?.status === "connected");

View File

@ -79,6 +79,23 @@ export async function POST(request: Request) {
return rateLimitResponse(limit) 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 body = await request.json()
const { const {
recipients: newRecipients, recipients: newRecipients,
@ -120,7 +137,7 @@ export async function POST(request: Request) {
const { data: config, error: configError } = await supabase const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
.eq('user_id', user.id) .eq('account_id', accountId)
.single() .single()
if (configError || !config) { if (configError || !config) {
@ -143,7 +160,7 @@ export async function POST(request: Request) {
const { data: rawTemplateRow } = await supabase const { data: rawTemplateRow } = await supabase
.from('message_templates') .from('message_templates')
.select('*') .select('*')
.eq('user_id', user.id) .eq('account_id', accountId)
.eq('name', template_name) .eq('name', template_name)
.eq('language', template_language || 'en_US') .eq('language', template_language || 'en_US')
.maybeSingle() .maybeSingle()

View File

@ -8,6 +8,29 @@ import {
} from '@/lib/whatsapp/meta-api' } from '@/lib/whatsapp/meta-api'
import { encrypt, decrypt } from '@/lib/whatsapp/encryption' 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<ReturnType<typeof createClient>>,
userId: string,
): Promise<string | null> {
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 // Lazy-initialised service-role client. We need it to detect a
// phone_number_id already claimed by a *different* user — under RLS, // 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 // 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 }) 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 const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('phone_number_id, access_token, status') .select('phone_number_id, access_token, status')
.eq('user_id', user.id) .eq('account_id', accountId)
.maybeSingle() .maybeSingle()
if (configError) { if (configError) {
@ -141,6 +176,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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 body = await request.json()
const { phone_number_id, waba_id, access_token, verify_token, pin } = body 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. // Reject if another account has already claimed this phone_number_id.
// wacrm is single-tenant-per-WhatsApp-number — letting two users // wacrm is single-tenant-per-WhatsApp-number — letting two accounts
// bind the same number causes the webhook's `.single()` lookup to // bind the same number causes the webhook's `.single()` lookup to
// throw PGRST116 ("multiple rows"), silently dropping every // 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() const { data: claimed, error: claimedError } = await supabaseAdmin()
.from('whatsapp_config') .from('whatsapp_config')
.select('user_id') .select('account_id')
.eq('phone_number_id', phone_number_id) .eq('phone_number_id', phone_number_id)
.neq('user_id', user.id) .neq('account_id', accountId)
.maybeSingle() .maybeSingle()
if (claimedError) { 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 // Look up any pre-existing row for this account so we know whether
// already registered with Meta — if so we can skip /register when // this number is already registered with Meta — if so we can skip
// the user didn't provide a PIN this time around. // /register when the user didn't provide a PIN this time around.
const { data: existing } = await supabase const { data: existing } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('id, registered_at, phone_number_id') .select('id, registered_at, phone_number_id')
.eq('user_id', user.id) .eq('account_id', accountId)
.maybeSingle() .maybeSingle()
const sameNumber = const sameNumber =
@ -318,7 +363,7 @@ export async function POST(request: Request) {
const { error: updateError } = await supabase const { error: updateError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.update(baseRow) .update(baseRow)
.eq('user_id', user.id) .eq('account_id', accountId)
if (updateError) { if (updateError) {
console.error('Error updating whatsapp_config:', updateError) console.error('Error updating whatsapp_config:', updateError)
@ -328,9 +373,17 @@ export async function POST(request: Request) {
) )
} }
} else { } 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 const { error: insertError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.insert({ user_id: user.id, ...baseRow }) .insert({
account_id: accountId,
user_id: user.id,
...baseRow,
})
if (insertError) { if (insertError) {
console.error('Error inserting whatsapp_config:', insertError) console.error('Error inserting whatsapp_config:', insertError)
@ -386,10 +439,18 @@ export async function DELETE() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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 const { error: deleteError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.delete() .delete()
.eq('user_id', user.id) .eq('account_id', accountId)
if (deleteError) { if (deleteError) {
console.error('Error deleting whatsapp_config:', deleteError) console.error('Error deleting whatsapp_config:', deleteError)

View File

@ -38,10 +38,27 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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 const { data: config } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
.eq('user_id', user.id) .eq('account_id', accountId)
.maybeSingle() .maybeSingle()
if (!config) { if (!config) {

View File

@ -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 // Fetch and decrypt WhatsApp config
const { data: config, error: configError } = await supabase const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
.eq('user_id', user.id) .eq('account_id', accountId)
.single() .single()
if (configError || !config) { if (configError || !config) {

View File

@ -36,6 +36,21 @@ export async function POST(request: Request) {
return rateLimitResponse(limit); 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 body = await request.json();
const { message_id, emoji } = body as { const { message_id, emoji } = body as {
message_id?: string; message_id?: string;
@ -71,9 +86,9 @@ export async function POST(request: Request) {
const { data: conversation, error: convError } = await supabase const { data: conversation, error: convError } = await supabase
.from('conversations') .from('conversations')
.select('id, user_id, contact:contacts(phone)') .select('id, account_id, contact:contacts(phone)')
.eq('id', targetMessage.conversation_id) .eq('id', targetMessage.conversation_id)
.eq('user_id', user.id) .eq('account_id', accountId)
.maybeSingle(); .maybeSingle();
if (convError || !conversation) { 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 const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('phone_number_id, access_token') .select('phone_number_id, access_token')
.eq('user_id', user.id) .eq('account_id', accountId)
.single(); .single();
if (configError || !config) { if (configError || !config) {

View File

@ -40,6 +40,23 @@ export async function POST(request: Request) {
return rateLimitResponse(limit) 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 body = await request.json()
const { const {
conversation_id, conversation_id,
@ -79,7 +96,7 @@ export async function POST(request: Request) {
.from('conversations') .from('conversations')
.select('*, contact:contacts(*)') .select('*, contact:contacts(*)')
.eq('id', conversation_id) .eq('id', conversation_id)
.eq('user_id', user.id) .eq('account_id', accountId)
.single() .single()
if (convError || !conversation) { if (convError || !conversation) {
@ -110,7 +127,7 @@ export async function POST(request: Request) {
const { data: config, error: configError } = await supabase const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
.eq('user_id', user.id) .eq('account_id', accountId)
.single() .single()
if (configError || !config) { if (configError || !config) {
@ -196,7 +213,7 @@ export async function POST(request: Request) {
const { data } = await supabase const { data } = await supabase
.from('message_templates') .from('message_templates')
.select('*') .select('*')
.eq('user_id', user.id) .eq('account_id', accountId)
.eq('name', template_name) .eq('name', template_name)
.eq('language', template_language || 'en_US') .eq('language', template_language || 'en_US')
.maybeSingle() .maybeSingle()
@ -337,7 +354,7 @@ export async function POST(request: Request) {
ended_at: new Date().toISOString(), ended_at: new Date().toISOString(),
end_reason: 'agent_replied', end_reason: 'agent_replied',
}) })
.eq('user_id', user.id) .eq('account_id', accountId)
.eq('contact_id', contact.id) .eq('contact_id', contact.id)
.eq('status', 'active') .eq('status', 'active')
if (pauseErr) { if (pauseErr) {

View File

@ -64,6 +64,21 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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 let payload: TemplatePayload
try { try {
payload = (await request.json()) as TemplatePayload payload = (await request.json()) as TemplatePayload
@ -77,7 +92,7 @@ export async function PATCH(
.from('message_templates') .from('message_templates')
.select('id, name, status, meta_template_id, language') .select('id, name, status, meta_template_id, language')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('account_id', accountId)
.maybeSingle() .maybeSingle()
if (lookupErr || !existing) { if (lookupErr || !existing) {
return NextResponse.json({ error: 'Template not found.' }, { status: 404 }) return NextResponse.json({ error: 'Template not found.' }, { status: 404 })
@ -127,7 +142,7 @@ export async function PATCH(
const { data: config, error: configError } = await supabase const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
.eq('user_id', user.id) .eq('account_id', accountId)
.single() .single()
if (configError || !config) { if (configError || !config) {
return NextResponse.json( return NextResponse.json(
@ -224,11 +239,27 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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 const { data: existing, error: lookupErr } = await supabase
.from('message_templates') .from('message_templates')
.select('id, name, meta_template_id') .select('id, name, meta_template_id')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('account_id', accountId)
.maybeSingle() .maybeSingle()
if (lookupErr || !existing) { if (lookupErr || !existing) {
return NextResponse.json({ error: 'Template not found.' }, { status: 404 }) return NextResponse.json({ error: 'Template not found.' }, { status: 404 })
@ -238,7 +269,7 @@ export async function DELETE(
const { data: config, error: configError } = await supabase const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
.eq('user_id', user.id) .eq('account_id', accountId)
.single() .single()
if (configError || !config || !config.waba_id) { if (configError || !config || !config.waba_id) {
return NextResponse.json( return NextResponse.json(

View File

@ -16,6 +16,7 @@ import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
* fields here means adding a column later only touches one spot. * fields here means adding a column later only touches one spot.
*/ */
function buildUpsertRow( function buildUpsertRow(
accountId: string,
userId: string, userId: string,
payload: TemplatePayload, payload: TemplatePayload,
extras: { extras: {
@ -25,6 +26,13 @@ function buildUpsertRow(
}, },
) { ) {
return { 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, user_id: userId,
name: payload.name, name: payload.name,
category: payload.category, category: payload.category,
@ -51,6 +59,11 @@ async function upsertTemplateRow(
supabase: SupabaseClient, supabase: SupabaseClient,
row: ReturnType<typeof buildUpsertRow>, row: ReturnType<typeof buildUpsertRow>,
) { ) {
// 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 return supabase
.from('message_templates') .from('message_templates')
.upsert(row, { onConflict: 'user_id,name,language' }) .upsert(row, { onConflict: 'user_id,name,language' })
@ -83,6 +96,21 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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 let payload: TemplatePayload
try { try {
payload = (await request.json()) as TemplatePayload payload = (await request.json()) as TemplatePayload
@ -125,7 +153,7 @@ export async function POST(request: Request) {
const { data: config, error: configError } = await supabase const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
.eq('user_id', user.id) .eq('account_id', accountId)
.single() .single()
if (configError || !config) { if (configError || !config) {
return NextResponse.json( return NextResponse.json(
@ -161,7 +189,7 @@ export async function POST(request: Request) {
// until they fix and re-submit. // until they fix and re-submit.
await upsertTemplateRow( await upsertTemplateRow(
supabase, supabase,
buildUpsertRow(user.id, payload, { buildUpsertRow(accountId, user.id, payload, {
status: 'DRAFT', status: 'DRAFT',
metaTemplateId: null, metaTemplateId: null,
submissionError: message, submissionError: message,
@ -181,7 +209,7 @@ export async function POST(request: Request) {
const { data: row, error: upsertErr } = await upsertTemplateRow( const { data: row, error: upsertErr } = await upsertTemplateRow(
supabase, supabase,
buildUpsertRow(user.id, payload, { buildUpsertRow(accountId, user.id, payload, {
status: normalizeStatus(metaStatus), status: normalizeStatus(metaStatus),
metaTemplateId, metaTemplateId,
submissionError: null, submissionError: null,

View File

@ -135,10 +135,25 @@ export async function POST() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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 const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
.eq('user_id', user.id) .eq('account_id', accountId)
.single() .single()
if (configError || !config) { if (configError || !config) {
@ -218,6 +233,10 @@ export async function POST() {
: null : null
const row = { 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, user_id: user.id,
name: t.name, name: t.name,
category: normalizeCategory(t.category), category: normalizeCategory(t.category),
@ -238,7 +257,7 @@ export async function POST() {
const { data: existing, error: lookupErr } = await supabase const { data: existing, error: lookupErr } = await supabase
.from('message_templates') .from('message_templates')
.select('id') .select('id')
.eq('user_id', user.id) .eq('account_id', accountId)
.eq('name', t.name) .eq('name', t.name)
.eq('language', t.language) .eq('language', t.language)
.maybeSingle() .maybeSingle()

View File

@ -36,7 +36,12 @@ type ResetReason = 'token_corrupted' | 'meta_api_error' | null;
export function WhatsAppConfig() { export function WhatsAppConfig() {
const supabase = createClient(); 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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -79,14 +84,19 @@ export function WhatsAppConfig() {
? `${window.location.origin}/api/whatsapp/webhook` ? `${window.location.origin}/api/whatsapp/webhook`
: ''; : '';
const fetchConfig = useCallback(async (userId: string) => { const fetchConfig = useCallback(async (acctId: string) => {
setLoading(true); setLoading(true);
try { 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 const { data, error } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
.eq('user_id', userId) .eq('account_id', acctId)
.maybeSingle(); .maybeSingle();
if (error) { if (error) {
@ -146,13 +156,18 @@ export function WhatsAppConfig() {
}, [supabase]); }, [supabase]);
useEffect(() => { useEffect(() => {
if (authLoading) return; // Need both the auth session (`!authLoading`) AND the profile
if (!user) { // (`!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); setLoading(false);
return; return;
} }
fetchConfig(user.id); fetchConfig(accountId);
}, [authLoading, user, fetchConfig]); }, [authLoading, profileLoading, user, accountId, fetchConfig]);
async function handleSave() { async function handleSave() {
if (!phoneNumberId.trim()) { if (!phoneNumberId.trim()) {
@ -230,7 +245,7 @@ export function WhatsAppConfig() {
setPin(''); setPin('');
} }
if (user) await fetchConfig(user.id); if (accountId) await fetchConfig(accountId);
} catch (err) { } catch (err) {
console.error('Save error:', err); console.error('Save error:', err);
toast.error('Failed to save configuration'); toast.error('Failed to save configuration');
@ -286,7 +301,7 @@ export function WhatsAppConfig() {
{ duration: 8000 }, { duration: 8000 },
); );
} }
if (user) await fetchConfig(user.id); if (accountId) await fetchConfig(accountId);
} catch (err) { } catch (err) {
console.error('verify-registration failed:', err); console.error('verify-registration failed:', err);
toast.error('Could not reach the verification endpoint.'); toast.error('Could not reach the verification endpoint.');