@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
@@ -1,8 +1,10 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { db } from '@stacksjs/database'
4
+ import { transaction } from '@stacksjs/orm'
4
5
  import { response } from '@stacksjs/router'
5
- import { findPost, invalidPostContent, invalidPostReference, postPayload, publishedAtFor, timestamp } from './post-input'
6
+ import { dashboardOperationalError } from '../dashboard-response'
7
+ import { findPost, invalidPostContent, invalidPostReference, postPayload, publishedAtFor, syncPostRelations, timestamp } from './post-input'
6
8
 
7
9
  /**
8
10
  * `PATCH /api/dashboard/posts/{id}` — updates a CMS post from the dashboard.
@@ -27,45 +29,55 @@ export default new Action({
27
29
  if (invalidContent)
28
30
  return response.json({ message: invalidContent }, 422)
29
31
 
30
- const invalidReference = await invalidPostReference(payload)
31
- if (invalidReference)
32
- return response.json({ message: invalidReference }, 422)
32
+ try {
33
+ const result = await transaction(async (rawTrx) => {
34
+ const trx = rawTrx as unknown as typeof db
35
+ const invalidReference = await invalidPostReference(payload, trx)
36
+ if (invalidReference)
37
+ return { kind: 'invalid', message: invalidReference } as const
33
38
 
34
- const existing = await db
35
- .selectFrom('posts')
36
- .select(['id', 'published_at'])
37
- .where('id', '=', id)
38
- .executeTakeFirst() as { id: number, published_at: string | null } | undefined
39
+ const existing = await trx
40
+ .selectFrom('posts')
41
+ .select(['id', 'published_at'])
42
+ .where('id', '=', id)
43
+ .executeTakeFirst() as { id: number, published_at: string | null } | undefined
39
44
 
40
- if (!existing)
41
- return response.json({ message: 'Post not found.' }, 404)
45
+ if (!existing)
46
+ return { kind: 'not-found' } as const
42
47
 
43
- await db
44
- .updateTable('posts')
45
- .set({
46
- title: payload.title,
47
- excerpt: payload.excerpt,
48
- content: payload.content,
49
- poster: payload.poster,
50
- status: payload.status,
51
- author_id: payload.authorId,
52
- is_featured: payload.featured ? 1 : 0,
53
- published_at: publishedAtFor(payload.status, existing.published_at, timestamp()),
54
- updated_at: timestamp(),
55
- } as any)
56
- .where('id', '=', id)
57
- .execute()
48
+ await trx
49
+ .updateTable('posts')
50
+ .set({
51
+ title: payload.title,
52
+ excerpt: payload.excerpt,
53
+ content: payload.content,
54
+ poster: payload.poster,
55
+ status: payload.status,
56
+ author_id: payload.authorId,
57
+ is_featured: payload.featured ? 1 : 0,
58
+ published_at: publishedAtFor(payload.status, existing.published_at, timestamp()),
59
+ updated_at: timestamp(),
60
+ } as any)
61
+ .where('id', '=', id)
62
+ .execute()
58
63
 
59
- const post = await Post.find(id)
64
+ await syncPostRelations(trx, id, payload)
60
65
 
61
- if (!post)
62
- return response.json({ message: 'Updated post could not be loaded.' }, 500)
66
+ const updated = await findPost(id, trx)
67
+ if (!updated)
68
+ throw new Error('Updated post could not be read back.')
69
+ return { kind: 'updated', post: updated } as const
70
+ })
63
71
 
64
- await Promise.all([
65
- post.categories().sync(payload.categoryIds),
66
- post.tags().sync(payload.tagIds),
67
- ])
72
+ if (result.kind === 'invalid')
73
+ return response.json({ message: result.message }, 422)
74
+ if (result.kind === 'not-found')
75
+ return response.json({ message: 'Post not found.' }, 404)
68
76
 
69
- return response.json(await findPost(id))
77
+ return response.json(result.post)
78
+ }
79
+ catch (error) {
80
+ return dashboardOperationalError(error, 'Post could not be updated.', 'PostUpdateAction', 500)
81
+ }
70
82
  },
71
83
  })
@@ -1,13 +1,16 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
+ import { db } from '@stacksjs/database'
4
+ import { transaction } from '@stacksjs/orm'
3
5
  import { response } from '@stacksjs/router'
6
+ import { dashboardOperationalError } from '../dashboard-response'
4
7
  import { rowId } from './content-input'
5
8
 
6
9
  /**
7
10
  * `DELETE /api/dashboard/tags/{id}` — deletes a CMS tag.
8
11
  *
9
- * Detaches related posts through the model relation before removing the tag so
10
- * no pivot rows are orphaned.
12
+ * Detaches related posts through the model-declared pivot before removing the
13
+ * tag, with both writes committed by the same transaction.
11
14
  */
12
15
  export default new Action({
13
16
  name: 'TagDestroyAction',
@@ -19,13 +22,29 @@ export default new Action({
19
22
  if (!id)
20
23
  return response.json({ message: 'A valid tag id is required.' }, 422)
21
24
 
22
- const tag = await Tag.find(id)
23
- if (!tag)
24
- return response.json({ message: 'Tag not found.' }, 404)
25
+ try {
26
+ const deleted = await transaction(async (rawTrx) => {
27
+ const trx = rawTrx as unknown as typeof db
28
+ const tag = await trx.selectFrom('tags').select(['id']).where('id', '=', id).executeTakeFirst()
29
+ if (!tag)
30
+ return false
25
31
 
26
- await tag.posts().detach()
27
- await tag.delete()
32
+ await trx
33
+ .deleteFrom('taggable_models')
34
+ .where('tag_id', '=', id)
35
+ .where('taggable_type', '=', 'posts')
36
+ .execute()
37
+ await trx.deleteFrom('tags').where('id', '=', id).execute()
38
+ return true
39
+ })
28
40
 
29
- return response.json({ message: 'Tag deleted.', id })
41
+ if (!deleted)
42
+ return response.json({ message: 'Tag not found.' }, 404)
43
+
44
+ return response.json({ message: 'Tag deleted.', id })
45
+ }
46
+ catch (error) {
47
+ return dashboardOperationalError(error, 'Tag could not be deleted.', 'TagDestroyAction', 500)
48
+ }
30
49
  },
31
50
  })
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database'
3
+ import { dashboardOperationalError } from '../dashboard-response'
3
4
 
4
5
  interface TagRow {
5
6
  id: number
@@ -28,32 +29,37 @@ export default new Action({
28
29
  method: 'GET',
29
30
  apiResponse: true,
30
31
  async handle() {
31
- const [rows, countRows] = await Promise.all([
32
- db
33
- .selectFrom('tags')
34
- .select(['id', 'name', 'slug', 'description', 'color', 'created_at', 'updated_at'])
35
- .orderBy('created_at', 'desc')
36
- .execute() as unknown as Promise<TagRow[]>,
37
- db
38
- .selectFrom('taggable_models')
39
- .select(['tag_id', db.fn.count('taggable_id').as('count')])
40
- .where('taggable_type', '=', 'posts')
41
- .groupBy('tag_id')
42
- .execute() as unknown as Promise<TagCountRow[]>,
43
- ])
44
- const countByTag = new Map(countRows.map(row => [Number(row.tag_id), Number(row.count)]))
32
+ try {
33
+ const [rows, countRows] = await Promise.all([
34
+ db
35
+ .selectFrom('tags')
36
+ .select(['id', 'name', 'slug', 'description', 'color', 'created_at', 'updated_at'])
37
+ .orderBy('created_at', 'desc')
38
+ .execute() as unknown as Promise<TagRow[]>,
39
+ db
40
+ .selectFrom('taggable_models')
41
+ .select(['tag_id', db.fn.count('taggable_id').as('count')])
42
+ .where('taggable_type', '=', 'posts')
43
+ .groupBy('tag_id')
44
+ .execute() as unknown as Promise<TagCountRow[]>,
45
+ ])
46
+ const countByTag = new Map(countRows.map(row => [Number(row.tag_id), Number(row.count)]))
45
47
 
46
- const tags = rows.map(row => ({
47
- id: Number(row.id),
48
- name: String(row.name || ''),
49
- slug: String(row.slug || ''),
50
- description: String(row.description || ''),
51
- color: String(row.color || ''),
52
- post_count: countByTag.get(Number(row.id)) || 0,
53
- created_at: row.created_at || null,
54
- updated_at: row.updated_at || null,
55
- }))
48
+ const tags = rows.map(row => ({
49
+ id: Number(row.id),
50
+ name: String(row.name || ''),
51
+ slug: String(row.slug || ''),
52
+ description: String(row.description || ''),
53
+ color: String(row.color || ''),
54
+ post_count: countByTag.get(Number(row.id)) || 0,
55
+ created_at: row.created_at || null,
56
+ updated_at: row.updated_at || null,
57
+ }))
56
58
 
57
- return { tags }
59
+ return { tags }
60
+ }
61
+ catch (error) {
62
+ return dashboardOperationalError(error, 'Tags could not be loaded.', 'TagIndexAction')
63
+ }
58
64
  },
59
65
  })
@@ -1,8 +1,10 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { db } from '@stacksjs/database'
4
+ import { isUniqueViolation, transaction } from '@stacksjs/orm'
4
5
  import { response } from '@stacksjs/router'
5
6
  import { randomUUIDv7 } from 'bun'
7
+ import { dashboardOperationalError } from '../dashboard-response'
6
8
  import { findRow, insertedId, slugify, str, timestamp } from './content-input'
7
9
 
8
10
  /**
@@ -26,34 +28,53 @@ export default new Action({
26
28
  if (!slug)
27
29
  return response.json({ message: 'Slug could not be derived from the name; enter one.' }, 422)
28
30
 
29
- const duplicate = await db
30
- .selectFrom('tags')
31
- .select(['id'])
32
- .where('slug', '=', slug)
33
- .executeTakeFirst()
31
+ try {
32
+ const tag = await transaction(async (rawTrx) => {
33
+ const trx = rawTrx as unknown as typeof db
34
+ const duplicate = await trx
35
+ .selectFrom('tags')
36
+ .select(['id'])
37
+ .where('slug', '=', slug)
38
+ .orWhere('name', '=', name)
39
+ .executeTakeFirst()
34
40
 
35
- if (duplicate)
36
- return response.json({ message: 'A tag with that slug already exists.' }, 422)
41
+ if (duplicate)
42
+ return null
37
43
 
38
- const now = timestamp()
44
+ const now = timestamp()
39
45
 
40
- const result = await db
41
- .insertInto('tags')
42
- .values({
43
- uuid: randomUUIDv7(),
44
- name,
45
- slug,
46
- description,
47
- created_at: now,
48
- updated_at: now,
49
- } as any)
50
- .executeTakeFirst()
46
+ const result = await trx
47
+ .insertInto('tags')
48
+ .values({
49
+ uuid: randomUUIDv7(),
50
+ name,
51
+ slug,
52
+ description,
53
+ created_at: now,
54
+ updated_at: now,
55
+ } as any)
56
+ .executeTakeFirst()
51
57
 
52
- const id = insertedId(result)
58
+ const id = insertedId(result)
53
59
 
54
- if (!id)
55
- return response.json({ message: 'Could not create tag.' }, 500)
60
+ if (!id)
61
+ throw new Error('Tag insert did not return an id.')
56
62
 
57
- return response.json(await findRow('tags', id), 201)
63
+ const created = await findRow('tags', id, trx)
64
+ if (!created)
65
+ throw new Error('Created tag could not be loaded.')
66
+ return created
67
+ })
68
+
69
+ if (!tag)
70
+ return response.json({ message: 'A tag with that name or slug already exists.' }, 422)
71
+
72
+ return response.json(tag, 201)
73
+ }
74
+ catch (error) {
75
+ if (isUniqueViolation(error))
76
+ return response.json({ message: 'A tag with that name or slug already exists.' }, 422)
77
+ return dashboardOperationalError(error, 'Tag could not be created.', 'TagStoreAction', 500)
78
+ }
58
79
  },
59
80
  })
@@ -53,13 +53,13 @@ export function slugify(value: string): string {
53
53
  * `table` is a literal at every call site; the cast is only here because the
54
54
  * query builder's types want a table union rather than a `string`.
55
55
  */
56
- export async function findRow(table: string, id: number): Promise<unknown> {
57
- return await (db as any).selectFrom(table).selectAll().where('id', '=', id).executeTakeFirst()
56
+ export async function findRow(table: string, id: number, database: typeof db = db): Promise<unknown> {
57
+ return await (database as any).selectFrom(table).selectAll().where('id', '=', id).executeTakeFirst()
58
58
  }
59
59
 
60
60
  /** Whether a row with this id exists — the 404 check every write shares. */
61
- export async function rowExists(table: string, id: number): Promise<boolean> {
62
- const row = await (db as any).selectFrom(table).select(['id']).where('id', '=', id).executeTakeFirst()
61
+ export async function rowExists(table: string, id: number, database: typeof db = db): Promise<boolean> {
62
+ const row = await (database as any).selectFrom(table).select(['id']).where('id', '=', id).executeTakeFirst()
63
63
 
64
64
  return Boolean(row)
65
65
  }
@@ -1,8 +1,8 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { db } from '@stacksjs/database'
3
- import { str } from './content-input'
3
+ import { insertedId, str, timestamp } from './content-input'
4
4
 
5
- export { insertedId, timestamp } from './content-input'
5
+ export { insertedId, timestamp }
6
6
 
7
7
  export interface PostPayload {
8
8
  title: string
@@ -42,8 +42,8 @@ export function publishedAtFor(status: string, existing: string | null, now: str
42
42
  }
43
43
 
44
44
  /** Reads a post row back after a write — see `findRow` for why writes re-select. */
45
- export async function findPost(id: number): Promise<unknown> {
46
- return await db.selectFrom('posts').selectAll().where('id', '=', id).executeTakeFirst()
45
+ export async function findPost(id: number, database: typeof db = db): Promise<unknown> {
46
+ return await database.selectFrom('posts').selectAll().where('id', '=', id).executeTakeFirst()
47
47
  }
48
48
 
49
49
  function ids(value: unknown): number[] {
@@ -83,16 +83,16 @@ export function invalidPostContent(payload: PostPayload): string | null {
83
83
  return null
84
84
  }
85
85
 
86
- export async function invalidPostReference(payload: PostPayload): Promise<string | null> {
86
+ export async function invalidPostReference(payload: PostPayload, database: typeof db = db): Promise<string | null> {
87
87
  const [author, categoryRows, tagRows] = await Promise.all([
88
88
  payload.authorId
89
- ? db.selectFrom('authors').select(['id']).where('id', '=', payload.authorId).executeTakeFirst()
89
+ ? database.selectFrom('authors').select(['id']).where('id', '=', payload.authorId).executeTakeFirst()
90
90
  : Promise.resolve(undefined),
91
91
  payload.categoryIds.length
92
- ? db.selectFrom('categories').whereIn('id', payload.categoryIds).select(['id']).execute()
92
+ ? database.selectFrom('categories').whereIn('id', payload.categoryIds).select(['id']).execute()
93
93
  : Promise.resolve([]),
94
94
  payload.tagIds.length
95
- ? db.selectFrom('tags').whereIn('id', payload.tagIds).select(['id']).execute()
95
+ ? database.selectFrom('tags').whereIn('id', payload.tagIds).select(['id']).execute()
96
96
  : Promise.resolve([]),
97
97
  ])
98
98
 
@@ -106,6 +106,58 @@ export async function invalidPostReference(payload: PostPayload): Promise<string
106
106
  return null
107
107
  }
108
108
 
109
+ async function replacePostPivot(
110
+ database: typeof db,
111
+ table: 'categorizable_models' | 'taggable_models',
112
+ foreignKey: 'category_id' | 'tag_id',
113
+ ownerKey: 'categorizable_id' | 'taggable_id',
114
+ typeKey: 'categorizable_type' | 'taggable_type',
115
+ postId: number,
116
+ relatedIds: number[],
117
+ now: string,
118
+ ): Promise<void> {
119
+ await (database as any)
120
+ .deleteFrom(table)
121
+ .where(ownerKey, '=', postId)
122
+ .where(typeKey, '=', 'posts')
123
+ .execute()
124
+
125
+ if (relatedIds.length === 0)
126
+ return
127
+
128
+ await (database as any)
129
+ .insertInto(table)
130
+ .values(relatedIds.map(relatedId => ({
131
+ [foreignKey]: relatedId,
132
+ [ownerKey]: postId,
133
+ [typeKey]: 'posts',
134
+ created_at: now,
135
+ updated_at: now,
136
+ })))
137
+ .execute()
138
+ }
139
+
140
+ /** Replaces both post pivot sets on the caller's database or transaction. */
141
+ export async function syncPostRelations(database: typeof db, postId: number, payload: Pick<PostPayload, 'categoryIds' | 'tagIds'>): Promise<void> {
142
+ const now = timestamp()
143
+ await replacePostPivot(database, 'categorizable_models', 'category_id', 'categorizable_id', 'categorizable_type', postId, payload.categoryIds, now)
144
+ await replacePostPivot(database, 'taggable_models', 'tag_id', 'taggable_id', 'taggable_type', postId, payload.tagIds, now)
145
+ }
146
+
147
+ /** Removes both post pivot sets on the caller's database or transaction. */
148
+ export async function detachPostRelations(database: typeof db, postId: number): Promise<void> {
149
+ await (database as any)
150
+ .deleteFrom('categorizable_models')
151
+ .where('categorizable_id', '=', postId)
152
+ .where('categorizable_type', '=', 'posts')
153
+ .execute()
154
+ await (database as any)
155
+ .deleteFrom('taggable_models')
156
+ .where('taggable_id', '=', postId)
157
+ .where('taggable_type', '=', 'posts')
158
+ .execute()
159
+ }
160
+
109
161
  /** Maps a dashboard request body onto the writable post columns. */
110
162
  export function postPayload(request: RequestInstance): PostPayload {
111
163
  return {
@@ -1,6 +1,7 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { Activity } from '@stacksjs/orm'
3
3
  import { safeGet } from '../../../resources/functions/dashboard/data'
4
+ import { dashboardOperationalError } from './dashboard-response'
4
5
  import { dateValue, numberValue, textValue } from './Data/data-records'
5
6
 
6
7
  export function activityStatus(type: string): 'success' | 'warning' {
@@ -35,11 +36,7 @@ export default new Action({
35
36
  return { activity, count: activity.length }
36
37
  }
37
38
  catch (error) {
38
- return {
39
- activity: [],
40
- count: 0,
41
- error: error instanceof Error ? error.message : 'Activity records could not be loaded.',
42
- }
39
+ return dashboardOperationalError(error, 'Activity records could not be loaded.', 'DashboardActivityAction')
43
40
  }
44
41
  },
45
42
  })
@@ -1,6 +1,7 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { Activity } from '@stacksjs/orm'
3
3
  import { safeGet } from '../../../../resources/functions/dashboard/data'
4
+ import { dashboardOperationalError } from '../dashboard-response'
4
5
  import { dateValue, daysAgoIso, numberValue, textValue } from './data-records'
5
6
 
6
7
  export default new Action({
@@ -37,11 +38,7 @@ export default new Action({
37
38
  }
38
39
  }
39
40
  catch (error) {
40
- return {
41
- activities: [],
42
- stats: { total: 0, last24Hours: 0, last7Days: 0 },
43
- error: error instanceof Error ? error.message : 'Activity could not be loaded.',
44
- }
41
+ return dashboardOperationalError(error, 'Activity could not be loaded.', 'ActivityIndexAction')
45
42
  }
46
43
  },
47
44
  })
@@ -1,6 +1,7 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { Subscriber } from '@stacksjs/orm'
3
3
  import { safeGet } from '../../../../resources/functions/dashboard/data'
4
+ import { dashboardOperationalError } from '../dashboard-response'
4
5
  import { dateValue, daysAgoIso, numberValue, textValue } from './data-records'
5
6
 
6
7
  export default new Action({
@@ -38,11 +39,7 @@ export default new Action({
38
39
  }
39
40
  }
40
41
  catch (error) {
41
- return {
42
- subscribers: [],
43
- stats: { total: 0, subscribed: 0, unsubscribed: 0, newThisMonth: 0 },
44
- error: error instanceof Error ? error.message : 'Subscribers could not be loaded.',
45
- }
42
+ return dashboardOperationalError(error, 'Subscribers could not be loaded.', 'SubscriberIndexAction')
46
43
  }
47
44
  },
48
45
  })
@@ -1,6 +1,7 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { Team } from '@stacksjs/orm'
3
3
  import { safeGet } from '../../../../resources/functions/dashboard/data'
4
+ import { dashboardOperationalError } from '../dashboard-response'
4
5
  import { dateValue, daysAgoIso, numberValue, textValue } from './data-records'
5
6
 
6
7
  export default new Action({
@@ -39,11 +40,7 @@ export default new Action({
39
40
  }
40
41
  }
41
42
  catch (error) {
42
- return {
43
- teams: [],
44
- stats: { total: 0, active: 0, members: 0, newThisMonth: 0 },
45
- error: error instanceof Error ? error.message : 'Teams could not be loaded.',
46
- }
43
+ return dashboardOperationalError(error, 'Teams could not be loaded.', 'TeamIndexAction')
47
44
  }
48
45
  },
49
46
  })
@@ -1,6 +1,7 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { User } from '@stacksjs/orm'
3
3
  import { safeGet } from '../../../../resources/functions/dashboard/data'
4
+ import { dashboardOperationalError } from '../dashboard-response'
4
5
  import { dateValue, daysAgoIso, numberValue, textValue } from './data-records'
5
6
 
6
7
  export default new Action({
@@ -36,11 +37,7 @@ export default new Action({
36
37
  }
37
38
  }
38
39
  catch (error) {
39
- return {
40
- users: [],
41
- stats: { total: 0, newThisWeek: 0, verified: 0 },
42
- error: error instanceof Error ? error.message : 'Users could not be loaded.',
43
- }
40
+ return dashboardOperationalError(error, 'Users could not be loaded.', 'UserIndexAction')
44
41
  }
45
42
  },
46
43
  })
@@ -4,6 +4,7 @@ import { join } from 'node:path'
4
4
  import process from 'node:process'
5
5
  import { Deployment } from '@stacksjs/orm'
6
6
  import { response } from '@stacksjs/router'
7
+ import { dashboardOperationalError, dashboardOperationalIssue } from '../dashboard-response'
7
8
  import { booleanValue, deploymentCommandArgs } from './deployment-input'
8
9
 
9
10
  function gitValue(args: string[]): string {
@@ -63,10 +64,7 @@ export default new Action({
63
64
  })
64
65
  }
65
66
  catch (error) {
66
- return response.json({
67
- success: false,
68
- message: error instanceof Error ? error.message : 'Deployment record could not be created.',
69
- }, { status: 500 })
67
+ return dashboardOperationalError(error, 'Deployment record could not be created.', 'CreateDeployment.record', 500)
70
68
  }
71
69
  }
72
70
 
@@ -81,27 +79,33 @@ export default new Action({
81
79
  }
82
80
  catch (error) {
83
81
  if (deployment) {
84
- await deployment.update({
85
- status: 'failed',
86
- duration: 0,
87
- errorLog: error instanceof Error ? error.message : String(error),
88
- })
82
+ try {
83
+ await deployment.update({
84
+ status: 'failed',
85
+ duration: 0,
86
+ errorLog: 'Deployment process could not be started.',
87
+ })
88
+ }
89
+ catch (updateError) {
90
+ dashboardOperationalIssue(updateError, 'Deployment failure status could not be recorded.', 'CreateDeployment.failureRecord')
91
+ }
89
92
  }
90
- return response.json({
91
- success: false,
92
- message: error instanceof Error ? error.message : 'Deployment process could not be started.',
93
- }, { status: 500 })
93
+ return dashboardOperationalError(error, 'Deployment process could not be started.', 'CreateDeployment.process', 500)
94
94
  }
95
95
 
96
96
  if (deployment) {
97
97
  const record = deployment
98
- void child.exited.then(async (exitCode) => {
99
- await record.update({
100
- status: exitCode === 0 ? 'success' : 'failed',
101
- duration: Math.max(0, Math.round((Date.now() - startedAt) / 1000)),
102
- errorLog: exitCode === 0 ? '' : `buddy deploy exited with code ${exitCode}`,
98
+ void child.exited
99
+ .then(async (exitCode) => {
100
+ await record.update({
101
+ status: exitCode === 0 ? 'success' : 'failed',
102
+ duration: Math.max(0, Math.round((Date.now() - startedAt) / 1000)),
103
+ errorLog: exitCode === 0 ? '' : `buddy deploy exited with code ${exitCode}`,
104
+ })
105
+ })
106
+ .catch((error) => {
107
+ dashboardOperationalIssue(error, 'Deployment completion could not be recorded.', 'CreateDeployment.completionRecord')
103
108
  })
104
- })
105
109
  }
106
110
 
107
111
  return {
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { Deployment } from '@stacksjs/orm'
3
+ import { dashboardOperationalError } from '../dashboard-response'
3
4
  import { averageRecordedDuration } from './deployment-input'
4
5
 
5
6
  export default new Action({
@@ -9,7 +10,12 @@ export default new Action({
9
10
  apiResponse: true,
10
11
 
11
12
  async handle() {
12
- const deployments = await Deployment.all()
13
- return { average_seconds: averageRecordedDuration(deployments) }
13
+ try {
14
+ const deployments = await Deployment.all()
15
+ return { average_seconds: averageRecordedDuration(deployments) }
16
+ }
17
+ catch (error) {
18
+ return dashboardOperationalError(error, 'Average deployment time could not be loaded.', 'GetAverageDeploymentTime')
19
+ }
14
20
  },
15
21
  })