feat(mcp): supplier and date filters on list_supplier_invoices (#2035)
* feat(mcp): supplier and date filters on list_supplier_invoices Add supplier_id, supplier_name (case-insensitive substring, resolved server-side against the suppliers table), date_from and date_to (inclusive, on invoice_date) to gnubok_list_supplier_invoices, mirroring the v1 REST supplier-invoices filter shapes (eq on supplier_id, gte/lte on invoice_date). Unknown supplier_name returns an empty result without touching the invoice table; malformed dates and non-uuid supplier_id fail loudly with pointer messages. Schema text is kept deliberately terse: the tools/list payload guard sits at near-zero headroom, so the date format hint lives once in the tool description and the redundant status enum prose was trimmed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 * fix(mcp): bound the supplier_name match set fed to the IN clause PR Agent review flagged the unbounded id list: a broad substring on a large supplier register could build an IN clause past PostgREST URL limits. Cap name matches at 200 (cap + 1 fetched so overflow is detected) and fail loudly with a refine hint instead of degrading. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 * fix(mcp): loud validation, literal name matching, truncation signal on list_supplier_invoices Skeptic review refuted three behaviors; all three fixed: 1. Silent truncation: date filters invite period reconciliation but the 50-row cap was unsignalled. The invoice query now uses count exact and returns total_count and has_more (same contract as list_invoices), with a unique-id order tiebreaker so identical calls return identical subsets. 2. Silent filter drop: non-string values for the new params (and blank supplier_name) fell through typeof guards and returned the UNFILTERED ledger presented as filtered, the same class arg-guard exists for. They now throw clear errors. Dates are also calendar-validated, so 2026-02-30 fails loudly instead of as a raw Postgres cast error. 3. Wildcard broadening: PostgREST rewrites * in ilike values to %, so Star*Mart matched Starke Martinsson AB. supplier_name is now matched as a literal case-insensitive substring in JS over the company's suppliers (fetchAllRows, parity with list_suppliers), cap unchanged. Two redundant property descriptions dropped to fund the outputSchema additions under the tools/list payload ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8f68421fef
commit
15df4c741c
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { tools } from '../server'
|
||||
|
||||
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_list_supplier_invoices')!
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const USER = 'user-1'
|
||||
const SUPPLIER_UUID = '11111111-2222-4333-8444-555555555555'
|
||||
|
||||
const invoiceRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'si-1',
|
||||
supplier_invoice_number: 'F-1001',
|
||||
invoice_date: '2026-03-10',
|
||||
due_date: '2026-04-09',
|
||||
status: 'approved',
|
||||
total: 1250,
|
||||
total_sek: 1250,
|
||||
currency: 'SEK',
|
||||
vat_treatment: 'standard',
|
||||
remaining_amount: 1250,
|
||||
default_dimensions: null,
|
||||
supplier: { id: SUPPLIER_UUID, name: 'Office Depot AB' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
type ListResult = {
|
||||
invoices: Array<Record<string, unknown>>
|
||||
count: number
|
||||
total_count: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
describe('gnubok_list_supplier_invoices: filters', () => {
|
||||
it('filters by supplier_id alone (eq on supplier_id, no supplier lookup)', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [invoiceRow()], count: 1 })
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ supplier_id: SUPPLIER_UUID },
|
||||
COMPANY,
|
||||
USER,
|
||||
supabase as never,
|
||||
)) as ListResult
|
||||
|
||||
expect(result.count).toBe(1)
|
||||
expect(result.total_count).toBe(1)
|
||||
expect(result.has_more).toBe(false)
|
||||
expect(result.invoices[0].id).toBe('si-1')
|
||||
const eqCalls = findCalls('supplier_invoices', 'eq')
|
||||
expect(eqCalls).toContainEqual(['supplier_id', SUPPLIER_UUID])
|
||||
// Resolving supplier_name is the only reason to touch suppliers.
|
||||
expect(findCalls('suppliers', 'select')).toHaveLength(0)
|
||||
expect(findCalls('supplier_invoices', 'gte')).toHaveLength(0)
|
||||
expect(findCalls('supplier_invoices', 'lte')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a non-uuid supplier_id with a pointer to supplier_name', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tool().execute({ supplier_id: 'Office Depot' }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/supplier UUID.*supplier_name/)
|
||||
})
|
||||
|
||||
it('rejects non-string filter values instead of silently returning the unfiltered list', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tool().execute({ date_from: 20260101 }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/date_from must be a string/)
|
||||
await expect(
|
||||
tool().execute({ supplier_name: ['Office Depot'] }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/supplier_name must be a string/)
|
||||
await expect(
|
||||
tool().execute({ supplier_id: 42 }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/supplier_id must be a string/)
|
||||
await expect(
|
||||
tool().execute({ date_to: null }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/date_to must be a string/)
|
||||
})
|
||||
|
||||
it('rejects a blank supplier_name instead of dropping the filter', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tool().execute({ supplier_name: ' ' }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/supplier_name must not be blank/)
|
||||
})
|
||||
|
||||
it('filters by supplier_name alone: literal case-insensitive substring resolved to supplier ids', async () => {
|
||||
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'sup-1', name: 'Office Depot AB' },
|
||||
{ id: 'sup-2', name: 'Nordic OFFICE Supplies' },
|
||||
{ id: 'sup-3', name: 'Byggmax AB' },
|
||||
],
|
||||
}) // suppliers fetch
|
||||
enqueue({ data: [invoiceRow()], count: 1 }) // invoice list
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ supplier_name: 'office' },
|
||||
COMPANY,
|
||||
USER,
|
||||
supabase as never,
|
||||
)) as ListResult
|
||||
|
||||
expect(result.count).toBe(1)
|
||||
expect(findCall('suppliers', 'select')).toEqual(['id, name'])
|
||||
expect(findCalls('suppliers', 'eq')).toContainEqual(['company_id', COMPANY])
|
||||
expect(findCalls('supplier_invoices', 'in')).toContainEqual([
|
||||
'supplier_id',
|
||||
['sup-1', 'sup-2'],
|
||||
])
|
||||
})
|
||||
|
||||
it('matches supplier_name literally: * and % are not wildcards', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'sup-1', name: 'Star*Mart' },
|
||||
{ id: 'sup-2', name: 'Starke Martinsson AB' },
|
||||
{ id: 'sup-3', name: '100%_AB' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [], count: 0 })
|
||||
|
||||
await tool().execute({ supplier_name: 'Star*Mart' }, COMPANY, USER, supabase as never)
|
||||
|
||||
expect(findCalls('supplier_invoices', 'in')).toContainEqual(['supplier_id', ['sup-1']])
|
||||
})
|
||||
|
||||
it('unknown supplier_name yields an empty result without querying invoices', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'sup-1', name: 'Byggmax AB' }] }) // no substring match
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ supplier_name: 'no such vendor' },
|
||||
COMPANY,
|
||||
USER,
|
||||
supabase as never,
|
||||
)) as ListResult
|
||||
|
||||
expect(result).toEqual({ invoices: [], count: 0, total_count: 0, has_more: false })
|
||||
expect(findCalls('supplier_invoices', 'select')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a supplier_name matching more suppliers than the cap', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: Array.from({ length: 201 }, (_, i) => ({ id: `sup-${i}`, name: `Byrå AB ${i}` })),
|
||||
})
|
||||
|
||||
await expect(
|
||||
tool().execute({ supplier_name: 'byrå' }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/matches more than 200 suppliers/)
|
||||
})
|
||||
|
||||
it('filters by date_from alone (gte on invoice_date)', async () => {
|
||||
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [], count: 0 })
|
||||
|
||||
await tool().execute({ date_from: '2026-01-01' }, COMPANY, USER, supabase as never)
|
||||
|
||||
expect(findCall('supplier_invoices', 'gte')).toEqual(['invoice_date', '2026-01-01'])
|
||||
expect(findCalls('supplier_invoices', 'lte')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('filters by date_to alone (lte on invoice_date)', async () => {
|
||||
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [], count: 0 })
|
||||
|
||||
await tool().execute({ date_to: '2026-06-30' }, COMPANY, USER, supabase as never)
|
||||
|
||||
expect(findCall('supplier_invoices', 'lte')).toEqual(['invoice_date', '2026-06-30'])
|
||||
expect(findCalls('supplier_invoices', 'gte')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('combines status, supplier_id, supplier_name and date range', async () => {
|
||||
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: SUPPLIER_UUID, name: 'Office Depot AB' }] }) // suppliers fetch
|
||||
enqueue({ data: [invoiceRow()], count: 1 }) // invoice list
|
||||
|
||||
const result = (await tool().execute(
|
||||
{
|
||||
status: 'to_pay',
|
||||
supplier_id: SUPPLIER_UUID,
|
||||
supplier_name: 'office',
|
||||
date_from: '2026-01-01',
|
||||
date_to: '2026-12-31',
|
||||
},
|
||||
COMPANY,
|
||||
USER,
|
||||
supabase as never,
|
||||
)) as ListResult
|
||||
|
||||
expect(result.count).toBe(1)
|
||||
const inCalls = findCalls('supplier_invoices', 'in')
|
||||
expect(inCalls).toContainEqual(['status', ['approved', 'overdue']])
|
||||
expect(inCalls).toContainEqual(['supplier_id', [SUPPLIER_UUID]])
|
||||
expect(findCalls('supplier_invoices', 'eq')).toContainEqual(['supplier_id', SUPPLIER_UUID])
|
||||
expect(findCall('supplier_invoices', 'gte')).toEqual(['invoice_date', '2026-01-01'])
|
||||
expect(findCall('supplier_invoices', 'lte')).toEqual(['invoice_date', '2026-12-31'])
|
||||
// Deterministic order: due_date with the unique id as tiebreaker.
|
||||
const orderCalls = findCalls('supplier_invoices', 'order')
|
||||
expect(orderCalls).toContainEqual(['due_date', { ascending: true }])
|
||||
expect(orderCalls).toContainEqual(['id', { ascending: true }])
|
||||
})
|
||||
|
||||
it('rejects a malformed date', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tool().execute({ date_from: '01/02/2026' }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/date_from must be a valid ISO date/)
|
||||
await expect(
|
||||
tool().execute({ date_to: '2026-6-1' }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/date_to must be a valid ISO date/)
|
||||
})
|
||||
|
||||
it('rejects an impossible calendar date that passes the shape regex', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tool().execute({ date_from: '2026-02-30' }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/date_from must be a valid ISO date/)
|
||||
await expect(
|
||||
tool().execute({ date_to: '2026-13-01' }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/date_to must be a valid ISO date/)
|
||||
})
|
||||
|
||||
it('rejects a reversed date range', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ date_from: '2026-12-31', date_to: '2026-01-01' },
|
||||
COMPANY,
|
||||
USER,
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/date_from must not be after date_to/)
|
||||
})
|
||||
|
||||
it('signals truncation: total_count and has_more expose rows past the limit', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: Array.from({ length: 50 }, (_, i) => invoiceRow({ id: `si-${i}` })),
|
||||
count: 60,
|
||||
})
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ date_from: '2026-01-01', date_to: '2026-03-31' },
|
||||
COMPANY,
|
||||
USER,
|
||||
supabase as never,
|
||||
)) as ListResult
|
||||
|
||||
expect(result.count).toBe(50)
|
||||
expect(result.total_count).toBe(60)
|
||||
expect(result.has_more).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the unfiltered path unchanged: no supplier lookup, no date filters', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [invoiceRow(), invoiceRow({ id: 'si-2' })], count: 2 })
|
||||
|
||||
const result = (await tool().execute({}, COMPANY, USER, supabase as never)) as ListResult
|
||||
|
||||
expect(result.count).toBe(2)
|
||||
expect(result.total_count).toBe(2)
|
||||
expect(result.has_more).toBe(false)
|
||||
expect(findCalls('suppliers', 'select')).toHaveLength(0)
|
||||
expect(findCalls('supplier_invoices', 'gte')).toHaveLength(0)
|
||||
expect(findCalls('supplier_invoices', 'lte')).toHaveLength(0)
|
||||
expect(findCalls('supplier_invoices', 'in')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -7583,16 +7583,19 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_list_supplier_invoices',
|
||||
title: 'List Supplier Invoices',
|
||||
description: 'List supplier invoices (leverantörsfakturor), sorted by due date. Optional status filter; "to_pay" combines approved+overdue.',
|
||||
description: 'List supplier invoices (leverantörsfakturor), sorted by due date. Filters: status (to_pay = approved+overdue), supplier_id, supplier_name (substring), date_from/date_to on invoice_date (YYYY-MM-DD).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Filter: registered, approved, overdue, paid, to_pay, all (default)',
|
||||
enum: ['registered', 'approved', 'overdue', 'paid', 'to_pay', 'all'],
|
||||
},
|
||||
supplier_id: { type: 'string' },
|
||||
supplier_name: { type: 'string' },
|
||||
date_from: { type: 'string' },
|
||||
date_to: { type: 'string' },
|
||||
limit: { type: 'number', description: 'Max results 1-100 (default 50)' },
|
||||
},
|
||||
},
|
||||
@@ -7602,6 +7605,8 @@ export const tools: McpTool[] = [
|
||||
properties: {
|
||||
invoices: { type: 'array', items: { type: 'object' } },
|
||||
count: { type: 'number' },
|
||||
total_count: { type: 'number' },
|
||||
has_more: { type: 'boolean' },
|
||||
},
|
||||
required: ['invoices', 'count'],
|
||||
},
|
||||
@@ -7615,9 +7620,92 @@ export const tools: McpTool[] = [
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
|
||||
const status = (args.status as string) || 'all'
|
||||
|
||||
// Loud validation: a mistyped filter must never silently degrade to the
|
||||
// UNFILTERED list (the silent-drop failure class arg-guard exists for,
|
||||
// feedback seq 261545). Hosts do not reliably enforce inputSchema types,
|
||||
// so non-string values reach execute() and are rejected here.
|
||||
for (const key of ['supplier_id', 'supplier_name', 'date_from', 'date_to'] as const) {
|
||||
if (args[key] !== undefined && typeof args[key] !== 'string') {
|
||||
throw new Error(`${key} must be a string.`)
|
||||
}
|
||||
}
|
||||
const supplierId = args.supplier_id as string | undefined
|
||||
const supplierName = (args.supplier_name as string | undefined)?.trim()
|
||||
const dateFrom = args.date_from as string | undefined
|
||||
const dateTo = args.date_to as string | undefined
|
||||
|
||||
if (supplierName === '') {
|
||||
throw new Error('supplier_name must not be blank.')
|
||||
}
|
||||
// Clear message instead of a raw Postgres uuid cast error: an agent may
|
||||
// well pass a supplier's name here; point it at supplier_name instead.
|
||||
if (supplierId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(supplierId)) {
|
||||
throw new Error('supplier_id must be a supplier UUID (suppliers.id). To filter by name, use supplier_name.')
|
||||
}
|
||||
for (const [key, value] of [['date_from', dateFrom], ['date_to', dateTo]] as const) {
|
||||
if (value === undefined) continue
|
||||
// Shape AND calendar validity: '2026-02-30' must fail here with a
|
||||
// clear message, not as a raw Postgres date-cast error downstream.
|
||||
const parsed = new Date(`${value}T00:00:00Z`)
|
||||
if (
|
||||
!ISO_DATE_RE.test(value) ||
|
||||
Number.isNaN(parsed.getTime()) ||
|
||||
parsed.toISOString().slice(0, 10) !== value
|
||||
) {
|
||||
throw new Error(`${key} must be a valid ISO date (YYYY-MM-DD).`)
|
||||
}
|
||||
}
|
||||
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||||
throw new Error('date_from must not be after date_to.')
|
||||
}
|
||||
|
||||
// supplier_name resolves server-side to supplier ids: a LITERAL
|
||||
// case-insensitive substring matched in JS over the company's suppliers
|
||||
// (fetchAllRows, same shape as gnubok_list_suppliers). Not ilike:
|
||||
// PostgREST rewrites `*` in like/ilike values to `%`, so a DB-side
|
||||
// pattern silently broadens names containing `*` and there is no
|
||||
// server-side escape for it. Archived suppliers are included on
|
||||
// purpose: their invoices still exist. The match set is bounded: an
|
||||
// unbounded id list fed to .in() below could exceed PostgREST URL
|
||||
// limits, so past the cap the tool fails loudly with a refine hint
|
||||
// instead of degrading.
|
||||
const NAME_MATCH_CAP = 200
|
||||
let nameMatchedSupplierIds: string[] | undefined
|
||||
if (supplierName) {
|
||||
const needle = supplierName.toLowerCase()
|
||||
let suppliers: { id: string; name: string | null }[]
|
||||
try {
|
||||
suppliers = await fetchAllRows<{ id: string; name: string | null }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('suppliers')
|
||||
.select('id, name')
|
||||
.eq('company_id', companyId)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
} catch (error) {
|
||||
throw dbError(error)
|
||||
}
|
||||
const matchedIds = suppliers
|
||||
.filter((s) => (s.name ?? '').toLowerCase().includes(needle))
|
||||
.map((s) => s.id)
|
||||
if (matchedIds.length === 0) {
|
||||
return { invoices: [], count: 0, total_count: 0, has_more: false }
|
||||
}
|
||||
if (matchedIds.length > NAME_MATCH_CAP) {
|
||||
throw new Error(
|
||||
`supplier_name matches more than ${NAME_MATCH_CAP} suppliers; refine the name or use supplier_id.`,
|
||||
)
|
||||
}
|
||||
nameMatchedSupplierIds = matchedIds
|
||||
}
|
||||
|
||||
// count: 'exact' so truncation is SIGNALLED (total_count/has_more): the
|
||||
// date filters invite period reconciliation, and a silently capped
|
||||
// listing reads as complete (same contract as gnubok_list_invoices).
|
||||
let query = supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, default_dimensions, supplier:suppliers(id, name)')
|
||||
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, default_dimensions, supplier:suppliers(id, name)', { count: 'exact' })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (status !== 'all') {
|
||||
@@ -7628,11 +7716,30 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('due_date', { ascending: true }).limit(limit)
|
||||
// Same filter shapes as the v1 supplier-invoices list: eq on
|
||||
// supplier_id, gte/lte on invoice_date.
|
||||
if (supplierId) query = query.eq('supplier_id', supplierId)
|
||||
if (nameMatchedSupplierIds) query = query.in('supplier_id', nameMatchedSupplierIds)
|
||||
if (dateFrom) query = query.gte('invoice_date', dateFrom)
|
||||
if (dateTo) query = query.lte('invoice_date', dateTo)
|
||||
|
||||
// Tiebreak on the unique id: due_date alone leaves which rows fall past
|
||||
// the cap nondeterministic between identical calls.
|
||||
const { data, error, count } = await query
|
||||
.order('due_date', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
.limit(limit)
|
||||
|
||||
if (error) throw dbError(error)
|
||||
|
||||
return { invoices: data ?? [], count: data?.length ?? 0 }
|
||||
const invoices = data ?? []
|
||||
const totalCount = count ?? invoices.length
|
||||
return {
|
||||
invoices,
|
||||
count: invoices.length,
|
||||
total_count: totalCount,
|
||||
has_more: totalCount > invoices.length,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user