Files
MattssonandClaude Fable 5.1 fefef038c5 fix(sales-orders): pin the sales_order_items embed FK and teach the embed guard composite keys (#2207)
* 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>
2026-09-03 12:18:47 +02:00

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 },
)