* fix(woocommerce): require both verified keys and browser confirmation to activate a connection The wc-auth callback alone used to flip a connection to active. Until the initiator's browser reached the return route the row was syncable, so an approver who never came back (closed tab, skipped sign-in, or lured into approving a connect someone else started) left their store's keys active inside another company's books, reachable by manual sync within seconds. Now the callback only stages the verified keys on the pending row, the return leg records browser_confirmed_at after the initiator check, and one conditional update flips the row to active exactly once when both signals are present, in either arrival order. A DB CHECK (20260907100000) makes an active row without both signals impossible; every consumer selects status = 'active', so staged keys can never sync. Also: 15-minute handshake TTL on both legs, duplicate callback refused, stale pending rows swept (keys wiped) at the start of the nightly orders cron, manual key entry records the confirmation itself, every path that closes a pending row wipes staged keys, expired/conflict toasts in sv + en. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): drop the callback-leg TTL and state the gate's real scope Skeptic pass on the activation gate: - WooCommerce answers any non-200 callback response by deleting the key it just minted and showing a store-side error page, never redirecting back. A 410 for a slow approval therefore stranded the merchant. The callback now stages regardless of age; the session-bound return leg and the nightly sweep enforce expiry, and a stale pending row cannot sync either way. - The second activation signal comes from the initiating user, so the gate does not stop a store admin from approving a link someone else generated (wc-auth delivers keys server-to-server and identifies no approver). The migration header, route comments, decision log and PR body now say so instead of claiming otherwise. Proof of store control is a follow-up. - Every path that parks a pending row also wipes the store metadata the probe staged, so a refused handshake leaves nothing of the store behind. - A duplicate callback POST is a 200 no-op instead of a 409, so the status code no longer tells the state holder whether the merchant has approved. - The expiry message tells the user to remove the unused key in the store. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): carry the handshake TTL in the activation flip, wipe metadata on denial Re-verification of the skeptic fixes found two gaps: - The initiator can open the return URL early, so a row confirmed at minute one still flipped when the keys landed hours later; the callback no longer refuses stale rows, so nothing bounded that. The conditional activation update now also requires created_at within the TTL. The callback still answers 200 (no store-side wp_die); the flip matches zero rows and the sweep parks the row. Covered by a pg-real case with both signals present on a 20-minute-old row. - The store-denied path parked the row without clearing the staged store metadata. It now wipes the same five columns as every other parking path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * chore(migrations): move the WooCommerce activation gate to 20260907143000 after main took 20260907100000 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): make browser_confirmed_at server-only, finish replayed callbacks, budget the cron sweep Review round on PR #2375: - Superagent P1: browser_confirmed_at was member-writable through the row-scoped RLS policy, so any writer of the company could supply the initiator's signal on a colleague's pending row. New migration 20260907150000 adds a trigger keyed on the JWT role claim (same pattern as enforce_company_writer_role): end-user sessions cannot insert or update the column, service role and migrations pass. Manual key entry now inserts on the service client with company_id/user_id from the verified context. pg-real covers refusal on insert and update plus the server path. - CodeRabbit: a replayed callback for an already-keyed row now runs the activation flip instead of returning early, so a callback cut off between staging and activating is completed by its retry. - CodeRabbit: the cron deadline is fixed before the stale-handshake sweep, so the sweep counts against the route's maxDuration budget. - CodeRabbit: the activation CHECK is added NOT VALID (rows were already conformed by the backfill) and validated in 20260907150000 under the weaker lock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): make activation itself server-only, install the trigger before validating the gate Second review round on PR #2375: - CodeRabbit: an authenticated company member could still UPDATE ... SET status = 'active' on a fully staged row through PostgREST; the CHECK only proves both signals exist, and the 15-minute TTL lives in the server's conditional activation update. The server-only trigger now also refuses any end-user transition into 'active' (insert or update). Leaving 'active' (disconnect, supersede, revoked-key marking) stays member-writable. pg-real covers an expired, fully staged row: 42501 from a user session, then a member disconnect after the server activates. - Superagent P2: the VALIDATE CONSTRAINT now runs after the trigger is installed, so the gate is never enforced while its signal is still member-writable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
589 lines
21 KiB
TypeScript
589 lines
21 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
|
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
|
import { requireCapability } from '@/lib/entitlements/has-capability'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
|
|
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
|
import { isWooCommerceConfigured, encryptCredential } from './lib/credentials'
|
|
import { normalizeStoreUrl, testConnectionAndFetchStoreInfo } from './lib/api-client'
|
|
import { buildAuthorizeUrl } from './lib/connect'
|
|
import { syncWooCommerceOrders } from './lib/order-sync'
|
|
import type { WooCommerceConnection, WooCommerceStatusResponse } from './types'
|
|
|
|
// Per-user limits: connect/disconnect start outward-facing handshakes, sync
|
|
// hits the merchant's WooCommerce host.
|
|
const RATE_LIMIT_CONNECT = { maxRequests: 10, windowMs: 60_000 }
|
|
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
|
|
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
|
|
|
|
// A pending row younger than this blocks a second connect attempt so a
|
|
// double-click cannot start two handshake round-trips (only one state would
|
|
// survive, stranding the other at the callback).
|
|
const PENDING_FRESH_MS = 60_000
|
|
|
|
const NOT_CONFIGURED_MESSAGE =
|
|
'WooCommerce-integrationen är inte konfigurerad på den här installationen.'
|
|
|
|
/** Columns safe to hand to the browser: never the encrypted credentials. */
|
|
const STATUS_COLUMNS =
|
|
'id, status, store_url, store_name, currency, error_message, connected_at, transaction_sync_enabled, last_order_synced_at'
|
|
|
|
type AuthedContext = {
|
|
supabase: ExtensionContext['supabase']
|
|
userId: string
|
|
isAnonymous: boolean
|
|
companyId: string
|
|
}
|
|
|
|
/** Shared auth preamble: cookie user + company context, or an error response. */
|
|
async function requireUserAndCompany(
|
|
ctx: ExtensionContext | undefined,
|
|
): Promise<AuthedContext | NextResponse> {
|
|
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
if (!ctx?.companyId) {
|
|
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
|
}
|
|
return {
|
|
supabase,
|
|
userId: user.id,
|
|
isAnonymous: Boolean(user.is_anonymous),
|
|
companyId: ctx.companyId,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Guards shared by both connect paths: sandbox users never reach external
|
|
* stores (same doctrine as Stripe connect), and the feed is a paid
|
|
* capability (woocommerce_sync).
|
|
*/
|
|
async function guardConnectPreconditions(auth: AuthedContext): Promise<NextResponse | null> {
|
|
if (auth.isAnonymous) return sandboxBlockedResponse()
|
|
const sandboxBlocked = await guardSandbox(auth.supabase, auth.companyId)
|
|
if (sandboxBlocked) return sandboxBlocked
|
|
return requireCapability(auth.supabase, auth.companyId, CAPABILITY.woocommerce_sync)
|
|
}
|
|
|
|
/**
|
|
* Existing-connection preflight for both connect paths, scoped to the SAME
|
|
* store: 409 when this store is already actively connected or mid-handshake,
|
|
* and supersede stale pendings for it so their oauth_state can never
|
|
* complete a late callback. Other stores are untouched: a company may
|
|
* connect several stores (multi-store, migration 20260811073422).
|
|
*/
|
|
async function blockOrSupersedeExisting(
|
|
auth: AuthedContext,
|
|
storeUrl: string,
|
|
): Promise<NextResponse | null> {
|
|
const { data: existing } = await auth.supabase
|
|
.from('woocommerce_connections')
|
|
.select('id, status, created_at')
|
|
.eq('company_id', auth.companyId)
|
|
.eq('store_url', storeUrl)
|
|
.in('status', ['active', 'pending'])
|
|
.order('created_at', { ascending: false })
|
|
|
|
if (existing?.some((c) => c.status === 'active')) {
|
|
return NextResponse.json(
|
|
{ error: 'Butiken är redan ansluten. Koppla från den först om du vill ansluta om den.' },
|
|
{ status: 409 },
|
|
)
|
|
}
|
|
const pending = existing?.filter((c) => c.status === 'pending') ?? []
|
|
const freshPending = pending.find(
|
|
(c) => Date.now() - new Date(c.created_at).getTime() < PENDING_FRESH_MS,
|
|
)
|
|
if (freshPending) {
|
|
return NextResponse.json(
|
|
{ error: 'En anslutning pågår redan. Vänta och försök igen.' },
|
|
{ status: 409 },
|
|
)
|
|
}
|
|
if (pending.length > 0) {
|
|
await auth.supabase
|
|
.from('woocommerce_connections')
|
|
.update({
|
|
status: 'error',
|
|
error_message: 'Superseded by new connection attempt',
|
|
oauth_state: null,
|
|
consumer_key_encrypted: null,
|
|
consumer_secret_encrypted: null,
|
|
store_name: null,
|
|
currency: null,
|
|
prices_include_tax: null,
|
|
wc_version: null,
|
|
key_permissions: null,
|
|
})
|
|
.eq('company_id', auth.companyId)
|
|
.eq('store_url', storeUrl)
|
|
.eq('status', 'pending')
|
|
}
|
|
return null
|
|
}
|
|
|
|
export const woocommerceApiRoutes: ApiRouteDefinition[] = [
|
|
{
|
|
method: 'GET',
|
|
path: '/status',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
const auth = await requireUserAndCompany(ctx)
|
|
if (auth instanceof NextResponse) return auth
|
|
|
|
// Multi-store: every active connection, plus the most recent inactive
|
|
// row when nothing is active (so the panel can show a pending/error/
|
|
// revoked state instead of an empty list). `connection` mirrors the
|
|
// first entry for callers of the old single-store shape.
|
|
const { data: rows } = await auth.supabase
|
|
.from('woocommerce_connections')
|
|
.select(STATUS_COLUMNS)
|
|
.eq('company_id', auth.companyId)
|
|
.order('created_at', { ascending: false })
|
|
.limit(25)
|
|
|
|
const active = (rows ?? []).filter((r) => r.status === 'active')
|
|
const connections = active.length > 0 ? active : rows?.[0] ? [rows[0]] : []
|
|
const payload: WooCommerceStatusResponse = {
|
|
configured: isWooCommerceConfigured(),
|
|
connection: connections[0] ?? null,
|
|
connections,
|
|
}
|
|
return NextResponse.json(payload)
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/connect',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
const auth = await requireUserAndCompany(ctx)
|
|
if (auth instanceof NextResponse) return auth
|
|
|
|
const blocked = await guardConnectPreconditions(auth)
|
|
if (blocked) return blocked
|
|
|
|
const rl = await checkRateLimit({
|
|
prefix: 'woocommerce:connect',
|
|
identifier: auth.userId,
|
|
...RATE_LIMIT_CONNECT,
|
|
})
|
|
if (!rl.ok) return rl.response!
|
|
|
|
if (!isWooCommerceConfigured()) {
|
|
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
|
|
}
|
|
|
|
const body = (await request.json().catch(() => ({}))) as { store_url?: unknown }
|
|
const storeUrl =
|
|
typeof body.store_url === 'string' ? normalizeStoreUrl(body.store_url) : null
|
|
if (!storeUrl) {
|
|
return NextResponse.json(
|
|
{ error: 'Ange butikens adress som en giltig https-URL.' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const conflict = await blockOrSupersedeExisting(auth, storeUrl)
|
|
if (conflict) return conflict
|
|
|
|
// Persist the CSRF state BEFORE handing the user to the store: the
|
|
// callback locates the row by oauth_state alone, so the row must exist
|
|
// before the store can ever POST back with that state.
|
|
const oauthState = crypto.randomUUID()
|
|
const { data: created, error: insertError } = await auth.supabase
|
|
.from('woocommerce_connections')
|
|
.insert({
|
|
company_id: auth.companyId,
|
|
user_id: auth.userId,
|
|
store_url: storeUrl,
|
|
status: 'pending',
|
|
oauth_state: oauthState,
|
|
})
|
|
.select('id')
|
|
.single()
|
|
|
|
if (insertError || !created) {
|
|
log.error('[woocommerce] Failed to stage pending connection', {
|
|
message: insertError?.message,
|
|
code: insertError?.code,
|
|
companyId: auth.companyId,
|
|
})
|
|
return NextResponse.json(
|
|
{ error: 'Kunde inte starta anslutningen. Försök igen.' },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
|
|
log.info('[woocommerce] Starting wc-auth handshake', {
|
|
connection_id: created.id,
|
|
company_id: auth.companyId,
|
|
})
|
|
return NextResponse.json({ url: buildAuthorizeUrl(storeUrl, oauthState) })
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/manual-connect',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
const auth = await requireUserAndCompany(ctx)
|
|
if (auth instanceof NextResponse) return auth
|
|
|
|
const blocked = await guardConnectPreconditions(auth)
|
|
if (blocked) return blocked
|
|
|
|
const rl = await checkRateLimit({
|
|
prefix: 'woocommerce:connect',
|
|
identifier: auth.userId,
|
|
...RATE_LIMIT_CONNECT,
|
|
})
|
|
if (!rl.ok) return rl.response!
|
|
|
|
if (!isWooCommerceConfigured()) {
|
|
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
|
|
}
|
|
|
|
const body = (await request.json().catch(() => ({}))) as {
|
|
store_url?: unknown
|
|
consumer_key?: unknown
|
|
consumer_secret?: unknown
|
|
}
|
|
const storeUrl =
|
|
typeof body.store_url === 'string' ? normalizeStoreUrl(body.store_url) : null
|
|
const consumerKey =
|
|
typeof body.consumer_key === 'string' ? body.consumer_key.trim() : ''
|
|
const consumerSecret =
|
|
typeof body.consumer_secret === 'string' ? body.consumer_secret.trim() : ''
|
|
if (!storeUrl) {
|
|
return NextResponse.json(
|
|
{ error: 'Ange butikens adress som en giltig https-URL.' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
if (!consumerKey || !consumerSecret) {
|
|
return NextResponse.json(
|
|
{ error: 'Ange både konsumentnyckel och konsumenthemlighet.' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const conflict = await blockOrSupersedeExisting(auth, storeUrl)
|
|
if (conflict) return conflict
|
|
|
|
// Verify before storing: a typo'd key must fail here, not at 03:45.
|
|
let storeInfo
|
|
try {
|
|
storeInfo = await testConnectionAndFetchStoreInfo({
|
|
storeUrl,
|
|
consumerKey,
|
|
consumerSecret,
|
|
})
|
|
} catch (probeError) {
|
|
log.warn('[woocommerce] Manual credential probe failed', {
|
|
companyId: auth.companyId,
|
|
message: probeError instanceof Error ? probeError.message : String(probeError),
|
|
})
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
'Kunde inte ansluta till butiken med de angivna nycklarna. Kontrollera adressen och att nyckeln har läsbehörighet.',
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// The keys were typed in under this user's session: that IS the browser
|
|
// confirmation the activation CHECK requires. browser_confirmed_at is
|
|
// server-only (trigger, 20260907150000), so the insert runs on the
|
|
// service client; membership was already proven by requireUserAndCompany
|
|
// and company_id/user_id come from that context, never from the body.
|
|
const { data: created, error: insertError } = await createServiceClientNoCookies()
|
|
.from('woocommerce_connections')
|
|
.insert({
|
|
company_id: auth.companyId,
|
|
user_id: auth.userId,
|
|
store_url: storeUrl,
|
|
store_name: storeInfo.name,
|
|
currency: storeInfo.currency,
|
|
prices_include_tax: storeInfo.prices_include_tax,
|
|
wc_version: storeInfo.wc_version,
|
|
consumer_key_encrypted: encryptCredential(consumerKey),
|
|
consumer_secret_encrypted: encryptCredential(consumerSecret),
|
|
status: 'active',
|
|
connected_at: new Date().toISOString(),
|
|
browser_confirmed_at: new Date().toISOString(),
|
|
transaction_sync_enabled: true,
|
|
})
|
|
.select('id, store_url')
|
|
.single()
|
|
|
|
if (insertError || !created) {
|
|
const isConflict = insertError?.code === '23505'
|
|
log.error('[woocommerce] Failed to create manual connection', {
|
|
message: insertError?.message,
|
|
code: insertError?.code,
|
|
companyId: auth.companyId,
|
|
})
|
|
return NextResponse.json(
|
|
{
|
|
error: isConflict
|
|
? 'Butiken är redan ansluten till ett företag.'
|
|
: 'Kunde inte spara anslutningen. Försök igen.',
|
|
},
|
|
{ status: isConflict ? 409 : 500 },
|
|
)
|
|
}
|
|
|
|
if (ctx?.emit) {
|
|
try {
|
|
await ctx.emit({
|
|
type: 'woocommerce.connected',
|
|
payload: {
|
|
connectionId: created.id,
|
|
storeUrl: created.store_url,
|
|
userId: auth.userId,
|
|
companyId: auth.companyId,
|
|
},
|
|
})
|
|
} catch {
|
|
// Audit event failure must not block the connect itself.
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ success: true, connection_id: created.id })
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/sync',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
const auth = await requireUserAndCompany(ctx)
|
|
if (auth instanceof NextResponse) return auth
|
|
|
|
const capabilityBlocked = await requireCapability(
|
|
auth.supabase,
|
|
auth.companyId,
|
|
CAPABILITY.woocommerce_sync,
|
|
)
|
|
if (capabilityBlocked) return capabilityBlocked
|
|
|
|
const rl = await checkRateLimit({
|
|
prefix: 'woocommerce:sync',
|
|
identifier: auth.userId,
|
|
...RATE_LIMIT_SYNC,
|
|
})
|
|
if (!rl.ok) return rl.response!
|
|
|
|
// Membership-scoped lookup via the user client; the sync itself runs on
|
|
// the service client (cursor updates and upserts are service paths).
|
|
// The manual button ignores transaction_sync_enabled (that flag gates
|
|
// the nightly cron): pressing it IS the opt-in. connection_id targets
|
|
// one store; omitted, every active store syncs within one time budget.
|
|
const body = (await request.json().catch(() => ({}))) as { connection_id?: string }
|
|
let query = auth.supabase
|
|
.from('woocommerce_connections')
|
|
.select('*')
|
|
.eq('company_id', auth.companyId)
|
|
.eq('status', 'active')
|
|
if (body.connection_id) query = query.eq('id', body.connection_id)
|
|
const { data: connections } = await query.order('last_order_synced_at', {
|
|
ascending: true,
|
|
nullsFirst: true,
|
|
})
|
|
|
|
if (!connections || connections.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Ingen ansluten WooCommerce-butik.' },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
|
|
try {
|
|
const serviceClient = createServiceClientNoCookies()
|
|
// Bounded like the cron: without a deadline a huge first sync against
|
|
// a slow host would be killed at the dispatcher's maxDuration with no
|
|
// cursor persisted; with one it stops cleanly, reports a partial sync
|
|
// and resumes where it stopped on the next press.
|
|
const deadlineMs = Date.now() + 240_000
|
|
// One entry per processed store, plus an explicit skipped count:
|
|
// returning only the last summary hid per-store failures and reported
|
|
// success for zero work when the deadline expired early.
|
|
const results: Array<{
|
|
connection_id: string
|
|
store_url: string
|
|
summary: Awaited<ReturnType<typeof syncWooCommerceOrders>>
|
|
}> = []
|
|
let skipped = 0
|
|
for (const connection of connections as WooCommerceConnection[]) {
|
|
if (Date.now() >= deadlineMs) {
|
|
skipped += 1
|
|
continue
|
|
}
|
|
const summary = await syncWooCommerceOrders(
|
|
serviceClient,
|
|
connection,
|
|
undefined,
|
|
deadlineMs,
|
|
)
|
|
results.push({
|
|
connection_id: connection.id,
|
|
store_url: connection.store_url,
|
|
summary,
|
|
})
|
|
}
|
|
return NextResponse.json({
|
|
success: true,
|
|
results,
|
|
skipped,
|
|
// Single-store shape kept for the panel's toast summary: the panel
|
|
// always syncs one connection_id, so this IS that store's summary.
|
|
transactions: results[results.length - 1]?.summary ?? null,
|
|
})
|
|
} catch (error) {
|
|
log.error('[woocommerce] Manual sync failed', {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
})
|
|
return NextResponse.json(
|
|
{ error: 'Synkroniseringen misslyckades. Försök igen.' },
|
|
{ status: 502 },
|
|
)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/transaction-sync',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
const auth = await requireUserAndCompany(ctx)
|
|
if (auth instanceof NextResponse) return auth
|
|
|
|
const capabilityBlocked = await requireCapability(
|
|
auth.supabase,
|
|
auth.companyId,
|
|
CAPABILITY.woocommerce_sync,
|
|
)
|
|
if (capabilityBlocked) return capabilityBlocked
|
|
|
|
const rl = await checkRateLimit({
|
|
prefix: 'woocommerce:transaction-sync-toggle',
|
|
identifier: auth.userId,
|
|
...RATE_LIMIT_SYNC,
|
|
})
|
|
if (!rl.ok) return rl.response!
|
|
|
|
const body = (await request.json().catch(() => ({}))) as {
|
|
enabled?: unknown
|
|
connection_id?: string
|
|
}
|
|
if (typeof body.enabled !== 'boolean') {
|
|
return NextResponse.json({ error: 'enabled (boolean) krävs.' }, { status: 400 })
|
|
}
|
|
|
|
let updateQuery = auth.supabase
|
|
.from('woocommerce_connections')
|
|
.update({ transaction_sync_enabled: body.enabled })
|
|
.eq('company_id', auth.companyId)
|
|
.eq('status', 'active')
|
|
if (body.connection_id) updateQuery = updateQuery.eq('id', body.connection_id)
|
|
const { data: updated, error: updateError } = await updateQuery.select('id')
|
|
|
|
if (updateError) {
|
|
return NextResponse.json(
|
|
{ error: 'Kunde inte spara inställningen. Försök igen.' },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
if (!updated || updated.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Ingen ansluten WooCommerce-butik.' },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
return NextResponse.json({ success: true, enabled: body.enabled })
|
|
},
|
|
},
|
|
{
|
|
method: 'DELETE',
|
|
path: '/disconnect',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
const auth = await requireUserAndCompany(ctx)
|
|
if (auth instanceof NextResponse) return auth
|
|
|
|
const rl = await checkRateLimit({
|
|
prefix: 'woocommerce:disconnect',
|
|
identifier: auth.userId,
|
|
...RATE_LIMIT_DISCONNECT,
|
|
})
|
|
if (!rl.ok) return rl.response!
|
|
|
|
const body = (await request.json().catch(() => ({}))) as { connection_id?: string }
|
|
const base = auth.supabase
|
|
.from('woocommerce_connections')
|
|
.select('id, status, store_url')
|
|
.eq('company_id', auth.companyId)
|
|
const query = body.connection_id
|
|
? base.eq('id', body.connection_id).limit(1)
|
|
: base.neq('status', 'revoked').order('created_at', { ascending: false }).limit(1)
|
|
const { data: rows, error: findError } = await query
|
|
const connection = rows?.[0]
|
|
|
|
if (findError || !connection) {
|
|
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
|
}
|
|
|
|
// There is no remote revoke API: the consumer key lives in the store's
|
|
// wp-admin and only the merchant can delete it there. We drop our copy
|
|
// of the credentials outright (nothing reads them after revoke, and a
|
|
// reconnect inserts a fresh row); the audit row keeps store_url and the
|
|
// connect/disconnect timestamps. The panel tells the user to remove the
|
|
// key in WooCommerce as well.
|
|
const { error: updateError } = await auth.supabase
|
|
.from('woocommerce_connections')
|
|
.update({
|
|
status: 'revoked',
|
|
oauth_state: null,
|
|
consumer_key_encrypted: null,
|
|
consumer_secret_encrypted: null,
|
|
disconnected_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', connection.id)
|
|
.eq('company_id', auth.companyId)
|
|
|
|
if (updateError) {
|
|
log.error('[woocommerce] Failed to mark connection revoked', {
|
|
message: updateError.message,
|
|
connection_id: connection.id,
|
|
})
|
|
return NextResponse.json(
|
|
{ error: 'Kunde inte koppla från. Försök igen.' },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
|
|
if (ctx?.emit) {
|
|
try {
|
|
await ctx.emit({
|
|
type: 'woocommerce.disconnected',
|
|
payload: {
|
|
connectionId: connection.id,
|
|
storeUrl: connection.store_url ?? null,
|
|
reason: 'user',
|
|
userId: auth.userId,
|
|
companyId: auth.companyId,
|
|
},
|
|
})
|
|
} catch {
|
|
// Audit event failure must not block the disconnect itself.
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ success: true })
|
|
},
|
|
},
|
|
]
|