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

Commit cef0362

Browse files
fix(dashboard): Reviews is searched, filtered, sorted, paged and summarised by the database
The Reviews page read the newest 500 reviews and did everything in the browser, so a review past the 500th could not be found or moderated, and the summary counted those 500. It also read the first 500 products and the first 500 customers by name, so one review of any later product, or by any later customer, failed the whole page with "references missing Product" or "references missing Customer". GET /api/dashboard/commerce/reviews now takes q, product, rating (1-5 or none), moderation (approved, pending, featured, verified), sort, direction, page and per_page, refuses a bad value with a 422, and answers one page with how many reviews match. Search covers the customer's name and email, the product's name, and the review's title and content, ignoring case, with LIKE wildcards escaped. Unrated reviews come last in both directions of a rating sort, as the page sorted them; ties read newest first, then by id. The summary counts every review, with the average over rated reviews only, as summarizeReviews does. Products and customers are read by exactly the ids the page references. The product filter now lists every product that has a review, read whole and ordered by LOWER(name). It listed the first 500 products by name, reviewed or not. The page and table hand paging and column sorting to the server and keep only the newest answer. Approving, featuring, editing and deleting reload the page being looked at. Tested on SQLite, PostgreSQL (BOOLEAN flags) and MySQL (TINYINT(1)) with 30 reviews over 600 products and 600 customers. Three controls went red on all three: dropping the unrated-last ordering, leaving the search unescaped, and reading the first 500 products and customers ("Review 10 references missing Customer 599"). Live, against the 50 dev reviews: the summary (50, 24 approved, 26 pending, 18 verified, 3.0 average), the rating and moderation filters, a product filter, a search and a column-header rating sort all match SQL. Refs #3218
1 parent cf6ef28 commit cef0362

7 files changed

Lines changed: 585 additions & 107 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ export const actions = {
335335
'Actions/Dashboard/Commerce/commerce-product-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-product-list.ts',
336336
'Actions/Dashboard/Commerce/commerce-product-records': '../defaults/app/Actions/Dashboard/Commerce/commerce-product-records.ts',
337337
'Actions/Dashboard/Commerce/commerce-record': '../defaults/app/Actions/Dashboard/Commerce/commerce-record.ts',
338+
'Actions/Dashboard/Commerce/commerce-review-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-review-list.ts',
338339
'Actions/Dashboard/Commerce/commerce-sql': '../defaults/app/Actions/Dashboard/Commerce/commerce-sql.ts',
339340
'Actions/Dashboard/Commerce/coupon-records': '../defaults/app/Actions/Dashboard/Commerce/coupon-records.ts',
340341
'Actions/Dashboard/Commerce/courier-records': '../defaults/app/Actions/Dashboard/Commerce/courier-records.ts',
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"fingerprint": "c7c46952f906aa8e902009c85362ab5ef66ee1f5d1b092800e7d317d957914e1"
2+
"fingerprint": "01fa62d41caaef588e6b8aefbff3eaf1b9aee5e8efdd377c7d500bcc00febb4f"
33
}

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

Lines changed: 22 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,35 @@
1+
import type { RequestInstance } from '@stacksjs/types'
12
import { Action } from '@stacksjs/actions/runtime'
2-
import { Customer, Product, Review } from '@stacksjs/orm'
3+
import { response } from '@stacksjs/router'
34
import { dashboardOperationalError } from '../dashboard-response'
4-
import {
5-
normalizeReviewCustomerContext,
6-
normalizeReviewProductOption,
7-
normalizeReviewRecord,
8-
summarizeReviews,
9-
} from './review-records'
5+
import { parseReviewListQuery, readReviewPage } from './commerce-review-list'
106

7+
/**
8+
* `GET /api/dashboard/commerce/reviews` - one page of reviews, searched,
9+
* filtered, sorted and counted by the database, with the summary of every
10+
* review (commerce-review-list.ts, stacksjs/stacks#3218). It used to return
11+
* the newest 500 for the browser to do all of that with.
12+
*/
1113
export default new Action({
1214
name: 'ReviewIndexAction',
13-
description: 'Returns persisted reviews with their product and customer context for dashboard moderation.',
15+
description: 'Returns one page of persisted reviews with their product and customer context for dashboard moderation.',
1416
method: 'GET',
1517
apiResponse: true,
1618

17-
async handle() {
18-
try {
19-
const [reviews, products, customers] = await Promise.all([
20-
Review.orderByDesc('id').limit(500).get(),
21-
Product.orderBy('name', 'asc').limit(500).get(),
22-
Customer.orderBy('name', 'asc').limit(500).get(),
23-
])
24-
const productOptions = products
25-
.map(normalizeReviewProductOption)
26-
.sort((left, right) => left.label.localeCompare(right.label))
27-
const productMap = new Map(productOptions.map(product => [
28-
product.id,
29-
{ name: product.label },
30-
]))
31-
const customerContexts = customers.map(normalizeReviewCustomerContext)
32-
const customerMap = new Map(customerContexts.map(customer => [
33-
customer.id,
34-
customer.context,
35-
]))
36-
const records = reviews.map(review => normalizeReviewRecord(review, productMap, customerMap))
19+
async handle(request: RequestInstance) {
20+
const query = parseReviewListQuery(key => request.get<unknown>(key, ''))
21+
if ('error' in query)
22+
return response.json({ message: query.error }, 422)
3723

24+
try {
25+
const reviews = await readReviewPage(query)
3826
return {
39-
records,
40-
summary: summarizeReviews(records),
41-
products: productOptions,
27+
records: reviews.records,
28+
total: reviews.total,
29+
page: reviews.page,
30+
perPage: query.perPage,
31+
summary: reviews.summary,
32+
products: reviews.products,
4233
}
4334
}
4435
catch (error) {
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { Database } from 'bun:sqlite'
2+
import { SQL } from 'bun'
3+
import { describe, expect, test } from 'bun:test'
4+
import type { ReviewListQuery } from './commerce-review-list'
5+
import { parseReviewListQuery, readReviewPage } from './commerce-review-list'
6+
import type { CommerceSqlRunner } from './commerce-sql'
7+
8+
/**
9+
* The Reviews list against each dialect's real engine (stacksjs/stacks#3218),
10+
* with the flags as each dialect stores booleans, and products and customers
11+
* past the 500 the page used to read.
12+
*/
13+
type Dialect = 'sqlite' | 'postgres' | 'mysql'
14+
15+
const NOW = new Date('2026-11-06T12:00:00.000Z')
16+
const ROWS = 600
17+
const PRODUCT_NAMES: Record<number, string> = { 1: 'Coffee Beans', 2: 'apron', 3: 'Mug', 4: 'Unreviewed', 600: 'zebra Tea' }
18+
const CUSTOMER_NAMES: Record<number, string> = { 5: 'zoe', 6: 'Adam', 599: 'mia' }
19+
const productName = (id: number) => PRODUCT_NAMES[id] ?? `Product ${id}`
20+
const customerName = (id: number) => CUSTOMER_NAMES[id] ?? `Customer ${id}`
21+
22+
/** 30 reviews, one an hour back from NOW; 20 and 21 share a creation time. */
23+
const REVIEWS = Array.from({ length: 30 }, (_, index) => {
24+
const id = index + 1
25+
return {
26+
id,
27+
product_id: id === 29 ? null : [1, 2, 3, 600][id % 4]!,
28+
customer_id: [5, 6, 599, null][id % 4] ?? null,
29+
rating: id % 7 === 0 ? null : (id % 5) + 1,
30+
title: id === 11 ? 'Great_100% value' : id === 12 ? 'Great 1000 value' : `Review ${id}`,
31+
content: id === 17 ? 'Smells LIKE roses' : `Words for review ${id}`,
32+
is_approved: id % 2 === 0,
33+
is_featured: id % 5 === 0,
34+
is_verified_purchase: id % 3 === 0,
35+
helpful_votes: (id * 7) % 10,
36+
unhelpful_votes: id % 3,
37+
hoursAgo: id === 21 ? 20 : id,
38+
}
39+
})
40+
type Review = typeof REVIEWS[number]
41+
42+
const ids = (reviews: Review[]) => reviews.map(review => String(review.id))
43+
const newestFirst = (a: Review, b: Review) => a.hoursAgo - b.hoursAgo || b.id - a.id
44+
45+
function query(overrides: Partial<ReviewListQuery> = {}): ReviewListQuery {
46+
return { search: '', product: '', rating: '', moderation: '', sort: 'created', direction: 'desc', page: 1, perPage: 10, ...overrides }
47+
}
48+
49+
async function setUp(run: (sql: string, values?: unknown[]) => Promise<unknown>, dialect: Dialect) {
50+
const pk = dialect === 'postgres' ? 'BIGINT PRIMARY KEY' : 'INTEGER PRIMARY KEY'
51+
const text = dialect === 'mysql' ? 'VARCHAR(255)' : 'TEXT'
52+
const flag = dialect === 'postgres' ? 'BOOLEAN' : dialect === 'mysql' ? 'TINYINT(1)' : 'INTEGER'
53+
const time = dialect === 'sqlite' ? 'TEXT' : dialect === 'mysql' ? 'DATETIME(3)' : 'TIMESTAMP'
54+
const p = (index: number) => dialect === 'postgres' ? `$${index}` : '?'
55+
await run(`CREATE TABLE products (id ${pk}, name ${text})`)
56+
await run(`CREATE TABLE customers (id ${pk}, name ${text}, email ${text})`)
57+
await run(`CREATE TABLE reviews (id ${pk}, rating INTEGER NULL, title ${text} NULL, content ${text} NULL, is_verified_purchase ${flag}, is_approved ${flag}, is_featured ${flag}, helpful_votes INTEGER, unhelpful_votes INTEGER, product_id INTEGER NULL, customer_id INTEGER NULL, created_at ${time})`)
58+
const all = Array.from({ length: ROWS }, (_, i) => i + 1)
59+
await run(`INSERT INTO products (id, name) VALUES ${all.map((_, i) => `(${p(2 * i + 1)}, ${p(2 * i + 2)})`).join(', ')}`, all.flatMap(id => [id, productName(id)]))
60+
await run(`INSERT INTO customers (id, name, email) VALUES ${all.map((_, i) => `(${p(3 * i + 1)}, ${p(3 * i + 2)}, ${p(3 * i + 3)})`).join(', ')}`, all.flatMap(id => [id, customerName(id), `customer${id}@shop.test`]))
61+
const bool = (value: boolean) => dialect === 'postgres' ? value : (value ? 1 : 0)
62+
for (const review of REVIEWS) {
63+
await run(
64+
`INSERT INTO reviews (id, rating, title, content, is_verified_purchase, is_approved, is_featured, helpful_votes, unhelpful_votes, product_id, customer_id, created_at) VALUES (${Array.from({ length: 12 }, (_, i) => p(i + 1)).join(', ')})`,
65+
[
66+
review.id, review.rating, review.title, review.content, bool(review.is_verified_purchase), bool(review.is_approved),
67+
bool(review.is_featured), review.helpful_votes, review.unhelpful_votes, review.product_id, review.customer_id,
68+
new Date(NOW.getTime() - review.hoursAgo * 3_600_000).toISOString().slice(0, 19).replace('T', ' '),
69+
],
70+
)
71+
}
72+
}
73+
74+
async function exercise(runner: CommerceSqlRunner, dialect: Dialect) {
75+
const read = (overrides: Partial<ReviewListQuery> = {}) => readReviewPage(query(overrides), runner, dialect)
76+
const all = async (overrides: Partial<ReviewListQuery> = {}) => (await read({ perPage: 100, ...overrides })).records.map(record => record.id)
77+
78+
// The summary counts every review, whatever the page is filtered to.
79+
const filtered = await read({ moderation: 'featured' })
80+
const rated = REVIEWS.flatMap(review => review.rating === null ? [] : [review.rating])
81+
expect(filtered.summary).toEqual({
82+
total: REVIEWS.length,
83+
approved: REVIEWS.filter(review => review.is_approved).length,
84+
pending: REVIEWS.filter(review => !review.is_approved).length,
85+
featured: REVIEWS.filter(review => review.is_featured).length,
86+
verified: REVIEWS.filter(review => review.is_verified_purchase).length,
87+
averageRating: Math.round(rated.reduce((sum, rating) => sum + rating, 0) / rated.length * 10) / 10,
88+
})
89+
expect(filtered.total).toBe(REVIEWS.filter(review => review.is_featured).length)
90+
91+
// The product choices are the reviewed products, by name ignoring case.
92+
expect(filtered.products).toEqual([
93+
{ id: '2', label: 'apron' },
94+
{ id: '1', label: 'Coffee Beans' },
95+
{ id: '3', label: 'Mug' },
96+
{ id: '600', label: 'zebra Tea' },
97+
])
98+
99+
// Filters.
100+
const matching = (keep: (review: Review) => boolean) => ids(REVIEWS.filter(keep).sort(newestFirst))
101+
expect(await all({ product: '600' })).toEqual(matching(review => review.product_id === 600))
102+
expect(await all({ rating: '5' })).toEqual(matching(review => review.rating === 5))
103+
expect(await all({ rating: 'none' })).toEqual(matching(review => review.rating === null))
104+
expect(await all({ moderation: 'approved' })).toEqual(matching(review => review.is_approved))
105+
expect(await all({ moderation: 'pending' })).toEqual(matching(review => !review.is_approved))
106+
expect(await all({ moderation: 'verified' })).toEqual(matching(review => review.is_verified_purchase))
107+
expect(await all({ product: '1', moderation: 'approved' })).toEqual(matching(review => review.product_id === 1 && review.is_approved))
108+
109+
// Search: customer name and email, product name, title with a literal %, content, ignoring case.
110+
expect(await all({ search: 'ZOE' })).toEqual(matching(review => review.customer_id === 5))
111+
expect(await all({ search: 'customer599@' })).toEqual(matching(review => review.customer_id === 599))
112+
expect(await all({ search: 'ZEBRA' })).toEqual(matching(review => review.product_id === 600))
113+
expect(await all({ search: '100%' })).toEqual(['11'])
114+
expect(await all({ search: 'like roses' })).toEqual(['17'])
115+
116+
// Newest first, the id breaking the tie between 20 and 21, stable across pages.
117+
const newest = [...REVIEWS].sort(newestFirst)
118+
const pages = [await read({ page: 1 }), await read({ page: 2 }), await read({ page: 3 })]
119+
expect(pages.flatMap(result => result.records.map(record => record.id))).toEqual(ids(newest))
120+
expect((await read({ page: 9 })).page).toBe(3)
121+
122+
// Sorts: unrated last either way; ties newest first.
123+
const unrated = REVIEWS.filter(review => review.rating === null).sort(newestFirst)
124+
const ratedReviews = REVIEWS.filter(review => review.rating !== null)
125+
expect(await all({ sort: 'rating', direction: 'desc' })).toEqual(ids([...[...ratedReviews].sort((a, b) => b.rating! - a.rating! || newestFirst(a, b)), ...unrated]))
126+
expect(await all({ sort: 'rating', direction: 'asc' })).toEqual(ids([...[...ratedReviews].sort((a, b) => a.rating! - b.rating! || newestFirst(a, b)), ...unrated]))
127+
expect(await all({ sort: 'helpful', direction: 'desc' })).toEqual(ids([...REVIEWS].sort((a, b) => b.helpful_votes - a.helpful_votes || newestFirst(a, b))))
128+
const customerKey = (review: Review) => review.customer_id === null ? '' : customerName(review.customer_id).toLowerCase()
129+
expect(await all({ sort: 'customer', direction: 'asc' })).toEqual(ids([...REVIEWS].sort((a, b) => customerKey(a).localeCompare(customerKey(b)) || newestFirst(a, b))))
130+
const productKey = (review: Review) => review.product_id === null ? '' : productName(review.product_id).toLowerCase()
131+
expect(await all({ sort: 'product', direction: 'asc' })).toEqual(ids([...REVIEWS].sort((a, b) => productKey(a).localeCompare(productKey(b)) || newestFirst(a, b))))
132+
expect(await all({ sort: 'approved', direction: 'desc' })).toEqual(ids([...REVIEWS].sort((a, b) => Number(b.is_approved) - Number(a.is_approved) || newestFirst(a, b))))
133+
134+
// Products and customers past the 500th, read by the ids the page references.
135+
const records = (await read({ perPage: 100 })).records
136+
expect(records.find(record => record.productId === '600')).toMatchObject({ productName: 'zebra Tea' })
137+
expect(records.find(record => record.customerId === '599')).toMatchObject({ customerName: 'mia', customerEmail: 'customer599@shop.test' })
138+
expect(records.find(record => record.id === '29')).toMatchObject({ productId: '', productName: '' })
139+
}
140+
141+
describe('readReviewPage on SQLite', () => {
142+
test('summary, product choices, filters, search, sorts, pages and references', async () => {
143+
const database = new Database(':memory:')
144+
await setUp(async (sql, values = []) => database.query(sql).run(...(values as never[])), 'sqlite')
145+
await exercise({ unsafe: (sql, values = []) => ({ execute: async () => database.query(sql).all(...(values as never[])) }) }, 'sqlite')
146+
})
147+
})
148+
149+
for (const dialect of ['postgres', 'mysql'] as const) {
150+
const connection = dialect === 'postgres' ? process.env.STACKS_TEST_POSTGRES_URL : process.env.STACKS_TEST_MYSQL_URL
151+
152+
test.skipIf(!connection)(`readReviewPage on ${dialect}: summary, product choices, filters, search, sorts, pages and references`, async () => {
153+
const url = new URL(connection!)
154+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))
155+
throw new Error('Review list tests require a local disposable database server')
156+
157+
const name = `stacks_review_list_${crypto.randomUUID().replaceAll('-', '')}`
158+
const quoted = dialect === 'mysql' ? `\`${name}\`` : `"${name}"`
159+
const admin = new SQL(url.href)
160+
await admin.unsafe(`CREATE DATABASE ${quoted}`)
161+
const scoped = new URL(url.href)
162+
scoped.pathname = `/${name}`
163+
const database = new SQL(scoped.href)
164+
165+
try {
166+
await setUp(async (sql, values = []) => await database.unsafe(sql, values), dialect)
167+
await exercise({ unsafe: (sql, values = []) => ({ execute: async () => await database.unsafe(sql, values) }) }, dialect)
168+
}
169+
finally {
170+
await database.close()
171+
try { await admin.unsafe(`DROP DATABASE ${quoted}${dialect === 'postgres' ? ' WITH (FORCE)' : ''}`) }
172+
finally { await admin.close() }
173+
}
174+
}, 60_000)
175+
}
176+
177+
describe('parseReviewListQuery', () => {
178+
const reader = (values: Record<string, unknown>) => (key: string) => values[key] ?? ''
179+
180+
test('defaults to every review, newest first, 25 a page', () => {
181+
expect(parseReviewListQuery(reader({}))).toEqual({ search: '', product: '', rating: '', moderation: '', sort: 'created', direction: 'desc', page: 1, perPage: 25 })
182+
expect(parseReviewListQuery(reader({ sort: 'helpful' }))).toHaveProperty('direction', 'desc')
183+
expect(parseReviewListQuery(reader({ sort: 'customer' }))).toHaveProperty('direction', 'asc')
184+
})
185+
186+
test('refuses values the page does not offer', () => {
187+
expect(parseReviewListQuery(reader({ product: 'abc' }))).toHaveProperty('error')
188+
expect(parseReviewListQuery(reader({ rating: '6' }))).toHaveProperty('error')
189+
expect(parseReviewListQuery(reader({ moderation: 'spam' }))).toHaveProperty('error')
190+
expect(parseReviewListQuery(reader({ sort: 'images' }))).toHaveProperty('error')
191+
})
192+
})

0 commit comments

Comments
 (0)