@stacksjs/defaults 0.70.355 → 0.70.357

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 (180) hide show
  1. package/ai/skills/stacks-email/SKILL.md +38 -1
  2. package/ai/skills/stacks-orm/SKILL.md +6 -0
  3. package/app/Actions/ContactAction.ts +2 -2
  4. package/app/Actions/Dashboard/Analytics/EventAnalyticsAction.ts +2 -3
  5. package/app/Actions/Dashboard/Analytics/MarketingAnalyticsAction.ts +2 -3
  6. package/app/Actions/Dashboard/Analytics/SalesAnalyticsAction.ts +2 -3
  7. package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +9 -3
  8. package/app/Actions/Dashboard/Auth/MeAction.ts +4 -7
  9. package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +2 -3
  10. package/app/Actions/Dashboard/Cloud/CloudIndexAction.ts +8 -1
  11. package/app/Actions/Dashboard/Cloud/ServerlessIndexAction.ts +15 -8
  12. package/app/Actions/Dashboard/Commerce/CommerceCategoriesAction.ts +2 -4
  13. package/app/Actions/Dashboard/Commerce/CommerceCouponsAction.ts +2 -4
  14. package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +2 -4
  15. package/app/Actions/Dashboard/Commerce/CommerceDashboardAction.ts +2 -4
  16. package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +2 -4
  17. package/app/Actions/Dashboard/Commerce/CommerceGiftCardsAction.ts +2 -4
  18. package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +2 -4
  19. package/app/Actions/Dashboard/Commerce/CommercePaymentsAction.ts +2 -4
  20. package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +2 -4
  21. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +75 -61
  22. package/app/Actions/Dashboard/Commerce/CommercePrintDevicesAction.ts +2 -4
  23. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +2 -4
  24. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +3 -4
  25. package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +2 -4
  26. package/app/Actions/Dashboard/Commerce/CommerceTaxesAction.ts +2 -4
  27. package/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts +2 -4
  28. package/app/Actions/Dashboard/Commerce/DigitalDeliveryIndexAction.ts +2 -4
  29. package/app/Actions/Dashboard/Commerce/DriverIndexAction.ts +2 -4
  30. package/app/Actions/Dashboard/Commerce/LicenseKeyIndexAction.ts +2 -4
  31. package/app/Actions/Dashboard/Commerce/LicenseKeyOptionsAction.ts +2 -4
  32. package/app/Actions/Dashboard/Commerce/ManufacturerIndexAction.ts +2 -4
  33. package/app/Actions/Dashboard/Commerce/ProductUnitIndexAction.ts +2 -4
  34. package/app/Actions/Dashboard/Commerce/ProductVariantIndexAction.ts +2 -4
  35. package/app/Actions/Dashboard/Commerce/ProductWaitlistIndexAction.ts +2 -4
  36. package/app/Actions/Dashboard/Commerce/RestaurantWaitlistIndexAction.ts +2 -4
  37. package/app/Actions/Dashboard/Commerce/ReviewIndexAction.ts +2 -4
  38. package/app/Actions/Dashboard/Commerce/ShippingMethodIndexAction.ts +2 -4
  39. package/app/Actions/Dashboard/Commerce/ShippingRateIndexAction.ts +2 -4
  40. package/app/Actions/Dashboard/Commerce/ShippingZoneIndexAction.ts +2 -4
  41. package/app/Actions/Dashboard/Content/AuthorDestroyAction.ts +23 -9
  42. package/app/Actions/Dashboard/Content/AuthorIndexAction.ts +32 -26
  43. package/app/Actions/Dashboard/Content/AuthorStoreAction.ts +44 -24
  44. package/app/Actions/Dashboard/Content/AuthorUpdateAction.ts +48 -26
  45. package/app/Actions/Dashboard/Content/CategoryDestroyAction.ts +27 -8
  46. package/app/Actions/Dashboard/Content/CategoryIndexAction.ts +21 -15
  47. package/app/Actions/Dashboard/Content/CategoryStoreAction.ts +47 -29
  48. package/app/Actions/Dashboard/Content/CommentDestroyAction.ts +17 -4
  49. package/app/Actions/Dashboard/Content/CommentIndexAction.ts +23 -17
  50. package/app/Actions/Dashboard/Content/CommentUpdateAction.ts +30 -12
  51. package/app/Actions/Dashboard/Content/ContentDashboardAction.ts +43 -37
  52. package/app/Actions/Dashboard/Content/PageDestroyAction.ts +17 -4
  53. package/app/Actions/Dashboard/Content/PageIndexAction.ts +23 -17
  54. package/app/Actions/Dashboard/Content/PageStoreAction.ts +33 -18
  55. package/app/Actions/Dashboard/Content/PageUpdateAction.ts +40 -22
  56. package/app/Actions/Dashboard/Content/PostDestroyAction.ts +24 -11
  57. package/app/Actions/Dashboard/Content/PostIndexAction.ts +55 -49
  58. package/app/Actions/Dashboard/Content/PostStoreAction.ts +43 -33
  59. package/app/Actions/Dashboard/Content/PostUpdateAction.ts +46 -34
  60. package/app/Actions/Dashboard/Content/TagDestroyAction.ts +27 -8
  61. package/app/Actions/Dashboard/Content/TagIndexAction.ts +31 -25
  62. package/app/Actions/Dashboard/Content/TagStoreAction.ts +44 -23
  63. package/app/Actions/Dashboard/Content/content-input.ts +4 -4
  64. package/app/Actions/Dashboard/Content/post-input.ts +60 -8
  65. package/app/Actions/Dashboard/DashboardActivityAction.ts +2 -5
  66. package/app/Actions/Dashboard/Data/ActivityIndexAction.ts +2 -5
  67. package/app/Actions/Dashboard/Data/SubscriberIndexAction.ts +2 -5
  68. package/app/Actions/Dashboard/Data/TeamIndexAction.ts +2 -5
  69. package/app/Actions/Dashboard/Data/UserIndexAction.ts +2 -5
  70. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +23 -19
  71. package/app/Actions/Dashboard/Deployments/GetAverageDeploymentTime.ts +8 -2
  72. package/app/Actions/Dashboard/Deployments/GetDeployScript.ts +3 -6
  73. package/app/Actions/Dashboard/Deployments/GetDeployment.ts +4 -5
  74. package/app/Actions/Dashboard/Deployments/GetDeploymentCount.ts +7 -1
  75. package/app/Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput.ts +10 -9
  76. package/app/Actions/Dashboard/Deployments/GetDeployments.ts +2 -4
  77. package/app/Actions/Dashboard/Deployments/GetRecentDeployments.ts +8 -2
  78. package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +18 -5
  79. package/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +2 -4
  80. package/app/Actions/Dashboard/Email/CapturedMailShowAction.ts +3 -4
  81. package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +15 -14
  82. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +2 -8
  83. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +2 -4
  84. package/app/Actions/Dashboard/Jobs/JobShowAction.ts +13 -8
  85. package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -4
  86. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +2 -3
  87. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +2 -3
  88. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +2 -3
  89. package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +2 -3
  90. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +2 -3
  91. package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +2 -3
  92. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +2 -3
  93. package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +2 -3
  94. package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +2 -3
  95. package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +2 -3
  96. package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +2 -3
  97. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +2 -3
  98. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +2 -3
  99. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +2 -3
  100. package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +2 -3
  101. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +2 -3
  102. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +2 -3
  103. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +2 -3
  104. package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +2 -3
  105. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +2 -3
  106. package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +2 -3
  107. package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +2 -3
  108. package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +2 -3
  109. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +2 -3
  110. package/app/Actions/Dashboard/Kanban/kanban-response.ts +5 -0
  111. package/app/Actions/Dashboard/Library/CreateComponent.ts +15 -6
  112. package/app/Actions/Dashboard/Library/CreateFunction.ts +15 -6
  113. package/app/Actions/Dashboard/Library/DependencyIndexAction.ts +88 -77
  114. package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +21 -15
  115. package/app/Actions/Dashboard/Library/GetComponents.ts +14 -7
  116. package/app/Actions/Dashboard/Library/GetDownloadCount.ts +14 -5
  117. package/app/Actions/Dashboard/Library/GetFunctions.ts +12 -5
  118. package/app/Actions/Dashboard/Library/GetReleaseCount.ts +14 -5
  119. package/app/Actions/Dashboard/Library/GetReleases.ts +17 -10
  120. package/app/Actions/Dashboard/Library/PackageIndexAction.ts +14 -7
  121. package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +7 -5
  122. package/app/Actions/Dashboard/Models/ModelShowAction.ts +82 -174
  123. package/app/Actions/Dashboard/Models/ModelStoreAction.ts +6 -4
  124. package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +9 -7
  125. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +47 -17
  126. package/app/Actions/Dashboard/Models/model-write.ts +20 -5
  127. package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +46 -36
  128. package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +14 -6
  129. package/app/Actions/Dashboard/Notifications/NotificationDeliveryOverviewAction.ts +96 -84
  130. package/app/Actions/Dashboard/Notifications/NotificationDeliveryRetryAction.ts +34 -21
  131. package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +2 -4
  132. package/app/Actions/Dashboard/Queries/QueryShowAction.ts +2 -3
  133. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +2 -4
  134. package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +2 -4
  135. package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +2 -2
  136. package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +2 -2
  137. package/app/Actions/Dashboard/Rbac/PermissionsIndexAction.ts +2 -3
  138. package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +2 -2
  139. package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +2 -2
  140. package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +2 -6
  141. package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +2 -2
  142. package/app/Actions/Dashboard/Rbac/RolesIndexAction.ts +4 -6
  143. package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +2 -2
  144. package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +2 -9
  145. package/app/Actions/Dashboard/Rbac/UsersListAction.ts +2 -3
  146. package/app/Actions/Dashboard/Rbac/rbac-response.ts +10 -0
  147. package/app/Actions/Dashboard/Realtime/RealtimeStatsAction.ts +2 -4
  148. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +68 -55
  149. package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +34 -16
  150. package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +52 -29
  151. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +102 -71
  152. package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +36 -17
  153. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +39 -26
  154. package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +71 -58
  155. package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +29 -7
  156. package/app/Actions/Dashboard/Teams/team-member-count.ts +4 -2
  157. package/app/Actions/Dashboard/Teams/team-records.test.ts +2 -0
  158. package/app/Actions/Dashboard/Teams/team-records.ts +2 -0
  159. package/app/Actions/Dashboard/dashboard-response.ts +16 -0
  160. package/app/Actions/SendWelcomeEmail.ts +1 -1
  161. package/app/Actions/Teams/AcceptInvitationAction.ts +74 -51
  162. package/app/Actions/Teams/ShowInvitationAction.ts +21 -14
  163. package/app/Actions/Teams/team-response.ts +23 -0
  164. package/app/Mail/OrderConfirmation.ts +1 -1
  165. package/app/Mail/SubscriptionConfirmation.ts +1 -1
  166. package/app/Mail/TeamInvitation.ts +1 -1
  167. package/app/Models/EmailIdempotency.ts +64 -0
  168. package/app/Models/EmailSuppression.ts +52 -0
  169. package/app/Models/EmailWebhookEvent.ts +51 -0
  170. package/app/Models/TeamInvitation.ts +13 -0
  171. package/functions/data.ts +0 -1
  172. package/functions/jobs.ts +0 -1
  173. package/ide/vscode/package.json +1 -1
  174. package/package.json +1 -1
  175. package/resources/components/Dashboard/Data/DataRecords.stx +0 -4
  176. package/resources/components/Dashboard/Jobs/JobDetail.stx +3 -2
  177. package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +7 -13
  178. package/resources/components/Dashboard/Models/ModelsOverview.stx +1 -1
  179. package/views/dashboard/.discovered-models.json +30 -0
  180. package/views/dashboard/stores/auth.ts +15 -14
@@ -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 { dashboardOperationalError, dashboardOperationalIssue } from '../dashboard-response'
6
7
  import { modelApiConfiguration } from './model-api'
7
8
 
8
9
  /**
@@ -41,6 +42,12 @@ interface ModelGroup {
41
42
  countLabel: string
42
43
  }
43
44
 
45
+ interface DiscoveredModel {
46
+ name: string
47
+ file: string
48
+ source: 'userland' | 'framework'
49
+ }
50
+
44
51
  function pascalToSnake(s: string): string {
45
52
  return s.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase()
46
53
  }
@@ -92,6 +99,21 @@ function categoryLabel(k: string): string {
92
99
  return k === 'userland' ? 'Your Models' : k.charAt(0).toUpperCase() + k.slice(1)
93
100
  }
94
101
 
102
+ function unavailableModelRow(model: DiscoveredModel, error: string): ModelRow {
103
+ return {
104
+ name: model.name,
105
+ table: pluralize(pascalToSnake(model.name)),
106
+ href: `/models/${pascalToKebab(model.name)}`,
107
+ count: null,
108
+ attributeCount: 0,
109
+ category: categorize(model),
110
+ source: model.source,
111
+ apiUri: '',
112
+ apiRoutes: [],
113
+ error,
114
+ }
115
+ }
116
+
95
117
  export default new Action({
96
118
  name: 'Dashboard Models Index',
97
119
  description: 'List every model + live record count for the dashboard models overview.',
@@ -102,11 +124,18 @@ export default new Action({
102
124
  const userlandDir = join(cwd, 'app', 'Models')
103
125
  const defaultsDir = join(cwd, 'storage/framework/defaults/app/Models')
104
126
 
105
- const userland = walkModels(userlandDir, false)
106
- const defaults = walkModels(defaultsDir, true)
127
+ let userland: Array<Omit<DiscoveredModel, 'source'>>
128
+ let defaults: Array<Omit<DiscoveredModel, 'source'>>
129
+ try {
130
+ userland = walkModels(userlandDir, false)
131
+ defaults = walkModels(defaultsDir, true)
132
+ }
133
+ catch (error) {
134
+ return dashboardOperationalError(error, 'Model catalog could not be loaded.', 'ModelsIndexAction.catalog')
135
+ }
107
136
 
108
137
  const seen = new Set<string>()
109
- const merged: Array<{ name: string, file: string, source: 'userland' | 'framework' }> = []
138
+ const merged: DiscoveredModel[] = []
110
139
  for (const list of [userland, defaults]) {
111
140
  for (const m of list) {
112
141
  if (seen.has(m.name)) continue
@@ -122,28 +151,29 @@ export default new Action({
122
151
  Model = module.default ?? module
123
152
  }
124
153
  catch (error) {
125
- return {
126
- name: m.name,
127
- table: pluralize(pascalToSnake(m.name)),
128
- href: `/models/${pascalToKebab(m.name)}`,
129
- count: null,
130
- attributeCount: 0,
131
- category: categorize(m),
132
- source: m.source,
133
- apiUri: '',
134
- apiRoutes: [],
135
- error: error instanceof Error ? error.message : String(error),
136
- }
154
+ return unavailableModelRow(
155
+ m,
156
+ dashboardOperationalIssue(error, 'Model definition could not be loaded.', `ModelsIndexAction.import.${m.name}`),
157
+ )
137
158
  }
138
159
 
139
- const api = modelApiConfiguration(Model)
160
+ let api: ReturnType<typeof modelApiConfiguration>
161
+ try {
162
+ api = modelApiConfiguration(Model)
163
+ }
164
+ catch (error) {
165
+ return unavailableModelRow(
166
+ m,
167
+ dashboardOperationalIssue(error, 'Model metadata could not be loaded.', `ModelsIndexAction.metadata.${m.name}`),
168
+ )
169
+ }
140
170
  let count: number | null = null
141
171
  let error: string | null = null
142
172
  try {
143
173
  count = await countRows(Model)
144
174
  }
145
175
  catch (cause) {
146
- error = cause instanceof Error ? cause.message : String(cause)
176
+ error = dashboardOperationalIssue(cause, 'Model count could not be loaded.', `ModelsIndexAction.count.${m.name}`)
147
177
  }
148
178
 
149
179
  return {
@@ -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 { dashboardOperationalIssue } from '../dashboard-response'
10
11
  import { modelApiConfiguration } from './model-api'
11
12
 
12
13
  /** Never writable from a generic admin table, whatever the model declares. */
@@ -42,6 +43,11 @@ export interface ResolvedModel {
42
43
  modelName: string
43
44
  }
44
45
 
46
+ export interface ModelResolutionError {
47
+ error: string
48
+ status: 400 | 404 | 405 | 500
49
+ }
50
+
45
51
  export type ModelWriteOperation = 'store' | 'update' | 'destroy'
46
52
 
47
53
  export interface ModelWriteCapabilities {
@@ -285,19 +291,28 @@ export function prepareModelFields(
285
291
  * injects (the same object the rest of the dashboard queries, so scopes,
286
292
  * casts and observers all apply) over the path-map lookup.
287
293
  */
288
- export async function resolveWritableModel(slug: string, operation?: ModelWriteOperation): Promise<ResolvedModel | { error: string }> {
294
+ export async function resolveWritableModel(slug: string, operation?: ModelWriteOperation): Promise<ResolvedModel | ModelResolutionError> {
289
295
  if (!isValidModelSlug(slug))
290
- return { error: 'Model slug must be lowercase kebab-case.' }
296
+ return { error: 'Model slug must be lowercase kebab-case.', status: 400 }
291
297
 
292
298
  const modelName = slugToPascal(slug)
293
299
  const injected = (globalThis as Record<string, any>)[modelName]
294
- const Model = (injected && typeof injected.where === 'function') ? injected : await loadModelIfExists(modelName)
300
+ let Model: any
301
+ try {
302
+ Model = (injected && typeof injected.where === 'function') ? injected : await loadModelIfExists(modelName)
303
+ }
304
+ catch (error) {
305
+ return {
306
+ error: dashboardOperationalIssue(error, `${modelName} could not be loaded.`, `resolveWritableModel.${operation || 'read'}`),
307
+ status: 500,
308
+ }
309
+ }
295
310
  if (!Model || typeof Model.find !== 'function')
296
- return { error: `No ORM model named ${modelName}. Rows from tables without a model are read-only.` }
311
+ return { error: `No ORM model named ${modelName}. Rows from tables without a model are read-only.`, status: 404 }
297
312
  if (operation) {
298
313
  const capability = operation === 'store' ? 'create' : operation
299
314
  if (!modelWriteCapabilities(Model)[capability])
300
- return { error: `${modelName} does not declare the ${operation} route in its useApi trait.` }
315
+ return { error: `${modelName} does not declare the ${operation} route in its useApi trait.`, status: 405 }
301
316
  }
302
317
  return { Model, modelName }
303
318
  }
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { db } from '@stacksjs/database'
4
4
  import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
  import { dashboardRequestValue } from '../dashboard-request'
6
7
  import {
7
8
  type NotificationDeliveryRow,
@@ -30,13 +31,15 @@ export default new Action({
30
31
  method: 'GET',
31
32
  apiResponse: true,
32
33
  async handle(request: RequestInstance) {
33
- const page = Math.max(1, Number.parseInt(queryValue(request, 'page') || '1', 10) || 1)
34
+ const page = Math.min(1_000_000, Math.max(1, Number.parseInt(queryValue(request, 'page') || '1', 10) || 1))
34
35
  const perPage = Math.min(100, Math.max(1, Number.parseInt(queryValue(request, 'per_page') || '20', 10) || 20))
35
36
  const channel = allowedValue(queryValue(request, 'channel').toLowerCase(), CHANNELS)
36
37
  const status = allowedValue(queryValue(request, 'status').toLowerCase(), STATUSES)
37
38
  const sort = allowedValue(queryValue(request, 'sort').toLowerCase(), SORTS) || 'sent_at'
38
39
  const direction = queryValue(request, 'direction').toLowerCase() === 'asc' ? 'asc' : 'desc'
39
40
  const search = queryValue(request, 'search')
41
+ if (search.length > 200)
42
+ return response.json({ message: 'Notification delivery search must be 200 characters or fewer.' }, 422)
40
43
  const searchPattern = `%${search}%`
41
44
 
42
45
  const applyFilters = <T extends {
@@ -58,42 +61,49 @@ export default new Action({
58
61
  return filtered
59
62
  }
60
63
 
61
- const countQuery = applyFilters(
62
- db
63
- .selectFrom('notification_deliveries')
64
- .select(db.fn.count('id').as('count')),
65
- )
66
- const rowsQuery = applyFilters(
67
- db
68
- .selectFrom('notification_deliveries')
69
- .selectAll(),
70
- )
64
+ try {
65
+ const countQuery = applyFilters(
66
+ db
67
+ .selectFrom('notification_deliveries')
68
+ .select(db.fn.count('id').as('count')),
69
+ )
70
+ const rowsQuery = applyFilters(
71
+ db
72
+ .selectFrom('notification_deliveries')
73
+ .selectAll(),
74
+ )
71
75
 
72
- const [countRow, rows] = await Promise.all([
73
- countQuery.executeTakeFirst() as Promise<{ count: number | string } | undefined>,
74
- rowsQuery
75
- .orderBy(sort as DeliverySort, direction)
76
- .limit(perPage)
77
- .offset((page - 1) * perPage)
78
- .execute() as Promise<NotificationDeliveryRow[]>,
79
- ])
76
+ const [countRow, rows] = await Promise.all([
77
+ countQuery.executeTakeFirst() as Promise<{ count: number | string } | undefined>,
78
+ rowsQuery
79
+ .orderBy(sort as DeliverySort, direction)
80
+ .limit(perPage)
81
+ .offset((page - 1) * perPage)
82
+ .execute() as Promise<NotificationDeliveryRow[]>,
83
+ ])
80
84
 
81
- const total = Number(countRow?.count || 0)
82
- return response.json({
83
- deliveries: rows.map(serializeNotificationDelivery),
84
- pagination: {
85
- page,
86
- per_page: perPage,
87
- total,
88
- total_pages: Math.max(1, Math.ceil(total / perPage)),
89
- },
90
- filters: {
91
- channel: channel as DeliveryChannel | null,
92
- status: status as DeliveryStatus | null,
93
- search,
94
- sort,
95
- direction,
96
- },
97
- })
85
+ const total = Number(countRow?.count || 0)
86
+ if (!Number.isSafeInteger(total) || total < 0)
87
+ throw new TypeError('Notification delivery count must be a non-negative integer.')
88
+ return response.json({
89
+ deliveries: rows.map(serializeNotificationDelivery),
90
+ pagination: {
91
+ page,
92
+ per_page: perPage,
93
+ total,
94
+ total_pages: Math.max(1, Math.ceil(total / perPage)),
95
+ },
96
+ filters: {
97
+ channel: channel as DeliveryChannel | null,
98
+ status: status as DeliveryStatus | null,
99
+ search,
100
+ sort,
101
+ direction,
102
+ },
103
+ })
104
+ }
105
+ catch (error) {
106
+ return dashboardOperationalError(error, 'Notification delivery history could not be loaded.', 'NotificationDeliveryHistoryAction')
107
+ }
98
108
  },
99
109
  })
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { NotificationDelivery } from '@stacksjs/orm'
4
4
  import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
  import { dashboardRequestValue } from '../dashboard-request'
6
7
  import { parseDeliveryMetadata } from './notification-delivery'
7
8
 
@@ -37,9 +38,14 @@ export default new Action({
37
38
  if (!channel)
38
39
  return response.json({ message: 'Channel must be email or sms.' }, 422)
39
40
 
40
- const records = await NotificationDelivery.where('channel', '=', channel).get()
41
- const deliveries = records
42
- .map(record => ({
41
+ try {
42
+ const records = await NotificationDelivery
43
+ .where('channel', '=', channel)
44
+ .orderByDesc('sent_at')
45
+ .orderByDesc('created_at')
46
+ .limit(500)
47
+ .get()
48
+ const deliveries = records.map(record => ({
43
49
  id: Number(record.get('id')),
44
50
  user_id: record.get('user_id') ? Number(record.get('user_id')) : null,
45
51
  channel,
@@ -55,9 +61,11 @@ export default new Action({
55
61
  sent_at: String(record.get('sent_at') || record.get('created_at') || ''),
56
62
  created_at: String(record.get('created_at') || ''),
57
63
  }))
58
- .sort((a, b) => new Date(b.sent_at).getTime() - new Date(a.sent_at).getTime())
59
- .slice(0, 500)
60
64
 
61
- return response.json(deliveries)
65
+ return response.json(deliveries)
66
+ }
67
+ catch (error) {
68
+ return dashboardOperationalError(error, 'Notification deliveries could not be loaded.', 'NotificationDeliveryIndexAction')
69
+ }
62
70
  },
63
71
  })
@@ -1,6 +1,7 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database'
3
3
  import { response } from '@stacksjs/router'
4
+ import { dashboardOperationalError } from '../dashboard-response'
4
5
  import {
5
6
  type NotificationDeliveryRow,
6
7
  serializeNotificationDelivery,
@@ -22,99 +23,110 @@ const DELIVERY_STATUSES = ['sent', 'delivered', 'failed', 'pending'] as const
22
23
 
23
24
  function numericCount(value: number | string | undefined): number {
24
25
  const count = Number(value || 0)
25
- return Number.isFinite(count) ? count : 0
26
+ if (!Number.isSafeInteger(count) || count < 0)
27
+ throw new TypeError('Notification delivery count must be a non-negative integer.')
28
+ return count
26
29
  }
27
30
 
28
- export default new Action({
29
- name: 'Notification Delivery Overview',
30
- description: 'Returns aggregate transport health and recent notification delivery attempts.',
31
- method: 'GET',
32
- apiResponse: true,
33
- async handle() {
34
- const [totalRow, statusRows, channelRows, recentRows] = await Promise.all([
35
- db
36
- .selectFrom('notification_deliveries')
37
- .select(db.fn.count('id').as('count'))
38
- .executeTakeFirst() as Promise<CountRow | undefined>,
39
- db
40
- .selectFrom('notification_deliveries')
41
- .select(['status', db.fn.count('id').as('count')])
42
- .groupBy('status')
43
- .execute() as Promise<StatusCountRow[]>,
44
- db
45
- .selectFrom('notification_deliveries')
46
- .select(['channel', 'status', db.fn.count('id').as('count')])
47
- .groupBy(['channel', 'status'])
48
- .execute() as Promise<ChannelStatusCountRow[]>,
49
- db
50
- .selectFrom('notification_deliveries')
51
- .selectAll()
52
- .orderBy('sent_at', 'desc')
53
- .limit(8)
54
- .execute() as Promise<NotificationDeliveryRow[]>,
55
- ])
31
+ async function notificationDeliveryOverview() {
32
+ const [totalRow, statusRows, channelRows, recentRows] = await Promise.all([
33
+ db
34
+ .selectFrom('notification_deliveries')
35
+ .select(db.fn.count('id').as('count'))
36
+ .executeTakeFirst() as Promise<CountRow | undefined>,
37
+ db
38
+ .selectFrom('notification_deliveries')
39
+ .select(['status', db.fn.count('id').as('count')])
40
+ .groupBy('status')
41
+ .execute() as Promise<StatusCountRow[]>,
42
+ db
43
+ .selectFrom('notification_deliveries')
44
+ .select(['channel', 'status', db.fn.count('id').as('count')])
45
+ .groupBy(['channel', 'status'])
46
+ .execute() as Promise<ChannelStatusCountRow[]>,
47
+ db
48
+ .selectFrom('notification_deliveries')
49
+ .selectAll()
50
+ .orderBy('sent_at', 'desc')
51
+ .limit(8)
52
+ .execute() as Promise<NotificationDeliveryRow[]>,
53
+ ])
56
54
 
57
- const statuses = Object.fromEntries(
58
- DELIVERY_STATUSES.map(status => [status, 0]),
59
- ) as Record<typeof DELIVERY_STATUSES[number], number>
55
+ const statuses = Object.fromEntries(
56
+ DELIVERY_STATUSES.map(status => [status, 0]),
57
+ ) as Record<typeof DELIVERY_STATUSES[number], number>
60
58
 
61
- for (const row of statusRows) {
62
- if (DELIVERY_STATUSES.includes(row.status as typeof DELIVERY_STATUSES[number]))
63
- statuses[row.status as typeof DELIVERY_STATUSES[number]] = numericCount(row.count)
64
- }
59
+ for (const row of statusRows) {
60
+ if (DELIVERY_STATUSES.includes(row.status as typeof DELIVERY_STATUSES[number]))
61
+ statuses[row.status as typeof DELIVERY_STATUSES[number]] = numericCount(row.count)
62
+ }
65
63
 
66
- const channelMap = new Map<string, {
67
- channel: string
68
- total: number
69
- sent: number
70
- delivered: number
71
- failed: number
72
- pending: number
73
- }>()
64
+ const channelMap = new Map<string, {
65
+ channel: string
66
+ total: number
67
+ sent: number
68
+ delivered: number
69
+ failed: number
70
+ pending: number
71
+ }>()
74
72
 
75
- for (const row of channelRows) {
76
- const channel = String(row.channel || 'unknown')
77
- const entry = channelMap.get(channel) || {
78
- channel,
79
- total: 0,
80
- sent: 0,
81
- delivered: 0,
82
- failed: 0,
83
- pending: 0,
84
- }
85
- const count = numericCount(row.count)
86
- entry.total += count
87
- if (DELIVERY_STATUSES.includes(row.status as typeof DELIVERY_STATUSES[number]))
88
- entry[row.status as typeof DELIVERY_STATUSES[number]] += count
89
- channelMap.set(channel, entry)
73
+ for (const row of channelRows) {
74
+ const channel = String(row.channel || 'unknown')
75
+ const entry = channelMap.get(channel) || {
76
+ channel,
77
+ total: 0,
78
+ sent: 0,
79
+ delivered: 0,
80
+ failed: 0,
81
+ pending: 0,
90
82
  }
83
+ const count = numericCount(row.count)
84
+ entry.total += count
85
+ if (DELIVERY_STATUSES.includes(row.status as typeof DELIVERY_STATUSES[number]))
86
+ entry[row.status as typeof DELIVERY_STATUSES[number]] += count
87
+ channelMap.set(channel, entry)
88
+ }
91
89
 
92
- const channels = [...channelMap.values()]
93
- .map(channel => ({
94
- ...channel,
95
- success_rate: channel.total > 0
96
- ? Number((((channel.sent + channel.delivered) / channel.total) * 100).toFixed(1))
97
- : 0,
98
- }))
99
- .sort((a, b) => b.total - a.total || a.channel.localeCompare(b.channel))
90
+ const channels = [...channelMap.values()]
91
+ .map(channel => ({
92
+ ...channel,
93
+ success_rate: channel.total > 0
94
+ ? Number((((channel.sent + channel.delivered) / channel.total) * 100).toFixed(1))
95
+ : 0,
96
+ }))
97
+ .sort((a, b) => b.total - a.total || a.channel.localeCompare(b.channel))
100
98
 
101
- const total = numericCount(totalRow?.count)
102
- const successful = statuses.sent + statuses.delivered
99
+ const total = numericCount(totalRow?.count)
100
+ const successful = statuses.sent + statuses.delivered
101
+
102
+ return {
103
+ stats: {
104
+ total,
105
+ successful,
106
+ failed: statuses.failed,
107
+ pending: statuses.pending,
108
+ success_rate: total > 0
109
+ ? Number(((successful / total) * 100).toFixed(1))
110
+ : 0,
111
+ active_channels: channels.length,
112
+ },
113
+ statuses,
114
+ channels,
115
+ recent: recentRows.map(serializeNotificationDelivery),
116
+ }
117
+ }
103
118
 
104
- return response.json({
105
- stats: {
106
- total,
107
- successful,
108
- failed: statuses.failed,
109
- pending: statuses.pending,
110
- success_rate: total > 0
111
- ? Number(((successful / total) * 100).toFixed(1))
112
- : 0,
113
- active_channels: channels.length,
114
- },
115
- statuses,
116
- channels,
117
- recent: recentRows.map(serializeNotificationDelivery),
118
- })
119
+ export default new Action({
120
+ name: 'Notification Delivery Overview',
121
+ description: 'Returns aggregate transport health and recent notification delivery attempts.',
122
+ method: 'GET',
123
+ apiResponse: true,
124
+ async handle() {
125
+ try {
126
+ return response.json(await notificationDeliveryOverview())
127
+ }
128
+ catch (error) {
129
+ return dashboardOperationalError(error, 'Notification delivery overview could not be loaded.', 'NotificationDeliveryOverviewAction')
130
+ }
119
131
  },
120
132
  })
@@ -4,6 +4,7 @@ import { Action } from '@stacksjs/actions'
4
4
  import { notify } from '@stacksjs/notifications'
5
5
  import { NotificationDelivery } from '@stacksjs/orm'
6
6
  import { response } from '@stacksjs/router'
7
+ import { dashboardOperationalError } from '../dashboard-response'
7
8
  import { parseDeliveryMetadata } from './notification-delivery'
8
9
 
9
10
  type RetryChannel = 'email' | 'sms'
@@ -19,10 +20,16 @@ export default new Action({
19
20
  apiResponse: true,
20
21
  async handle(request: RequestInstance) {
21
22
  const id = Number(request.getParam('id'))
22
- if (!Number.isFinite(id) || id <= 0)
23
- return response.json({ message: 'A valid delivery ID is required.' }, 422)
23
+ if (!Number.isSafeInteger(id) || id <= 0)
24
+ return response.json({ message: 'A valid delivery ID is required.' }, 400)
24
25
 
25
- const delivery = await NotificationDelivery.find(id)
26
+ let delivery: Awaited<ReturnType<typeof NotificationDelivery.find>>
27
+ try {
28
+ delivery = await NotificationDelivery.find(id)
29
+ }
30
+ catch (error) {
31
+ return dashboardOperationalError(error, 'Notification delivery could not be loaded.', 'NotificationDeliveryRetryAction.lookup')
32
+ }
26
33
  if (!delivery)
27
34
  return response.json({ message: 'Notification delivery not found.' }, 404)
28
35
 
@@ -34,27 +41,33 @@ export default new Action({
34
41
  if (!recipientValue)
35
42
  return response.json({ message: 'The original delivery has no recipient.' }, 422)
36
43
 
37
- const channels: NotificationChannel[] = [channel]
38
- const results = await notify(
39
- {
40
- userId: delivery.get('user_id') ? Number(delivery.get('user_id')) : undefined,
41
- ...(channel === 'email' ? { email: recipientValue } : { phone: recipientValue }),
42
- },
43
- {
44
- subject: String(delivery.get('subject') || ''),
45
- body: String(delivery.get('body') || ''),
46
- data: parseDeliveryMetadata(
47
- delivery.get('metadata'),
48
- `notification delivery ${id} metadata`,
49
- ),
50
- },
51
- channels,
52
- { ignorePreferences: true },
53
- )
44
+ let results: Awaited<ReturnType<typeof notify>>
45
+ try {
46
+ const channels: NotificationChannel[] = [channel]
47
+ results = await notify(
48
+ {
49
+ userId: delivery.get('user_id') ? Number(delivery.get('user_id')) : undefined,
50
+ ...(channel === 'email' ? { email: recipientValue } : { phone: recipientValue }),
51
+ },
52
+ {
53
+ subject: String(delivery.get('subject') || ''),
54
+ body: String(delivery.get('body') || ''),
55
+ data: parseDeliveryMetadata(
56
+ delivery.get('metadata'),
57
+ `notification delivery ${id} metadata`,
58
+ ),
59
+ },
60
+ channels,
61
+ { ignorePreferences: true },
62
+ )
63
+ }
64
+ catch (error) {
65
+ return dashboardOperationalError(error, 'The notification retry could not be completed.', 'NotificationDeliveryRetryAction.provider', 502)
66
+ }
54
67
 
55
68
  const result = results[0]
56
69
  if (!result?.success)
57
- return response.json({ message: result?.error?.message || 'The retry failed.' }, 502)
70
+ return dashboardOperationalError(result?.error, 'The notification retry failed.', 'NotificationDeliveryRetryAction.result', 502)
58
71
 
59
72
  return response.json({ success: true })
60
73
  },
@@ -1,7 +1,7 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { config } from '@stacksjs/config'
3
3
  import { db } from '@stacksjs/database'
4
- import { response } from '@stacksjs/router'
4
+ import { dashboardOperationalError } from '../dashboard-response'
5
5
  import { dashboardQueryColumns, mapDashboardQueryLog, type QueryLogSourceRow } from './query-dashboard'
6
6
 
7
7
  export default new Action({
@@ -27,9 +27,7 @@ export default new Action({
27
27
  }
28
28
  }
29
29
  catch (error) {
30
- return response.json({
31
- message: error instanceof Error ? error.message : 'Query logs could not be loaded.',
32
- }, 503)
30
+ return dashboardOperationalError(error, 'Query logs could not be loaded.', 'QueryDashboardAction')
33
31
  }
34
32
  },
35
33
  })
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { db } from '@stacksjs/database'
4
4
  import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
  import { dashboardQueryColumns, mapDashboardQueryLog, type QueryLogSourceRow } from './query-dashboard'
6
7
 
7
8
  export default new Action({
@@ -28,9 +29,7 @@ export default new Action({
28
29
  return { query: mapDashboardQueryLog(row as QueryLogSourceRow) }
29
30
  }
30
31
  catch (error) {
31
- return response.json({
32
- message: error instanceof Error ? error.message : 'Query log could not be loaded.',
33
- }, 503)
32
+ return dashboardOperationalError(error, 'Query log could not be loaded.', 'QueryShowAction')
34
33
  }
35
34
  },
36
35
  })
@@ -1,7 +1,7 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { FailedJob, Job } from '@stacksjs/orm'
3
3
  import { getGlobalMetrics } from '@stacksjs/queue'
4
- import { response } from '@stacksjs/router'
4
+ import { dashboardOperationalError } from '../dashboard-response'
5
5
 
6
6
  interface QueueBucket {
7
7
  pending: number
@@ -81,9 +81,7 @@ export default new Action({
81
81
  return { queues, stats, queueConnected: true }
82
82
  }
83
83
  catch (error) {
84
- return response.json({
85
- message: error instanceof Error ? error.message : 'Queue statistics could not be loaded.',
86
- }, 503)
84
+ return dashboardOperationalError(error, 'Queue statistics could not be loaded.', 'QueueStatsAction')
87
85
  }
88
86
  },
89
87
  })