mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
fix(whatsapp): require a write role on the WhatsApp write routes
The five write handlers under /api/whatsapp resolved the caller's
account_id straight off their profile row, which only proves account
membership — never a role. Every other write surface in the app
(account/, ai/, automations/, flows/, quick-replies/) already goes
through requireRole(); this family was missed.
RLS did not backstop it, because each of these routes calls Meta
*before* it persists anything:
- broadcast: makes no database write at all, so there was no policy
involved on any path
- send / react: the Meta call precedes the insert, so the outbound
message or reaction reached the customer and only the local mirror
was refused
- templates/submit, templates/sync: push to Meta before upserting the
local catalog
Gate each handler at the role its data class already implies, matching
lib/auth/roles and the migration 017 policies:
- send, react, broadcast -> 'agent' (canSendMessages)
- templates/submit, sync -> 'admin' (canEditSettings, and the
message_templates_insert/update
policies)
Also switches these handlers to toErrorResponse so an authorization
failure returns 401/403 instead of being folded into a generic 500. The
two template routes keep their existing message-surfacing 500 for
non-auth errors, so a Meta failure still reports its own reason.
Adds role-enforcement coverage to the send route's test: a viewer is
refused with 403 and sendTemplateMessage is never called, while an agent
passes through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5cbc7faf08
commit
242ecdf2c5
@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { requireRole, toErrorResponse } from '@/lib/auth/account'
|
||||||
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||||
import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder'
|
import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder'
|
||||||
@ -60,42 +60,28 @@ interface NewRecipient {
|
|||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const supabase = await createClient()
|
// Requires the 'agent' role — `canSendMessages` in lib/auth/roles is
|
||||||
|
// explicit that running broadcasts is a write operation and that
|
||||||
const {
|
// viewers are read-only.
|
||||||
data: { user },
|
//
|
||||||
error: authError,
|
// This endpoint writes NOTHING to the database: it reads the config
|
||||||
} = await supabase.auth.getUser()
|
// and template, then calls Meta directly. So unlike the rest of the
|
||||||
|
// app there was no RLS policy backstopping a missing role check —
|
||||||
if (authError || !user) {
|
// resolving `account_id` straight off the profile (which only needs
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
// 'viewer') was the ONLY gate, and it let a viewer blast a template
|
||||||
}
|
// to arbitrary phone numbers from the account's WhatsApp number.
|
||||||
|
// Nothing about that is recoverable after the fact, so the check has
|
||||||
|
// to happen here.
|
||||||
|
const { supabase, accountId, userId } = await requireRole('agent')
|
||||||
|
|
||||||
// Per-user broadcast budget. Note: this limits how often a user
|
// Per-user broadcast budget. Note: this limits how often a user
|
||||||
// can *start* a campaign, not how many messages go out inside
|
// can *start* a campaign, not how many messages go out inside
|
||||||
// one — the fan-out loop below runs without additional gating.
|
// one — the fan-out loop below runs without additional gating.
|
||||||
const limit = checkRateLimit(`broadcast:${user.id}`, RATE_LIMITS.broadcast)
|
const limit = checkRateLimit(`broadcast:${userId}`, RATE_LIMITS.broadcast)
|
||||||
if (!limit.success) {
|
if (!limit.success) {
|
||||||
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,
|
||||||
@ -254,10 +240,9 @@ export async function POST(request: Request) {
|
|||||||
results,
|
results,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// requireRole throws Unauthorized/Forbidden; toErrorResponse maps
|
||||||
|
// those to 401/403 and collapses anything else to a generic 500.
|
||||||
console.error('Error in WhatsApp broadcast POST:', error)
|
console.error('Error in WhatsApp broadcast POST:', error)
|
||||||
return NextResponse.json(
|
return toErrorResponse(error)
|
||||||
{ error: 'Failed to process broadcast' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { requireRole, toErrorResponse } from '@/lib/auth/account';
|
||||||
import { sendReactionMessage } from '@/lib/whatsapp/meta-api';
|
import { sendReactionMessage } from '@/lib/whatsapp/meta-api';
|
||||||
import { decrypt } from '@/lib/whatsapp/encryption';
|
import { decrypt } from '@/lib/whatsapp/encryption';
|
||||||
import { sanitizePhoneForMeta } from '@/lib/whatsapp/phone-utils';
|
import { sanitizePhoneForMeta } from '@/lib/whatsapp/phone-utils';
|
||||||
@ -20,37 +20,17 @@ import {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const supabase = await createClient();
|
// Reacting is a write operation (`canSendMessages`), and it pushes the
|
||||||
|
// reaction to Meta before mirroring it locally — so, as on /send, a
|
||||||
|
// missing role check let a read-only viewer put a visible reaction on
|
||||||
|
// the customer's message even though RLS blocked the local mirror.
|
||||||
|
const { supabase, accountId, userId } = await requireRole('agent');
|
||||||
|
|
||||||
const {
|
const limit = checkRateLimit(`react:${userId}`, RATE_LIMITS.react);
|
||||||
data: { user },
|
|
||||||
error: authError,
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (authError || !user) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = checkRateLimit(`react:${user.id}`, RATE_LIMITS.react);
|
|
||||||
if (!limit.success) {
|
if (!limit.success) {
|
||||||
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;
|
||||||
@ -150,7 +130,7 @@ export async function POST(request: Request) {
|
|||||||
.delete()
|
.delete()
|
||||||
.eq('message_id', targetMessage.id)
|
.eq('message_id', targetMessage.id)
|
||||||
.eq('actor_type', 'agent')
|
.eq('actor_type', 'agent')
|
||||||
.eq('actor_id', user.id);
|
.eq('actor_id', userId);
|
||||||
|
|
||||||
if (delError) {
|
if (delError) {
|
||||||
console.error('[whatsapp/react] DB delete failed:', delError.message);
|
console.error('[whatsapp/react] DB delete failed:', delError.message);
|
||||||
@ -167,7 +147,7 @@ export async function POST(request: Request) {
|
|||||||
message_id: targetMessage.id,
|
message_id: targetMessage.id,
|
||||||
conversation_id: targetMessage.conversation_id,
|
conversation_id: targetMessage.conversation_id,
|
||||||
actor_type: 'agent',
|
actor_type: 'agent',
|
||||||
actor_id: user.id,
|
actor_id: userId,
|
||||||
emoji,
|
emoji,
|
||||||
},
|
},
|
||||||
{ onConflict: 'message_id,actor_type,actor_id' },
|
{ onConflict: 'message_id,actor_type,actor_id' },
|
||||||
@ -184,10 +164,9 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// requireRole throws Unauthorized/Forbidden; toErrorResponse maps
|
||||||
|
// those to 401/403 and collapses anything else to a generic 500.
|
||||||
console.error('Error in WhatsApp react POST:', error);
|
console.error('Error in WhatsApp react POST:', error);
|
||||||
return NextResponse.json(
|
return toErrorResponse(error);
|
||||||
{ error: 'Failed to react to message' },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,9 @@ const messageInserts: Array<Record<string, unknown>> = []
|
|||||||
// Toggles for the per-test scenario.
|
// Toggles for the per-test scenario.
|
||||||
let existingConversation: Record<string, unknown> | null = null
|
let existingConversation: Record<string, unknown> | null = null
|
||||||
let contactRow: Record<string, unknown> | null = null
|
let contactRow: Record<string, unknown> | null = null
|
||||||
|
// The caller's role, as `requireRole` reads it off the profile. Sending
|
||||||
|
// requires 'agent'; 'viewer' must be refused before anything reaches Meta.
|
||||||
|
let callerRole: string = 'admin'
|
||||||
// A conversation created during the request becomes retrievable by id —
|
// A conversation created during the request becomes retrievable by id —
|
||||||
// the shared send core re-loads the conversation (with its contact) from
|
// the shared send core re-loads the conversation (with its contact) from
|
||||||
// just the id, so the mock must model insert-then-select-by-id.
|
// just the id, so the mock must model insert-then-select-by-id.
|
||||||
@ -35,7 +38,12 @@ function makeSupabaseMock() {
|
|||||||
const selectResult = () => {
|
const selectResult = () => {
|
||||||
switch (table) {
|
switch (table) {
|
||||||
case 'profiles':
|
case 'profiles':
|
||||||
return { data: { account_id: 'acct-1' }, error: null }
|
return {
|
||||||
|
data: { account_id: 'acct-1', account_role: callerRole },
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
case 'accounts':
|
||||||
|
return { data: { id: 'acct-1', name: 'Acme' }, error: null }
|
||||||
case 'contacts':
|
case 'contacts':
|
||||||
return { data: contactRow, error: null }
|
return { data: contactRow, error: null }
|
||||||
case 'conversations':
|
case 'conversations':
|
||||||
@ -179,6 +187,7 @@ describe('POST /api/whatsapp/send — contact_id template path', () => {
|
|||||||
existingConversation = null
|
existingConversation = null
|
||||||
createdConversation = null
|
createdConversation = null
|
||||||
contactRow = CONTACT
|
contactRow = CONTACT
|
||||||
|
callerRole = 'admin'
|
||||||
supabaseMock = makeSupabaseMock()
|
supabaseMock = makeSupabaseMock()
|
||||||
sendTemplateMessage.mockClear()
|
sendTemplateMessage.mockClear()
|
||||||
})
|
})
|
||||||
@ -259,3 +268,49 @@ describe('POST /api/whatsapp/send — contact_id template path', () => {
|
|||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('POST /api/whatsapp/send — role enforcement', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
conversationInserts.length = 0
|
||||||
|
messageInserts.length = 0
|
||||||
|
existingConversation = {
|
||||||
|
id: 'conv-existing',
|
||||||
|
account_id: 'acct-1',
|
||||||
|
contact_id: 'contact-1',
|
||||||
|
contact: CONTACT,
|
||||||
|
}
|
||||||
|
createdConversation = null
|
||||||
|
contactRow = CONTACT
|
||||||
|
callerRole = 'admin'
|
||||||
|
supabaseMock = makeSupabaseMock()
|
||||||
|
sendTemplateMessage.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a viewer with 403 and never reaches Meta', async () => {
|
||||||
|
// A viewer is read-only (`canSendMessages`). The route used to resolve
|
||||||
|
// account_id straight off the profile with no role check: RLS blocked
|
||||||
|
// the message INSERT, but the send core calls Meta first, so the
|
||||||
|
// customer still received a real WhatsApp message that RLS could not
|
||||||
|
// un-send. The gate has to come before any outbound call.
|
||||||
|
callerRole = 'viewer'
|
||||||
|
|
||||||
|
const res = await postContactTemplate()
|
||||||
|
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
expect(sendTemplateMessage).not.toHaveBeenCalled()
|
||||||
|
expect(messageInserts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows an agent through', async () => {
|
||||||
|
callerRole = 'agent'
|
||||||
|
|
||||||
|
const res = await postContactTemplate()
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(sendTemplateMessage).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { requireRole, toErrorResponse } from '@/lib/auth/account'
|
||||||
import {
|
import {
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
rateLimitResponse,
|
rateLimitResponse,
|
||||||
@ -22,44 +23,24 @@ import {
|
|||||||
// dashboard's internal `{ error }` shape.
|
// dashboard's internal `{ error }` shape.
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const supabase = await createClient()
|
// Requires the 'agent' role, matching both `canSendMessages` and the
|
||||||
|
// `messages_modify` RLS policy (migration 017).
|
||||||
const {
|
//
|
||||||
data: { user },
|
// Resolving `account_id` off the profile — which any 'viewer' has —
|
||||||
error: authError,
|
// was previously the only gate. RLS did block the message INSERT, but
|
||||||
} = await supabase.auth.getUser()
|
// the send core calls Meta BEFORE it persists, so a viewer's request
|
||||||
|
// still delivered a real WhatsApp message to the customer and merely
|
||||||
if (authError || !user) {
|
// failed to record it (surfacing as "sent to Meta but failed to save
|
||||||
return NextResponse.json(
|
// to DB"). RLS can't un-send that, so the role check belongs here.
|
||||||
{ error: 'Unauthorized' },
|
const { supabase, accountId, userId } = await requireRole('agent')
|
||||||
{ status: 401 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-user rate limit. Bucket key is scoped to this route so
|
// Per-user rate limit. Bucket key is scoped to this route so
|
||||||
// `/broadcast` has an independent budget.
|
// `/broadcast` has an independent budget.
|
||||||
const limit = checkRateLimit(`send:${user.id}`, RATE_LIMITS.send)
|
const limit = checkRateLimit(`send:${userId}`, RATE_LIMITS.send)
|
||||||
if (!limit.success) {
|
if (!limit.success) {
|
||||||
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` targets an existing thread (inbox). `contact_id`
|
// `conversation_id` targets an existing thread (inbox). `contact_id`
|
||||||
@ -148,7 +129,7 @@ export async function POST(request: Request) {
|
|||||||
const resolved = await findOrCreateConversation(
|
const resolved = await findOrCreateConversation(
|
||||||
supabase,
|
supabase,
|
||||||
accountId,
|
accountId,
|
||||||
user.id,
|
userId,
|
||||||
contact_id
|
contact_id
|
||||||
)
|
)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
@ -201,11 +182,10 @@ export async function POST(request: Request) {
|
|||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// requireRole throws Unauthorized/Forbidden; toErrorResponse maps
|
||||||
|
// those to 401/403 and collapses anything else to a generic 500.
|
||||||
console.error('Error in WhatsApp send POST:', error)
|
console.error('Error in WhatsApp send POST:', error)
|
||||||
return NextResponse.json(
|
return toErrorResponse(error)
|
||||||
{ error: 'Failed to send message' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import {
|
||||||
|
ForbiddenError,
|
||||||
|
UnauthorizedError,
|
||||||
|
requireRole,
|
||||||
|
toErrorResponse,
|
||||||
|
} from '@/lib/auth/account'
|
||||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||||
import { submitMessageTemplate } from '@/lib/whatsapp/meta-api'
|
import { submitMessageTemplate } from '@/lib/whatsapp/meta-api'
|
||||||
import {
|
import {
|
||||||
@ -88,29 +93,13 @@ async function upsertTemplateRow(
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const supabase = await createClient()
|
// Message templates are settings-class data: `canEditSettings` and the
|
||||||
const {
|
// message_templates_insert/update RLS policies (migration 017) both
|
||||||
data: { user },
|
// require 'admin'. Resolving account_id off the profile only proved
|
||||||
error: authError,
|
// membership, so a viewer or agent could push a template to Meta for
|
||||||
} = await supabase.auth.getUser()
|
// approval — an external side effect RLS can't roll back — before the
|
||||||
if (authError || !user) {
|
// local upsert was refused.
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
const { supabase, accountId, userId } = await requireRole('admin')
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
||||||
@ -203,7 +192,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(accountId, user.id, payload, {
|
buildUpsertRow(accountId, userId, payload, {
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
metaTemplateId: null,
|
metaTemplateId: null,
|
||||||
submissionError: message,
|
submissionError: message,
|
||||||
@ -223,7 +212,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const { data: row, error: upsertErr } = await upsertTemplateRow(
|
const { data: row, error: upsertErr } = await upsertTemplateRow(
|
||||||
supabase,
|
supabase,
|
||||||
buildUpsertRow(accountId, user.id, payload, {
|
buildUpsertRow(accountId, userId, payload, {
|
||||||
status: normalizeStatus(metaStatus),
|
status: normalizeStatus(metaStatus),
|
||||||
metaTemplateId,
|
metaTemplateId,
|
||||||
submissionError: null,
|
submissionError: null,
|
||||||
@ -249,6 +238,16 @@ export async function POST(request: Request) {
|
|||||||
dry_run: dryRun,
|
dry_run: dryRun,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Auth failures map to 401/403. Handled before the generic branch
|
||||||
|
// below, which surfaces `error.message` as a 500 — reporting "you
|
||||||
|
// aren't an admin" as a template submission failure would send the
|
||||||
|
// user chasing the wrong problem.
|
||||||
|
if (
|
||||||
|
error instanceof UnauthorizedError ||
|
||||||
|
error instanceof ForbiddenError
|
||||||
|
) {
|
||||||
|
return toErrorResponse(error)
|
||||||
|
}
|
||||||
console.error('Error submitting template:', error)
|
console.error('Error submitting template:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import {
|
||||||
|
ForbiddenError,
|
||||||
|
UnauthorizedError,
|
||||||
|
requireRole,
|
||||||
|
toErrorResponse,
|
||||||
|
} from '@/lib/auth/account'
|
||||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||||
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
|
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
|
||||||
import type { TemplateButton, TemplateSampleValues } from '@/types'
|
import type { TemplateButton, TemplateSampleValues } from '@/types'
|
||||||
@ -124,31 +129,11 @@ function extractSampleValues(
|
|||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
const supabase = await createClient()
|
// Syncing rewrites the account-wide template catalog, which is
|
||||||
|
// settings-class data: `canEditSettings` and the message_templates
|
||||||
const {
|
// insert/update RLS policies (migration 017) both require 'admin'.
|
||||||
data: { user },
|
// Resolving account_id off the profile only proved membership.
|
||||||
error: authError,
|
const { supabase, accountId, userId } = await requireRole('admin')
|
||||||
} = await supabase.auth.getUser()
|
|
||||||
|
|
||||||
if (authError || !user) {
|
|
||||||
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')
|
||||||
@ -237,7 +222,7 @@ export async function POST() {
|
|||||||
// route. account_id is NOT NULL on message_templates
|
// route. account_id is NOT NULL on message_templates
|
||||||
// post-017, so an INSERT without it errors.
|
// post-017, so an INSERT without it errors.
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
user_id: user.id,
|
user_id: userId,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
category: normalizeCategory(t.category),
|
category: normalizeCategory(t.category),
|
||||||
language: t.language,
|
language: t.language,
|
||||||
@ -310,6 +295,14 @@ export async function POST() {
|
|||||||
truncated: pageCount >= PAGE_CAP && nextUrl !== null,
|
truncated: pageCount >= PAGE_CAP && nextUrl !== null,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Auth failures map to 401/403 rather than being folded into the
|
||||||
|
// generic 500 below, which surfaces `error.message` as a sync failure.
|
||||||
|
if (
|
||||||
|
error instanceof UnauthorizedError ||
|
||||||
|
error instanceof ForbiddenError
|
||||||
|
) {
|
||||||
|
return toErrorResponse(error)
|
||||||
|
}
|
||||||
console.error('Error syncing WhatsApp templates:', error)
|
console.error('Error syncing WhatsApp templates:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user