mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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:
parent
7aac97e3ef
commit
2e911d5e5a
@ -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");
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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.');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user