* fix(sales-orders): pin the sales_order_items embed FK and teach the embed guard composite keys Migration 20260902180000_sales_orders_hardening added a composite (sales_order_id, company_id) foreign key from sales_order_items to sales_orders next to the original single-column one. PostgREST then saw two relationships and answered every `items:sales_order_items(*)` embed with HTTP 300 / PGRST201, so kundorder list, detail, create and the MCP list tool all failed on prod and staging with "Oväntat serverfel". - Hint the three embeds with `!sales_order_items_sales_order_id_fkey` (route, load service, MCP list tool). - scripts/checks/ambiguous-embed.mjs only parsed single-column `FOREIGN KEY (col)`, which is why the ratchet reported 0 for this pair. It now reads composite column lists (named or default constraint name) in both CREATE TABLE and ALTER TABLE, derives the same 17 ambiguous pairs prod's pg_constraint reports, and flags all three shipped sites on main. - Unit tests for the composite shapes: alongside a single-column key, replacing one, and inline in CREATE TABLE. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7xJQL2aZo6iRHxCZNntUq * fix(checks): drop composite embed edges when DROP COLUMN removes a member column Postgres drops every foreign key a column takes part in, so the ambiguous-embed parser must release a composite edge (and its constraint name) when one of its columns is dropped, not only the single-column key. Otherwise a later migration would keep a pair armed for a relationship that no longer exists and reject valid embeds. Regression case added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7xJQL2aZo6iRHxCZNntUq --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
63 lines
2.9 KiB
TypeScript
63 lines
2.9 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { validateBody, validateQuery } from '@/lib/api/validate'
|
|
import { CreateSalesOrderSchema, SalesOrderListQuerySchema } from '@/lib/api/schemas'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { maskEmbeddedCustomer } from '@/lib/customers/protect-personal-number'
|
|
import { decorate, fetchInvoicedQuantities } from '@/lib/sales-orders/load'
|
|
import { createSalesOrder } from '@/lib/sales-orders/write'
|
|
import { serviceFailureResponse } from '@/lib/sales-orders/respond'
|
|
import { errorResponse } from '@/lib/errors/get-structured-error'
|
|
import type { SalesOrder } from '@/types'
|
|
|
|
ensureInitialized()
|
|
|
|
/**
|
|
* GET /api/sales-orders: the company's kundorder with customer, lines and
|
|
* the derived delivery/invoicing progress. Filters: status, customer_id, q
|
|
* (order number; the list page matches customer names client-side over the
|
|
* embedded customer).
|
|
*/
|
|
export const GET = withRouteContext('sales_order.list', async (request, { supabase, companyId, log, requestId }) => {
|
|
const query = validateQuery(request, SalesOrderListQuerySchema)
|
|
if (!query.success) return query.response
|
|
const { status, customer_id, q } = query.data
|
|
|
|
let orders: SalesOrder[]
|
|
try {
|
|
orders = await fetchAllRows<SalesOrder>(({ from, to }) => {
|
|
let qb = supabase
|
|
.from('sales_orders')
|
|
.select('*, customer:customers(id, name, customer_number, customer_type), items:sales_order_items!sales_order_items_sales_order_id_fkey(*)')
|
|
.eq('company_id', companyId)
|
|
if (status) qb = qb.eq('status', status)
|
|
if (customer_id) qb = qb.eq('customer_id', customer_id)
|
|
if (q) qb = qb.ilike('order_number', `%${q}%`)
|
|
return qb.order('order_date', { ascending: false }).order('created_at', { ascending: false }).range(from, to)
|
|
})
|
|
} catch (err) {
|
|
return errorResponse(err, log, { requestId })
|
|
}
|
|
|
|
const invoiced = await fetchInvoicedQuantities(supabase, orders.map((o) => o.id))
|
|
if (!invoiced.ok) return errorResponse(invoiced.dbError, log, { requestId })
|
|
|
|
const data = orders.map((o) => decorate(maskEmbeddedCustomer(o), invoiced.byItem))
|
|
return NextResponse.json({ data })
|
|
})
|
|
|
|
/** POST /api/sales-orders: create a draft order with lines. */
|
|
export const POST = withRouteContext(
|
|
'sales_order.create',
|
|
async (request, { supabase, user, companyId, log, requestId }) => {
|
|
const validation = await validateBody(request, CreateSalesOrderSchema, { log, operation: 'sales_order.create' })
|
|
if (!validation.success) return validation.response
|
|
|
|
const result = await createSalesOrder(supabase, { companyId, userId: user.id, input: validation.data })
|
|
if (!result.ok) return serviceFailureResponse(result, log, requestId)
|
|
return NextResponse.json({ data: result.order }, { status: 201 })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|