镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Commit 795bb73

Browse files
fix(dashboard): Products is searched, filtered, sorted, paged and summarised by the database
The Products page read the whole catalogue and did everything in the browser, so its search, category and availability filters, sort, totals and CSV only ever saw the rows one request could carry. GET /api/dashboard/commerce/products now takes q, category, availability, sort, direction, page and per_page, refuses a bad value with a 422, and answers one page with how many products match. Search covers the id, name, description, category, manufacturer and allergens, ignoring case, with LIKE wildcards escaped. Sorting by price, inventory, availability or category falls back to the name and then the id, so pages never overlap. The summary (total, available, unavailable, out of stock, units in stock) counts the whole catalogue. Category and manufacturer choices are read in full, ordered by LOWER(name), instead of the first 500. GET /api/dashboard/commerce/products/export writes the same 15 CSV columns for every product that matches, reading 500 at a time. It is registered before /commerce/products/{id} so it is never taken for an id. The page and table hand paging and column sorting to the server and keep only the newest answer when requests overlap. named-search.ts orders by LOWER(name) too: PostgreSQL and MySQL collations put names in a different order from SQLite. Tested on SQLite, PostgreSQL and MySQL with 28 products (is_available as BOOLEAN, TINYINT(1) and INTEGER). Two controls went red: comparing the PG boolean as an integer without a cast (PG only), and dropping the name tie-break (all three). Live: ?sort=bogus answers 422 and /export reaches the export action, but both answer 503 on this dev database: products has no slug column, so the record normaliser rejects every row. That was already true before this change and is a migration matter, not this one. Refs #3218
1 parent 5e4551e commit 795bb73

10 files changed

Lines changed: 657 additions & 142 deletions

File tree

‎storage/framework/auto-imports/actions.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ export const actions = {
302302
'Actions/Dashboard/Commerce/CommercePrintLogsAction': '../defaults/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts',
303303
'Actions/Dashboard/Commerce/CommerceProductDetailAction': '../defaults/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts',
304304
'Actions/Dashboard/Commerce/CommerceProductsAction': '../defaults/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts',
305+
'Actions/Dashboard/Commerce/CommerceProductsExportAction': '../defaults/app/Actions/Dashboard/Commerce/CommerceProductsExportAction.ts',
305306
'Actions/Dashboard/Commerce/CommerceTaxesAction': '../defaults/app/Actions/Dashboard/Commerce/CommerceTaxesAction.ts',
306307
'Actions/Dashboard/Commerce/CourierIndexAction': '../defaults/app/Actions/Dashboard/Commerce/CourierIndexAction.ts',
307308
'Actions/Dashboard/Commerce/DeliveryRouteIndexAction': '../defaults/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts',
@@ -329,6 +330,7 @@ export const actions = {
329330
'Actions/Dashboard/Commerce/commerce-order-records': '../defaults/app/Actions/Dashboard/Commerce/commerce-order-records.ts',
330331
'Actions/Dashboard/Commerce/commerce-payment-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-payment-list.ts',
331332
'Actions/Dashboard/Commerce/commerce-pos': '../defaults/app/Actions/Dashboard/Commerce/commerce-pos.ts',
333+
'Actions/Dashboard/Commerce/commerce-product-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-product-list.ts',
332334
'Actions/Dashboard/Commerce/commerce-product-records': '../defaults/app/Actions/Dashboard/Commerce/commerce-product-records.ts',
333335
'Actions/Dashboard/Commerce/commerce-record': '../defaults/app/Actions/Dashboard/Commerce/commerce-record.ts',
334336
'Actions/Dashboard/Commerce/commerce-sql': '../defaults/app/Actions/Dashboard/Commerce/commerce-sql.ts',
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"fingerprint": "dca68d09a816ca7ec981d27b0460a25cf825c4dec690e5f5bcab5bc1b1cb79ed"
2+
"fingerprint": "8df15d0555d2c28982b6e3858d54c6d004bd4d368b0fae98302cdc4cc64fd73c"
33
}

‎storage/framework/defaults/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts‎

Lines changed: 24 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,38 @@
1+
import type { RequestInstance } from '@stacksjs/types'
12
import { Action } from '@stacksjs/actions/runtime'
23
import { config } from '@stacksjs/config'
3-
import { Category, Manufacturer, Product, ProductUnit, ProductVariant, Review } from '@stacksjs/orm'
4+
import { response } from '@stacksjs/router'
45
import { dashboardOperationalError } from '../dashboard-response'
5-
import {
6-
commerceRecordIdentifier,
7-
countProductRelations,
8-
normalizeCommerceCurrency,
9-
normalizeCommerceProductRecord,
10-
normalizeManufacturerOption,
11-
normalizeProductOption,
12-
summarizeCommerceProducts,
13-
} from './commerce-product-records'
14-
import { referencedIds } from './commerce-record'
6+
import { parseProductListQuery, readProductPage } from './commerce-product-list'
7+
import { normalizeCommerceCurrency } from './commerce-product-records'
158

9+
/**
10+
* `GET /api/dashboard/commerce/products` - one page of products, searched,
11+
* filtered, sorted and counted by the database, with the catalogue summary
12+
* (commerce-product-list.ts, stacksjs/stacks#3218). It used to return the
13+
* newest 500 for the browser to do all of that with.
14+
*/
1615
export default new Action({
1716
name: 'CommerceProductsAction',
18-
description: 'Returns persisted Product records with native relationship context for dashboard management.',
17+
description: 'Returns one page of persisted Product records with native relationship context.',
1918
method: 'GET',
2019
apiResponse: true,
2120

22-
async handle() {
23-
try {
24-
const products = await Product.orderByDesc('id').limit(500).get()
25-
const productIds = products.map(product => commerceRecordIdentifier(product, 'Product'))
26-
// Identifiers normalize to strings; `product_id` is numeric.
27-
const numericProductIds = productIds.map(Number).filter(Number.isSafeInteger)
28-
// The categories and manufacturers these products reference, read by id.
29-
// A product whose category or manufacturer is missing from what it is
30-
// given is refused (normalizeCommerceProductRecord), and these came from
31-
// the first 500 of each, so one product in the 501st category failed the
32-
// whole Products page (stacksjs/stacks#3218).
33-
const categoryReferences = referencedIds(products, 'category_id', 'categoryId')
34-
const manufacturerReferences = referencedIds(products, 'manufacturer_id', 'manufacturerId')
35-
const [categories, manufacturers, referencedCategories, referencedManufacturers, variants, units, reviews] = await Promise.all([
36-
// The product dialog's choices.
37-
Category.orderBy('name', 'asc').limit(500).get(),
38-
Manufacturer.orderBy('manufacturer', 'asc').limit(500).get(),
39-
categoryReferences.length > 0 ? Category.whereIn('id', categoryReferences).get() : [],
40-
manufacturerReferences.length > 0 ? Manufacturer.whereIn('id', manufacturerReferences).get() : [],
41-
numericProductIds.length > 0 ? ProductVariant.whereIn('product_id', numericProductIds).get() : [],
42-
numericProductIds.length > 0 ? ProductUnit.whereIn('product_id', numericProductIds).get() : [],
43-
numericProductIds.length > 0 ? Review.whereIn('product_id', numericProductIds).get() : [],
44-
])
45-
const categoryOptions = categories.map(normalizeProductOption)
46-
const manufacturerOptions = manufacturers.map(normalizeManufacturerOption)
47-
const categoryMap = new Map(referencedCategories.map(normalizeProductOption).map(option => [option.id, option.label]))
48-
const manufacturerMap = new Map(referencedManufacturers.map(normalizeManufacturerOption).map(option => [option.id, option.label]))
49-
const variantCounts = countProductRelations(variants)
50-
const unitCounts = countProductRelations(units)
51-
const reviewCounts = countProductRelations(reviews)
52-
const records = products.map(product => normalizeCommerceProductRecord(
53-
product,
54-
categoryMap,
55-
manufacturerMap,
56-
variantCounts,
57-
unitCounts,
58-
reviewCounts,
59-
))
21+
async handle(request: RequestInstance) {
22+
const query = parseProductListQuery(key => request.get<unknown>(key, ''))
23+
if ('error' in query)
24+
return response.json({ message: query.error }, 422)
6025

26+
try {
27+
const products = await readProductPage(query)
6128
return {
62-
records,
63-
summary: summarizeCommerceProducts(records),
64-
categories: categoryOptions,
65-
manufacturers: manufacturerOptions,
29+
records: products.records,
30+
total: products.total,
31+
page: products.page,
32+
perPage: query.perPage,
33+
summary: products.summary,
34+
categories: products.categories,
35+
manufacturers: products.manufacturers,
6636
defaultCurrency: normalizeCommerceCurrency(config.commerce?.currency),
6737
}
6838
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { RequestInstance } from '@stacksjs/types'
2+
import { Action } from '@stacksjs/actions/runtime'
3+
import { minorToInput } from '@stacksjs/commerce/money'
4+
import { config } from '@stacksjs/config'
5+
import { response } from '@stacksjs/router'
6+
import { dashboardOperationalError } from '../dashboard-response'
7+
import { parseProductListQuery, productsCsv, readProductsForExport } from './commerce-product-list'
8+
import { normalizeCommerceCurrency } from './commerce-product-records'
9+
10+
/**
11+
* `GET /api/dashboard/commerce/products/export` - every product matching the
12+
* Products page's search, filters and sort, as CSV (stacksjs/stacks#3218). The
13+
* page built the file from the newest 500.
14+
*/
15+
export default new Action({
16+
name: 'CommerceProductsExportAction',
17+
description: 'Exports every product matching the Products page filters as CSV.',
18+
method: 'GET',
19+
apiResponse: false,
20+
21+
async handle(request: RequestInstance) {
22+
const query = parseProductListQuery(key => request.get<unknown>(key, ''))
23+
if ('error' in query)
24+
return response.json({ message: query.error }, 422)
25+
26+
try {
27+
const currency = normalizeCommerceCurrency(config.commerce?.currency)
28+
const csv = productsCsv(await readProductsForExport(query), currency, minorToInput)
29+
const day = new Date().toISOString().slice(0, 10)
30+
return new Response(csv, {
31+
status: 200,
32+
headers: {
33+
'Cache-Control': 'private, no-store',
34+
'Content-Disposition': `attachment; filename="products-${day}.csv"`,
35+
'Content-Type': 'text/csv; charset=utf-8',
36+
'X-Content-Type-Options': 'nosniff',
37+
},
38+
})
39+
}
40+
catch (error) {
41+
return dashboardOperationalError(error, 'Products could not be exported.', 'CommerceProductsExportAction')
42+
}
43+
},
44+
})
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { Database } from 'bun:sqlite'
2+
import { SQL } from 'bun'
3+
import { describe, expect, test } from 'bun:test'
4+
import type { CommerceSqlRunner } from './commerce-sql'
5+
import type { ProductListQuery } from './commerce-product-list'
6+
import { parseProductListQuery, productsCsv, readProductPage, readProductsForExport } from './commerce-product-list'
7+
8+
/**
9+
* The Products list against each dialect's real engine (stacksjs/stacks#3218),
10+
* with is_available as each dialect's own boolean.
11+
*/
12+
type Dialect = 'sqlite' | 'postgres' | 'mysql'
13+
14+
const CATEGORIES: Array<[number, string]> = [[1, 'Pastry'], [2, 'drinks'], [3, 'Mains']]
15+
const MANUFACTURERS: Array<[number, string]> = [[1, 'Acme Mills'], [2, 'Bean Co']]
16+
17+
/** 28 products across three categories, some unavailable, some out of stock. */
18+
const PRODUCTS = Array.from({ length: 28 }, (_, index) => {
19+
const id = index + 1
20+
return {
21+
id,
22+
name: id === 9 ? 'Flat White 100%' : `Product ${String.fromCharCode(65 + (id % 26))}${id}`,
23+
slug: `product-${id}`,
24+
price: (id * 37) % 23 * 100,
25+
is_available: id % 4 !== 0,
26+
inventory_count: id % 5 === 0 ? 0 : id * 3,
27+
category_id: (id % 3) + 1,
28+
manufacturer_id: id % 2 === 0 ? 2 : (id % 7 === 0 ? null : 1),
29+
allergens: id === 11 ? '["peanuts","milk"]' : '[]',
30+
created_at: `2026-10-${String(1 + (id % 10)).padStart(2, '0')} 09:00:00`,
31+
}
32+
})
33+
34+
function query(overrides: Partial<ProductListQuery> = {}): ProductListQuery {
35+
return { search: '', category: '', availability: '', sort: 'created', direction: 'desc', page: 1, perPage: 10, ...overrides }
36+
}
37+
38+
async function setUp(run: (sql: string, values?: unknown[]) => Promise<unknown>, dialect: Dialect) {
39+
const pk = dialect === 'postgres' ? 'BIGINT PRIMARY KEY' : 'INTEGER PRIMARY KEY'
40+
const text = dialect === 'mysql' ? 'VARCHAR(255)' : 'TEXT'
41+
const bool = dialect === 'postgres' ? 'BOOLEAN' : dialect === 'mysql' ? 'TINYINT(1)' : 'INTEGER'
42+
const time = dialect === 'sqlite' ? 'TEXT' : dialect === 'mysql' ? 'DATETIME' : 'TIMESTAMP'
43+
await run(`CREATE TABLE categories (id ${pk}, name ${text})`)
44+
await run(`CREATE TABLE manufacturers (id ${pk}, manufacturer ${text})`)
45+
for (const table of ['product_variants', 'product_units', 'reviews'])
46+
await run(`CREATE TABLE ${table} (id ${pk}, product_id INTEGER)`)
47+
await run(`CREATE TABLE products (id ${pk}, name ${text}, slug ${text}, description ${text} NULL, price INTEGER, image_url ${text} NULL, is_available ${bool}, inventory_count INTEGER, preparation_time INTEGER, allergens ${text} NULL, nutritional_info ${text} NULL, category_id INTEGER NULL, manufacturer_id INTEGER NULL, created_at ${time})`)
48+
const p = (count: number) => Array.from({ length: count }, (_, i) => dialect === 'postgres' ? `$${i + 1}` : '?').join(', ')
49+
for (const [id, name] of CATEGORIES)
50+
await run(`INSERT INTO categories (id, name) VALUES (${p(2)})`, [id, name])
51+
for (const [id, name] of MANUFACTURERS)
52+
await run(`INSERT INTO manufacturers (id, manufacturer) VALUES (${p(2)})`, [id, name])
53+
for (const product of PRODUCTS) {
54+
await run(
55+
`INSERT INTO products (id, name, slug, price, is_available, inventory_count, preparation_time, allergens, category_id, manufacturer_id, created_at) VALUES (${p(11)})`,
56+
[product.id, product.name, product.slug, product.price, dialect === 'postgres' ? product.is_available : (product.is_available ? 1 : 0), product.inventory_count, 5, product.allergens, product.category_id, product.manufacturer_id, product.created_at],
57+
)
58+
}
59+
// Product 4 has two variants, one unit and three reviews.
60+
let next = 1
61+
for (const [table, count] of [['product_variants', 2], ['product_units', 1], ['reviews', 3]] as const) {
62+
for (let i = 0; i < count; i++)
63+
await run(`INSERT INTO ${table} (id, product_id) VALUES (${p(2)})`, [next++, 4])
64+
}
65+
}
66+
67+
async function exercise(runner: CommerceSqlRunner, dialect: Dialect) {
68+
const read = (overrides: Partial<ProductListQuery> = {}) => readProductPage(query(overrides), runner, dialect)
69+
70+
// The summary covers the catalogue, not the page.
71+
const first = await read()
72+
expect(first.total).toBe(28)
73+
expect(first.records).toHaveLength(10)
74+
expect(first.summary).toEqual({
75+
total: 28,
76+
available: PRODUCTS.filter(product => product.is_available).length,
77+
unavailable: PRODUCTS.filter(product => !product.is_available).length,
78+
outOfStock: PRODUCTS.filter(product => product.inventory_count === 0).length,
79+
totalInventory: PRODUCTS.reduce((sum, product) => sum + product.inventory_count, 0),
80+
})
81+
// Case is ignored, the same on every dialect.
82+
expect(first.categories.map(option => option.label)).toEqual(['drinks', 'Mains', 'Pastry'])
83+
84+
// Filters.
85+
expect((await read({ category: '2', perPage: 100 })).total).toBe(PRODUCTS.filter(product => product.category_id === 2).length)
86+
expect((await read({ availability: 'available', perPage: 100 })).total).toBe(PRODUCTS.filter(product => product.is_available).length)
87+
expect((await read({ availability: 'unavailable', perPage: 100 })).total).toBe(PRODUCTS.filter(product => !product.is_available).length)
88+
expect((await read({ availability: 'out-of-stock', perPage: 100 })).total).toBe(PRODUCTS.filter(product => product.inventory_count === 0).length)
89+
90+
// Search: the joined category and manufacturer, allergens, a literal %.
91+
expect((await read({ search: 'DRINKS', perPage: 100 })).total).toBe(PRODUCTS.filter(product => product.category_id === 2).length)
92+
expect((await read({ search: 'bean co', perPage: 100 })).total).toBe(PRODUCTS.filter(product => product.manufacturer_id === 2).length)
93+
expect((await read({ search: 'peanut' })).records.map(record => record.id)).toEqual(['11'])
94+
expect((await read({ search: '100%' })).records.map(record => record.id)).toEqual(['9'])
95+
96+
// Newest first with id breaking ties, stable across pages.
97+
const newest = [...PRODUCTS].sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id - a.id).map(product => String(product.id))
98+
const pages = [await read({ page: 1 }), await read({ page: 2 }), await read({ page: 3 })]
99+
expect(pages.flatMap(result => result.records.map(record => record.id))).toEqual(newest)
100+
expect((await read({ page: 7 })).page).toBe(3)
101+
102+
// Price high first, ties by name.
103+
const byPrice = (await read({ sort: 'price', direction: 'desc', perPage: 100 })).records
104+
const expectedPrice = [...PRODUCTS].sort((a, b) => b.price - a.price || a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || b.id - a.id).map(product => String(product.id))
105+
expect(byPrice.map(record => record.id)).toEqual(expectedPrice)
106+
107+
// Names sort ignoring case.
108+
const names = (await read({ sort: 'name', direction: 'asc', perPage: 100 })).records.map(record => record.name)
109+
expect(names).toEqual([...names].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0))
110+
111+
// Relations and references come through.
112+
const four = (await read({ search: 'product e4' })).records.find(record => record.id === '4')
113+
expect(four).toEqual(expect.objectContaining({ variantCount: 2, unitCount: 1, reviewCount: 3, categoryName: 'drinks', manufacturerName: 'Bean Co' }))
114+
115+
// The export holds every match, across batches, in order.
116+
const exported = await readProductsForExport({ search: '', category: '', availability: '', sort: 'created', direction: 'desc' }, runner, dialect, 4)
117+
expect(exported.map(record => record.id)).toEqual(newest)
118+
}
119+
120+
describe('readProductPage on SQLite', () => {
121+
test('summary, filters, search, sorts, pages and export', async () => {
122+
const database = new Database(':memory:')
123+
await setUp(async (sql, values = []) => database.query(sql).run(...(values as never[])), 'sqlite')
124+
await exercise({ unsafe: (sql, values = []) => ({ execute: async () => database.query(sql).all(...(values as never[])) }) }, 'sqlite')
125+
})
126+
})
127+
128+
for (const dialect of ['postgres', 'mysql'] as const) {
129+
const connection = dialect === 'postgres' ? process.env.STACKS_TEST_POSTGRES_URL : process.env.STACKS_TEST_MYSQL_URL
130+
131+
test.skipIf(!connection)(`readProductPage on ${dialect}: summary, filters, search, sorts, pages and export`, async () => {
132+
const url = new URL(connection!)
133+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))
134+
throw new Error('Product list tests require a local disposable database server')
135+
136+
const name = `stacks_product_list_${crypto.randomUUID().replaceAll('-', '')}`
137+
const quoted = dialect === 'mysql' ? `\`${name}\`` : `"${name}"`
138+
const admin = new SQL(url.href)
139+
await admin.unsafe(`CREATE DATABASE ${quoted}`)
140+
const scoped = new URL(url.href)
141+
scoped.pathname = `/${name}`
142+
const database = new SQL(scoped.href)
143+
144+
try {
145+
await setUp(async (sql, values = []) => await database.unsafe(sql, values), dialect)
146+
await exercise({ unsafe: (sql, values = []) => ({ execute: async () => await database.unsafe(sql, values) }) }, dialect)
147+
}
148+
finally {
149+
await database.close()
150+
try { await admin.unsafe(`DROP DATABASE ${quoted}${dialect === 'postgres' ? ' WITH (FORCE)' : ''}`) }
151+
finally { await admin.close() }
152+
}
153+
}, 60_000)
154+
}
155+
156+
describe('parseProductListQuery', () => {
157+
const reader = (values: Record<string, unknown>) => (key: string) => values[key] ?? ''
158+
159+
test('defaults to the newest 25', () => {
160+
expect(parseProductListQuery(reader({}))).toEqual({ search: '', category: '', availability: '', sort: 'created', direction: 'desc', page: 1, perPage: 25 })
161+
})
162+
163+
test('rejects what it cannot honour', () => {
164+
expect(parseProductListQuery(reader({ category: '1 OR 1=1' }))).toHaveProperty('error')
165+
expect(parseProductListQuery(reader({ availability: 'maybe' }))).toHaveProperty('error')
166+
expect(parseProductListQuery(reader({ sort: 'cost' }))).toHaveProperty('error')
167+
})
168+
})
169+
170+
test('the CSV keeps the columns the page exported', () => {
171+
const [header] = productsCsv([], 'USD', amount => String(amount)).replace('', '').split('\n')
172+
expect(header).toBe('"Product ID","Name","Description","Price","Currency","Availability","Inventory","Preparation minutes","Category","Manufacturer","Allergens","Variants","Units","Reviews","Created at"')
173+
})

0 commit comments

Comments
 (0)