feat(woocommerce): store order/refund feed extension (#1442)
* feat(woocommerce): store order/refund feed extension Connect a WooCommerce store via the wc-auth key handshake (manual key fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest, and import paid orders and refunds into the transactions inbox as a bank-style feed on the 1680 cash account. Feed-only: nothing auto-books, gateway fees/payouts are out of scope (core wc/v3 does not expose them). Sync is cursor-paginated on modified_after (offset pages only inside same-second date_modified ties), terminates on an empty page, holds the cursor below failed refund fetches / ingest errors / deadline-skipped work, checks the time budget between refund fetches, and drops rows dated on or before bookkeeping_locked_through on every run. Nightly cron gated on the extension registry + new paid capability woocommerce_sync (backfilled to existing bank_sync grant holders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): move woocommerce migrations past main's 20260806090000 origin/main gained 20260806090000_recurring_schedule_interval_months while this branch was in flight; identical version timestamps abort the Supabase apply, so the two new migrations move to 20260806170000/20260806170100. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit review findings - callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is unset: encryptCredential would otherwise throw after the probe and strand the pending row without error_message - disconnect and upstream-revoke clear the encrypted consumer key/secret: nothing reads them after revoke and keeping decryptable dead credentials is unnecessary retention - manual sync gets a 240s time budget and the panel reports a truncated run as 'partial, sync again' instead of a normal completion - listOrderRefunds terminates on an empty batch (hosts may cap per_page), dedupes by id against hosts that ignore page, and caps total pages - unparseable money strings count as errors and log instead of being silently identical to a zero total - pg test uses per-run unique store URLs so committed rows cannot hit the store_url partial unique index across pg-real runs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit cycle-2 findings - listOrderRefunds throws when the page cap is exhausted with data still flowing, instead of returning a silently partial list the sync cursor would advance past; the error routes into the existing held-cursor refund-retry path - partial sync results keep the row-error count, and the partial toast string surfaces it (ICU plural, hidden at zero) in both locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI after dropped push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
cd344b6dbb
commit
707d597b2e
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn() } }))
|
||||
vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() }))
|
||||
vi.mock('@/lib/extensions/registry', () => ({ extensionRegistry: { get: vi.fn() } }))
|
||||
vi.mock('@/extensions/general/woocommerce/lib/api-client', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@/extensions/general/woocommerce/lib/api-client')>()
|
||||
return { ...actual, testConnectionAndFetchStoreInfo: vi.fn() }
|
||||
})
|
||||
|
||||
import { POST } from '../callback/route'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { testConnectionAndFetchStoreInfo } from '@/extensions/general/woocommerce/lib/api-client'
|
||||
import { decryptCredential } from '@/extensions/general/woocommerce/lib/credentials'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const STATE = '123e4567-e89b-12d3-a456-426614174000'
|
||||
|
||||
function makeCallbackRequest(body: unknown): Request {
|
||||
return new Request('https://test.local/api/extensions/woocommerce/callback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
const VALID_BODY = {
|
||||
key_id: 1,
|
||||
user_id: STATE,
|
||||
consumer_key: 'ck_new',
|
||||
consumer_secret: 'cs_new',
|
||||
key_permissions: 'read',
|
||||
}
|
||||
|
||||
describe('POST /api/extensions/woocommerce/callback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubEnv('WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
|
||||
vi.mocked(extensionRegistry.get).mockReturnValue(
|
||||
{ id: 'woocommerce' } as ReturnType<typeof extensionRegistry.get>,
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('refuses with 503 when the extension is disabled', async () => {
|
||||
vi.mocked(extensionRegistry.get).mockReturnValue(undefined)
|
||||
const res = await POST(makeCallbackRequest(VALID_BODY))
|
||||
expect(res.status).toBe(503)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('EXTENSION_DISABLED')
|
||||
})
|
||||
|
||||
it('rejects a non-JSON body with 400', async () => {
|
||||
const res = await POST(makeCallbackRequest('not json'))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a missing or non-UUID state with 400', async () => {
|
||||
const res = await POST(
|
||||
makeCallbackRequest({ ...VALID_BODY, user_id: 'not-a-uuid' }),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
|
||||
const res2 = await POST(makeCallbackRequest({ user_id: STATE }))
|
||||
expect(res2.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for an unknown or already-consumed state', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
vi.mocked(createServiceClient).mockResolvedValue(
|
||||
supabase as unknown as Awaited<ReturnType<typeof createServiceClient>>,
|
||||
)
|
||||
enqueue({ data: null, error: { message: 'no rows', code: 'PGRST116' } })
|
||||
const res = await POST(makeCallbackRequest(VALID_BODY))
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('marks the row error and returns 502 when the credential probe fails', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
vi.mocked(createServiceClient).mockResolvedValue(
|
||||
supabase as unknown as Awaited<ReturnType<typeof createServiceClient>>,
|
||||
)
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
store_url: 'https://shop.example.se',
|
||||
},
|
||||
})
|
||||
enqueue({ data: null }) // markError update
|
||||
vi.mocked(testConnectionAndFetchStoreInfo).mockRejectedValue(new Error('403'))
|
||||
|
||||
const res = await POST(makeCallbackRequest(VALID_BODY))
|
||||
expect(res.status).toBe(502)
|
||||
const errorUpdate = findCall('woocommerce_connections', 'update')?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(errorUpdate.status).toBe('error')
|
||||
// The probe ran against the STORED store_url, not anything the caller sent.
|
||||
expect(vi.mocked(testConnectionAndFetchStoreInfo).mock.calls[0][0]).toMatchObject({
|
||||
storeUrl: 'https://shop.example.se',
|
||||
consumerKey: 'ck_new',
|
||||
})
|
||||
})
|
||||
|
||||
it('encrypts the keys, activates the row and emits the audit event', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
vi.mocked(createServiceClient).mockResolvedValue(
|
||||
supabase as unknown as Awaited<ReturnType<typeof createServiceClient>>,
|
||||
)
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
store_url: 'https://shop.example.se',
|
||||
},
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
store_url: 'https://shop.example.se',
|
||||
},
|
||||
}) // activation update
|
||||
vi.mocked(testConnectionAndFetchStoreInfo).mockResolvedValue({
|
||||
name: 'Testbutiken',
|
||||
currency: 'SEK',
|
||||
prices_include_tax: true,
|
||||
wc_version: '9.9.5',
|
||||
})
|
||||
|
||||
const res = await POST(makeCallbackRequest(VALID_BODY))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const updates = findCalls('woocommerce_connections', 'update')
|
||||
const activation = updates[0][0] as Record<string, string | boolean | null>
|
||||
expect(activation.status).toBe('active')
|
||||
expect(activation.transaction_sync_enabled).toBe(true)
|
||||
expect(activation.oauth_state).toBeNull()
|
||||
expect(activation.store_name).toBe('Testbutiken')
|
||||
// Secrets never stored in plaintext, and they decrypt back.
|
||||
expect(String(activation.consumer_key_encrypted)).not.toContain('ck_new')
|
||||
expect(decryptCredential(String(activation.consumer_key_encrypted))).toBe('ck_new')
|
||||
expect(decryptCredential(String(activation.consumer_secret_encrypted))).toBe('cs_new')
|
||||
|
||||
expect(eventBus.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'woocommerce.connected' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { loadExtensions } from '@/lib/extensions/loader'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import {
|
||||
encryptCredential,
|
||||
isWooCommerceConfigured,
|
||||
} from '@/extensions/general/woocommerce/lib/credentials'
|
||||
import { testConnectionAndFetchStoreInfo } from '@/extensions/general/woocommerce/lib/api-client'
|
||||
|
||||
// This route emits woocommerce.connected (audit trail). ensureInitialized()
|
||||
// must run at module load so the event_log handler has subscribed before the
|
||||
// first emit on a cold instance.
|
||||
ensureInitialized()
|
||||
|
||||
const log = createLogger('woocommerce/callback')
|
||||
|
||||
// The credential probe talks to an arbitrary (often slow) WooCommerce host.
|
||||
export const maxDuration = 60
|
||||
|
||||
/**
|
||||
* POST /api/extensions/woocommerce/callback
|
||||
*
|
||||
* Server-to-server delivery of the wc-auth handshake result: WooCommerce
|
||||
* POSTs { key_id, user_id, consumer_key, consumer_secret, key_permissions }
|
||||
* here after the merchant approves. Must be a real Next.js route (not an
|
||||
* extension dispatcher handler) because the store calls it directly,
|
||||
* unauthenticated: the single-use oauth_state riding in user_id locates the
|
||||
* pending row, and the received keys are verified against that row's stored
|
||||
* store_url before anything is persisted.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
loadExtensions()
|
||||
if (!extensionRegistry.get('woocommerce')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'WooCommerce extension is not enabled', code: 'EXTENSION_DISABLED' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
// The registry does not check manifest requiredEnvVars, so this route can be
|
||||
// live without the encryption key; without this guard encryptCredential()
|
||||
// would throw AFTER the probe, escaping the markError path entirely.
|
||||
if (!isWooCommerceConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'WooCommerce integration is not configured', code: 'NOT_CONFIGURED' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: {
|
||||
user_id?: unknown
|
||||
consumer_key?: unknown
|
||||
consumer_secret?: unknown
|
||||
key_permissions?: unknown
|
||||
}
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const state = typeof body.user_id === 'string' ? body.user_id : null
|
||||
const consumerKey = typeof body.consumer_key === 'string' ? body.consumer_key : null
|
||||
const consumerSecret = typeof body.consumer_secret === 'string' ? body.consumer_secret : null
|
||||
const keyPermissions =
|
||||
typeof body.key_permissions === 'string' ? body.key_permissions : null
|
||||
// The state is a UUID we generated; reject anything else before it reaches
|
||||
// the DB (the column is typed uuid and would error opaquely).
|
||||
const isUuid =
|
||||
state !== null &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(state)
|
||||
if (!isUuid || !consumerKey || !consumerSecret) {
|
||||
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
const { data: pending, error: findError } = await supabase
|
||||
.from('woocommerce_connections')
|
||||
.select('id, company_id, user_id, store_url')
|
||||
.eq('oauth_state', state)
|
||||
.eq('status', 'pending')
|
||||
.single()
|
||||
|
||||
if (findError || !pending) {
|
||||
log.warn('no pending connection for handshake state', {
|
||||
hasRow: Boolean(pending),
|
||||
code: findError?.code,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unknown or expired state' }, { status: 404 })
|
||||
}
|
||||
|
||||
const markError = (message: string) =>
|
||||
supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({ status: 'error', error_message: message, oauth_state: null })
|
||||
.eq('id', pending.id)
|
||||
.eq('status', 'pending')
|
||||
|
||||
// Authenticity check: the keys must actually work against the store URL the
|
||||
// user asked to connect. A forged callback with someone else's (or made-up)
|
||||
// keys fails here and never gets stored.
|
||||
let storeInfo
|
||||
try {
|
||||
storeInfo = await testConnectionAndFetchStoreInfo({
|
||||
storeUrl: pending.store_url,
|
||||
consumerKey,
|
||||
consumerSecret,
|
||||
})
|
||||
} catch (probeError) {
|
||||
log.error('credential probe failed during handshake', {
|
||||
connectionId: pending.id,
|
||||
message: probeError instanceof Error ? probeError.message : String(probeError),
|
||||
})
|
||||
await markError('Nycklarna kunde inte verifieras mot butiken.')
|
||||
return NextResponse.json({ error: 'Credential verification failed' }, { status: 502 })
|
||||
}
|
||||
|
||||
const { data: activated, error: updateError } = await supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({
|
||||
consumer_key_encrypted: encryptCredential(consumerKey),
|
||||
consumer_secret_encrypted: encryptCredential(consumerSecret),
|
||||
key_permissions: keyPermissions,
|
||||
store_name: storeInfo.name,
|
||||
currency: storeInfo.currency,
|
||||
prices_include_tax: storeInfo.prices_include_tax,
|
||||
wc_version: storeInfo.wc_version,
|
||||
status: 'active',
|
||||
connected_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
oauth_state: null, // Clear to prevent replay
|
||||
// Feed-only product: connecting the store means fetching its orders, so
|
||||
// the nightly feed starts on by default; the panel toggle is the opt-out.
|
||||
transaction_sync_enabled: true,
|
||||
})
|
||||
.eq('id', pending.id)
|
||||
.eq('status', 'pending')
|
||||
.select('id, company_id, user_id, store_url')
|
||||
.single()
|
||||
|
||||
if (updateError || !activated) {
|
||||
// 23505 = a partial unique index: the store is already actively connected
|
||||
// (to this or another company), or the company connected in a parallel tab.
|
||||
const isConflict = updateError?.code === '23505'
|
||||
log.error('failed to activate connection', {
|
||||
connectionId: pending.id,
|
||||
code: updateError?.code,
|
||||
message: updateError?.message,
|
||||
})
|
||||
await markError(
|
||||
isConflict
|
||||
? 'Butiken är redan ansluten till ett företag.'
|
||||
: 'Anslutningen kunde inte slutföras.',
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: isConflict ? 'Store already connected' : 'Activation failed' },
|
||||
{ status: isConflict ? 409 : 500 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'woocommerce.connected',
|
||||
payload: {
|
||||
connectionId: activated.id,
|
||||
storeUrl: activated.store_url,
|
||||
userId: activated.user_id,
|
||||
companyId: activated.company_id,
|
||||
},
|
||||
})
|
||||
} catch (emitError) {
|
||||
// Non-fatal: the DB state (source of truth) is already committed.
|
||||
log.error('failed to emit woocommerce.connected', {
|
||||
connectionId: activated.id,
|
||||
message: emitError instanceof Error ? emitError.message : String(emitError),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { loadExtensions } from '@/lib/extensions/loader'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { isWooCommerceConfigured } from '@/extensions/general/woocommerce/lib/credentials'
|
||||
import { syncWooCommerceOrders } from '@/extensions/general/woocommerce/lib/order-sync'
|
||||
import type { WooCommerceConnection } from '@/extensions/general/woocommerce/types'
|
||||
|
||||
export const maxDuration = 300
|
||||
|
||||
/**
|
||||
* GET /api/extensions/woocommerce/orders/cron
|
||||
* Nightly order sync for connections that opted in (transaction_sync_enabled):
|
||||
* imports each connected store's paid orders and refunds into the
|
||||
* transactions inbox as a bank-style feed on the 1680 cash account.
|
||||
*
|
||||
* Read-only against the stores, and it never posts to the journal: rows land
|
||||
* unbooked; booking stays a human decision. Idempotent via the
|
||||
* (company_id, external_id) unique index, so overlapping windows and re-runs
|
||||
* are no-ops. Emits no events, so no ensureInitialized() is needed.
|
||||
*/
|
||||
export const GET = withCronContext('cron.woocommerce_order_sync', async (_request, ctx) => {
|
||||
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
|
||||
// including the core-with-zero-extensions one: the registry (generated from
|
||||
// extensions.config.json) is what actually switches an extension on. A
|
||||
// scheduled-but-disabled cron must fail visibly (503) instead of quietly
|
||||
// doing the work anyway.
|
||||
loadExtensions()
|
||||
if (!extensionRegistry.get('woocommerce')) {
|
||||
ctx.log.warn('woocommerce extension is not enabled; cron refused')
|
||||
return NextResponse.json(
|
||||
{ error: 'WooCommerce extension is not enabled', code: 'EXTENSION_DISABLED' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'Missing Supabase configuration' },
|
||||
})
|
||||
}
|
||||
if (!isWooCommerceConfigured()) {
|
||||
return NextResponse.json({ message: 'WooCommerce not configured', processed: 0 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
const { data: connections, error: connError } = await supabase
|
||||
.from('woocommerce_connections')
|
||||
.select('*')
|
||||
.eq('status', 'active')
|
||||
.eq('transaction_sync_enabled', true)
|
||||
.order('last_order_synced_at', { ascending: true, nullsFirst: true })
|
||||
.limit(50)
|
||||
|
||||
if (connError) {
|
||||
ctx.log.error('failed to fetch woocommerce connections', connError, {
|
||||
message: connError.message,
|
||||
code: connError.code,
|
||||
})
|
||||
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
if (!connections || connections.length === 0) {
|
||||
return NextResponse.json({
|
||||
message: 'No connections with transaction sync enabled',
|
||||
processed: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 240_000 // leave a minute of margin inside maxDuration
|
||||
// Shared with syncWooCommerceOrders: it stops between pages and persists
|
||||
// its cursor, so a truncated connection resumes next night.
|
||||
const deadlineMs = startTime + TIME_BUDGET_MS
|
||||
|
||||
const results: Array<{
|
||||
connectionId: string
|
||||
imported: number
|
||||
duplicates: number
|
||||
status: 'synced' | 'revoked' | 'error'
|
||||
}> = []
|
||||
|
||||
for (const connection of connections as WooCommerceConnection[]) {
|
||||
if (Date.now() >= deadlineMs) {
|
||||
ctx.log.info('time budget reached', { processedSoFar: results.length })
|
||||
break
|
||||
}
|
||||
|
||||
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.woocommerce_sync))) {
|
||||
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = await syncWooCommerceOrders(supabase, connection, ctx.log, deadlineMs)
|
||||
if (summary.deadlineReached) {
|
||||
ctx.log.info('connection stopped early on time budget; remaining rows resume next run', {
|
||||
connectionId: connection.id,
|
||||
})
|
||||
}
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
imported: summary.imported,
|
||||
duplicates: summary.duplicates,
|
||||
status: summary.revoked ? 'revoked' : 'synced',
|
||||
})
|
||||
} catch (error) {
|
||||
ctx.log.error('woocommerce order sync failed for connection', error as Error, {
|
||||
connectionId: connection.id,
|
||||
companyId: connection.company_id,
|
||||
})
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totalImported = results.reduce((acc, r) => acc + r.imported, 0)
|
||||
ctx.log.info('woocommerce order sync summary', {
|
||||
processed: results.length,
|
||||
totalImported,
|
||||
failed: results.filter((r) => r.status === 'error').length,
|
||||
})
|
||||
|
||||
return NextResponse.json({ processed: results.length, imported: totalImported, results })
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { loadExtensions } from '@/lib/extensions/loader'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('woocommerce/return')
|
||||
|
||||
/**
|
||||
* GET /api/extensions/woocommerce/return
|
||||
*
|
||||
* Browser leg of the wc-auth handshake: WooCommerce redirects the merchant
|
||||
* here with ?success=1|0&user_id=<our oauth_state>. The credentials arrive on
|
||||
* the separate server-to-server callback (usually before this redirect, but
|
||||
* ordering is not guaranteed), so on success this route only sends the user
|
||||
* back to the import page; the panel polls /status until the row is active.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
loadExtensions()
|
||||
if (!extensionRegistry.get('woocommerce')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'WooCommerce extension is not enabled', code: 'EXTENSION_DISABLED' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const success = searchParams.get('success')
|
||||
const state = searchParams.get('user_id')
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
// The WooCommerce surface lives on the import page; the base already has a
|
||||
// query, so appended params below must use '&'.
|
||||
const returnUrl = `${baseUrl}/import?mode=woocommerce`
|
||||
|
||||
if (success === '1') {
|
||||
return NextResponse.redirect(`${returnUrl}&woocommerce_connected=true`)
|
||||
}
|
||||
|
||||
// Denied (or malformed): close out the pending row so its state can never
|
||||
// complete a late callback, then surface the denial to the panel.
|
||||
if (state) {
|
||||
try {
|
||||
const supabase = await createServiceClient()
|
||||
await supabase
|
||||
.from('woocommerce_connections')
|
||||
.update({
|
||||
status: 'error',
|
||||
error_message: 'Anslutningen nekades i butiken.',
|
||||
oauth_state: null,
|
||||
})
|
||||
.eq('oauth_state', state)
|
||||
.eq('status', 'pending')
|
||||
} catch (cleanupError) {
|
||||
log.error('failed to clean up denied connection', {
|
||||
message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${returnUrl}&woocommerce_error=denied`)
|
||||
}
|
||||
Reference in New Issue
Block a user