From f338850bd0f382a2d9989b92151be2811a8fc53d Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 13:35:27 +0200 Subject: [PATCH] fix: hide API-archived customers and suppliers from lists and pickers (#1927) * fix: hide API-archived customers and suppliers from lists and pickers The v1 API soft-archives customers and suppliers (archived_at, plus is_active=false on suppliers) and its own list routes hide those rows behind ?include_archived=true. No other surface filtered archived_at, so an archived counterparty stayed a normal row in the dashboard rosters, the internal /api/customers and /api/suppliers list routes, the MCP list tools and every customer/supplier picker. Apply the same canonical `archived_at IS NULL` filter on every non-v1 list and picker path: - /api/customers GET, /api/suppliers GET (feeds the customers page and the supplier-invoice form) - suppliers dashboard page (reads suppliers via browser Supabase) - InvoiceEditor and NewRecurringScheduleDialog customer pickers; an invoice or schedule being edited keeps its current customer visible (archiving does not refuse on drafts, so a draft can point at one) - deadlines page and CalendarWorkspace customer pickers - InvoicePreviewCard sample customer - gnubok_list_customers and gnubok_list_suppliers: hidden by default, optional include_archived boolean mirroring the v1 flag; rows now carry archived_at so an agent can tell them apart when opted in Detail routes and by-id lookups are untouched: an archived row still opens. The delete-vs-archive semantics are unchanged. The tools/list payload guard moves 60.7K to 60.8K: main had ~6 tokens of headroom, so even the bare boolean contract crossed. Co-Authored-By: Claude Fable 5 * test(schema): raise the unresolvable-expression ceiling by 2 for the archived-customer picker filters The two .or('archived_at.is.null,id.eq.') filters keep an edited draft's archived customer selectable. The uuid is a runtime value, so the scanner cannot resolve the expression; both columns exist and the filter is covered by the archived-counterparty tests. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/deadlines/page.tsx | 1 + app/(dashboard)/suppliers/page.tsx | 3 + .../customers/__tests__/list-archived.test.ts | 66 +++++++++++++++ app/api/customers/route.ts | 5 ++ app/api/suppliers/__tests__/route.test.ts | 84 +++++++++++++++++++ app/api/suppliers/route.ts | 4 + .../extensions/general/CalendarWorkspace.tsx | 1 + components/invoices/InvoiceEditor.tsx | 15 ++-- .../invoices/NewRecurringScheduleDialog.tsx | 15 ++-- components/settings/InvoicePreviewCard.tsx | 1 + .../list-archived-counterparties.test.ts | 51 +++++++++++ extensions/general/mcp-server/server.ts | 44 ++++++---- tests/schema/no-phantom-columns.test.ts | 9 +- 14 files changed, 273 insertions(+), 27 deletions(-) create mode 100644 app/api/customers/__tests__/list-archived.test.ts create mode 100644 app/api/suppliers/__tests__/route.test.ts create mode 100644 extensions/general/mcp-server/__tests__/list-archived-counterparties.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 3f5f189e..da3bcbf9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1247,6 +1247,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent. [2026-08-26] Support-dialog attachments use the existing email delivery path without storage or schema changes: this keeps the feature scoped to the contact form. The budget is 5 files / 4 MB total under the 4.5 MB hosted request-body ceiling, with client-side image shrinking when needed. [2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/, and /.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised. +[2026-08-26] Archived customers/suppliers hidden via archived_at IS NULL on every non-v1 list/picker (not is_active): customers have no is_active column and v1 already treats archived_at as canonical; is_active on suppliers stays a legacy mirror. MCP list tools got a bare include_archived boolean and the tools/list ceiling moved 60.7K to 60.8K instead of trimming unrelated tool prose: main had ~6 tokens of headroom, so any contract at all crossed. [2026-08-26] MCP serverInfo.version, extension version and /api/health version reuse currentAppVersion() (12-char SHA, '1.0.0' fallback) instead of a new 7-char slice: one identifier across behandlingshistorik, health and MCP so a support thread can match a deploy by a single string; gnubok_get_vacation_balance got a real estimated_liability_sek by exporting semesterberedning's dayValueSek rather than dropping the description's promise, with descriptions trimmed to stay under the tools/list ceiling. [2026-08-26] raw-route-auth guard judges each top-level export segment of a route file, not the whole file: transactions/[id] (wrapped PATCH + hand-rolled DELETE) and transactions (wrapped POST + hand-rolled GET) passed the file-level check for months because one withRouteContext call exempted every sibling handler. Baseline unchanged (mcp-oauth/authorize is the one grandfathered file). [2026-08-26] No ratchet on direct requireAuth() calls in app/api: requireAuth() is the MFA (AAL2) guard withRouteContext itself calls, and .claude/rules/api-routes.md sanctions it for routes without a company context (onboarding, account, user prefs). The 20 remaining direct callers skip request ids and the canonical envelope, not MFA; migrating them is a consistency campaign, not a security fix, so it was not folded into the bypass PR. diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index 2aa72642..7b61eaa3 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -85,6 +85,7 @@ export default function DeadlinesPage() { .from('customers') .select('id, name') .eq('company_id', companyId) + .is('archived_at', null) .order('name', { ascending: true }) .order('id', { ascending: true }) .range(from, to), diff --git a/app/(dashboard)/suppliers/page.tsx b/app/(dashboard)/suppliers/page.tsx index 1d80f370..9eb491ee 100644 --- a/app/(dashboard)/suppliers/page.tsx +++ b/app/(dashboard)/suppliers/page.tsx @@ -68,10 +68,13 @@ export default function SuppliersPage() { async function fetchSuppliers() { if (!company) return setIsLoading(true) + // Archived suppliers (v1 API soft-delete) are kept for retention but are + // not part of the roster: same filter as /api/suppliers and the v1 list. const { data, error } = await supabase .from('suppliers') .select('*') .eq('company_id', company.id) + .is('archived_at', null) .order('name', { ascending: true }) if (error) { diff --git a/app/api/customers/__tests__/list-archived.test.ts b/app/api/customers/__tests__/list-archived.test.ts new file mode 100644 index 00000000..663a0624 --- /dev/null +++ b/app/api/customers/__tests__/list-archived.test.ts @@ -0,0 +1,66 @@ +/** + * GET /api/customers: the roster hides customers archived through the v1 API + * (archived_at set). Archived rows stay in the table for BFL retention, so the + * filter is the only thing keeping them out of the dashboard list. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, + makeCustomer, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { GET } from '../route' + +describe('GET /api/customers: archived rows', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const response = await GET(createMockRequest('/api/customers'), { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('filters on archived_at IS NULL and still returns the active roster', async () => { + const customers = [makeCustomer({ name: 'Beta AB' }), makeCustomer({ name: 'Alfa AB' })] + enqueue({ data: customers, error: null }) + + const response = await GET(createMockRequest('/api/customers'), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: Array<{ name: string }> }>(response) + + expect(status).toBe(200) + expect(body.data.map((c) => c.name)).toEqual(['Alfa AB', 'Beta AB']) + expect(findCall('customers', 'eq')).toEqual(['company_id', 'company-1']) + expect(findCall('customers', 'is')).toEqual(['archived_at', null]) + }) +}) diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index 99536c3c..3d05e7a4 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -23,6 +23,10 @@ export const GET = withRouteContext( // hand the roster page a silently truncated customer list. Ordered on the // PK because paging is only stable under a unique total order; the // name sort callers expect is re-applied below. + // + // Archived rows (soft-deleted via the v1 API) stay in the table for BFL + // retention but are not part of the roster: same canonical + // `archived_at IS NULL` filter as the v1 list route. let rows: Customer[] try { rows = await fetchAllRows( @@ -31,6 +35,7 @@ export const GET = withRouteContext( .from('customers') .select('*') .eq('company_id', companyId) + .is('archived_at', null) .order('id', { ascending: true }) .range(from, to), { dedupeBy: (row) => row.id }, diff --git a/app/api/suppliers/__tests__/route.test.ts b/app/api/suppliers/__tests__/route.test.ts new file mode 100644 index 00000000..13aae838 --- /dev/null +++ b/app/api/suppliers/__tests__/route.test.ts @@ -0,0 +1,84 @@ +/** + * GET /api/suppliers: the roster hides suppliers archived through the v1 API + * (archived_at set, is_active=false). Archived rows stay in the table for BFL + * retention, so the filter is the only thing keeping them out of the list and + * the supplier-invoice picker that reads this route. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, + makeSupplier, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { GET } from '../route' + +describe('GET /api/suppliers', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const response = await GET(createMockRequest('/api/suppliers'), { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('lists suppliers for the active company', async () => { + const suppliers = [makeSupplier({ name: 'Alfa AB' }), makeSupplier({ name: 'Beta AB' })] + enqueue({ data: suppliers, error: null }) + + const response = await GET(createMockRequest('/api/suppliers'), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(suppliers) + expect(findCall('suppliers', 'eq')).toEqual(['company_id', 'company-1']) + }) + + it('hides API-archived suppliers: filters on archived_at IS NULL', async () => { + enqueue({ data: [], error: null }) + + await GET(createMockRequest('/api/suppliers'), { params: Promise.resolve({}) }) + + expect(findCall('suppliers', 'is')).toEqual(['archived_at', null]) + }) + + it('returns the error envelope when the query fails', async () => { + enqueue({ data: null, error: { message: 'boom', code: '42P01' } }) + + const response = await GET(createMockRequest('/api/suppliers'), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ error: unknown }>(response) + + expect(status).toBeGreaterThanOrEqual(400) + expect(body.error).toBeDefined() + }) +}) diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts index ca631734..e7102dd5 100644 --- a/app/api/suppliers/route.ts +++ b/app/api/suppliers/route.ts @@ -15,10 +15,14 @@ export const GET = withRouteContext( async (_request, ctx) => { const { supabase, companyId, log, requestId } = ctx + // Archived rows (soft-deleted via the v1 API: archived_at + is_active=false) + // stay in the table for BFL retention but are not part of the roster. + // Same canonical "active" filter as the v1 list route. const { data, error } = await supabase .from('suppliers') .select('*') .eq('company_id', companyId) + .is('archived_at', null) .order('name', { ascending: true }) if (error) { diff --git a/components/extensions/general/CalendarWorkspace.tsx b/components/extensions/general/CalendarWorkspace.tsx index a5a482b1..dedf117e 100644 --- a/components/extensions/general/CalendarWorkspace.tsx +++ b/components/extensions/general/CalendarWorkspace.tsx @@ -39,6 +39,7 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) { const { data: customersData, error: customersError } = await supabase .from('customers') .select('id, name') + .is('archived_at', null) .order('name', { ascending: true }) if (customersError) throw customersError diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 7486f78a..2d57bf77 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -1042,11 +1042,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat async function fetchCustomers() { if (!company?.id) return - const { data, error } = await supabase - .from('customers') - .select('*') - .eq('company_id', company.id) - .order('name', { ascending: true }) + // Archived customers (v1 API soft-delete) are not offered in the picker. + // An existing draft or copied invoice may still point at one (archiving + // only refuses when open invoices exist, drafts do not count), so that + // single row is kept in the list or the select would render blank. + const keepCustomerId = initial?.customer_id ?? copyInitial?.customer_id ?? null + const base = supabase.from('customers').select('*').eq('company_id', company.id) + const query = keepCustomerId + ? base.or(`archived_at.is.null,id.eq.${keepCustomerId}`) + : base.is('archived_at', null) + const { data, error } = await query.order('name', { ascending: true }) if (error) { toast({ diff --git a/components/invoices/NewRecurringScheduleDialog.tsx b/components/invoices/NewRecurringScheduleDialog.tsx index 5c1f5e0b..43fa6710 100644 --- a/components/invoices/NewRecurringScheduleDialog.tsx +++ b/components/invoices/NewRecurringScheduleDialog.tsx @@ -178,13 +178,14 @@ function NewRecurringScheduleForm({ useEffect(() => { if (!company) return - supabase - .from('customers') - .select('*') - .eq('company_id', company.id) - .order('name') - .then(({ data }) => setCustomers(data ?? [])) - }, [company]) + // Archived customers (v1 API soft-delete) are not offered in the picker, + // but a schedule being edited keeps its current customer visible. + const base = supabase.from('customers').select('*').eq('company_id', company.id) + const query = schedule?.customer_id + ? base.or(`archived_at.is.null,id.eq.${schedule.customer_id}`) + : base.is('archived_at', null) + query.order('name').then(({ data }) => setCustomers(data ?? [])) + }, [company, schedule?.customer_id]) async function onSubmit(data: FormData) { setIsSubmitting(true) diff --git a/components/settings/InvoicePreviewCard.tsx b/components/settings/InvoicePreviewCard.tsx index feb8606d..5c0da9a3 100644 --- a/components/settings/InvoicePreviewCard.tsx +++ b/components/settings/InvoicePreviewCard.tsx @@ -56,6 +56,7 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) { .from('customers') .select('id') .eq('company_id', companyId) + .is('archived_at', null) .limit(1) .maybeSingle() diff --git a/extensions/general/mcp-server/__tests__/list-archived-counterparties.test.ts b/extensions/general/mcp-server/__tests__/list-archived-counterparties.test.ts new file mode 100644 index 00000000..23032d2b --- /dev/null +++ b/extensions/general/mcp-server/__tests__/list-archived-counterparties.test.ts @@ -0,0 +1,51 @@ +/** + * gnubok_list_customers / gnubok_list_suppliers: rows archived through the v1 + * API (archived_at set) are hidden by default and only returned when the + * caller passes include_archived=true, mirroring the v1 list routes. + */ +import { describe, expect, it } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { tools } from '../server' + +const listCustomers = () => tools.find((t) => t.name === 'gnubok_list_customers')! +const listSuppliers = () => tools.find((t) => t.name === 'gnubok_list_suppliers')! + +describe('archived counterparties are hidden from the MCP list tools by default', () => { + it('gnubok_list_customers filters on archived_at IS NULL unless include_archived=true', async () => { + const { supabase, enqueue, findCalls, reset } = createQueuedMockSupabase() + enqueue({ data: [{ id: 'c-1', name: 'Acme AB', customer_type: 'swedish_business', org_number: null, personal_number: null }] }) + + const result = (await listCustomers().execute({}, 'company-1', 'user-1', supabase as never)) as { count: number } + expect(result.count).toBe(1) + expect(findCalls('customers', 'is')).toEqual([['archived_at', null]]) + + reset() + enqueue({ data: [] }) + await listCustomers().execute({ include_archived: true }, 'company-1', 'user-1', supabase as never) + expect(findCalls('customers', 'is')).toEqual([]) + }) + + it('gnubok_list_suppliers filters on archived_at IS NULL unless include_archived=true', async () => { + const { supabase, enqueue, findCalls, reset } = createQueuedMockSupabase() + enqueue({ data: [{ id: 's-1', name: 'Leverantör AB' }] }) + + const result = (await listSuppliers().execute({}, 'company-1', 'user-1', supabase as never)) as { count: number } + expect(result.count).toBe(1) + expect(findCalls('suppliers', 'is')).toEqual([['archived_at', null]]) + + reset() + enqueue({ data: [] }) + await listSuppliers().execute({ include_archived: true }, 'company-1', 'user-1', supabase as never) + expect(findCalls('suppliers', 'is')).toEqual([]) + }) + + it('declares include_archived as an optional boolean on both tools', () => { + for (const tool of [listCustomers(), listSuppliers()]) { + const schema = tool.inputSchema as { additionalProperties: boolean; properties: Record; required?: string[] } + expect(schema.additionalProperties).toBe(false) + expect(schema.properties.include_archived).toEqual(expect.objectContaining({ type: 'boolean' })) + expect(schema.required ?? []).not.toContain('include_archived') + expect(tool.description.length).toBeLessThanOrEqual(280) + } + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index b6650a59..abdc1c18 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -5295,8 +5295,12 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_customers', title: 'List Customers', - description: 'List all customers for the active company. Use to look up customer_id for invoice creation.', - inputSchema: { type: 'object', additionalProperties: false, properties: {} }, + description: 'List active customers. Use to look up customer_id for invoice creation. include_archived=true adds archived rows.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { include_archived: { type: 'boolean' } }, + }, outputSchema: { type: 'object', additionalProperties: false, @@ -5312,7 +5316,10 @@ export const tools: McpTool[] = [ idempotentHint: true, openWorldHint: false, }, - async execute(_args, companyId, userId, supabase) { + async execute(args, companyId, userId, supabase) { + // Archived rows (v1 API soft-delete) are hidden by default: same + // `archived_at IS NULL` convention and opt-in flag as the v1 list. + const includeArchived = args.include_archived === true // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at // 1000 rows. Page on the unique id, then re-sort by name for display. type ListedCustomer = { @@ -5324,14 +5331,15 @@ export const tools: McpTool[] = [ } let rows: ListedCustomer[] try { - rows = await fetchAllRows(({ from, to }) => - supabase + rows = await fetchAllRows(({ from, to }) => { + const query = supabase .from('customers') - .select('id, name, customer_type, email, org_number, vat_number, personal_number, default_payment_terms, city, country') + .select('id, name, customer_type, email, org_number, vat_number, personal_number, default_payment_terms, city, country, archived_at') .eq('company_id', companyId) + return (includeArchived ? query : query.is('archived_at', null)) .order('id', { ascending: true }) .range(from, to) - ) + }) } catch (error) { throw new Error(`Database error: ${error instanceof Error ? error.message : 'unknown error'}`) } @@ -6971,8 +6979,12 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_suppliers', title: 'List Suppliers (Leverantörer)', - description: 'List all suppliers (leverantörer) with contact and payment details, sorted by name.', - inputSchema: { type: 'object', additionalProperties: false, properties: {} }, + description: 'List active suppliers (leverantörer) with contact and payment details, sorted by name. include_archived=true adds archived rows.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { include_archived: { type: 'boolean' } }, + }, outputSchema: { type: 'object', additionalProperties: false, @@ -6988,19 +7000,23 @@ export const tools: McpTool[] = [ idempotentHint: true, openWorldHint: false, }, - async execute(_args, companyId, userId, supabase) { + async execute(args, companyId, userId, supabase) { + // Archived rows (v1 API soft-delete) are hidden by default: same + // `archived_at IS NULL` convention and opt-in flag as the v1 list. + const includeArchived = args.include_archived === true // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at // 1000 rows. Page on the unique id, then re-sort by name for display. let suppliers: { id: string; name: string }[] try { - suppliers = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => - supabase + suppliers = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => { + const query = supabase .from('suppliers') - .select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country') + .select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country, archived_at') .eq('company_id', companyId) + return (includeArchived ? query : query.is('archived_at', null)) .order('id', { ascending: true }) .range(from, to) - ) + }) } catch (error) { throw new Error(`Database error: ${error instanceof Error ? error.message : 'unknown error'}`) } diff --git a/tests/schema/no-phantom-columns.test.ts b/tests/schema/no-phantom-columns.test.ts index 857ed74e..ac251023 100644 --- a/tests/schema/no-phantom-columns.test.ts +++ b/tests/schema/no-phantom-columns.test.ts @@ -98,6 +98,13 @@ const KNOWN_STALE_ON_CONFLICT: Record = {} * parent/legacy links). Writing the shapes as inline literals would need one * variant per key combination; the row shapes are covered by ingest.test.ts. * + * 2026-08-26 +2: the customer pickers in InvoiceEditor and + * NewRecurringScheduleDialog hide archived customers but must keep the one the + * draft already points at, which is a PostgREST logical filter + * `.or('archived_at.is.null,id.eq.')`. The id is a runtime value, so the + * expression cannot be a literal; both columns are real and the filter is + * covered by the archived-counterparty tests. + * * 2026-08-17 +1: lib/import/skattekonto-file/import-service.ts inserts parsed * statement rows via a mapped batch (same shape as every other file importer); * the row shape is covered by the execute route tests and the pg-real suite. @@ -107,7 +114,7 @@ const KNOWN_STALE_ON_CONFLICT: Record = {} * routed / unrouted / converted / failed, all partial); the column set is * pinned by peppol-inbound.test.ts and the pg-real immutability test. */ -const UNRESOLVED_CEILING = 380 +const UNRESOLVED_CEILING = 382 /** * Floor on statically resolved column references. Guards the guard: if a change