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

Commit de6b101

Browse files
fix(dashboard): licence keys are listed by the database and the key dialog searches every customer, product and order
The Licence Keys page read the newest 500 keys and did everything in the browser, so an older key could not be found, edited or deleted from it, and its cards counted those 500. It also loaded the first 500 customers, products and orders, for two reasons: the key dialog's selects, so a key could not be linked to any later one, and to label each key's links, which overwrote the links the server had already sent, so a key linked to a later customer, product or order showed no link at all. GET /api/dashboard/commerce/license-keys now takes q, sort, direction, page and per_page, refuses a bad value with a 422, and answers one page with how many keys match and a summary of every key (total, active, assigned, expiring within 30 days). Search covers the key, template, status, the customer's name and email, the product's name and the order number, ignoring case, with LIKE wildcards escaped. Template and status sort by name, not enum declaration order; ties read newest first, then by id. Each key's customer, product and order are read by exactly the ids the page references, and the page now shows those. The dialog searches each link as it is typed in, through GET /api/dashboard/commerce/license-key-options?kind=customers|products|orders&q= (customers by name or email, products by name or id, orders by number or status, newest first), keeps only the newest answer, says when more match than it shows, and always lists the key's own links. Without `kind` the endpoint answers the first 25 of each with their totals, the { customers, products, orders } shape useLicenseKeys().fetchLicenseKeyOptions reads; it was the first 500 with no way to tell there were more. useLicenseKeys().fetchLicenseKeys reads every page (`paged: true`, cfae0e6). The page uses useServerList (cfae0e6), keeps useRecordsDashboard for the dialog and the delete confirmation, and reloads the page after a save or delete. Tested on SQLite, PostgreSQL and MySQL with real enums, 30 keys linked to customers, products and orders past the 500th, and expiry dates on both sides of 30 days. Two controls went red: reading the first 500 customers ("LicenseKey 4 references missing Customer 515", all three), and sorting template as the enum (PostgreSQL and MySQL). Live, against the 10 dev keys: the summary (10, 3 active, 10 assigned, 0 expiring), template search and sort, and the links match SQL; in the edit dialog the key's product and order are selected, and an order search for "delivered" lists the four delivered orders SQL finds, keeping the key's own order. Nothing was saved. Refs #3218
1 parent cfae0e6 commit de6b101

10 files changed

Lines changed: 633 additions & 142 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ export const actions = {
334334
'Actions/Dashboard/Commerce/commerce-delivery-route-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-delivery-route-list.ts',
335335
'Actions/Dashboard/Commerce/commerce-delivery': '../defaults/app/Actions/Dashboard/Commerce/commerce-delivery.ts',
336336
'Actions/Dashboard/Commerce/commerce-gift-card-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-gift-card-list.ts',
337+
'Actions/Dashboard/Commerce/commerce-license-key-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-license-key-list.ts',
337338
'Actions/Dashboard/Commerce/commerce-order-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-order-list.ts',
338339
'Actions/Dashboard/Commerce/commerce-order-records': '../defaults/app/Actions/Dashboard/Commerce/commerce-order-records.ts',
339340
'Actions/Dashboard/Commerce/commerce-payment-list': '../defaults/app/Actions/Dashboard/Commerce/commerce-payment-list.ts',
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"fingerprint": "4f2c0a6599ac49fcec8d88e6c39bf2a34ed18ac4f7e6048f05df7d509abf7e94"
2+
"fingerprint": "3369da254f6f8132820989bb681ba828a81fff16f8ee7e1ab66e697182ffbfe6"
33
}

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

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,35 @@
1+
import type { RequestInstance } from '@stacksjs/types'
12
import { Action } from '@stacksjs/actions/runtime'
2-
import { Customer, LicenseKey, Order, Product } from '@stacksjs/orm'
3+
import { response } from '@stacksjs/router'
34
import { dashboardOperationalError } from '../dashboard-response'
4-
import {
5-
indexLicenseKeyOptions,
6-
normalizeLicenseKeyCustomer,
7-
normalizeLicenseKeyOrder,
8-
normalizeLicenseKeyProduct,
9-
normalizeLicenseKeyRecord,
10-
} from './license-key-records'
5+
import { parseLicenseKeyListQuery, readLicenseKeyPage } from './commerce-license-key-list'
116

7+
/**
8+
* `GET /api/dashboard/commerce/license-keys` - one page of licence keys,
9+
* searched, sorted and counted by the database, with the summary of every key
10+
* and each key's customer, product and order (commerce-license-key-list.ts,
11+
* stacksjs/stacks#3218). It used to return the newest 500 as a bare array.
12+
*/
1213
export default new Action({
1314
name: 'Dashboard License Keys',
14-
description: 'Returns license keys with lightweight customer, product, and order summaries.',
15+
description: 'Returns one page of license keys with lightweight customer, product, and order summaries.',
1516
method: 'GET',
1617
apiResponse: true,
17-
async handle() {
18+
19+
async handle(request: RequestInstance) {
20+
const query = parseLicenseKeyListQuery(key => request.get<unknown>(key, ''))
21+
if ('error' in query)
22+
return response.json({ message: query.error }, 422)
23+
1824
try {
19-
const keys = await LicenseKey.orderByDesc('id').limit(500).get()
20-
const customerIds = [...new Set(keys.map(key => key.get('customer_id')).filter(Boolean))]
21-
const productIds = [...new Set(keys.map(key => key.get('product_id')).filter(Boolean))]
22-
const orderIds = [...new Set(keys.map(key => key.get('order_id')).filter(Boolean))]
23-
const [customers, products, orders] = await Promise.all([
24-
customerIds.length ? Customer.whereIn('id', customerIds).get() : [],
25-
productIds.length ? Product.whereIn('id', productIds).get() : [],
26-
orderIds.length ? Order.whereIn('id', orderIds).get() : [],
27-
])
28-
const customerMap = indexLicenseKeyOptions(customers.map(normalizeLicenseKeyCustomer))
29-
const productMap = indexLicenseKeyOptions(products.map(normalizeLicenseKeyProduct))
30-
const orderMap = indexLicenseKeyOptions(orders.map(normalizeLicenseKeyOrder))
31-
return keys.map(key => normalizeLicenseKeyRecord(key, customerMap, productMap, orderMap))
25+
const keys = await readLicenseKeyPage(query)
26+
return {
27+
records: keys.records,
28+
total: keys.total,
29+
page: keys.page,
30+
perPage: query.perPage,
31+
summary: keys.summary,
32+
}
3233
}
3334
catch (error) {
3435
return dashboardOperationalError(error, 'License key records could not be read.', 'LicenseKeyIndexAction')

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

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,47 @@
1+
import type { RequestInstance } from '@stacksjs/types'
12
import { Action } from '@stacksjs/actions/runtime'
2-
import { Customer, Order, Product } from '@stacksjs/orm'
3+
import { response } from '@stacksjs/router'
34
import { dashboardOperationalError } from '../dashboard-response'
4-
import {
5-
normalizeLicenseKeyCustomer,
6-
normalizeLicenseKeyOrder,
7-
normalizeLicenseKeyProduct,
8-
} from './license-key-records'
5+
import { parseNamedSearchQuery } from '../named-search'
6+
import type { LicenseKeyChoiceKind } from './commerce-license-key-list'
7+
import { LICENSE_KEY_CHOICE_KINDS, searchLicenseKeyChoices } from './commerce-license-key-list'
98

9+
/**
10+
* `GET /api/dashboard/commerce/license-key-options` - the customers, products
11+
* and orders the licence key dialog offers (stacksjs/stacks#3218). It used to
12+
* send the first 500 of each.
13+
*
14+
* With `kind` (customers, products or orders) and `q`, it searches that one
15+
* and answers `{ choices, total }`, which is what the dialog asks for as each
16+
* search box is typed in. Without `kind` it answers the first `limit` of each
17+
* (25 unless asked) as `{ customers, products, orders, totals }`, the shape
18+
* useLicenseKeys().fetchLicenseKeyOptions reads, with how many there are.
19+
*/
1020
export default new Action({
1121
name: 'License Key Options',
12-
description: 'Returns lightweight relationship options for license key forms.',
22+
description: 'Searches the customers, products and orders the licence key dialog offers.',
1323
method: 'GET',
1424
apiResponse: true,
15-
async handle() {
25+
26+
async handle(request: RequestInstance) {
27+
const query = parseNamedSearchQuery(key => request.get<unknown>(key, ''))
28+
if ('error' in query)
29+
return response.json({ message: query.error }, 422)
30+
const kind = String(request.get<unknown>('kind', '') ?? '').trim()
31+
if (kind && !(LICENSE_KEY_CHOICE_KINDS as readonly string[]).includes(kind))
32+
return response.json({ message: `kind must be one of ${LICENSE_KEY_CHOICE_KINDS.join(', ')}.` }, 422)
33+
1634
try {
17-
const [customers, products, orders] = await Promise.all([
18-
Customer.orderBy('name', 'asc').limit(500).get(),
19-
Product.orderBy('name', 'asc').limit(500).get(),
20-
Order.orderByDesc('id').limit(500).get(),
21-
])
35+
if (kind)
36+
return { ...await searchLicenseKeyChoices(kind as LicenseKeyChoiceKind, query), limit: query.limit }
37+
const [customers, products, orders] = await Promise.all(
38+
LICENSE_KEY_CHOICE_KINDS.map(each => searchLicenseKeyChoices(each, query)),
39+
)
2240
return {
23-
customers: customers.map(normalizeLicenseKeyCustomer),
24-
products: products.map(normalizeLicenseKeyProduct),
25-
orders: orders.map(normalizeLicenseKeyOrder),
41+
customers: customers!.choices,
42+
products: products!.choices,
43+
orders: orders!.choices,
44+
totals: { customers: customers!.total, products: products!.total, orders: orders!.total },
2645
}
2746
}
2847
catch (error) {
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { Database } from 'bun:sqlite'
2+
import { SQL } from 'bun'
3+
import { describe, expect, test } from 'bun:test'
4+
import type { LicenseKeyListQuery } from './commerce-license-key-list'
5+
import { parseLicenseKeyListQuery, readLicenseKeyPage, searchLicenseKeyChoices } from './commerce-license-key-list'
6+
import type { CommerceSqlRunner } from './commerce-sql'
7+
8+
/**
9+
* The Licence Keys list and its dialog's choices against each dialect's real
10+
* engine (stacksjs/stacks#3218), with template and status as real enums, over
11+
* 520 customers, products and orders: the page used to read the first 500 of
12+
* each.
13+
*/
14+
type Dialect = 'sqlite' | 'postgres' | 'mysql'
15+
16+
const NOW = new Date('2026-11-06T12:00:00.000Z')
17+
const DAY = 86_400_000
18+
const ROWS = 520
19+
const TEMPLATES = ['Standard License', 'Premium License', 'Enterprise License'] as const
20+
const STATUSES = ['active', 'inactive', 'unassigned'] as const
21+
const stamp = (ms: number) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ')
22+
const customerName = (id: number) => id === 515 ? 'zelda Late' : id === 3 ? 'Zed early' : `Customer ${String(id).padStart(3, '0')}`
23+
24+
/** 30 keys, one a day back from NOW; 11 and 12 share a creation time. */
25+
const KEYS = Array.from({ length: 30 }, (_, index) => {
26+
const id = index + 1
27+
const group = (n: number) => String(n).padStart(4, '0')
28+
return {
29+
id,
30+
key: `K${group(id).slice(1)}-AAAA-BBBB-CCCC-${id === 9 ? 'X1X0' : group(id)}`,
31+
template: TEMPLATES[id % 3]!,
32+
status: STATUSES[id % 3]!,
33+
customerId: id % 4 === 0 ? 515 : id % 4 === 1 ? 3 : null,
34+
productId: id % 5 === 0 ? 520 : null,
35+
orderId: id % 6 === 0 ? 510 : null,
36+
// Day offsets around the 30-day window: some inside, some past, some long gone.
37+
expiryDays: [-5, 1, 10, 29, 31, 90][id % 6]!,
38+
daysAgo: id === 12 ? 11 : id,
39+
}
40+
})
41+
type Key = typeof KEYS[number]
42+
43+
const ids = (keys: Key[]) => keys.map(key => String(key.id))
44+
const newestFirst = (a: Key, b: Key) => a.daysAgo - b.daysAgo || b.id - a.id
45+
46+
function query(overrides: Partial<LicenseKeyListQuery> = {}): LicenseKeyListQuery {
47+
return { search: '', sort: 'created', direction: 'desc', page: 1, perPage: 10, ...overrides }
48+
}
49+
50+
async function setUp(run: (sql: string, values?: unknown[]) => Promise<unknown>, dialect: Dialect) {
51+
const pk = dialect === 'postgres' ? 'BIGINT PRIMARY KEY' : 'INTEGER PRIMARY KEY'
52+
const text = dialect === 'mysql' ? 'VARCHAR(255)' : 'TEXT'
53+
const time = dialect === 'sqlite' ? 'TEXT' : dialect === 'mysql' ? 'DATETIME(3)' : 'TIMESTAMP'
54+
const p = (index: number) => dialect === 'postgres' ? `$${index}` : '?'
55+
const rows = (count: number, columns: number) => Array.from({ length: count }, (_, i) => `(${Array.from({ length: columns }, (_, c) => p(i * columns + c + 1)).join(', ')})`).join(', ')
56+
const enumOf = (values: readonly string[]) => values.map(value => `'${value}'`).join(', ')
57+
let template = text
58+
let status = text
59+
if (dialect === 'postgres') {
60+
await run(`CREATE TYPE key_template AS ENUM (${enumOf(TEMPLATES)})`)
61+
await run(`CREATE TYPE key_status AS ENUM (${enumOf(STATUSES)})`)
62+
template = 'key_template'
63+
status = 'key_status'
64+
}
65+
if (dialect === 'mysql') {
66+
template = `ENUM(${enumOf(TEMPLATES)})`
67+
status = `ENUM(${enumOf(STATUSES)})`
68+
}
69+
await run(`CREATE TABLE customers (id ${pk}, name ${text}, email ${text})`)
70+
await run(`CREATE TABLE products (id ${pk}, name ${text})`)
71+
await run(`CREATE TABLE orders (id ${pk}, status ${text})`)
72+
await run(`CREATE TABLE license_keys (id ${pk}, ${dialect === 'mysql' ? '`key`' : 'key'} ${text}, template ${template}, expiry_date ${time}, status ${status}, customer_id INTEGER NULL, product_id INTEGER NULL, order_id INTEGER NULL, created_at ${time}, updated_at ${time} NULL, uuid ${text} NULL)`)
73+
const all = Array.from({ length: ROWS }, (_, i) => i + 1)
74+
await run(`INSERT INTO customers (id, name, email) VALUES ${rows(ROWS, 3)}`, all.flatMap(id => [id, customerName(id), `buyer${id}@shop.test`]))
75+
await run(`INSERT INTO products (id, name) VALUES ${rows(ROWS, 2)}`, all.flatMap(id => [id, id === 520 ? 'Zebra Suite' : `Product ${String(id).padStart(3, '0')}`]))
76+
await run(`INSERT INTO orders (id, status) VALUES ${rows(ROWS, 2)}`, all.flatMap(id => [id, id === 510 ? 'refunded' : 'completed']))
77+
const keyColumn = dialect === 'mysql' ? '`key`' : 'key'
78+
await run(
79+
`INSERT INTO license_keys (id, ${keyColumn}, template, expiry_date, status, customer_id, product_id, order_id, created_at) VALUES ${rows(KEYS.length, 9)}`,
80+
KEYS.flatMap(key => [key.id, key.key, key.template, stamp(NOW.getTime() + key.expiryDays * DAY), key.status, key.customerId, key.productId, key.orderId, stamp(NOW.getTime() - key.daysAgo * DAY)]),
81+
)
82+
}
83+
84+
async function exercise(runner: CommerceSqlRunner, dialect: Dialect) {
85+
const read = (overrides: Partial<LicenseKeyListQuery> = {}) => readLicenseKeyPage(query(overrides), runner, dialect, NOW)
86+
const all = async (overrides: Partial<LicenseKeyListQuery> = {}) => (await read({ perPage: 100, ...overrides })).records.map(record => String(record.id))
87+
const matching = (keep: (key: Key) => boolean) => ids(KEYS.filter(keep).sort(newestFirst))
88+
89+
// The summary counts every key, whatever the page is searched for.
90+
const searched = await read({ search: 'zelda' })
91+
expect(searched.summary).toEqual({
92+
total: KEYS.length,
93+
active: KEYS.filter(key => key.status === 'active').length,
94+
assigned: KEYS.filter(key => key.customerId || key.productId || key.orderId).length,
95+
expiring: KEYS.filter(key => key.expiryDays >= 0 && key.expiryDays <= 30).length,
96+
})
97+
expect(searched.total).toBe(KEYS.filter(key => key.customerId === 515).length)
98+
99+
// Search: the key with a digit pattern, the customer's name and email, the product, the order id, the enums.
100+
expect(await all({ search: 'x1x0' })).toEqual(['9'])
101+
expect(await all({ search: 'BUYER515@' })).toEqual(matching(key => key.customerId === 515))
102+
expect(await all({ search: 'zebra suite' })).toEqual(matching(key => key.productId === 520))
103+
expect(await all({ search: '510' })).toEqual(matching(key => key.orderId === 510))
104+
expect(await all({ search: 'ENTERPRISE' })).toEqual(matching(key => key.template === 'Enterprise License'))
105+
106+
// Newest first, the id breaking the tie between 11 and 12, stable across pages.
107+
const newest = [...KEYS].sort(newestFirst)
108+
const pages = [await read({ page: 1 }), await read({ page: 2 }), await read({ page: 3 })]
109+
expect(pages.flatMap(result => result.records.map(record => String(record.id)))).toEqual(ids(newest))
110+
111+
// Sorts by name, not enum order; ties newest first.
112+
const by = (value: (key: Key) => string) => (a: Key, b: Key) => value(a).localeCompare(value(b)) || newestFirst(a, b)
113+
expect(await all({ sort: 'template', direction: 'asc' })).toEqual(ids([...KEYS].sort(by(key => key.template))))
114+
expect(await all({ sort: 'status', direction: 'asc' })).toEqual(ids([...KEYS].sort(by(key => key.status))))
115+
expect(await all({ sort: 'customer', direction: 'asc' })).toEqual(ids([...KEYS].sort(by(key => key.customerId ? customerName(key.customerId).toLowerCase() : ''))))
116+
117+
// The links past the 500th come with the key.
118+
const linked = (await read({ search: 'BUYER515@' })).records.find(record => record.product_id === 520 || record.order_id === 510) ?? (await read({ search: 'BUYER515@' })).records[0]!
119+
expect(linked.customer).toEqual({ id: 515, label: 'zelda Late', detail: 'buyer515@shop.test' })
120+
const withProduct = (await read({ search: 'zebra suite' })).records[0]!
121+
expect(withProduct.product).toEqual({ id: 520, label: 'Zebra Suite' })
122+
const withOrder = (await read({ search: '510' })).records[0]!
123+
expect(withOrder.order).toEqual({ id: 510, label: 'Order #510', detail: 'refunded' })
124+
125+
// The dialog's choices: bounded, searched, past the 500th.
126+
const choose = (kind: 'customers' | 'products' | 'orders', search: string, limit = 25) => searchLicenseKeyChoices(kind, { search, limit }, runner, dialect)
127+
expect((await choose('customers', 'ZE')).choices.map(choice => choice.label)).toEqual(['Zed early', 'zelda Late'])
128+
expect(await choose('products', '520')).toEqual({ choices: [{ id: 520, label: 'Zebra Suite' }], total: 1 })
129+
expect((await choose('orders', 'refund')).choices).toEqual([{ id: 510, label: 'Order #510', detail: 'refunded' }])
130+
const newestOrders = await choose('orders', '', 3)
131+
expect(newestOrders.choices.map(choice => choice.id)).toEqual([520, 519, 518])
132+
expect(newestOrders.total).toBe(ROWS)
133+
}
134+
135+
describe('readLicenseKeyPage on SQLite', () => {
136+
test('summary, search, sorts, pages, links and choices', async () => {
137+
const database = new Database(':memory:')
138+
await setUp(async (sql, values = []) => database.query(sql).run(...(values as never[])), 'sqlite')
139+
await exercise({ unsafe: (sql, values = []) => ({ execute: async () => database.query(sql).all(...(values as never[])) }) }, 'sqlite')
140+
})
141+
})
142+
143+
for (const dialect of ['postgres', 'mysql'] as const) {
144+
const connection = dialect === 'postgres' ? process.env.STACKS_TEST_POSTGRES_URL : process.env.STACKS_TEST_MYSQL_URL
145+
146+
test.skipIf(!connection)(`readLicenseKeyPage on ${dialect}: summary, search, sorts, pages, links and choices`, async () => {
147+
const url = new URL(connection!)
148+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))
149+
throw new Error('Licence key list tests require a local disposable database server')
150+
151+
const name = `stacks_license_keys_${crypto.randomUUID().replaceAll('-', '')}`
152+
const quoted = dialect === 'mysql' ? `\`${name}\`` : `"${name}"`
153+
const admin = new SQL(url.href)
154+
await admin.unsafe(`CREATE DATABASE ${quoted}`)
155+
const scoped = new URL(url.href)
156+
scoped.pathname = `/${name}`
157+
const database = new SQL(scoped.href)
158+
159+
try {
160+
await setUp(async (sql, values = []) => await database.unsafe(sql, values), dialect)
161+
await exercise({ unsafe: (sql, values = []) => ({ execute: async () => await database.unsafe(sql, values) }) }, dialect)
162+
}
163+
finally {
164+
await database.close()
165+
try { await admin.unsafe(`DROP DATABASE ${quoted}${dialect === 'postgres' ? ' WITH (FORCE)' : ''}`) }
166+
finally { await admin.close() }
167+
}
168+
}, 60_000)
169+
}
170+
171+
describe('parseLicenseKeyListQuery', () => {
172+
const reader = (values: Record<string, unknown>) => (key: string) => values[key] ?? ''
173+
174+
test('defaults to every key, newest first, 25 a page', () => {
175+
expect(parseLicenseKeyListQuery(reader({}))).toEqual({ search: '', sort: 'created', direction: 'desc', page: 1, perPage: 25 })
176+
expect(parseLicenseKeyListQuery(reader({ sort: 'expiry' }))).toHaveProperty('direction', 'asc')
177+
})
178+
179+
test('refuses values the page does not offer', () => {
180+
expect(parseLicenseKeyListQuery(reader({ sort: 'uuid' }))).toHaveProperty('error')
181+
expect(parseLicenseKeyListQuery(reader({ direction: 'up' }))).toHaveProperty('error')
182+
})
183+
})

0 commit comments

Comments
 (0)