@stacksjs/defaults 0.75.482 → 0.75.484
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-native/CATALOG.md +4 -4
- package/ai/skills/stacks-queue/DURABLE-WORKFLOWS.md +12 -0
- package/app/Actions/Dashboard/Content/CommentIndexAction.ts +22 -112
- package/app/Actions/Dashboard/Content/PageIndexAction.ts +26 -25
- package/app/Actions/Dashboard/Content/PostIndexAction.ts +30 -35
- package/app/Actions/Dashboard/Content/PostShowAction.ts +41 -0
- package/app/Actions/Dashboard/Content/comment-input.ts +25 -1
- package/app/Actions/Dashboard/Content/comment-list.ts +311 -0
- package/app/Actions/Dashboard/Content/content-list.ts +69 -0
- package/app/Actions/Dashboard/Content/page-list.ts +108 -0
- package/app/Actions/Dashboard/Content/post-list.ts +140 -0
- package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +12 -24
- package/app/Actions/Dashboard/Jobs/job-list.ts +168 -0
- package/app/Actions/Dashboard/Jobs/job-records.ts +8 -3
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +8 -2
- package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +20 -10
- package/app/Actions/Dashboard/Queries/query-dashboard.ts +56 -9
- package/app/Actions/Dashboard/Queries/query-list.ts +161 -0
- package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +14 -52
- package/app/Actions/Dashboard/Queue/queue-stats.ts +69 -0
- package/app/Actions/Monitoring/ErrorIndexAction.ts +9 -4
- package/app/Actions/Monitoring/error-provider.ts +85 -7
- package/functions/api.ts +14 -10
- package/functions/commerce/coupons.ts +4 -6
- package/functions/commerce/customers.ts +4 -6
- package/functions/commerce/gift-cards.ts +4 -2
- package/functions/commerce/orders.ts +4 -2
- package/functions/commerce/payments.ts +4 -2
- package/functions/commerce/products/categories.ts +4 -6
- package/functions/commerce/products/manufacturers.ts +4 -6
- package/functions/commerce/products/products.ts +4 -6
- package/functions/commerce/products/reviews.ts +4 -6
- package/functions/commerce/products/units.ts +4 -6
- package/functions/commerce/products/variants.ts +4 -6
- package/functions/commerce/taxes.ts +4 -2
- package/functions/commerce/waitlist/products.ts +4 -6
- package/functions/commerce/waitlist/restaurants.ts +4 -6
- package/functions/monitoring/errors.ts +77 -49
- package/functions/queries.ts +34 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Analytics/AnalyticsHub.stx +4 -0
- package/resources/components/Dashboard/Auth/MagicLink.stx +6 -1
- package/resources/components/Dashboard/Content/CommentsDashboard.stx +90 -12
- package/resources/components/Dashboard/Content/PagesDashboard.stx +100 -16
- package/resources/components/Dashboard/Content/PostEditorDialog.stx +28 -8
- package/resources/components/Dashboard/Content/PostsDashboard.stx +179 -21
- package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +121 -36
- package/resources/components/Dashboard/Monitoring/ErrorTable.stx +29 -8
- package/resources/components/Dashboard/Queries/QueryDashboard.stx +73 -64
- package/resources/components/Dashboard/UI/Table.stx +30 -7
- package/routes/dashboard-api.ts +1 -0
- package/app/Actions/Buddy/JobsListAction.ts +0 -141
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
|
|
2
|
+
import type { CommentableRow, CommentStatus, DashboardComment } from './comment-input'
|
|
3
|
+
import { COMMENT_STATUSES, commentableRecord, dashboardStatusForCommentable, isMissingTableError, isSafeTableName, newestFirst, normalizeCommentStatus, parseCommentableRow } from './comment-input'
|
|
4
|
+
import type { ListQuery, QueryReader } from './content-list'
|
|
5
|
+
import { countOf, pageWithin, parseListQuery, queryText } from './content-list'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One page of the dashboard's comments, read by the database.
|
|
9
|
+
*
|
|
10
|
+
* Comments live in two tables: `comments`, the CMS table, and `commentables`,
|
|
11
|
+
* the polymorphic one the `commentable` trait writes. The list used to read
|
|
12
|
+
* both whole and the page rendered every comment at once
|
|
13
|
+
* (stacksjs/stacks#3193). Now each table is searched, filtered and counted in
|
|
14
|
+
* SQL. A page is newest first across both: for page p of n, each table gives
|
|
15
|
+
* its newest p * n matches, which must hold the page, and the two are merged
|
|
16
|
+
* by date and sliced. That costs more for deep pages, never more than the
|
|
17
|
+
* matching rows.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type CommentListQuery = ListQuery<'created_at'> & { status: CommentStatus | 'all' }
|
|
21
|
+
|
|
22
|
+
export interface CommentCounts {
|
|
23
|
+
all: number
|
|
24
|
+
pending: number
|
|
25
|
+
approved: number
|
|
26
|
+
spam: number
|
|
27
|
+
trash: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CommentPage {
|
|
31
|
+
rows: DashboardComment[]
|
|
32
|
+
/** Comments matching the search and status, which the pager counts. */
|
|
33
|
+
total: number
|
|
34
|
+
/** The page the rows are, moved back to the last one when it ran past it. */
|
|
35
|
+
page: number
|
|
36
|
+
/** Every comment by status, whatever the search, for the summary cards. */
|
|
37
|
+
counts: CommentCounts
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseCommentListQuery(read: QueryReader): CommentListQuery | { error: string } {
|
|
41
|
+
const list = parseListQuery(read, ['created_at'] as const)
|
|
42
|
+
if ('error' in list)
|
|
43
|
+
return list
|
|
44
|
+
if (list.direction !== 'desc')
|
|
45
|
+
return { error: 'Comments are listed newest first; direction must be desc.' }
|
|
46
|
+
|
|
47
|
+
const status = queryText(read, 'status') || 'all'
|
|
48
|
+
if (status !== 'all' && !(COMMENT_STATUSES as readonly string[]).includes(status))
|
|
49
|
+
return { error: `status must be all or one of ${COMMENT_STATUSES.join(', ')}.` }
|
|
50
|
+
|
|
51
|
+
return { ...list, status: status as CommentListQuery['status'] }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A timestamp as text that sorts: PostgreSQL hands back a Date, SQLite a string. */
|
|
55
|
+
function timestampText(value: unknown): string | null {
|
|
56
|
+
if (value instanceof Date)
|
|
57
|
+
return Number.isNaN(value.getTime()) ? null : value.toISOString()
|
|
58
|
+
return value === undefined || value === null || value === '' ? null : String(value)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ------------------------------------------------------------ comments */
|
|
62
|
+
|
|
63
|
+
interface CommentRow {
|
|
64
|
+
id: number
|
|
65
|
+
author_name: string | null
|
|
66
|
+
author_email: string | null
|
|
67
|
+
content: string | null
|
|
68
|
+
body: string | null
|
|
69
|
+
post_title: string | null
|
|
70
|
+
status: string | null
|
|
71
|
+
is_approved: number | boolean | null
|
|
72
|
+
created_at: unknown
|
|
73
|
+
updated_at: unknown
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function legacyRecord(row: CommentRow): DashboardComment {
|
|
77
|
+
return {
|
|
78
|
+
key: `comments:${row.id}`,
|
|
79
|
+
source: 'comments',
|
|
80
|
+
id: Number(row.id),
|
|
81
|
+
author_name: String(row.author_name || ''),
|
|
82
|
+
author_email: String(row.author_email || ''),
|
|
83
|
+
content: String(row.content || row.body || ''),
|
|
84
|
+
post_title: String(row.post_title || ''),
|
|
85
|
+
status: normalizeCommentStatus(row.status),
|
|
86
|
+
is_approved: Boolean(row.is_approved),
|
|
87
|
+
created_at: timestampText(row.created_at),
|
|
88
|
+
updated_at: timestampText(row.updated_at),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `comments.status` is an enum or CHECK of exactly these values, so it compares as is. */
|
|
93
|
+
function legacyFiltered(query: CommentListQuery): any {
|
|
94
|
+
let chain: any = db.selectFrom('comments')
|
|
95
|
+
if (query.status !== 'all')
|
|
96
|
+
chain = chain.where('status', '=', query.status)
|
|
97
|
+
if (query.search) {
|
|
98
|
+
const term = `%${query.search}%`
|
|
99
|
+
chain = chain.whereGroup((group: any) => group
|
|
100
|
+
.whereLike('content', term)
|
|
101
|
+
.orWhereLike('body', term)
|
|
102
|
+
.orWhereLike('author_name', term)
|
|
103
|
+
.orWhereLike('author_email', term)
|
|
104
|
+
.orWhereLike('post_title', term))
|
|
105
|
+
}
|
|
106
|
+
return chain
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function legacyTotal(query: CommentListQuery): Promise<number> {
|
|
110
|
+
return countOf((await legacyFiltered(query).select(db.fn.count('id').as('count')).executeTakeFirst() as { count?: unknown } | undefined)?.count)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function legacyRows(query: CommentListQuery, limit: number): Promise<DashboardComment[]> {
|
|
114
|
+
const rows = await legacyFiltered(query)
|
|
115
|
+
.select(['id', 'author_name', 'author_email', 'content', 'body', 'post_title', 'status', 'is_approved', 'created_at', 'updated_at'])
|
|
116
|
+
.orderBy('created_at', 'desc')
|
|
117
|
+
.orderBy('id', 'desc')
|
|
118
|
+
.limit(limit)
|
|
119
|
+
.execute() as CommentRow[]
|
|
120
|
+
|
|
121
|
+
return rows.map(legacyRecord)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* -------------------------------------------------------- commentables */
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The column that names each kind of thing a trait comment was left on, by
|
|
128
|
+
* table: its title, else its name. A type that is not a safe identifier, has
|
|
129
|
+
* no such table, or has neither column has none, and its comments show a
|
|
130
|
+
* plain `posts #3` label, as they always did.
|
|
131
|
+
*/
|
|
132
|
+
async function ownerLabelColumns(types: string[]): Promise<Map<string, 'title' | 'name'>> {
|
|
133
|
+
const columns = new Map<string, 'title' | 'name'>()
|
|
134
|
+
for (const type of types) {
|
|
135
|
+
if (!isSafeTableName(type))
|
|
136
|
+
continue
|
|
137
|
+
for (const column of ['title', 'name'] as const) {
|
|
138
|
+
try {
|
|
139
|
+
await db.unsafe(`SELECT id, ${column} FROM ${type} WHERE 1 = 0`).execute()
|
|
140
|
+
columns.set(type, column)
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// No such table or no such column: try the next column, then none.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return columns
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What each trait comment on the page was left on, keyed `type:id`. */
|
|
152
|
+
async function ownerLabels(rows: CommentableRow[], columns: Map<string, 'title' | 'name'>): Promise<Map<string, string>> {
|
|
153
|
+
const labels = new Map<string, string>()
|
|
154
|
+
const idsByType = new Map<string, number[]>()
|
|
155
|
+
|
|
156
|
+
for (const row of rows) {
|
|
157
|
+
const type = String(row.commentables_type || '')
|
|
158
|
+
labels.set(`${type}:${row.commentables_id}`, `${type} #${row.commentables_id}`)
|
|
159
|
+
if (columns.has(type))
|
|
160
|
+
idsByType.set(type, [...(idsByType.get(type) ?? []), Number(row.commentables_id)])
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [type, ids] of idsByType) {
|
|
164
|
+
const column = columns.get(type)!
|
|
165
|
+
const owners = await (db as any).selectFrom(type).select(['id', column]).whereIn('id', [...new Set(ids)]).execute() as Array<Record<string, unknown>>
|
|
166
|
+
for (const owner of owners) {
|
|
167
|
+
const label = String(owner[column] ?? '').trim()
|
|
168
|
+
if (label)
|
|
169
|
+
labels.set(`${type}:${owner.id}`, label)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return labels
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The trait's status column is free text, so it is compared lowercased. It has
|
|
178
|
+
* one hidden state, `rejected`, which the dashboard shows as spam, and none
|
|
179
|
+
* that shows as trash. The values are constants, never input.
|
|
180
|
+
*/
|
|
181
|
+
const COMMENTABLE_STATUS_SQL: Record<CommentStatus, string> = {
|
|
182
|
+
approved: `LOWER(status) = 'approved'`,
|
|
183
|
+
spam: `LOWER(status) = 'rejected'`,
|
|
184
|
+
trash: '1 = 0',
|
|
185
|
+
pending: `(status IS NULL OR LOWER(status) NOT IN ('approved', 'rejected'))`,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The WHERE clause for trait comments, and its bound values.
|
|
190
|
+
*
|
|
191
|
+
* Raw rather than the builder because a match on what a comment was left on
|
|
192
|
+
* is a subquery per owner table, and the builder takes no bound values in a
|
|
193
|
+
* raw fragment. Every value here is bound; only table and column names that
|
|
194
|
+
* passed `isSafeTableName` and the probe above are written into the text.
|
|
195
|
+
*/
|
|
196
|
+
function commentableWhere(query: CommentListQuery, columns: Map<string, 'title' | 'name'>, guestAuthors: boolean): { sql: string, values: unknown[] } {
|
|
197
|
+
const helpers = sqlHelpers(getDatabaseDialect())
|
|
198
|
+
const clauses: string[] = []
|
|
199
|
+
const values: unknown[] = []
|
|
200
|
+
const bind = (value: unknown): string => {
|
|
201
|
+
values.push(value)
|
|
202
|
+
return helpers.param(values.length)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (query.status !== 'all')
|
|
206
|
+
clauses.push(COMMENTABLE_STATUS_SQL[query.status])
|
|
207
|
+
|
|
208
|
+
if (query.search) {
|
|
209
|
+
const term = `%${query.search}%`
|
|
210
|
+
const searched = guestAuthors ? ['title', 'body', 'author_name', 'author_email'] : ['title', 'body']
|
|
211
|
+
const matches = searched.map(column => `LOWER(${column}) LIKE LOWER(${bind(term)})`)
|
|
212
|
+
for (const [type, column] of columns)
|
|
213
|
+
matches.push(`(commentables_type = '${type}' AND commentables_id IN (SELECT id FROM ${type} WHERE LOWER(${column}) LIKE LOWER(${bind(term)})))`)
|
|
214
|
+
clauses.push(`(${matches.join(' OR ')})`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { sql: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '', values }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
interface CommentableSource {
|
|
221
|
+
columns: Map<string, 'title' | 'name'>
|
|
222
|
+
guestAuthors: boolean
|
|
223
|
+
where: { sql: string, values: unknown[] }
|
|
224
|
+
total: number
|
|
225
|
+
statusRows: Array<{ status: unknown, count: unknown }>
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The trait comments' filter, match count and status counts, or null without the table. */
|
|
229
|
+
async function prepareCommentables(query: CommentListQuery): Promise<CommentableSource | null> {
|
|
230
|
+
let types: string[]
|
|
231
|
+
try {
|
|
232
|
+
const found = await db.unsafe('SELECT DISTINCT commentables_type FROM commentables').execute() as Array<{ commentables_type: string }>
|
|
233
|
+
types = found.map(row => String(row.commentables_type))
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
// An app that never migrated the trait tables has no trait comments,
|
|
237
|
+
// which is not an error; any other failure is.
|
|
238
|
+
if (isMissingTableError(error))
|
|
239
|
+
return null
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The guest-author columns came after the table, and `migrate` adds them.
|
|
244
|
+
// Until it has, a trait comment has no author name or email, as before,
|
|
245
|
+
// rather than the list failing.
|
|
246
|
+
let guestAuthors = true
|
|
247
|
+
try {
|
|
248
|
+
await db.unsafe('SELECT author_name, author_email FROM commentables WHERE 1 = 0').execute()
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
guestAuthors = false
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const columns = await ownerLabelColumns(types)
|
|
255
|
+
const where = commentableWhere(query, columns, guestAuthors)
|
|
256
|
+
const [[counted], statusRows] = await Promise.all([
|
|
257
|
+
db.unsafe(`SELECT COUNT(id) AS count FROM commentables ${where.sql}`, where.values).execute() as Promise<Array<{ count: unknown }>>,
|
|
258
|
+
db.unsafe('SELECT status, COUNT(id) AS count FROM commentables GROUP BY status').execute() as Promise<Array<{ status: unknown, count: unknown }>>,
|
|
259
|
+
])
|
|
260
|
+
|
|
261
|
+
return { columns, guestAuthors, where, total: countOf(counted?.count), statusRows }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function commentableRows(source: CommentableSource, limit: number): Promise<DashboardComment[]> {
|
|
265
|
+
const rows = (await db.unsafe(
|
|
266
|
+
`SELECT id, title, body, status, commentables_id, commentables_type, user_id, ${source.guestAuthors ? 'author_name, author_email' : 'NULL AS author_name, NULL AS author_email'}, created_at, updated_at
|
|
267
|
+
FROM commentables ${source.where.sql} ORDER BY created_at DESC, id DESC LIMIT ${Math.trunc(limit)}`,
|
|
268
|
+
source.where.values,
|
|
269
|
+
).execute()).map(parseCommentableRow)
|
|
270
|
+
|
|
271
|
+
const labels = await ownerLabels(rows, source.columns)
|
|
272
|
+
return rows.map(row => ({
|
|
273
|
+
...commentableRecord(row, labels.get(`${row.commentables_type}:${row.commentables_id}`) ?? ''),
|
|
274
|
+
created_at: timestampText(row.created_at),
|
|
275
|
+
updated_at: timestampText(row.updated_at),
|
|
276
|
+
}))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* ---------------------------------------------------------------- page */
|
|
280
|
+
|
|
281
|
+
async function legacyCounts(): Promise<Array<{ status: unknown, count: unknown }>> {
|
|
282
|
+
return await db.selectFrom('comments').select(['status', db.fn.count('id').as('count')]).groupBy('status').execute() as Array<{ status: unknown, count: unknown }>
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function readCommentPage(query: CommentListQuery): Promise<CommentPage> {
|
|
286
|
+
// The totals first, so the page can be moved back inside them before any
|
|
287
|
+
// row is read.
|
|
288
|
+
const [commentTotal, trait, legacyStatusRows] = await Promise.all([legacyTotal(query), prepareCommentables(query), legacyCounts()])
|
|
289
|
+
const total = commentTotal + (trait?.total ?? 0)
|
|
290
|
+
const page = pageWithin(query.page, total, query.perPage)
|
|
291
|
+
const limit = page * query.perPage
|
|
292
|
+
|
|
293
|
+
const [legacy, traitRows] = await Promise.all([
|
|
294
|
+
commentTotal > 0 ? legacyRows(query, limit) : Promise.resolve([]),
|
|
295
|
+
trait && trait.total > 0 ? commentableRows(trait, limit) : Promise.resolve([]),
|
|
296
|
+
])
|
|
297
|
+
|
|
298
|
+
const counts: CommentCounts = { all: 0, pending: 0, approved: 0, spam: 0, trash: 0 }
|
|
299
|
+
for (const row of legacyStatusRows)
|
|
300
|
+
counts[normalizeCommentStatus(row.status)] += countOf(row.count)
|
|
301
|
+
for (const row of trait?.statusRows ?? [])
|
|
302
|
+
counts[dashboardStatusForCommentable(row.status)] += countOf(row.count)
|
|
303
|
+
counts.all = counts.pending + counts.approved + counts.spam + counts.trash
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
rows: [...legacy, ...traitRows].sort(newestFirst).slice((page - 1) * query.perPage, limit),
|
|
307
|
+
total,
|
|
308
|
+
page,
|
|
309
|
+
counts,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The query a paged dashboard content list takes, read and checked once for
|
|
3
|
+
* every list that pages on the server (stacksjs/stacks#3191, #3192).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const LIST_PAGE_SIZES = [10, 25, 50, 100] as const
|
|
7
|
+
|
|
8
|
+
export interface ListQuery<Sort extends string> {
|
|
9
|
+
page: number
|
|
10
|
+
perPage: number
|
|
11
|
+
search: string
|
|
12
|
+
sort: Sort
|
|
13
|
+
direction: 'asc' | 'desc'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Reads one query value, in whatever shape the request holds it. */
|
|
17
|
+
export type QueryReader = (key: string) => unknown
|
|
18
|
+
|
|
19
|
+
/** One value as a string: the first of a repeated key, or empty when absent. */
|
|
20
|
+
export function queryText(read: QueryReader, key: string): string {
|
|
21
|
+
const value = read(key)
|
|
22
|
+
if (Array.isArray(value))
|
|
23
|
+
return value.length > 0 ? String(value[0]).trim() : ''
|
|
24
|
+
return value === undefined || value === null ? '' : String(value).trim()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The page, size, search and sort, or the reason they are invalid.
|
|
29
|
+
*
|
|
30
|
+
* Absent values take the defaults these pages have always shown: the first
|
|
31
|
+
* page of 25, newest first. A value that is present and wrong is an error
|
|
32
|
+
* rather than a silent default, so a broken caller is visible.
|
|
33
|
+
*/
|
|
34
|
+
export function parseListQuery<Sort extends string>(read: QueryReader, sorts: readonly Sort[]): ListQuery<Sort> | { error: string } {
|
|
35
|
+
const pageText = queryText(read, 'page')
|
|
36
|
+
const page = pageText === '' ? 1 : Number(pageText)
|
|
37
|
+
if (!Number.isInteger(page) || page < 1)
|
|
38
|
+
return { error: 'page must be a whole number of 1 or more.' }
|
|
39
|
+
|
|
40
|
+
const sizeText = queryText(read, 'per_page')
|
|
41
|
+
const perPage = sizeText === '' ? 25 : Number(sizeText)
|
|
42
|
+
if (!(LIST_PAGE_SIZES as readonly number[]).includes(perPage))
|
|
43
|
+
return { error: `per_page must be one of ${LIST_PAGE_SIZES.join(', ')}.` }
|
|
44
|
+
|
|
45
|
+
const sort = queryText(read, 'sort') || 'created_at'
|
|
46
|
+
if (!(sorts as readonly string[]).includes(sort))
|
|
47
|
+
return { error: `sort must be one of ${sorts.join(', ')}.` }
|
|
48
|
+
|
|
49
|
+
const directionText = queryText(read, 'direction').toLowerCase()
|
|
50
|
+
const direction = directionText === '' ? (sort === 'created_at' ? 'desc' : 'asc') : directionText
|
|
51
|
+
if (direction !== 'asc' && direction !== 'desc')
|
|
52
|
+
return { error: 'direction must be asc or desc.' }
|
|
53
|
+
|
|
54
|
+
const search = queryText(read, 'search')
|
|
55
|
+
if (search.length > 200)
|
|
56
|
+
return { error: 'search must be 200 characters or fewer.' }
|
|
57
|
+
|
|
58
|
+
return { page, perPage, search, sort: sort as Sort, direction }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The page to show: the one asked for, or the last one when it ran past the end. */
|
|
62
|
+
export function pageWithin(page: number, total: number, perPage: number): number {
|
|
63
|
+
return Math.min(page, Math.max(1, Math.ceil(total / perPage)))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A COUNT or SUM read back as a number, whichever type the driver returned. */
|
|
67
|
+
export function countOf(value: unknown): number {
|
|
68
|
+
return Number(value ?? 0) || 0
|
|
69
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { db, sql } from '@stacksjs/database/runtime'
|
|
2
|
+
import type { ListQuery, QueryReader } from './content-list'
|
|
3
|
+
import { countOf, pageWithin, parseListQuery } from './content-list'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One page of the dashboard's CMS pages list, read by the database.
|
|
7
|
+
*
|
|
8
|
+
* The list used to `SELECT *` every page, block documents included, and hand
|
|
9
|
+
* the whole table to the browser to search and page (stacksjs/stacks#3192).
|
|
10
|
+
* Now the database searches, sorts, counts and pages, and a row carries only
|
|
11
|
+
* the columns the list shows and the editor edits.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const PAGE_SORTS = ['created_at', 'title', 'published'] as const
|
|
15
|
+
|
|
16
|
+
export type PageSort = typeof PAGE_SORTS[number]
|
|
17
|
+
export type PageListQuery = ListQuery<PageSort>
|
|
18
|
+
|
|
19
|
+
export interface PageListRow {
|
|
20
|
+
id: number
|
|
21
|
+
title: string | null
|
|
22
|
+
template: string | null
|
|
23
|
+
views: number | null
|
|
24
|
+
conversions: number | null
|
|
25
|
+
published_at: string | null
|
|
26
|
+
author_id: number | null
|
|
27
|
+
created_at: string | null
|
|
28
|
+
updated_at: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PageListPage {
|
|
32
|
+
rows: PageListRow[]
|
|
33
|
+
/** Pages matching the search, which the pager counts. */
|
|
34
|
+
total: number
|
|
35
|
+
/** The page the rows are, moved back to the last one when it ran past it. */
|
|
36
|
+
page: number
|
|
37
|
+
/** Every page, whatever the search, for the summary cards. */
|
|
38
|
+
allCount: number
|
|
39
|
+
totalViews: number
|
|
40
|
+
totalConversions: number
|
|
41
|
+
/** The most recently created page's title, or empty when there are none. */
|
|
42
|
+
newestTitle: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parsePageListQuery(read: QueryReader): PageListQuery | { error: string } {
|
|
46
|
+
return parseListQuery(read, PAGE_SORTS)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The search that a page and its count share: the title, and the template shown under it. */
|
|
50
|
+
function filtered(query: PageListQuery, database: typeof db): any {
|
|
51
|
+
let chain: any = database.selectFrom('pages')
|
|
52
|
+
if (query.search) {
|
|
53
|
+
const term = `%${query.search}%`
|
|
54
|
+
chain = chain.whereGroup((group: any) => group
|
|
55
|
+
.whereLike('title', term)
|
|
56
|
+
.orWhereLike('template', term))
|
|
57
|
+
}
|
|
58
|
+
return chain
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The same order on every dialect. "Published" is whether a page has a
|
|
63
|
+
* publish date, drafts first ascending as the table always sorted the badge,
|
|
64
|
+
* then by that date; titles compare without case. The id breaks ties so a
|
|
65
|
+
* page boundary is stable between requests.
|
|
66
|
+
*/
|
|
67
|
+
function ordered(chain: any, query: PageListQuery): any {
|
|
68
|
+
const direction = query.direction === 'asc' ? 'ASC' : 'DESC'
|
|
69
|
+
if (query.sort === 'published')
|
|
70
|
+
chain = chain.orderByRaw(sql.raw(`CASE WHEN published_at IS NULL THEN 0 ELSE 1 END ${direction}, published_at ${direction}`))
|
|
71
|
+
else if (query.sort === 'title')
|
|
72
|
+
chain = chain.orderByRaw(sql.raw(`LOWER(title) ${direction}`))
|
|
73
|
+
else
|
|
74
|
+
chain = chain.orderBy(query.sort, query.direction)
|
|
75
|
+
return chain.orderBy('id', 'desc')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readPageListPage(query: PageListQuery, database: typeof db = db): Promise<PageListPage> {
|
|
79
|
+
const total = countOf((await filtered(query, database).select(database.fn.count('id').as('count')).executeTakeFirst() as { count?: unknown } | undefined)?.count)
|
|
80
|
+
const page = pageWithin(query.page, total, query.perPage)
|
|
81
|
+
|
|
82
|
+
const rows = total === 0
|
|
83
|
+
? []
|
|
84
|
+
: await ordered(filtered(query, database), query)
|
|
85
|
+
.select(['id', 'title', 'template', 'views', 'conversions', 'published_at', 'author_id', 'created_at', 'updated_at'])
|
|
86
|
+
.limit(query.perPage)
|
|
87
|
+
.offset((page - 1) * query.perPage)
|
|
88
|
+
.execute() as PageListRow[]
|
|
89
|
+
|
|
90
|
+
const [totals, newest] = await Promise.all([
|
|
91
|
+
database.selectFrom('pages').select([
|
|
92
|
+
database.fn.count('id').as('count'),
|
|
93
|
+
database.fn.sum('views').as('views'),
|
|
94
|
+
database.fn.sum('conversions').as('conversions'),
|
|
95
|
+
]).executeTakeFirst() as Promise<{ count?: unknown, views?: unknown, conversions?: unknown } | undefined>,
|
|
96
|
+
database.selectFrom('pages').select(['title']).orderBy('created_at', 'desc').orderBy('id', 'desc').limit(1).executeTakeFirst() as Promise<{ title?: string | null } | undefined>,
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
rows,
|
|
101
|
+
total,
|
|
102
|
+
page,
|
|
103
|
+
allCount: countOf(totals?.count),
|
|
104
|
+
totalViews: countOf(totals?.views),
|
|
105
|
+
totalConversions: countOf(totals?.conversions),
|
|
106
|
+
newestTitle: String(newest?.title || ''),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { db, sql } from '@stacksjs/database/runtime'
|
|
2
|
+
import type { ListQuery, QueryReader } from './content-list'
|
|
3
|
+
import { countOf, LIST_PAGE_SIZES, pageWithin, parseListQuery, queryText } from './content-list'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One page of the dashboard's posts list, read by the database.
|
|
7
|
+
*
|
|
8
|
+
* The list used to read every post, article bodies included, and hand the
|
|
9
|
+
* whole table to the browser to search and page through 25 at a time
|
|
10
|
+
* (stacksjs/stacks#3191). Now the database filters, counts, sorts and pages,
|
|
11
|
+
* and a row carries a short preview of its body instead of all of it. The
|
|
12
|
+
* editor reads one post's body when it opens (`PostShowAction`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const POST_PAGE_SIZES = LIST_PAGE_SIZES
|
|
16
|
+
export const POST_STATUSES = ['draft', 'published', 'archived'] as const
|
|
17
|
+
export const POST_SORTS = ['created_at', 'title', 'status', 'views'] as const
|
|
18
|
+
|
|
19
|
+
/** How much of a post's body a list row carries, for posts with no excerpt. */
|
|
20
|
+
export const POST_PREVIEW_LENGTH = 240
|
|
21
|
+
|
|
22
|
+
export type PostStatus = typeof POST_STATUSES[number]
|
|
23
|
+
export type PostSort = typeof POST_SORTS[number]
|
|
24
|
+
|
|
25
|
+
export interface PostListQuery extends ListQuery<PostSort> {
|
|
26
|
+
status: PostStatus | 'all'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PostListRow {
|
|
30
|
+
id: number
|
|
31
|
+
title: string
|
|
32
|
+
excerpt: string | null
|
|
33
|
+
preview: string | null
|
|
34
|
+
poster: string | null
|
|
35
|
+
status: string | null
|
|
36
|
+
views: number | null
|
|
37
|
+
published_at: string | null
|
|
38
|
+
created_at: string | null
|
|
39
|
+
updated_at: string | null
|
|
40
|
+
author_id: number | null
|
|
41
|
+
is_featured: number | boolean | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PostPage {
|
|
45
|
+
rows: PostListRow[]
|
|
46
|
+
/** Posts matching the search and status, which the pager counts. */
|
|
47
|
+
total: number
|
|
48
|
+
/** The page the rows are, moved back to the last one when it ran past it. */
|
|
49
|
+
page: number
|
|
50
|
+
/** Every post, whatever the search, for the page's summary cards. */
|
|
51
|
+
allCount: number
|
|
52
|
+
publishedCount: number
|
|
53
|
+
draftCount: number
|
|
54
|
+
archivedCount: number
|
|
55
|
+
totalViews: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The list query, with the post status filter on top of the shared one. */
|
|
59
|
+
export function parsePostListQuery(read: QueryReader): PostListQuery | { error: string } {
|
|
60
|
+
const list = parseListQuery(read, POST_SORTS)
|
|
61
|
+
if ('error' in list)
|
|
62
|
+
return list
|
|
63
|
+
|
|
64
|
+
const status = queryText(read, 'status') || 'all'
|
|
65
|
+
if (status !== 'all' && !(POST_STATUSES as readonly string[]).includes(status))
|
|
66
|
+
return { error: `status must be all or one of ${POST_STATUSES.join(', ')}.` }
|
|
67
|
+
|
|
68
|
+
return { ...list, status: status as PostListQuery['status'] }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The search and status that a page and its count share. */
|
|
72
|
+
function filtered(query: PostListQuery, database: typeof db): any {
|
|
73
|
+
let chain: any = database.selectFrom('posts')
|
|
74
|
+
if (query.status !== 'all')
|
|
75
|
+
chain = chain.where('status', '=', query.status)
|
|
76
|
+
if (query.search) {
|
|
77
|
+
// Bound, and compared lowercased on every dialect. A `%` or `_` the
|
|
78
|
+
// viewer types matches as a wildcard, as it does on the models page. The
|
|
79
|
+
// builder would parenthesise the ORs anyway; the group says so.
|
|
80
|
+
const term = `%${query.search}%`
|
|
81
|
+
chain = chain.whereGroup((group: any) => group
|
|
82
|
+
.whereLike('title', term)
|
|
83
|
+
.orWhereLike('excerpt', term)
|
|
84
|
+
.orWhereLike('content', term))
|
|
85
|
+
}
|
|
86
|
+
return chain
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The order, the same on every dialect. `status` is an enum, which PostgreSQL
|
|
91
|
+
* and MySQL sort by declaration and SQLite sorts as text, so its order is
|
|
92
|
+
* spelled out; titles compare without case, as the table always sorted them.
|
|
93
|
+
* The id last keeps a page boundary stable between requests.
|
|
94
|
+
*/
|
|
95
|
+
function ordered(chain: any, query: PostListQuery): any {
|
|
96
|
+
const direction = query.direction === 'asc' ? 'ASC' : 'DESC'
|
|
97
|
+
if (query.sort === 'status')
|
|
98
|
+
chain = chain.orderByRaw(sql.raw(`CASE status WHEN 'published' THEN 0 WHEN 'draft' THEN 1 ELSE 2 END ${direction}`))
|
|
99
|
+
else if (query.sort === 'title')
|
|
100
|
+
chain = chain.orderByRaw(sql.raw(`LOWER(title) ${direction}`))
|
|
101
|
+
else
|
|
102
|
+
chain = chain.orderBy(query.sort, query.direction)
|
|
103
|
+
return chain.orderBy('id', 'desc')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function readPostPage(query: PostListQuery, database: typeof db = db): Promise<PostPage> {
|
|
107
|
+
const total = countOf((await filtered(query, database).select(database.fn.count('id').as('count')).executeTakeFirst() as { count?: unknown } | undefined)?.count)
|
|
108
|
+
const page = pageWithin(query.page, total, query.perPage)
|
|
109
|
+
|
|
110
|
+
const rows = total === 0
|
|
111
|
+
? []
|
|
112
|
+
: await ordered(filtered(query, database), query)
|
|
113
|
+
.select([
|
|
114
|
+
'id', 'title', 'excerpt', 'poster', 'status', 'views', 'published_at', 'created_at', 'updated_at', 'author_id', 'is_featured',
|
|
115
|
+
sql.raw(`SUBSTR(content, 1, ${POST_PREVIEW_LENGTH}) AS preview`),
|
|
116
|
+
])
|
|
117
|
+
.limit(query.perPage)
|
|
118
|
+
.offset((page - 1) * query.perPage)
|
|
119
|
+
.execute() as PostListRow[]
|
|
120
|
+
|
|
121
|
+
const [byStatus, views] = await Promise.all([
|
|
122
|
+
database.selectFrom('posts').select(['status', database.fn.count('id').as('count')]).groupBy('status').execute() as Promise<Array<{ status: string, count: unknown }>>,
|
|
123
|
+
database.selectFrom('posts').select(database.fn.sum('views').as('views')).executeTakeFirst() as Promise<{ views?: unknown } | undefined>,
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
const statusCount = (status: PostStatus) => byStatus
|
|
127
|
+
.filter(row => String(row.status).toLowerCase() === status)
|
|
128
|
+
.reduce((sum, row) => sum + countOf(row.count), 0)
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
rows,
|
|
132
|
+
total,
|
|
133
|
+
page,
|
|
134
|
+
allCount: byStatus.reduce((sum, row) => sum + countOf(row.count), 0),
|
|
135
|
+
publishedCount: statusCount('published'),
|
|
136
|
+
draftCount: statusCount('draft'),
|
|
137
|
+
archivedCount: statusCount('archived'),
|
|
138
|
+
totalViews: countOf(views?.views),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { RequestInstance } from '@stacksjs/types'
|
|
2
2
|
import { Action } from '@stacksjs/actions/runtime'
|
|
3
|
-
import { FailedJob, Job } from '@stacksjs/orm'
|
|
4
3
|
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
|
-
import {
|
|
4
|
+
import { readJobPage } from './job-list'
|
|
6
5
|
|
|
7
6
|
export default new Action({
|
|
8
7
|
name: 'JobIndexAction',
|
|
@@ -16,31 +15,20 @@ export default new Action({
|
|
|
16
15
|
const search = String(request.get('search', '')).trim().toLowerCase()
|
|
17
16
|
|
|
18
17
|
try {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
// An unknown status matched no job before the database did the
|
|
19
|
+
// filtering, and still matches none.
|
|
20
|
+
if (statusFilter && statusFilter !== 'all' && !['queued', 'processing', 'failed'].includes(statusFilter))
|
|
21
|
+
return { data: [], total: 0, page, perPage, queues: [], queueConnected: true }
|
|
23
22
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const filtered = merged.filter((job) => {
|
|
32
|
-
if (queueFilter && queueFilter !== 'all' && job.queue !== queueFilter) return false
|
|
33
|
-
if (statusFilter && statusFilter !== 'all' && job.status !== statusFilter) return false
|
|
34
|
-
if (!matchesJobSearch(job, search)) return false
|
|
35
|
-
return true
|
|
23
|
+
const result = await readJobPage({
|
|
24
|
+
page,
|
|
25
|
+
perPage,
|
|
26
|
+
queue: queueFilter === 'all' ? '' : queueFilter,
|
|
27
|
+
status: statusFilter === 'all' ? '' : statusFilter as 'queued' | 'processing' | 'failed' | '',
|
|
28
|
+
search,
|
|
36
29
|
})
|
|
37
30
|
|
|
38
|
-
|
|
39
|
-
const start = (page - 1) * perPage
|
|
40
|
-
const data = filtered.slice(start, start + perPage)
|
|
41
|
-
const queues = Array.from(new Set(merged.map(j => j.queue))).sort()
|
|
42
|
-
|
|
43
|
-
return { data, total, page, perPage, queues, queueConnected: true }
|
|
31
|
+
return { data: result.data, total: result.total, page: result.page, perPage, queues: result.queues, queueConnected: true }
|
|
44
32
|
}
|
|
45
33
|
catch (error) {
|
|
46
34
|
return dashboardOperationalError(error, 'Job history could not be loaded.', 'JobIndexAction')
|