* fix(whatsapp): company question survives a Meta-rejected interactive send (#1589) The linked multi-company sender in #1589 never heard back because Meta rejected the reply-button payload synchronously (HTTP 400, #131009 "Duplicate button title"): the sender belongs to two companies with the same name, one of them archived. askCompanyQuestion rolled the question back and returned not_asked, the row stayed parked as staged_awaiting_company, and the channel went silent. - Exclude archived companies wherever the channel resolves memberships (isMember, resolveCompanyTarget, loadCompanyOptions, applyCompanyChoice, the M3 greeting count), same inner-join filter as the middleware. - uniqueTitles: interactive button/row titles are made unique (position suffix) so two live same-named companies, or names that truncate to the same prefix, no longer trip #131009. - Numbered-text fallback: when the interactive send is rejected at send time, ask the same M6 question as plain numbered text; roll back only when that fails too. A typed digit is recorded as via='numbered'. - Drain: when the sender now resolves as 'single', rows parked behind the dead question are re-opened and kicked instead of expiring at Meta. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(whatsapp): single-company drain also clears the dead company question (#1589) Re-opening the parked rows left the conversation in state awaiting_company with company_options and the company pending_question intact, so the sender stayed behind a zombie question for up to 48h: every typed word became a company_retry re-offering the archived company, 'byt' was swallowed, and finalizeBurst could not ask about the drained receipts until the TTL sweep. - After the drain, when a company question is open in any of its shapes (awaiting_company state, kept company_options, company pending_question), clear it through the guarded updateConversation: state -> idle, options and the company pending_question deleted, other question types untouched. - Tests: the clear in its awaiting_company and post-TTL (idle + options) shapes, and its no-op for a representation question. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1056 lines
42 KiB
TypeScript
1056 lines
42 KiB
TypeScript
/**
|
|
* WhatsApp intake extension (PR3 of the WhatsApp track).
|
|
*
|
|
* Receives receipts sent to the shared Accounted WhatsApp number and lands
|
|
* them in the document inbox (Underlag) through the same uploadAndExtract
|
|
* funnel as email intake. Phone numbers bind to Accounted users via one-time
|
|
* codes; unknown senders get one canned, throttled greeting and are never
|
|
* processed further (no LLM, no media download, no content persistence).
|
|
*
|
|
* Webhook lifecycle: persist-first. POST verifies the Meta signature over the
|
|
* RAW body, Zod-parses, persists inbound rows (wamid partial-unique index =
|
|
* the dedupe key against Meta's up-to-7-day redelivery), 200s fast, and
|
|
* defers media processing via the after() idiom (lib/process-inbound.ts).
|
|
* Rejected/rate-limited content always acks 200: a retryable status would
|
|
* only buy a redelivery of something we already decided to drop.
|
|
*
|
|
* Conversation layer (PR4): media replies are burst-debounced into ONE
|
|
* combined ack (M4/M5) sent by the single winner of the pending_ack claim;
|
|
* multi-company senders get the company question (buttons/list/numbered) with
|
|
* an 8h sliding pin; clarifying questions (M7/M9/M10) ride on the ack and
|
|
* free-text answers route to the deferred interpret-answer worker. The
|
|
* per-minute sweep cron (app/api/extensions/whatsapp-inbox/sweep/cron)
|
|
* re-claims stuck rows and expires stale questions/pins.
|
|
*
|
|
* Deferred to PR5: retention cron, FieldsRail surfacing, booking-notes
|
|
* threading.
|
|
*/
|
|
|
|
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
|
import { NextResponse } from 'next/server'
|
|
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { z } from 'zod'
|
|
import { createServiceClient } from '@/lib/supabase/server'
|
|
import { createLogger } from '@/lib/logger'
|
|
import type { WhatsAppConversation, WhatsAppPhoneLink } from '@/types'
|
|
import { verifyMetaSignature, verifyChallengeToken } from './lib/webhook-verify'
|
|
import { parseWebhookEnvelope, type ParsedInboundMessage } from './lib/webhook-parse'
|
|
import { hashPhone } from './lib/phone-crypto'
|
|
import {
|
|
consumeLinkCode,
|
|
createPhoneLink,
|
|
LinkCodeRateLimitError,
|
|
looksLikeLinkCode,
|
|
lookupActiveLink,
|
|
mintLinkCode,
|
|
} from './lib/linking'
|
|
import { sendText, sendReaction, getDisplayPhoneNumber, MAX_REPLY_BUTTONS } from './lib/graph-api'
|
|
import { botCopy, TEMPLATE } from './lib/messages'
|
|
import { kickInboundProcessing } from './lib/process-inbound'
|
|
import { CHAT_ALLOWED_MIME_TYPES, normalizeChatMime } from './lib/chat-mime'
|
|
import {
|
|
DEBOUNCE_WINDOW_MS,
|
|
badCodeThrottled,
|
|
getContext,
|
|
getOrCreateConversation,
|
|
greetingThrottled,
|
|
redactRawPayload,
|
|
resolveAnswerTarget,
|
|
updateConversation,
|
|
type ConversationContext,
|
|
} from './lib/conversation'
|
|
import { summarizeInboundEvent } from './lib/last-event'
|
|
import { applyCompanyChoice, type CompanyChoiceVia } from './lib/company-question'
|
|
|
|
const log = createLogger('whatsapp-inbox')
|
|
|
|
// ── Unknown-sender budgets ───────────────────────────────────
|
|
// Pre-binding limiter (check_and_increment_whatsapp_sender_quota): caps how
|
|
// much handling an unbound phone can consume at all. Beyond it: silence.
|
|
// When the limiter itself cannot be reached (RPC error or throw), the sender
|
|
// falls through to the greeting path instead (#1599): its own throttle
|
|
// (greetingThrottled in lib/conversation.ts: 1 M1 per hour for text, a
|
|
// 10-minute burst window for media, 3 per day, fail-closed on its own read
|
|
// error) remains the outbound volume cap. M2 (bad code) gets its own small
|
|
// fail-closed throttle in that mode (badCodeThrottled in lib/conversation.ts:
|
|
// 1 per 10 minutes, 3 per day) since this quota no longer bounds it.
|
|
const UNKNOWN_SENDER_MINUTE_MAX = 15
|
|
const UNKNOWN_SENDER_DAY_MAX = 200
|
|
// Declined-message trace rows (issue #1552) are capped per hash and day so an
|
|
// over-quota flood cannot turn the observability trail into a write amplifier.
|
|
const DECLINE_TRACE_DAY_MAX = 20
|
|
|
|
const SERVICE_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
|
|
// Exact whole-message keyword sets (normalized lowercase + trim). Text
|
|
// messages only, never captions, per the conversation spec.
|
|
const STOP_KEYWORDS = new Set(['stopp', 'stop', 'avsluta'])
|
|
const HELP_KEYWORDS = new Set(['hjälp', 'hjalp', 'help', 'support', 'människa', 'manniska'])
|
|
const START_KEYWORD = 'start'
|
|
// Clears the 8h company pin. m6CompanyConfirm literally teaches this word, so
|
|
// it is recognized in every state EXCEPT awaiting_company (where the expected
|
|
// reply is a company, and 'byt' gets the re-prompt instead of being swallowed
|
|
// as answer text on someone's receipt).
|
|
const BYT_KEYWORD = 'byt'
|
|
|
|
const DefaultCompanySchema = z.object({
|
|
companyId: z.string().uuid().nullable(),
|
|
})
|
|
|
|
function buildServiceClient(): SupabaseClient {
|
|
return createServiceRoleClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
)
|
|
}
|
|
|
|
// ── Unknown senders ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Metadata-only trace row for an inbound message from an unknown sender
|
|
* (issue #1552): direction, wamid, phone hash, type, disposition. NO body,
|
|
* NO media reference, NO raw payload, NO profile name: the trace must never
|
|
* become content persistence for someone who has not linked. phone_link_id
|
|
* stays null, so the 30-day unknown-sender retention pass deletes these
|
|
* wholesale.
|
|
*
|
|
* Returns 'recorded', 'duplicate' (wamid already persisted: a Meta
|
|
* redelivery of a message we already handled), or 'skipped' (trace cap or
|
|
* insert failure: observability must never break the webhook).
|
|
*/
|
|
async function recordUnknownSenderMessage(
|
|
supabase: SupabaseClient,
|
|
msg: ParsedInboundMessage,
|
|
phoneHash: string,
|
|
status: 'skipped' | 'done',
|
|
disposition: string,
|
|
): Promise<'recorded' | 'duplicate' | 'skipped'> {
|
|
try {
|
|
// The day cap guards the one unbounded path: 'skipped' declines from an
|
|
// over-quota flood. 'done' traces (a reply went out) skip it: they are
|
|
// already bounded upstream (M1 by the greeting throttle, M2 by the
|
|
// pre-binding quota), and capping them would let a redelivered wamid
|
|
// past the dedupe below into a second reply. Best-effort under
|
|
// concurrency: racing webhooks can overshoot by a few rows, which is
|
|
// fine for a metadata trail.
|
|
if (status === 'skipped') {
|
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
|
const { count } = await supabase
|
|
.from('whatsapp_messages')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('direction', 'inbound')
|
|
.eq('sender_phone_hash', phoneHash)
|
|
.is('phone_link_id', null)
|
|
.gte('created_at', since)
|
|
if ((count ?? 0) >= DECLINE_TRACE_DAY_MAX) return 'skipped'
|
|
}
|
|
|
|
const { error } = await supabase.from('whatsapp_messages').insert({
|
|
direction: 'inbound',
|
|
wamid: msg.wamid,
|
|
sender_phone_hash: phoneHash,
|
|
phone_link_id: null,
|
|
conversation_id: null,
|
|
message_type: msg.type,
|
|
processing_status: status,
|
|
error_message: disposition,
|
|
})
|
|
if (!error) return 'recorded'
|
|
if (error.code === '23505') return 'duplicate'
|
|
log.warn('Failed to record unknown-sender trace row', { error: error.message, disposition })
|
|
return 'skipped'
|
|
} catch (err) {
|
|
log.warn('Unknown-sender trace write errored', {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
})
|
|
return 'skipped'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unknown/unlinked sender. Hard rules: never download media, never persist
|
|
* message content or raw payloads, never touch any LLM. The only DB writes
|
|
* are the quota counters, a consumed link code, and outbound reply rows.
|
|
* The quota RPC failing open (#1599) never relaxes these: the degraded path
|
|
* is the same greeting path, under the same hard rules.
|
|
*/
|
|
async function handleUnknownSender(
|
|
supabase: SupabaseClient,
|
|
msg: ParsedInboundMessage,
|
|
phoneHash: string,
|
|
): Promise<void> {
|
|
// Fail OPEN when the limiter is unavailable (#1599): a transient DB error
|
|
// must not silence a first-time sender at the linking moment. The greeting
|
|
// throttle below (itself fail-closed) remains the outbound cap and the
|
|
// link-code claim is single-use regardless. Over-quota (ok: false) stays
|
|
// silent by design. No fail-closed trace insert here: the path below writes
|
|
// this message's trace row (#1552), and a second insert would 23505 on the
|
|
// wamid and be misread as a redelivery.
|
|
let quota: { ok?: boolean } | null = null
|
|
let quotaUnavailable = false
|
|
try {
|
|
const { data, error } = await supabase.rpc(
|
|
'check_and_increment_whatsapp_sender_quota',
|
|
{
|
|
p_phone_hash: phoneHash,
|
|
p_minute_max: UNKNOWN_SENDER_MINUTE_MAX,
|
|
p_day_max: UNKNOWN_SENDER_DAY_MAX,
|
|
},
|
|
)
|
|
if (error) {
|
|
quotaUnavailable = true
|
|
log.warn('sender quota RPC failed; failing open to the throttled greeting path', {
|
|
error: error.message,
|
|
})
|
|
} else {
|
|
quota = data as { ok?: boolean } | null
|
|
}
|
|
} catch (err) {
|
|
quotaUnavailable = true
|
|
log.warn('sender quota RPC threw; failing open to the throttled greeting path', {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
})
|
|
}
|
|
if (quota?.ok === false) {
|
|
await recordUnknownSenderMessage(
|
|
supabase, msg, phoneHash, 'skipped', 'Unknown sender: over pre-binding quota, declined',
|
|
)
|
|
return
|
|
}
|
|
// Disposition suffix (never a prefix: lib/last-event.ts matches these by
|
|
// startsWith) so support can tell a degraded-mode greeting from a normal one.
|
|
const degraded = quotaUnavailable ? ' (quota limiter unavailable)' : ''
|
|
|
|
const copy = botCopy('sv')
|
|
|
|
if (msg.type === 'text' && looksLikeLinkCode(msg.text)) {
|
|
const consumed = await consumeLinkCode(supabase, msg.text ?? '')
|
|
// A bad code earns M2. Normally the pre-binding quota bounds M2; in
|
|
// degraded mode it gets its own small fail-closed throttle instead
|
|
// (badCodeThrottled: 1 per 10 min, 3 per day), so a sender greeted with
|
|
// M1 inside the last hour who then sends an expired or mistyped code is
|
|
// still told the code was rejected instead of falling into silence.
|
|
// The short-circuit keeps the extra read off the normal path.
|
|
if (!consumed && (!quotaUnavailable || !(await badCodeThrottled(supabase, phoneHash)))) {
|
|
// Trace row first: a Meta redelivery of a message already answered
|
|
// (including a redelivered CONSUMED code, whose row the success path
|
|
// wrote) dedupes on the wamid instead of earning a second reply.
|
|
const traced = await recordUnknownSenderMessage(
|
|
supabase, msg, phoneHash, 'done', `Unknown sender: invalid link code, M2 sent${degraded}`,
|
|
)
|
|
if (traced === 'duplicate') return
|
|
await sendText(supabase, {
|
|
to: msg.from,
|
|
body: copy.m2BadCode(),
|
|
template: TEMPLATE.m2BadCode,
|
|
senderPhoneHash: phoneHash,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (consumed) {
|
|
const { link, conversationId } = await createPhoneLink(supabase, {
|
|
userId: consumed.userId,
|
|
phone: msg.from,
|
|
profileName: msg.profileName,
|
|
})
|
|
|
|
// Persist a content-free row for the code message so a Meta redelivery
|
|
// of the same wamid dedupes instead of falling into the keyword path.
|
|
await supabase.from('whatsapp_messages').insert({
|
|
direction: 'inbound',
|
|
wamid: msg.wamid,
|
|
sender_phone_hash: phoneHash,
|
|
phone_link_id: link.id,
|
|
conversation_id: conversationId,
|
|
message_type: 'text',
|
|
processing_status: 'done',
|
|
})
|
|
|
|
// Non-archived only: the greeting must describe what the channel will
|
|
// then do (name the sole live company, or say it will ask), not count a
|
|
// company receipts can never be filed into (#1589).
|
|
const { data: memberships } = await supabase
|
|
.from('company_members')
|
|
.select('company_id, companies!inner(archived_at)')
|
|
.eq('user_id', consumed.userId)
|
|
.is('companies.archived_at', null)
|
|
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
|
|
|
|
let companyName: string | null = null
|
|
if (companyIds.length === 1) {
|
|
const { data: company } = await supabase
|
|
.from('companies')
|
|
.select('name')
|
|
.eq('id', companyIds[0])
|
|
.maybeSingle()
|
|
companyName = (company as { name?: string } | null)?.name ?? null
|
|
}
|
|
|
|
await sendText(supabase, {
|
|
to: msg.from,
|
|
body: copy.m3Linked({ companyName, companyCount: Math.max(companyIds.length, 1) }),
|
|
template: TEMPLATE.m3Linked,
|
|
senderPhoneHash: phoneHash,
|
|
phoneLinkId: link.id,
|
|
conversationId,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Limiter unavailable and the M2 throttle declined (repeat bad codes,
|
|
// or its window was unreadable): fall through to the throttled M1
|
|
// below, which also tells the sender how to fetch a fresh code.
|
|
}
|
|
|
|
// Anything else from an unknown number: the AI-disclosure greeting, hard
|
|
// throttled per phone hash (EU AI Act Art 50 disclosure lives in M1).
|
|
// Media bypasses the hour rule (10-minute burst window instead, daily cap
|
|
// kept): a photo is a receipt someone expects to be handled, and pure
|
|
// silence taught senders their receipts were being filed when nothing was.
|
|
const carriesMedia = msg.type === 'image' || msg.type === 'document'
|
|
if (await greetingThrottled(supabase, phoneHash, { media: carriesMedia })) {
|
|
await recordUnknownSenderMessage(
|
|
supabase, msg, phoneHash, 'skipped', `Unknown sender: greeting throttled, declined${degraded}`,
|
|
)
|
|
return
|
|
}
|
|
const traced = await recordUnknownSenderMessage(
|
|
supabase, msg, phoneHash, 'done', `Unknown sender: greeted, M1 sent${degraded}`,
|
|
)
|
|
if (traced === 'duplicate') return
|
|
await sendText(supabase, {
|
|
to: msg.from,
|
|
body: copy.m1Unlinked(),
|
|
template: TEMPLATE.m1Unlinked,
|
|
senderPhoneHash: phoneHash,
|
|
})
|
|
}
|
|
|
|
// ── Linked senders ───────────────────────────────────────────
|
|
|
|
/** Why a message earns deliberate silence. Persisted on the skipped row
|
|
* (issue #1552) so support can answer "what happened" per message. */
|
|
type SilenceReason = 'muted' | 'stale_interactive' | 'ignorable_type'
|
|
|
|
const SILENCE_REASON_TEXT: Record<SilenceReason, string> = {
|
|
muted: 'Muted by stopp: message not read',
|
|
stale_interactive: 'Stale interactive tap after the question closed',
|
|
ignorable_type: 'Ignorable message type, no reply expected',
|
|
}
|
|
|
|
type Disposition =
|
|
| { kind: 'media' }
|
|
| { kind: 'stop' }
|
|
| { kind: 'start' }
|
|
| { kind: 'help' }
|
|
| { kind: 'byt' }
|
|
| { kind: 'company_digit'; digit: number }
|
|
| { kind: 'company_interactive'; companyId: string }
|
|
| { kind: 'company_retry' }
|
|
| { kind: 'answer' }
|
|
| { kind: 'text_open' }
|
|
| { kind: 'voice' }
|
|
| { kind: 'unsupported' }
|
|
| { kind: 'fallback' }
|
|
| { kind: 'silence'; reason: SilenceReason }
|
|
|
|
/** Dispositions with a durable side effect (mute, pin, company routing).
|
|
* Their effect runs BEFORE the terminal row is written: see
|
|
* handleLinkedSender. Reply-only dispositions keep the persist-first shape. */
|
|
const DURABLE_DISPOSITIONS: ReadonlySet<Disposition['kind']> = new Set([
|
|
'stop',
|
|
'start',
|
|
'byt',
|
|
'company_digit',
|
|
'company_interactive',
|
|
])
|
|
|
|
function classify(
|
|
msg: ParsedInboundMessage,
|
|
muted: boolean,
|
|
conversation: WhatsAppConversation | null,
|
|
): Disposition {
|
|
const state = conversation?.state ?? 'idle'
|
|
// company_options outlive the awaiting_company state: the 48h TTL resets the
|
|
// state but keeps the options so a LATE company answer still lands (media
|
|
// stays fetchable from Meta for ~30 days, well past the question TTL).
|
|
const companyChoiceOpen =
|
|
(conversation ? (getContext(conversation).company_options?.length ?? 0) : 0) > 0
|
|
if (msg.type === 'text') {
|
|
const normalized = (msg.text ?? '').trim().toLowerCase()
|
|
if (muted) {
|
|
// While muted only `start` is recognized; everything else is silence.
|
|
return normalized === START_KEYWORD
|
|
? { kind: 'start' }
|
|
: { kind: 'silence', reason: 'muted' }
|
|
}
|
|
if (STOP_KEYWORDS.has(normalized)) return { kind: 'stop' }
|
|
if (HELP_KEYWORDS.has(normalized)) return { kind: 'help' }
|
|
if (normalized === START_KEYWORD) return { kind: 'start' }
|
|
if (normalized === BYT_KEYWORD && state !== 'awaiting_company') return { kind: 'byt' }
|
|
if (state === 'awaiting_company' || companyChoiceOpen) {
|
|
// The answer is a tapped button/row (interactive) or a typed digit.
|
|
if (/^\d{1,2}$/.test(normalized)) return { kind: 'company_digit', digit: Number(normalized) }
|
|
// Anything else while the question is on screen: repeat the options.
|
|
// The M16 fallback ("I cannot answer questions") right after the bot
|
|
// asked one is the wrong answer to a typed company name.
|
|
if (state === 'awaiting_company') return { kind: 'company_retry' }
|
|
}
|
|
if (
|
|
state === 'awaiting_representation' ||
|
|
state === 'awaiting_context' ||
|
|
// Words cannot answer the re-send question, but they are still about
|
|
// that receipt: the worker keeps them as a note (M9 note) instead of
|
|
// binding them to some other receipt's open question.
|
|
state === 'awaiting_resend'
|
|
) {
|
|
return { kind: 'answer' }
|
|
}
|
|
// Idle free text: maybe a late answer to an earlier question (quoted
|
|
// reply or the most recent open one); resolved below.
|
|
return { kind: 'text_open' }
|
|
}
|
|
if (muted) return { kind: 'silence', reason: 'muted' }
|
|
if (msg.type === 'interactive') {
|
|
if ((conversation?.state === 'awaiting_company' || companyChoiceOpen) && msg.interactiveReplyId) {
|
|
return { kind: 'company_interactive', companyId: msg.interactiveReplyId }
|
|
}
|
|
// Stale button tap after the question closed: silence beats lecturing.
|
|
return { kind: 'silence', reason: 'stale_interactive' }
|
|
}
|
|
if (msg.type === 'image' || msg.type === 'document') {
|
|
return msg.media ? { kind: 'media' } : { kind: 'unsupported' }
|
|
}
|
|
if (msg.type === 'audio') return { kind: 'voice' }
|
|
if (msg.type === 'video' || msg.type === 'sticker' || msg.type === 'location' || msg.type === 'contacts') {
|
|
return { kind: 'unsupported' }
|
|
}
|
|
// Truly unknown types (reactions, ephemeral, future additions): stay
|
|
// silent rather than lecture someone for sending a thumbs-up.
|
|
return { kind: 'silence', reason: 'ignorable_type' }
|
|
}
|
|
|
|
/** True when this inbound wamid was already persisted (Meta redelivery). */
|
|
async function inboundAlreadyPersisted(
|
|
supabase: SupabaseClient,
|
|
wamid: string | null,
|
|
): Promise<boolean> {
|
|
if (!wamid) return false
|
|
const { data } = await supabase
|
|
.from('whatsapp_messages')
|
|
.select('id')
|
|
.eq('wamid', wamid)
|
|
.eq('direction', 'inbound')
|
|
.limit(1)
|
|
.maybeSingle()
|
|
return data != null
|
|
}
|
|
|
|
/** last_message_at + the 24h service window, touched for every inbound. */
|
|
async function touchInbound(
|
|
supabase: SupabaseClient,
|
|
link: WhatsAppPhoneLink,
|
|
conversationId: string | null,
|
|
now: Date,
|
|
armBurst: boolean,
|
|
): Promise<void> {
|
|
await supabase
|
|
.from('whatsapp_phone_links')
|
|
.update({ last_message_at: now.toISOString() })
|
|
.eq('id', link.id)
|
|
if (!conversationId) return
|
|
// Media arms the burst debounce: each file pushes the deadline forward,
|
|
// and the deferred worker whose deadline survives claims the ONE ack.
|
|
// Two literal payloads (not one built at runtime) so the phantom-column
|
|
// scanner can verify both shapes.
|
|
if (armBurst) {
|
|
await supabase
|
|
.from('whatsapp_conversations')
|
|
.update({
|
|
last_inbound_at: now.toISOString(),
|
|
service_window_expires_at: new Date(now.getTime() + SERVICE_WINDOW_MS).toISOString(),
|
|
debounce_until: new Date(now.getTime() + DEBOUNCE_WINDOW_MS).toISOString(),
|
|
pending_ack: true,
|
|
})
|
|
.eq('id', conversationId)
|
|
} else {
|
|
await supabase
|
|
.from('whatsapp_conversations')
|
|
.update({
|
|
last_inbound_at: now.toISOString(),
|
|
service_window_expires_at: new Date(now.getTime() + SERVICE_WINDOW_MS).toISOString(),
|
|
})
|
|
.eq('id', conversationId)
|
|
}
|
|
}
|
|
|
|
async function handleLinkedSender(
|
|
supabase: SupabaseClient,
|
|
msg: ParsedInboundMessage,
|
|
phoneHash: string,
|
|
link: WhatsAppPhoneLink,
|
|
deferredMessageIds: string[],
|
|
): Promise<void> {
|
|
const conversation = await getOrCreateConversation(supabase, link.id)
|
|
const conversationId = conversation?.id ?? null
|
|
const muted = link.muted_at != null
|
|
let disposition = classify(msg, muted, conversation)
|
|
|
|
// Late-answer probe: free text with no open awaiting state still answers a
|
|
// recent question when it quotes one of its messages or one is open <=7d.
|
|
if (disposition.kind === 'text_open') {
|
|
const target = conversation
|
|
? await resolveAnswerTarget(supabase, conversation, msg.contextWamid)
|
|
: null
|
|
disposition = target ? { kind: 'answer' } : { kind: 'fallback' }
|
|
}
|
|
|
|
const correlationId = crypto.randomUUID()
|
|
const copy = botCopy('sv')
|
|
const now = new Date()
|
|
const replyBase = {
|
|
senderPhoneHash: phoneHash,
|
|
phoneLinkId: link.id,
|
|
conversationId,
|
|
correlationId,
|
|
}
|
|
|
|
// ── Durable dispositions: effect first, terminal row after ──
|
|
// Writing the row 'done' up front made these at-most-once: an instance that
|
|
// died between the insert and the effect lost the action forever, because
|
|
// Meta's redelivery hits the wamid dedupe and the sweep never claims 'done'
|
|
// rows. A dropped STOP is a dropped opt-out, so the order is inverted here
|
|
// and dedupe becomes a pre-check: the worst case is now a repeated (and
|
|
// idempotent) effect plus a duplicate confirmation, never a lost one.
|
|
if (DURABLE_DISPOSITIONS.has(disposition.kind)) {
|
|
if (await inboundAlreadyPersisted(supabase, msg.wamid)) return
|
|
await touchInbound(supabase, link, conversationId, now, false)
|
|
|
|
switch (disposition.kind) {
|
|
case 'company_digit':
|
|
case 'company_interactive': {
|
|
if (!conversation) return
|
|
const options = getContext(conversation).company_options ?? []
|
|
const via: CompanyChoiceVia =
|
|
disposition.kind === 'company_digit'
|
|
? 'numbered'
|
|
: options.length <= MAX_REPLY_BUTTONS
|
|
? 'button'
|
|
: 'list'
|
|
const applied = await applyCompanyChoice(supabase, {
|
|
conversation,
|
|
link,
|
|
choice:
|
|
disposition.kind === 'company_digit'
|
|
? { digit: disposition.digit }
|
|
: { companyId: disposition.companyId },
|
|
via,
|
|
to: msg.from,
|
|
replyBase,
|
|
})
|
|
if (applied.ok) {
|
|
if (applied.stagedMessageIds.length > 0) {
|
|
kickInboundProcessing(applied.stagedMessageIds, { companySelectedVia: via })
|
|
}
|
|
} else if (applied.reason === 'invalid_option' && options.length > 0) {
|
|
// A typed digit outside the range is a typo, not a forged payload:
|
|
// silence just leaves the receipts parked until they expire.
|
|
await sendText(supabase, {
|
|
to: msg.from,
|
|
body: copy.m6CompanyRetry({ options: options.map((o) => o.name) }),
|
|
template: TEMPLATE.m6CompanyRetry,
|
|
...replyBase,
|
|
})
|
|
}
|
|
// Other rejections stay silent (stale or forged payloads).
|
|
break
|
|
}
|
|
case 'byt': {
|
|
if (conversation) {
|
|
await updateConversation(supabase, conversation, (_current, context) => {
|
|
const next: ConversationContext = { ...context }
|
|
delete next.pin_expires_at
|
|
delete next.pin_source
|
|
return { company_id: null, context: next }
|
|
})
|
|
}
|
|
await sendText(supabase, { to: msg.from, body: copy.m6BytPin(), template: TEMPLATE.m6BytPin, ...replyBase })
|
|
break
|
|
}
|
|
case 'stop':
|
|
await supabase
|
|
.from('whatsapp_phone_links')
|
|
.update({ muted_at: now.toISOString() })
|
|
.eq('id', link.id)
|
|
.is('muted_at', null)
|
|
await sendText(supabase, { to: msg.from, body: copy.m11Stop(), template: TEMPLATE.m11Stop, ...replyBase })
|
|
break
|
|
case 'start':
|
|
if (muted) {
|
|
await supabase
|
|
.from('whatsapp_phone_links')
|
|
.update({ muted_at: null })
|
|
.eq('id', link.id)
|
|
}
|
|
await sendText(supabase, { to: msg.from, body: copy.m12Start(), template: TEMPLATE.m12Start, ...replyBase })
|
|
break
|
|
}
|
|
|
|
// Terminal row last. A concurrent redelivery that won the race 23505s.
|
|
const { error: durableInsertError } = await supabase.from('whatsapp_messages').insert({
|
|
direction: 'inbound',
|
|
wamid: msg.wamid,
|
|
sender_phone_hash: phoneHash,
|
|
phone_link_id: link.id,
|
|
conversation_id: conversationId,
|
|
message_type: msg.type,
|
|
body_text: msg.type === 'text' ? msg.text : (msg.caption ?? null),
|
|
raw_payload: redactRawPayload(msg.raw),
|
|
processing_status: 'done',
|
|
correlation_id: correlationId,
|
|
})
|
|
if (durableInsertError && durableInsertError.code !== '23505') {
|
|
log.error('Failed to persist handled WhatsApp message', durableInsertError)
|
|
}
|
|
return
|
|
}
|
|
|
|
// ── Everything else: persist-first ──────────────────────────
|
|
// Dedupe on the inbound-wamid partial unique index. A redelivered wamid
|
|
// violates it (23505): already handled, stop entirely.
|
|
const initialStatus =
|
|
disposition.kind === 'media' || disposition.kind === 'answer'
|
|
? 'received'
|
|
: disposition.kind === 'silence'
|
|
? 'skipped'
|
|
: 'done'
|
|
// A muted sender is told the channel is paused, so nothing they send is
|
|
// read: match the unknown-sender discipline and keep no content at all.
|
|
const keepContent = !muted
|
|
const { data: inserted, error: insertError } = await supabase
|
|
.from('whatsapp_messages')
|
|
.insert({
|
|
direction: 'inbound',
|
|
wamid: msg.wamid,
|
|
sender_phone_hash: phoneHash,
|
|
phone_link_id: link.id,
|
|
conversation_id: conversationId,
|
|
message_type: msg.type,
|
|
body_text: keepContent ? (msg.type === 'text' ? msg.text : (msg.caption ?? null)) : null,
|
|
media_id: keepContent ? (msg.media?.id ?? null) : null,
|
|
media_mime: keepContent ? (msg.media?.mime ?? null) : null,
|
|
media_sha256: keepContent ? (msg.media?.sha256 ?? null) : null,
|
|
media_filename: keepContent ? (msg.media?.filename ?? null) : null,
|
|
// The sender's plaintext E.164 number lived in here on every single
|
|
// row, which defeated the AES-256-GCM phone_enc on the link. Replies
|
|
// decrypt the link instead (resolveRecipient).
|
|
raw_payload: keepContent ? redactRawPayload(msg.raw) : null,
|
|
processing_status: initialStatus,
|
|
// Deliberate silences carry their reason (#1552): a skipped row with
|
|
// no explanation is exactly the blind spot this exists to close.
|
|
error_message: disposition.kind === 'silence' ? SILENCE_REASON_TEXT[disposition.reason] : null,
|
|
correlation_id: correlationId,
|
|
})
|
|
.select('id')
|
|
.maybeSingle()
|
|
|
|
if (insertError) {
|
|
if (insertError.code === '23505') return // wamid dedupe: Meta redelivery
|
|
log.error('Failed to persist inbound WhatsApp message', insertError)
|
|
return
|
|
}
|
|
const messageId = (inserted as { id: string } | null)?.id ?? null
|
|
|
|
await touchInbound(supabase, link, conversationId, now, disposition.kind === 'media')
|
|
|
|
switch (disposition.kind) {
|
|
case 'media':
|
|
case 'answer':
|
|
// Media intake and answer interpretation both run deferred (the answer
|
|
// path may call the LLM; the webhook must 200 in seconds).
|
|
if (messageId) deferredMessageIds.push(messageId)
|
|
// Instant "received" signal: the detailed ack waits on extraction
|
|
// (10-60s), which read as a black hole to someone standing at a
|
|
// register. A reaction on the sender's own bubble lands within
|
|
// seconds, adds no chat noise, and the row just persisted plus the
|
|
// sweep guarantee (ingest or M18) make it honest. Only for files the
|
|
// pipeline will accept: junk earns M15, not a checkmark. One Graph
|
|
// call (~10s timeout worst case) stays inside Meta's webhook budget,
|
|
// and a redelivered wamid returns on the 23505 above, so no re-react.
|
|
if (disposition.kind === 'media' && msg.wamid) {
|
|
if (CHAT_ALLOWED_MIME_TYPES.has(normalizeChatMime(msg.media?.mime))) {
|
|
await sendReaction(msg.from, msg.wamid)
|
|
}
|
|
}
|
|
return
|
|
case 'company_retry': {
|
|
const options = conversation ? (getContext(conversation).company_options ?? []) : []
|
|
await sendText(supabase, {
|
|
to: msg.from,
|
|
body: options.length > 0 ? copy.m6CompanyRetry({ options: options.map((o) => o.name) }) : copy.m16Fallback(),
|
|
template: options.length > 0 ? TEMPLATE.m6CompanyRetry : TEMPLATE.m16Fallback,
|
|
...replyBase,
|
|
})
|
|
return
|
|
}
|
|
case 'help':
|
|
await sendText(supabase, { to: msg.from, body: copy.m13Help(), template: TEMPLATE.m13Help, ...replyBase })
|
|
return
|
|
case 'voice':
|
|
await sendText(supabase, { to: msg.from, body: copy.m14Voice(), template: TEMPLATE.m14Voice, ...replyBase })
|
|
return
|
|
case 'unsupported':
|
|
await sendText(supabase, { to: msg.from, body: copy.m15Unsupported(), template: TEMPLATE.m15Unsupported, ...replyBase })
|
|
return
|
|
case 'fallback':
|
|
await sendText(supabase, { to: msg.from, body: copy.m16Fallback(), template: TEMPLATE.m16Fallback, ...replyBase })
|
|
return
|
|
case 'silence':
|
|
return
|
|
}
|
|
}
|
|
|
|
// ── Extension definition ─────────────────────────────────────
|
|
|
|
export const whatsappInboxExtension: Extension = {
|
|
id: 'whatsapp-inbox',
|
|
name: 'WhatsApp-inkorg',
|
|
version: '1.0.0',
|
|
sector: 'general',
|
|
|
|
settingsPanel: {
|
|
label: 'WhatsApp',
|
|
path: '/settings/whatsapp',
|
|
},
|
|
|
|
apiRoutes: [
|
|
// ── Meta webhook: subscription handshake ────────────────
|
|
{
|
|
method: 'GET',
|
|
path: '/webhook',
|
|
skipAuth: true,
|
|
handler: async (request: Request) => {
|
|
const expected = process.env.WHATSAPP_VERIFY_TOKEN
|
|
if (!expected) {
|
|
return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 })
|
|
}
|
|
const url = new URL(request.url)
|
|
const mode = url.searchParams.get('hub.mode')
|
|
const token = url.searchParams.get('hub.verify_token')
|
|
const challenge = url.searchParams.get('hub.challenge')
|
|
if (mode === 'subscribe' && verifyChallengeToken(token, expected) && challenge != null) {
|
|
return new Response(challenge, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
})
|
|
}
|
|
return NextResponse.json({ error: 'Verification failed' }, { status: 403 })
|
|
},
|
|
},
|
|
|
|
// ── Meta webhook: inbound events ────────────────────────
|
|
{
|
|
method: 'POST',
|
|
path: '/webhook',
|
|
skipAuth: true,
|
|
handler: async (request: Request) => {
|
|
const appSecret = process.env.WHATSAPP_APP_SECRET
|
|
if (!appSecret) {
|
|
log.error('WHATSAPP_APP_SECRET not configured', undefined)
|
|
return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 })
|
|
}
|
|
|
|
// Signature over the RAW body, before any parsing.
|
|
const rawBody = await request.text()
|
|
const signature = request.headers.get('x-hub-signature-256')
|
|
if (!verifyMetaSignature(rawBody, signature, appSecret)) {
|
|
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
|
|
}
|
|
|
|
let body: unknown
|
|
try {
|
|
body = JSON.parse(rawBody)
|
|
} catch {
|
|
// Signed but unparseable: ack so Meta does not redeliver garbage.
|
|
return NextResponse.json({ data: { ignored: 'unparseable' } })
|
|
}
|
|
|
|
const parsed = parseWebhookEnvelope(body)
|
|
const supabase = buildServiceClient()
|
|
|
|
// Outbound delivery lifecycle updates (sent -> delivered -> read).
|
|
// A 'failed' status carries Meta's error detail (e.g. undeliverable,
|
|
// recipient unavailable): keep it on the row (#1552), it is the only
|
|
// record of WHY a reply never reached the sender. Two literal
|
|
// payloads so the phantom-column scanner can verify both shapes.
|
|
for (const status of parsed.statuses) {
|
|
if (status.status === 'failed' && status.errorDetail) {
|
|
await supabase
|
|
.from('whatsapp_messages')
|
|
.update({ delivery_status: status.status, error_message: status.errorDetail })
|
|
.eq('wamid', status.wamid)
|
|
.eq('direction', 'outbound')
|
|
} else {
|
|
await supabase
|
|
.from('whatsapp_messages')
|
|
.update({ delivery_status: status.status })
|
|
.eq('wamid', status.wamid)
|
|
.eq('direction', 'outbound')
|
|
}
|
|
}
|
|
|
|
const deferredMessageIds: string[] = []
|
|
for (const msg of parsed.messages) {
|
|
try {
|
|
const phoneHash = hashPhone(msg.from)
|
|
const link = await lookupActiveLink(supabase, phoneHash)
|
|
if (link) {
|
|
await handleLinkedSender(supabase, msg, phoneHash, link, deferredMessageIds)
|
|
} else {
|
|
await handleUnknownSender(supabase, msg, phoneHash)
|
|
}
|
|
} catch (err) {
|
|
// One bad message must not take down the batch or trigger a
|
|
// Meta redelivery of messages we already handled.
|
|
log.error('WhatsApp message handling failed', err, { wamid: msg.wamid })
|
|
}
|
|
}
|
|
|
|
// 200 first, processing after: extraction takes 10-60s, answer
|
|
// interpretation may call the LLM, and Meta expects the ack within
|
|
// seconds.
|
|
kickInboundProcessing(deferredMessageIds)
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
received: parsed.messages.length,
|
|
statuses: parsed.statuses.length,
|
|
queued: deferredMessageIds.length,
|
|
},
|
|
})
|
|
},
|
|
},
|
|
|
|
// ── Phone linking (authenticated settings panel) ────────
|
|
{
|
|
method: 'POST',
|
|
path: '/link/start',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
if (!process.env.WHATSAPP_ACCESS_TOKEN || !process.env.WHATSAPP_PHONE_NUMBER_ID) {
|
|
return NextResponse.json(
|
|
{ error: 'WhatsApp-kanalen är inte konfigurerad på den här installationen.' },
|
|
{ status: 503 },
|
|
)
|
|
}
|
|
|
|
// whatsapp_link_codes is service-role only (RLS with no policies).
|
|
const serviceClient = createServiceClient()
|
|
let minted: Awaited<ReturnType<typeof mintLinkCode>>
|
|
try {
|
|
minted = await mintLinkCode(serviceClient, ctx.userId)
|
|
} catch (err) {
|
|
if (err instanceof LinkCodeRateLimitError) {
|
|
return NextResponse.json(
|
|
{ error: 'För många koder begärda. Vänta en stund och försök igen.' },
|
|
{ status: 429 },
|
|
)
|
|
}
|
|
throw err
|
|
}
|
|
|
|
// The wa.me link needs the real number, not the Graph object id.
|
|
// WHATSAPP_PUBLIC_NUMBER (E.164 digits) is authoritative when set;
|
|
// otherwise resolve display_phone_number from the Graph API (cached).
|
|
const publicNumber = (process.env.WHATSAPP_PUBLIC_NUMBER ?? '').replace(/\D/g, '')
|
|
const displayNumber = publicNumber || (await getDisplayPhoneNumber())
|
|
const waLink = displayNumber
|
|
? `https://wa.me/${displayNumber}?text=${encodeURIComponent(minted.code)}`
|
|
: null
|
|
|
|
return NextResponse.json({
|
|
data: { code: minted.code, expiresAt: minted.expiresAt, waLink },
|
|
})
|
|
},
|
|
},
|
|
|
|
{
|
|
method: 'GET',
|
|
path: '/link',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
const { data } = await ctx.supabase
|
|
.from('whatsapp_phone_links')
|
|
.select('id, phone_masked, default_company_id, muted_at, verified_at')
|
|
.eq('user_id', ctx.userId)
|
|
.is('revoked_at', null)
|
|
.maybeSingle()
|
|
|
|
if (!data) return NextResponse.json({ data: { linked: false } })
|
|
const row = data as {
|
|
id: string
|
|
phone_masked: string
|
|
default_company_id: string | null
|
|
muted_at: string | null
|
|
verified_at: string
|
|
}
|
|
|
|
// Last inbound event + last reply delivery (#1552): the panel answers
|
|
// "when did you last hear from me and what happened". whatsapp_messages
|
|
// is service-role only, so read with the service client, keyed strictly
|
|
// by the link row RLS just proved the caller owns. Only a closed enum
|
|
// and timestamps leave the server, never message content.
|
|
const serviceClient = createServiceClient()
|
|
const { data: lastInbound } = await serviceClient
|
|
.from('whatsapp_messages')
|
|
.select('created_at, processing_status, error_message, inbox_item_id')
|
|
.eq('phone_link_id', row.id)
|
|
.eq('direction', 'inbound')
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
const { data: lastOutbound } = await serviceClient
|
|
.from('whatsapp_messages')
|
|
.select('delivery_status')
|
|
.eq('phone_link_id', row.id)
|
|
.eq('direction', 'outbound')
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
// Channel health for THIS link, 7-day window, head counts only: how
|
|
// many replies never reached the sender and how many inbound rows
|
|
// parked with a reason. The panel turns nonzero counts into one
|
|
// attention line; nothing else in the app reads delivery_status.
|
|
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
|
|
const { count: outboundFailed7d } = await serviceClient
|
|
.from('whatsapp_messages')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('phone_link_id', row.id)
|
|
.eq('direction', 'outbound')
|
|
.eq('delivery_status', 'failed')
|
|
.gte('created_at', weekAgo)
|
|
const { count: parkedInbound7d } = await serviceClient
|
|
.from('whatsapp_messages')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('phone_link_id', row.id)
|
|
.eq('direction', 'inbound')
|
|
.in('processing_status', ['skipped', 'error'])
|
|
.not('error_message', 'is', null)
|
|
.gte('created_at', weekAgo)
|
|
|
|
const inboundRow = lastInbound as {
|
|
created_at: string
|
|
processing_status: string
|
|
error_message: string | null
|
|
inbox_item_id: string | null
|
|
} | null
|
|
return NextResponse.json({
|
|
data: {
|
|
linked: true,
|
|
phoneMasked: row.phone_masked,
|
|
defaultCompanyId: row.default_company_id,
|
|
muted: row.muted_at != null,
|
|
verifiedAt: row.verified_at,
|
|
lastInboundAt: inboundRow?.created_at ?? null,
|
|
lastInboundEvent: inboundRow ? summarizeInboundEvent(inboundRow) : null,
|
|
lastReplyFailed:
|
|
(lastOutbound as { delivery_status: string | null } | null)?.delivery_status ===
|
|
'failed',
|
|
health: {
|
|
outboundFailed7d: outboundFailed7d ?? 0,
|
|
parkedInbound7d: parkedInbound7d ?? 0,
|
|
},
|
|
},
|
|
})
|
|
},
|
|
},
|
|
|
|
{
|
|
method: 'POST',
|
|
path: '/link/revoke',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
await ctx.supabase
|
|
.from('whatsapp_phone_links')
|
|
.update({ revoked_at: new Date().toISOString() })
|
|
.eq('user_id', ctx.userId)
|
|
.is('revoked_at', null)
|
|
return NextResponse.json({ data: { revoked: true } })
|
|
},
|
|
},
|
|
|
|
{
|
|
method: 'POST',
|
|
path: '/link/unmute',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
// Clears a stopp pause from the app (the in-chat counterpart is the
|
|
// literal text `start`). The user-scoped client is deliberate: RLS
|
|
// (whatsapp_phone_links_update_own) proves the caller owns the row.
|
|
// Idempotent on an unpaused link; only a missing active link 404s.
|
|
const { data } = await ctx.supabase
|
|
.from('whatsapp_phone_links')
|
|
.update({ muted_at: null })
|
|
.eq('user_id', ctx.userId)
|
|
.is('revoked_at', null)
|
|
.select('id')
|
|
if (!Array.isArray(data) || data.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Ingen aktiv WhatsApp-koppling hittades.' },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
return NextResponse.json({ data: { unmuted: true } })
|
|
},
|
|
},
|
|
|
|
{
|
|
method: 'POST',
|
|
path: '/link/default-company',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
|
|
let parsedBody: z.infer<typeof DefaultCompanySchema>
|
|
try {
|
|
parsedBody = DefaultCompanySchema.parse(await request.json())
|
|
} catch {
|
|
return NextResponse.json({ error: 'Ogiltig förfrågan.' }, { status: 400 })
|
|
}
|
|
|
|
// The caller must be a member of the company receipts are routed to:
|
|
// otherwise a user could point their intake at someone else's books.
|
|
if (parsedBody.companyId) {
|
|
const { data: membership } = await ctx.supabase
|
|
.from('company_members')
|
|
.select('id')
|
|
.eq('company_id', parsedBody.companyId)
|
|
.eq('user_id', ctx.userId)
|
|
.maybeSingle()
|
|
if (!membership) {
|
|
return NextResponse.json(
|
|
{ error: 'Du är inte medlem i det företaget.' },
|
|
{ status: 403 },
|
|
)
|
|
}
|
|
}
|
|
|
|
const { error } = await ctx.supabase
|
|
.from('whatsapp_phone_links')
|
|
.update({ default_company_id: parsedBody.companyId })
|
|
.eq('user_id', ctx.userId)
|
|
.is('revoked_at', null)
|
|
if (error) {
|
|
return NextResponse.json(
|
|
{ error: 'Kunde inte spara standardföretaget.' },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
return NextResponse.json({ data: { defaultCompanyId: parsedBody.companyId } })
|
|
},
|
|
},
|
|
],
|
|
}
|