@stacksjs/defaults 0.70.353 → 0.70.355

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.
Files changed (125) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +17 -2
  2. package/ai/skills/stacks-browse/scripts/browse.ts +207 -6
  3. package/ai/skills/stacks-dashboard/scripts/audit.ts +0 -1
  4. package/ai/skills/stacks-email/SKILL.md +35 -15
  5. package/app/Actions/Dashboard/Auth/MeAction.ts +8 -7
  6. package/app/Actions/Dashboard/Ci/RepoRunJobsAction.ts +10 -15
  7. package/app/Actions/Dashboard/Ci/RepoRunsAction.ts +12 -14
  8. package/app/Actions/Dashboard/Ci/RunnerHistoryAction.ts +11 -10
  9. package/app/Actions/Dashboard/Ci/StatusAction.ts +2 -11
  10. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +1 -0
  11. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +3 -4
  12. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +1 -0
  13. package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +1 -0
  14. package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -0
  15. package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -0
  16. package/app/Actions/Dashboard/Commerce/tax-rate-records.ts +9 -0
  17. package/app/Actions/Dashboard/Content/author-input.ts +1 -1
  18. package/app/Actions/Dashboard/Content/page-input.ts +1 -1
  19. package/app/Actions/Dashboard/DashboardStatsAction.ts +0 -1
  20. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +3 -2
  21. package/app/Actions/Dashboard/Deployments/GetDeployment.ts +1 -0
  22. package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +3 -2
  23. package/app/Actions/Dashboard/Email/InboxActivityAction.ts +28 -16
  24. package/app/Actions/Dashboard/Email/InboxAttachmentDownloadAction.ts +45 -0
  25. package/app/Actions/Dashboard/Email/InboxDeleteAction.ts +11 -6
  26. package/app/Actions/Dashboard/Email/InboxIndexAction.ts +5 -7
  27. package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +11 -6
  28. package/app/Actions/Dashboard/Email/InboxMarkUnreadAction.ts +11 -6
  29. package/app/Actions/Dashboard/Email/InboxPreferenceShowAction.ts +10 -4
  30. package/app/Actions/Dashboard/Email/InboxPreferenceUpdateAction.ts +8 -9
  31. package/app/Actions/Dashboard/Email/InboxShowAction.ts +7 -7
  32. package/app/Actions/Dashboard/Email/InboxStatsAction.ts +5 -7
  33. package/app/Actions/Dashboard/Email/inbox-request.ts +33 -0
  34. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +3 -4
  35. package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +3 -6
  36. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +8 -15
  37. package/app/Actions/Dashboard/Jobs/JobRetryAction.ts +4 -3
  38. package/app/Actions/Dashboard/Jobs/JobShowAction.ts +3 -3
  39. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +5 -5
  40. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +9 -16
  41. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +4 -3
  42. package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +5 -5
  43. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +1 -2
  44. package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +4 -3
  45. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +11 -9
  46. package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +10 -12
  47. package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +19 -43
  48. package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +44 -0
  49. package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +5 -5
  50. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +6 -6
  51. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +4 -4
  52. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +7 -6
  53. package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +5 -5
  54. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +4 -3
  55. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +5 -5
  56. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +4 -3
  57. package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +5 -5
  58. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +4 -3
  59. package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +4 -4
  60. package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +4 -3
  61. package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +5 -5
  62. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +1 -1
  63. package/app/Actions/Dashboard/Kanban/kanban-comment.ts +14 -0
  64. package/app/Actions/Dashboard/Kanban/kanban-model.test.ts +36 -0
  65. package/app/Actions/Dashboard/Kanban/kanban-model.ts +14 -7
  66. package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +4 -3
  67. package/app/Actions/Dashboard/Models/ModelShowAction.ts +15 -18
  68. package/app/Actions/Dashboard/Models/ModelStoreAction.ts +8 -8
  69. package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +9 -9
  70. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +4 -3
  71. package/app/Actions/Dashboard/Models/model-api.ts +27 -0
  72. package/app/Actions/Dashboard/Models/model-write.ts +2 -10
  73. package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +3 -4
  74. package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +6 -4
  75. package/app/Actions/Dashboard/Queries/QueryShowAction.ts +1 -0
  76. package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +4 -4
  77. package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +3 -2
  78. package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +4 -4
  79. package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +4 -4
  80. package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +4 -3
  81. package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +3 -2
  82. package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +4 -5
  83. package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +4 -4
  84. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +1 -0
  85. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +3 -3
  86. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +2 -2
  87. package/app/Actions/Dashboard/dashboard-request.ts +13 -0
  88. package/app/Models/CardComment.ts +3 -4
  89. package/app/Models/commerce/TaxRate.ts +48 -0
  90. package/functions/dashboard-api.ts +51 -10
  91. package/functions/inbox.test.ts +2 -0
  92. package/functions/inbox.ts +27 -2
  93. package/functions/public-application-url.test.ts +13 -0
  94. package/functions/public-application-url.ts +27 -0
  95. package/ide/vscode/package.json +1 -1
  96. package/package.json +1 -1
  97. package/resources/components/Dashboard/Auth/AccessTokens.stx +15 -0
  98. package/resources/components/Dashboard/Ci/CiDashboard.stx +24 -6
  99. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +23 -0
  100. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +4 -0
  101. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +9 -1
  102. package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +8 -7
  103. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +8 -7
  104. package/resources/components/Dashboard/Content/BlogDashboard.stx +14 -2
  105. package/resources/components/Dashboard/Content/PostsDashboard.stx +7 -1
  106. package/resources/components/Dashboard/Deployments/DeploymentDetail.stx +16 -2
  107. package/resources/components/Dashboard/Email/EmailNavigation.stx +5 -5
  108. package/resources/components/Dashboard/Email/EmailSidebar.stx +1 -1
  109. package/resources/components/Dashboard/Email/InboxAttachmentList.stx +53 -0
  110. package/resources/components/Dashboard/Email/InboxDashboard.stx +5 -4
  111. package/resources/components/Dashboard/Email/InboxMessageDetail.stx +6 -4
  112. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +63 -6
  113. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +3 -6
  114. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +4 -4
  115. package/resources/components/Dashboard/NotFoundDashboard.stx +2 -5
  116. package/resources/components/Dashboard/Queries/QueryDashboard.stx +6 -2
  117. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +4 -4
  118. package/resources/components/Dashboard/UI/EmptyState.stx +2 -2
  119. package/resources/components/Dashboard/UI/PageLayout.stx +3 -3
  120. package/resources/functions/dashboard/sidebar.ts +4 -4
  121. package/routes/dashboard-api.ts +8 -20
  122. package/views/dashboard/.discovered-models.json +24 -0
  123. package/views/dashboard/layouts/default.stx +19 -16
  124. package/views/dashboard/stores/ci.ts +13 -7
  125. package/views/dashboard/stores/kanban.ts +48 -11
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { Card } from '@stacksjs/orm'
3
4
  import { modelBoolean, modelNullableString, modelNumber, modelString, modelValue, refreshModel } from './kanban-model'
@@ -11,7 +12,7 @@ interface CardInput {
11
12
  }
12
13
 
13
14
  /**
14
- * `PATCH /api/dashboard/kanban/cards/:id` (stacksjs/stacks#1846 Phase 2).
15
+ * `PATCH /api/dashboard/kanban/cards/:id`.
15
16
  *
16
17
  * Partial update for the card body: title, description, due date,
17
18
  * archive flag. `position` and `columnId` are NOT updatable here —
@@ -25,14 +26,13 @@ export default new Action({
25
26
  description: 'Partial update of a card body (title, description, due date, archive).',
26
27
  method: 'PATCH',
27
28
  apiResponse: true,
28
- async handle(request) {
29
- const rawId = (request as any)?.params?.id ?? (request as any)?.param?.('id') ?? null
30
- const id = Number(rawId)
29
+ async handle(request: RequestInstance<CardInput>) {
30
+ const id = Number(request.getParam('id'))
31
31
  if (!Number.isFinite(id) || id <= 0) {
32
32
  return kanbanError('Invalid card id', 400)
33
33
  }
34
34
 
35
- const body = (request as any).jsonBody as CardInput | undefined ?? {}
35
+ const body = request.all()
36
36
  const set: Record<string, unknown> = {}
37
37
 
38
38
  if (typeof body.title === 'string') {
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { db } from '@stacksjs/database'
3
4
  import { kanbanError } from './kanban-response'
@@ -31,7 +32,7 @@ interface ReorderInput {
31
32
  }
32
33
 
33
34
  /**
34
- * `POST /api/dashboard/kanban/cards/reorder` (stacksjs/stacks#1846 Phase 2).
35
+ * `POST /api/dashboard/kanban/cards/reorder`.
35
36
  *
36
37
  * The drag-and-drop hot path. Rewrites card positions and column
37
38
  * assignments across one or more columns of a single board in a single
@@ -47,8 +48,8 @@ export default new Action({
47
48
  description: 'Bulk-rewrite cards.{column_id, position} across one or more columns of the same board.',
48
49
  method: 'POST',
49
50
  apiResponse: true,
50
- async handle(request) {
51
- const body = (request as any).jsonBody as ReorderInput | undefined ?? {}
51
+ async handle(request: RequestInstance<ReorderInput>) {
52
+ const body = request.all()
52
53
 
53
54
  if (!Array.isArray(body.columns) || body.columns.length === 0) {
54
55
  return kanbanError('`columns` must be a non-empty array.', 400)
@@ -1,9 +1,10 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { db } from '@stacksjs/database'
3
4
  import { kanbanError } from './kanban-response'
4
5
 
5
6
  /**
6
- * `DELETE /api/dashboard/kanban/columns/:id` (stacksjs/stacks#1846 Phase 2).
7
+ * `DELETE /api/dashboard/kanban/columns/:id`.
7
8
  *
8
9
  * Hard-deletes the column and cascade-cleans every card inside it
9
10
  * (plus the pivot rows referencing those cards). Same convention as
@@ -19,9 +20,8 @@ export default new Action({
19
20
  description: 'Hard-deletes a column and its cards.',
20
21
  method: 'DELETE',
21
22
  apiResponse: true,
22
- async handle(request) {
23
- const rawId = (request as any)?.params?.id ?? (request as any)?.param?.('id') ?? null
24
- const id = Number(rawId)
23
+ async handle(request: RequestInstance) {
24
+ const id = Number(request.getParam('id'))
25
25
  if (!Number.isFinite(id) || id <= 0) {
26
26
  return kanbanError('Invalid column id', 400)
27
27
  }
@@ -37,7 +37,7 @@ export default new Action({
37
37
  'DELETE FROM card_assignees WHERE card_id IN (SELECT id FROM cards WHERE column_id = ?)',
38
38
  [id],
39
39
  ).execute()
40
- // Card comments (Phase 3, stacksjs/stacks#1846).
40
+ // Card comments are card-scoped children.
41
41
  await qb.unsafe(
42
42
  'DELETE FROM card_comments WHERE card_id IN (SELECT id FROM cards WHERE column_id = ?)',
43
43
  [id],
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { db } from '@stacksjs/database'
3
4
  import { Board, BoardColumn } from '@stacksjs/orm'
@@ -11,7 +12,7 @@ interface ColumnInput {
11
12
  }
12
13
 
13
14
  /**
14
- * `POST /api/dashboard/kanban/columns` (stacksjs/stacks#1846 Phase 2).
15
+ * `POST /api/dashboard/kanban/columns`.
15
16
  *
16
17
  * Appends a new column to the end of the target board. Validates that
17
18
  * `boardId` points at a real board so a typo doesn't silently create
@@ -22,8 +23,8 @@ export default new Action({
22
23
  description: 'Creates a new column on a board.',
23
24
  method: 'POST',
24
25
  apiResponse: true,
25
- async handle(request) {
26
- const body = (request as any).jsonBody as ColumnInput | undefined ?? {}
26
+ async handle(request: RequestInstance<ColumnInput>) {
27
+ const body = request.all()
27
28
 
28
29
  const boardId = Number(body.boardId)
29
30
  if (!Number.isFinite(boardId) || boardId <= 0) {
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { BoardColumn } from '@stacksjs/orm'
3
4
  import { modelNullableString, modelNumber, modelString, modelValue, refreshModel } from './kanban-model'
@@ -10,7 +11,7 @@ interface ColumnInput {
10
11
  }
11
12
 
12
13
  /**
13
- * `PATCH /api/dashboard/kanban/columns/:id` (stacksjs/stacks#1846 Phase 2).
14
+ * `PATCH /api/dashboard/kanban/columns/:id`.
14
15
  *
15
16
  * Partial update for name / color / card limit. `position` and
16
17
  * `boardId` are NOT updatable here — moving a column between boards
@@ -23,14 +24,13 @@ export default new Action({
23
24
  description: 'Partial update of a board column.',
24
25
  method: 'PATCH',
25
26
  apiResponse: true,
26
- async handle(request) {
27
- const rawId = (request as any)?.params?.id ?? (request as any)?.param?.('id') ?? null
28
- const id = Number(rawId)
27
+ async handle(request: RequestInstance<ColumnInput>) {
28
+ const id = Number(request.getParam('id'))
29
29
  if (!Number.isFinite(id) || id <= 0) {
30
30
  return kanbanError('Invalid column id', 400)
31
31
  }
32
32
 
33
- const body = (request as any).jsonBody as ColumnInput | undefined ?? {}
33
+ const body = request.all()
34
34
  const set: Record<string, unknown> = {}
35
35
 
36
36
  if (typeof body.name === 'string') {
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { db } from '@stacksjs/database'
3
4
  import { kanbanError } from './kanban-response'
@@ -8,7 +9,7 @@ interface ReorderInput {
8
9
  }
9
10
 
10
11
  /**
11
- * `POST /api/dashboard/kanban/columns/reorder` (stacksjs/stacks#1846 Phase 2).
12
+ * `POST /api/dashboard/kanban/columns/reorder`.
12
13
  *
13
14
  * Bulk rewrite column positions for a board. The submitted `order`
14
15
  * is an array of column ids in their new display order; index becomes
@@ -27,8 +28,8 @@ export default new Action({
27
28
  description: 'Bulk-rewrite `position` on a board\'s columns.',
28
29
  method: 'POST',
29
30
  apiResponse: true,
30
- async handle(request) {
31
- const body = (request as any).jsonBody as ReorderInput | undefined ?? {}
31
+ async handle(request: RequestInstance<ReorderInput>) {
32
+ const body = request.all()
32
33
 
33
34
  const boardId = Number(body.boardId)
34
35
  if (!Number.isFinite(boardId) || boardId <= 0) {
@@ -1,9 +1,10 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { db } from '@stacksjs/database'
3
4
  import { kanbanError } from './kanban-response'
4
5
 
5
6
  /**
6
- * `DELETE /api/dashboard/kanban/labels/:id` (stacksjs/stacks#1846 Phase 3).
7
+ * `DELETE /api/dashboard/kanban/labels/:id`.
7
8
  *
8
9
  * Removes a label and detaches it from every card that carries it.
9
10
  * The label row goes from the `labels` table; every pivot row in
@@ -15,9 +16,8 @@ export default new Action({
15
16
  description: 'Hard-deletes a label and its card_labels pivot rows.',
16
17
  method: 'DELETE',
17
18
  apiResponse: true,
18
- async handle(request) {
19
- const rawId = (request as any)?.params?.id ?? (request as any)?.param?.('id') ?? null
20
- const id = Number(rawId)
19
+ async handle(request: RequestInstance) {
20
+ const id = Number(request.getParam('id'))
21
21
  if (!Number.isFinite(id) || id <= 0)
22
22
  return kanbanError('Invalid label id', 400)
23
23
 
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { Board, Label } from '@stacksjs/orm'
3
4
  import { kanbanError } from './kanban-response'
@@ -9,7 +10,7 @@ interface LabelInput {
9
10
  }
10
11
 
11
12
  /**
12
- * `POST /api/dashboard/kanban/labels` (stacksjs/stacks#1846 Phase 3).
13
+ * `POST /api/dashboard/kanban/labels`.
13
14
  *
14
15
  * Creates a label scoped to a board. Labels are unique per
15
16
  * `(board_id, name)` — the migration enforces this via a unique
@@ -21,8 +22,8 @@ export default new Action({
21
22
  description: 'Creates a label on a board.',
22
23
  method: 'POST',
23
24
  apiResponse: true,
24
- async handle(request) {
25
- const body = (request as any).jsonBody as LabelInput | undefined ?? {}
25
+ async handle(request: RequestInstance<LabelInput>) {
26
+ const body = request.all()
26
27
 
27
28
  const boardId = Number(body.boardId)
28
29
  if (!Number.isFinite(boardId) || boardId <= 0) {
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { Label } from '@stacksjs/orm'
3
4
  import { modelNumber, modelString, refreshModel } from './kanban-model'
@@ -9,7 +10,7 @@ interface LabelInput {
9
10
  }
10
11
 
11
12
  /**
12
- * `PATCH /api/dashboard/kanban/labels/:id` (stacksjs/stacks#1846 Phase 3).
13
+ * `PATCH /api/dashboard/kanban/labels/:id`.
13
14
  *
14
15
  * Partial update for label name + color. `board_id` is not movable —
15
16
  * a label on board A renaming to "Bug" must not collide with a
@@ -21,14 +22,13 @@ export default new Action({
21
22
  description: 'Partial update of a label name / color.',
22
23
  method: 'PATCH',
23
24
  apiResponse: true,
24
- async handle(request) {
25
- const rawId = (request as any)?.params?.id ?? (request as any)?.param?.('id') ?? null
26
- const id = Number(rawId)
25
+ async handle(request: RequestInstance<LabelInput>) {
26
+ const id = Number(request.getParam('id'))
27
27
  if (!Number.isFinite(id) || id <= 0) {
28
28
  return kanbanError('Invalid label id', 400)
29
29
  }
30
30
 
31
- const body = (request as any).jsonBody as LabelInput | undefined ?? {}
31
+ const body = request.all()
32
32
  const set: Record<string, unknown> = {}
33
33
  let renamingTo: string | null = null
34
34
 
@@ -3,7 +3,7 @@ import { db } from '@stacksjs/database'
3
3
  import { kanbanError } from './kanban-response'
4
4
 
5
5
  /**
6
- * `GET /api/dashboard/kanban/users` (stacksjs/stacks#1846 Phase 3).
6
+ * `GET /api/dashboard/kanban/users`.
7
7
  *
8
8
  * Lightweight user list for the assignee picker on the card detail
9
9
  * modal. Returns `{ id, name, email }` only — no roles, no profile,
@@ -0,0 +1,14 @@
1
+ import { modelNullableNumber, modelNullableString, modelNumber, modelString } from './kanban-model'
2
+
3
+ export function cardCommentResponse(record: object, author?: object | null) {
4
+ return {
5
+ id: modelNumber(record, 'id'),
6
+ uuid: modelNullableString(record, 'uuid'),
7
+ userId: modelNullableNumber(record, 'userId', 'user_id'),
8
+ body: modelString(record, 'body'),
9
+ authorName: author ? modelNullableString(author, 'name') : null,
10
+ authorEmail: author ? modelNullableString(author, 'email') : null,
11
+ createdAt: modelNullableString(record, 'createdAt', 'created_at'),
12
+ updatedAt: modelNullableString(record, 'updatedAt', 'updated_at'),
13
+ }
14
+ }
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
+ import { cardCommentResponse } from './kanban-comment'
2
3
  import {
3
4
  modelBoolean,
5
+ modelNullableNumber,
4
6
  modelNullableString,
5
7
  modelNumber,
6
8
  modelString,
@@ -31,12 +33,26 @@ describe('kanban model response values', () => {
31
33
  })
32
34
 
33
35
  test('normalizes nullable strings and persisted booleans', () => {
36
+ expect(modelNullableNumber(record({ value: null }), 'value')).toBeNull()
37
+ expect(modelNullableNumber(record({ value: '42' }), 'value')).toBe(42)
34
38
  expect(modelNullableString(record({ value: null }), 'value')).toBeNull()
35
39
  expect(modelBoolean(record({ enabled: 1 }), 'enabled')).toBe(true)
36
40
  expect(modelBoolean(record({ enabled: 'true' }), 'enabled')).toBe(true)
37
41
  expect(modelBoolean(record({ enabled: 0 }), 'enabled')).toBe(false)
38
42
  })
39
43
 
44
+ test('reads raw query rows as well as ORM model records', () => {
45
+ const row = {
46
+ board_id: 4,
47
+ archived: 1,
48
+ created_at: '2026-08-10 12:00:00',
49
+ }
50
+
51
+ expect(modelNumber(row, 'boardId', 'board_id')).toBe(4)
52
+ expect(modelBoolean(row, 'archived')).toBe(true)
53
+ expect(modelString(row, 'createdAt', 'created_at')).toBe('2026-08-10 12:00:00')
54
+ })
55
+
40
56
  test('refreshes generated database values after a write', async () => {
41
57
  const stale = {
42
58
  ...record({ updated_at: null }),
@@ -46,4 +62,24 @@ describe('kanban model response values', () => {
46
62
  const refreshed = await refreshModel(stale)
47
63
  expect(modelString(refreshed, 'updatedAt', 'updated_at')).toBe('2026-07-31 04:00:00')
48
64
  })
65
+
66
+ test('serializes model-backed comments with their author', () => {
67
+ expect(cardCommentResponse(record({
68
+ id: 7,
69
+ uuid: 'comment-uuid',
70
+ userId: 3,
71
+ body: 'Updated note',
72
+ createdAt: '2026-08-10 20:00:00',
73
+ updatedAt: '2026-08-10 21:00:00',
74
+ }), record({ name: 'Chris', email: 'chris@example.com' }))).toEqual({
75
+ id: 7,
76
+ uuid: 'comment-uuid',
77
+ userId: 3,
78
+ body: 'Updated note',
79
+ authorName: 'Chris',
80
+ authorEmail: 'chris@example.com',
81
+ createdAt: '2026-08-10 20:00:00',
82
+ updatedAt: '2026-08-10 21:00:00',
83
+ })
84
+ })
49
85
  })
@@ -10,28 +10,35 @@ export async function refreshModel(record: RefreshableKanbanModelRecord): Promis
10
10
  return await record.fresh() ?? record
11
11
  }
12
12
 
13
- export function modelValue(record: KanbanModelRecord, camelKey: string, snakeKey = camelKey): unknown {
14
- const camelValue = record.get(camelKey)
13
+ export function modelValue(record: object, camelKey: string, snakeKey = camelKey): unknown {
14
+ const model = record as Partial<KanbanModelRecord>
15
+ const row = record as Record<string, unknown>
16
+ const camelValue = typeof model.get === 'function' ? model.get(camelKey) : row[camelKey]
15
17
  if (camelValue !== undefined)
16
18
  return camelValue
17
- return record.get(snakeKey)
19
+ return typeof model.get === 'function' ? model.get(snakeKey) : row[snakeKey]
18
20
  }
19
21
 
20
- export function modelNumber(record: KanbanModelRecord, camelKey: string, snakeKey = camelKey): number {
22
+ export function modelNumber(record: object, camelKey: string, snakeKey = camelKey): number {
21
23
  return Number(modelValue(record, camelKey, snakeKey))
22
24
  }
23
25
 
24
- export function modelString(record: KanbanModelRecord, camelKey: string, snakeKey = camelKey): string {
26
+ export function modelNullableNumber(record: object, camelKey: string, snakeKey = camelKey): number | null {
27
+ const value = modelValue(record, camelKey, snakeKey)
28
+ return value === undefined || value === null ? null : Number(value)
29
+ }
30
+
31
+ export function modelString(record: object, camelKey: string, snakeKey = camelKey): string {
25
32
  const value = modelValue(record, camelKey, snakeKey)
26
33
  return value === undefined || value === null ? '' : String(value)
27
34
  }
28
35
 
29
- export function modelNullableString(record: KanbanModelRecord, camelKey: string, snakeKey = camelKey): string | null {
36
+ export function modelNullableString(record: object, camelKey: string, snakeKey = camelKey): string | null {
30
37
  const value = modelValue(record, camelKey, snakeKey)
31
38
  return value === undefined || value === null ? null : String(value)
32
39
  }
33
40
 
34
- export function modelBoolean(record: KanbanModelRecord, camelKey: string, snakeKey = camelKey): boolean {
41
+ export function modelBoolean(record: object, camelKey: string, snakeKey = camelKey): boolean {
35
42
  const value = modelValue(record, camelKey, snakeKey)
36
43
  return value === true || value === 1 || value === '1' || value === 'true'
37
44
  }
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { parseRowId, resolveWritableModel } from './model-write'
3
4
 
@@ -13,12 +14,12 @@ export default new Action({
13
14
  description: 'Deletes one row of a model from the dashboard model browser.',
14
15
  method: 'DELETE',
15
16
  apiResponse: true,
16
- async handle(req: { getParam?: (name: string) => unknown, route?: { params?: { slug?: string, id?: string } } }) {
17
- const resolved = await resolveWritableModel(String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? ''), 'destroy')
17
+ async handle(request: RequestInstance) {
18
+ const resolved = await resolveWritableModel(request.getParam('slug'), 'destroy')
18
19
  if ('error' in resolved)
19
20
  return { ok: false, error: resolved.error }
20
21
 
21
- const id = parseRowId(req?.getParam?.('id') ?? req?.route?.params?.id)
22
+ const id = parseRowId(request.getParam('id'))
22
23
  if (id === null)
23
24
  return { ok: false, error: 'A numeric row id is required.' }
24
25
 
@@ -1,6 +1,7 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { env } from '@stacksjs/env'
3
- import { request, response as routerResponse } from '@stacksjs/router'
4
+ import { response as routerResponse } from '@stacksjs/router'
4
5
  import { loadModelIfExists, safeGet } from '../../../../resources/functions/dashboard/data'
5
6
  import { isValidModelSlug, type ModelCreateField, type ModelWriteCapabilities, modelCreateFields, modelSchemaColumns, modelWriteCapabilities, slugToPascal } from './model-write'
6
7
 
@@ -108,14 +109,11 @@ function typeForColumn(column: string, values: unknown[]): ColumnMeta['type'] {
108
109
  return inferType(values)
109
110
  }
110
111
 
111
- function queryParams(): URLSearchParams {
112
- const query = ((request as any).query || {}) as Record<string, string | string[] | undefined>
113
- const params = new URLSearchParams()
114
- for (const [key, value] of Object.entries(query)) {
115
- if (value === undefined) continue
116
- params.set(key, Array.isArray(value) ? String(value[0]) : String(value))
117
- }
118
- return params
112
+ function queryValue(request: RequestInstance, key: string, fallback = ''): string {
113
+ const value = request.get<unknown>(key, fallback)
114
+ if (Array.isArray(value))
115
+ return value.length > 0 ? String(value[0]) : fallback
116
+ return value == null ? fallback : String(value)
119
117
  }
120
118
 
121
119
  /** `filters` arrives as a JSON object so column names stay unambiguous. */
@@ -176,23 +174,22 @@ export default new Action({
176
174
  description: 'Queries a single model by URL slug with paging, sorting, search and column filters.',
177
175
  method: 'GET',
178
176
  apiResponse: true,
179
- async handle(req: { getParam?: (name: string) => unknown, route?: { params?: { slug?: string } } }) {
180
- const slug = String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? '')
177
+ async handle(request: RequestInstance) {
178
+ const slug = request.getParam('slug')
181
179
  if (!isValidModelSlug(slug))
182
180
  return routerResponse.json({ message: 'Model slug must be lowercase kebab-case.' }, 400)
183
181
 
184
182
  const modelName = slugToPascal(slug)
185
183
  const tableName = pluralize(pascalToSnake(modelName))
186
184
 
187
- const params = queryParams()
188
- const page = Math.max(1, Number.parseInt(params.get('page') || '1', 10) || 1)
189
- const perPage = Math.min(MAX_PER_PAGE, Math.max(1, Number.parseInt(params.get('per_page') || String(DEFAULT_PER_PAGE), 10) || DEFAULT_PER_PAGE))
190
- const requestedSort = (params.get('sort') || '').trim()
191
- const dir: 'asc' | 'desc' = params.get('dir') === 'asc' ? 'asc' : 'desc'
192
- const q = (params.get('q') || '').trim()
185
+ const page = Math.max(1, Number.parseInt(queryValue(request, 'page', '1'), 10) || 1)
186
+ const perPage = Math.min(MAX_PER_PAGE, Math.max(1, Number.parseInt(queryValue(request, 'per_page', String(DEFAULT_PER_PAGE)), 10) || DEFAULT_PER_PAGE))
187
+ const requestedSort = queryValue(request, 'sort').trim()
188
+ const dir: 'asc' | 'desc' = queryValue(request, 'dir') === 'asc' ? 'asc' : 'desc'
189
+ const q = queryValue(request, 'q').trim()
193
190
  let filters: Record<string, string>
194
191
  try {
195
- filters = parseFilters(params.get('filters'))
192
+ filters = parseFilters(queryValue(request, 'filters') || null)
196
193
  }
197
194
  catch (error) {
198
195
  return routerResponse.json({
@@ -1,23 +1,23 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { toSnakeCaseKeys } from '@stacksjs/orm'
3
- import { request } from '@stacksjs/router'
4
4
  import { prepareModelFields, resolveWritableModel } from './model-write'
5
5
 
6
+ interface ModelWriteInput {
7
+ fields?: unknown
8
+ }
9
+
6
10
  export default new Action({
7
11
  name: 'Dashboard Model Store',
8
12
  description: 'Creates one row through a model declared useApi store contract.',
9
13
  method: 'POST',
10
14
  apiResponse: true,
11
- async handle(req: {
12
- getParam?: (name: string) => unknown
13
- all?: () => Record<string, unknown>
14
- route?: { params?: { slug?: string } }
15
- }) {
16
- const resolved = await resolveWritableModel(String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? ''), 'store')
15
+ async handle(request: RequestInstance<ModelWriteInput>) {
16
+ const resolved = await resolveWritableModel(request.getParam('slug'), 'store')
17
17
  if ('error' in resolved)
18
18
  return { ok: false, error: resolved.error }
19
19
 
20
- const input = (req.all?.() ?? (request as any).all?.() ?? {}) as Record<string, unknown>
20
+ const input = request.all()
21
21
  const raw = input.fields
22
22
  const body = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw as Record<string, unknown> : {}
23
23
  const { data, errors } = prepareModelFields(resolved.Model, body)
@@ -1,8 +1,12 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { toSnakeCaseKeys } from '@stacksjs/orm'
3
- import { request } from '@stacksjs/router'
4
4
  import { parseRowId, prepareModelFields, resolveWritableModel } from './model-write'
5
5
 
6
+ interface ModelWriteInput {
7
+ fields?: unknown
8
+ }
9
+
6
10
  /**
7
11
  * `PATCH /api/dashboard/models/{slug}/{id}`.
8
12
  *
@@ -16,16 +20,12 @@ export default new Action({
16
20
  description: 'Updates one row of a model from the dashboard model browser.',
17
21
  method: 'PATCH',
18
22
  apiResponse: true,
19
- async handle(req: {
20
- getParam?: (name: string) => unknown
21
- all?: () => Record<string, unknown>
22
- route?: { params?: { slug?: string, id?: string } }
23
- }) {
24
- const resolved = await resolveWritableModel(String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? ''), 'update')
23
+ async handle(request: RequestInstance<ModelWriteInput>) {
24
+ const resolved = await resolveWritableModel(request.getParam('slug'), 'update')
25
25
  if ('error' in resolved)
26
26
  return { ok: false, error: resolved.error }
27
27
 
28
- const id = parseRowId(req?.getParam?.('id') ?? req?.route?.params?.id)
28
+ const id = parseRowId(request.getParam('id'))
29
29
  if (id === null)
30
30
  return { ok: false, error: 'A numeric row id is required.' }
31
31
 
@@ -35,7 +35,7 @@ export default new Action({
35
35
  // columns — and `slug` is a real column on plenty of models, so it
36
36
  // cannot simply be blocklisted. One level of nesting keeps the two
37
37
  // namespaces apart.
38
- const input = (req.all?.() ?? (request as any).all?.() ?? {}) as Record<string, unknown>
38
+ const input = request.all()
39
39
  const raw = input.fields
40
40
  const body = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw as Record<string, unknown> : {}
41
41
  const prepared = prepareModelFields(resolved.Model, body, true)
@@ -3,6 +3,7 @@ import { existsSync, readdirSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import process from 'node:process'
5
5
  import { countRows } from '../../../../resources/functions/dashboard/data'
6
+ import { modelApiConfiguration } from './model-api'
6
7
 
7
8
  /**
8
9
  * `GET /api/dashboard/models` (stacksjs/stacks#1838).
@@ -135,7 +136,7 @@ export default new Action({
135
136
  }
136
137
  }
137
138
 
138
- const api = Model.traits?.useApi
139
+ const api = modelApiConfiguration(Model)
139
140
  let count: number | null = null
140
141
  let error: string | null = null
141
142
  try {
@@ -153,8 +154,8 @@ export default new Action({
153
154
  attributeCount: Object.keys(Model.attributes || {}).length,
154
155
  category: categorize(m),
155
156
  source: m.source,
156
- apiUri: typeof api === 'object' && typeof api.uri === 'string' ? `/api/${api.uri}` : '',
157
- apiRoutes: typeof api === 'object' && Array.isArray(api.routes) ? api.routes.map(String) : [],
157
+ apiUri: api.uri ? `/api/${api.uri}` : '',
158
+ apiRoutes: api.routes,
158
159
  error,
159
160
  }
160
161
  }))
@@ -0,0 +1,27 @@
1
+ export const DEFAULT_MODEL_API_ROUTES = ['index', 'show', 'store', 'update', 'destroy'] as const
2
+
3
+ export interface ModelApiConfiguration {
4
+ uri: string
5
+ routes: string[]
6
+ }
7
+
8
+ /**
9
+ * Normalize a model's useApi trait exactly as the ORM route generator does.
10
+ * An enabled trait without explicit routes receives the complete REST set,
11
+ * while an explicit empty routes array remains intentionally empty.
12
+ */
13
+ export function modelApiConfiguration(Model: any): ModelApiConfiguration {
14
+ const useApi = Model?.traits?.useApi
15
+ if (!useApi)
16
+ return { uri: '', routes: [] }
17
+
18
+ const config = typeof useApi === 'object' ? useApi : {}
19
+ const fallbackUri = String(Model?.table || `${String(Model?.name || '').toLowerCase()}s`)
20
+
21
+ return {
22
+ uri: String(config.uri || fallbackUri),
23
+ routes: Array.isArray(config.routes)
24
+ ? config.routes.map(String)
25
+ : [...DEFAULT_MODEL_API_ROUTES],
26
+ }
27
+ }
@@ -7,6 +7,7 @@
7
7
  * bypass every guarantee the ORM makes. Those rows stay read-only.
8
8
  */
9
9
  import { loadModelIfExists } from '../../../../resources/functions/dashboard/data'
10
+ import { modelApiConfiguration } from './model-api'
10
11
 
11
12
  /** Never writable from a generic admin table, whatever the model declares. */
12
13
  export const PROTECTED_COLUMNS: ReadonlySet<string> = new Set([
@@ -121,17 +122,8 @@ function isProtectedField(name: string): boolean {
121
122
  return PROTECTED_COLUMNS.has(name) || PROTECTED_COLUMNS.has(column)
122
123
  }
123
124
 
124
- function useApiRoutes(Model: any): string[] {
125
- const useApi = Model?.traits?.useApi
126
- if (!useApi)
127
- return []
128
- if (typeof useApi === 'object' && Array.isArray(useApi.routes))
129
- return useApi.routes.map(String)
130
- return ['index', 'store', 'show', 'update', 'destroy']
131
- }
132
-
133
125
  export function modelWriteCapabilities(Model: any): ModelWriteCapabilities {
134
- const routes = useApiRoutes(Model)
126
+ const routes = modelApiConfiguration(Model).routes
135
127
  return {
136
128
  create: routes.includes('store'),
137
129
  update: routes.includes('update'),
@@ -1,7 +1,8 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { db } from '@stacksjs/database'
4
- import { request as routerRequest, response } from '@stacksjs/router'
4
+ import { response } from '@stacksjs/router'
5
+ import { dashboardRequestValue } from '../dashboard-request'
5
6
  import {
6
7
  type NotificationDeliveryRow,
7
8
  serializeNotificationDelivery,
@@ -16,9 +17,7 @@ type DeliveryStatus = typeof STATUSES[number]
16
17
  type DeliverySort = typeof SORTS[number]
17
18
 
18
19
  function queryValue(request: RequestInstance, key: string): string {
19
- const query = ((routerRequest as any).query || {}) as Record<string, string | string[] | undefined>
20
- const value = query[key]
21
- return String((Array.isArray(value) ? value[0] : value) || request.get(key) || '').trim()
20
+ return dashboardRequestValue(request, key)
22
21
  }
23
22
 
24
23
  function allowedValue<T extends string>(value: string, values: readonly T[]): T | null {