mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #187 from ArnasDon/fix/whatsapp-config-shared-by-account
fix(multi-user): share whatsapp_config + templates by account
This commit is contained in:
commit
c247fd0a9b
@ -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");
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<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
|
||||
// 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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<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
|
||||
.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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user