@stacksjs/defaults 0.70.355 → 0.70.356

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 (155) hide show
  1. package/ai/skills/stacks-email/SKILL.md +38 -1
  2. package/app/Actions/ContactAction.ts +2 -2
  3. package/app/Actions/Dashboard/Analytics/EventAnalyticsAction.ts +2 -3
  4. package/app/Actions/Dashboard/Analytics/MarketingAnalyticsAction.ts +2 -3
  5. package/app/Actions/Dashboard/Analytics/SalesAnalyticsAction.ts +2 -3
  6. package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +9 -3
  7. package/app/Actions/Dashboard/Auth/MeAction.ts +4 -7
  8. package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +2 -3
  9. package/app/Actions/Dashboard/Cloud/CloudIndexAction.ts +8 -1
  10. package/app/Actions/Dashboard/Cloud/ServerlessIndexAction.ts +15 -8
  11. package/app/Actions/Dashboard/Commerce/CommerceCategoriesAction.ts +2 -4
  12. package/app/Actions/Dashboard/Commerce/CommerceCouponsAction.ts +2 -4
  13. package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +2 -4
  14. package/app/Actions/Dashboard/Commerce/CommerceDashboardAction.ts +2 -4
  15. package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +2 -4
  16. package/app/Actions/Dashboard/Commerce/CommerceGiftCardsAction.ts +2 -4
  17. package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +2 -4
  18. package/app/Actions/Dashboard/Commerce/CommercePaymentsAction.ts +2 -4
  19. package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +2 -4
  20. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +75 -61
  21. package/app/Actions/Dashboard/Commerce/CommercePrintDevicesAction.ts +2 -4
  22. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +2 -4
  23. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +3 -4
  24. package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +2 -4
  25. package/app/Actions/Dashboard/Commerce/CommerceTaxesAction.ts +2 -4
  26. package/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts +2 -4
  27. package/app/Actions/Dashboard/Commerce/DigitalDeliveryIndexAction.ts +2 -4
  28. package/app/Actions/Dashboard/Commerce/DriverIndexAction.ts +2 -4
  29. package/app/Actions/Dashboard/Commerce/LicenseKeyIndexAction.ts +2 -4
  30. package/app/Actions/Dashboard/Commerce/LicenseKeyOptionsAction.ts +2 -4
  31. package/app/Actions/Dashboard/Commerce/ManufacturerIndexAction.ts +2 -4
  32. package/app/Actions/Dashboard/Commerce/ProductUnitIndexAction.ts +2 -4
  33. package/app/Actions/Dashboard/Commerce/ProductVariantIndexAction.ts +2 -4
  34. package/app/Actions/Dashboard/Commerce/ProductWaitlistIndexAction.ts +2 -4
  35. package/app/Actions/Dashboard/Commerce/RestaurantWaitlistIndexAction.ts +2 -4
  36. package/app/Actions/Dashboard/Commerce/ReviewIndexAction.ts +2 -4
  37. package/app/Actions/Dashboard/Commerce/ShippingMethodIndexAction.ts +2 -4
  38. package/app/Actions/Dashboard/Commerce/ShippingRateIndexAction.ts +2 -4
  39. package/app/Actions/Dashboard/Commerce/ShippingZoneIndexAction.ts +2 -4
  40. package/app/Actions/Dashboard/DashboardActivityAction.ts +2 -5
  41. package/app/Actions/Dashboard/Data/ActivityIndexAction.ts +2 -5
  42. package/app/Actions/Dashboard/Data/SubscriberIndexAction.ts +2 -5
  43. package/app/Actions/Dashboard/Data/TeamIndexAction.ts +2 -5
  44. package/app/Actions/Dashboard/Data/UserIndexAction.ts +2 -5
  45. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +23 -19
  46. package/app/Actions/Dashboard/Deployments/GetAverageDeploymentTime.ts +8 -2
  47. package/app/Actions/Dashboard/Deployments/GetDeployScript.ts +3 -6
  48. package/app/Actions/Dashboard/Deployments/GetDeployment.ts +4 -5
  49. package/app/Actions/Dashboard/Deployments/GetDeploymentCount.ts +7 -1
  50. package/app/Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput.ts +10 -9
  51. package/app/Actions/Dashboard/Deployments/GetDeployments.ts +2 -4
  52. package/app/Actions/Dashboard/Deployments/GetRecentDeployments.ts +8 -2
  53. package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +18 -5
  54. package/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +2 -4
  55. package/app/Actions/Dashboard/Email/CapturedMailShowAction.ts +3 -4
  56. package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +15 -14
  57. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +2 -8
  58. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +2 -4
  59. package/app/Actions/Dashboard/Jobs/JobShowAction.ts +13 -8
  60. package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -4
  61. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +2 -3
  62. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +2 -3
  63. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +2 -3
  64. package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +2 -3
  65. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +2 -3
  66. package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +2 -3
  67. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +2 -3
  68. package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +2 -3
  69. package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +2 -3
  70. package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +2 -3
  71. package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +2 -3
  72. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +2 -3
  73. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +2 -3
  74. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +2 -3
  75. package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +2 -3
  76. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +2 -3
  77. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +2 -3
  78. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +2 -3
  79. package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +2 -3
  80. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +2 -3
  81. package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +2 -3
  82. package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +2 -3
  83. package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +2 -3
  84. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +2 -3
  85. package/app/Actions/Dashboard/Kanban/kanban-response.ts +5 -0
  86. package/app/Actions/Dashboard/Library/CreateComponent.ts +15 -6
  87. package/app/Actions/Dashboard/Library/CreateFunction.ts +15 -6
  88. package/app/Actions/Dashboard/Library/DependencyIndexAction.ts +88 -77
  89. package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +21 -15
  90. package/app/Actions/Dashboard/Library/GetComponents.ts +14 -7
  91. package/app/Actions/Dashboard/Library/GetDownloadCount.ts +14 -5
  92. package/app/Actions/Dashboard/Library/GetFunctions.ts +12 -5
  93. package/app/Actions/Dashboard/Library/GetReleaseCount.ts +14 -5
  94. package/app/Actions/Dashboard/Library/GetReleases.ts +17 -10
  95. package/app/Actions/Dashboard/Library/PackageIndexAction.ts +14 -7
  96. package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +7 -5
  97. package/app/Actions/Dashboard/Models/ModelShowAction.ts +82 -174
  98. package/app/Actions/Dashboard/Models/ModelStoreAction.ts +6 -4
  99. package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +9 -7
  100. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +47 -17
  101. package/app/Actions/Dashboard/Models/model-write.ts +20 -5
  102. package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +46 -36
  103. package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +14 -6
  104. package/app/Actions/Dashboard/Notifications/NotificationDeliveryOverviewAction.ts +96 -84
  105. package/app/Actions/Dashboard/Notifications/NotificationDeliveryRetryAction.ts +34 -21
  106. package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +2 -4
  107. package/app/Actions/Dashboard/Queries/QueryShowAction.ts +2 -3
  108. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +2 -4
  109. package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +2 -4
  110. package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +2 -2
  111. package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +2 -2
  112. package/app/Actions/Dashboard/Rbac/PermissionsIndexAction.ts +2 -3
  113. package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +2 -2
  114. package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +2 -2
  115. package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +2 -6
  116. package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +2 -2
  117. package/app/Actions/Dashboard/Rbac/RolesIndexAction.ts +4 -6
  118. package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +2 -2
  119. package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +2 -9
  120. package/app/Actions/Dashboard/Rbac/UsersListAction.ts +2 -3
  121. package/app/Actions/Dashboard/Rbac/rbac-response.ts +10 -0
  122. package/app/Actions/Dashboard/Realtime/RealtimeStatsAction.ts +2 -4
  123. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +68 -55
  124. package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +34 -16
  125. package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +52 -29
  126. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +102 -71
  127. package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +36 -17
  128. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +39 -26
  129. package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +71 -58
  130. package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +29 -7
  131. package/app/Actions/Dashboard/Teams/team-member-count.ts +4 -2
  132. package/app/Actions/Dashboard/Teams/team-records.test.ts +2 -0
  133. package/app/Actions/Dashboard/Teams/team-records.ts +2 -0
  134. package/app/Actions/Dashboard/dashboard-response.ts +16 -0
  135. package/app/Actions/SendWelcomeEmail.ts +1 -1
  136. package/app/Actions/Teams/AcceptInvitationAction.ts +74 -51
  137. package/app/Actions/Teams/ShowInvitationAction.ts +21 -14
  138. package/app/Actions/Teams/team-response.ts +23 -0
  139. package/app/Mail/OrderConfirmation.ts +1 -1
  140. package/app/Mail/SubscriptionConfirmation.ts +1 -1
  141. package/app/Mail/TeamInvitation.ts +1 -1
  142. package/app/Models/EmailIdempotency.ts +64 -0
  143. package/app/Models/EmailSuppression.ts +52 -0
  144. package/app/Models/EmailWebhookEvent.ts +51 -0
  145. package/app/Models/TeamInvitation.ts +13 -0
  146. package/functions/data.ts +0 -1
  147. package/functions/jobs.ts +0 -1
  148. package/ide/vscode/package.json +1 -1
  149. package/package.json +1 -1
  150. package/resources/components/Dashboard/Data/DataRecords.stx +0 -4
  151. package/resources/components/Dashboard/Jobs/JobDetail.stx +3 -2
  152. package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +7 -13
  153. package/resources/components/Dashboard/Models/ModelsOverview.stx +1 -1
  154. package/views/dashboard/.discovered-models.json +30 -0
  155. package/views/dashboard/stores/auth.ts +15 -14
@@ -2,9 +2,12 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import { Action } from '@stacksjs/actions'
4
4
  import { db } from '@stacksjs/database'
5
+ import { isUniqueViolation } from '@stacksjs/orm'
5
6
  import { response } from '@stacksjs/router'
7
+ import { TeamStateConflictError, teamOperationalError } from '../../Teams/team-response'
6
8
  import { deliverTeamInvitation } from './team-invitation-delivery'
7
9
  import {
10
+ changedRows,
8
11
  generateInvitationToken,
9
12
  hashInvitationToken,
10
13
  invitationExpiresAt,
@@ -38,95 +41,123 @@ export default new Action({
38
41
  if (!role)
39
42
  return response.json({ message: 'Choose a valid invitation role.' }, 422)
40
43
 
41
- const team = await db
42
- .selectFrom('teams')
43
- .where('id', '=', teamId)
44
- .select(['id', 'name'])
45
- .executeTakeFirst()
46
- if (!team)
47
- return response.json({ message: 'Team not found.' }, 404)
48
-
49
- const existingUser = await db
50
- .selectFrom('users')
51
- .where('email', '=', email)
52
- .select(['id'])
53
- .executeTakeFirst()
54
- if (existingUser) {
55
- const membership = await (db as any)
56
- .selectFrom('team_members')
57
- .where('team_id', '=', teamId)
58
- .where('user_id', '=', Number(existingUser.id))
59
- .select(['id'])
60
- .executeTakeFirst()
61
- if (membership)
62
- return response.json({ message: 'This person is already a member of the team.' }, 409)
63
- }
64
-
65
- const pending = await (db as any)
66
- .selectFrom('team_invitations')
67
- .where('team_id', '=', teamId)
68
- .where('email', '=', email)
69
- .where('status', '=', 'pending')
70
- .select(['id', 'expires_at'])
71
- .executeTakeFirst()
72
- if (pending && new Date(String(pending.expires_at).replace(' ', 'T')).getTime() > Date.now()) {
73
- return response.json({ message: 'A pending invitation already exists. Resend or revoke it from the invitation list.' }, 409)
74
- }
75
- if (pending) {
76
- await (db as any)
77
- .updateTable('team_invitations')
78
- .set({ status: 'expired', updated_at: sqlTimestamp() })
79
- .where('id', '=', Number(pending.id))
80
- .execute()
81
- }
82
-
83
44
  const token = generateInvitationToken()
84
45
  const tokenHash = hashInvitationToken(token)
46
+ const pendingKey = `${teamId}:${email}`
85
47
  const expiresAt = invitationExpiresAt()
86
- const requester = await request.user()
87
- const invitedByUserId = requester && Number.isInteger(Number(requester.id))
88
- ? Number(requester.id)
89
- : null
48
+ let created: { id: number, teamName: string } | Response
49
+ try {
50
+ const requester = await request.user()
51
+ const requesterId = Number(requester?.id)
52
+ const invitedByUserId = Number.isSafeInteger(requesterId) && requesterId > 0
53
+ ? requesterId
54
+ : null
90
55
 
91
- await (db as any)
92
- .insertInto('team_invitations')
93
- .values({
94
- team_id: teamId,
95
- email,
96
- role,
97
- token_hash: tokenHash,
98
- invited_by_user_id: invitedByUserId,
99
- status: 'pending',
100
- delivery_status: 'pending',
101
- expires_at: expiresAt,
102
- uuid: randomUUID(),
56
+ created = await db.transaction(async (rawTrx) => {
57
+ const trx = rawTrx as unknown as typeof db
58
+ const team = await trx
59
+ .selectFrom('teams')
60
+ .where('id', '=', teamId)
61
+ .select(['id', 'name'])
62
+ .executeTakeFirst()
63
+ if (!team)
64
+ return response.json({ message: 'Team not found.' }, 404)
65
+
66
+ const [existingUser, pending] = await Promise.all([
67
+ trx
68
+ .selectFrom('users')
69
+ .where('email', '=', email)
70
+ .select(['id'])
71
+ .executeTakeFirst(),
72
+ (trx as any)
73
+ .selectFrom('team_invitations')
74
+ .where('team_id', '=', teamId)
75
+ .where('email', '=', email)
76
+ .where('status', '=', 'pending')
77
+ .select(['id', 'expires_at', 'token_hash'])
78
+ .executeTakeFirst(),
79
+ ])
80
+ if (existingUser) {
81
+ const membership = await (trx as any)
82
+ .selectFrom('team_members')
83
+ .where('team_id', '=', teamId)
84
+ .where('user_id', '=', Number(existingUser.id))
85
+ .select(['id'])
86
+ .executeTakeFirst()
87
+ if (membership)
88
+ return response.json({ message: 'This person is already a member of the team.' }, 409)
89
+ }
90
+
91
+ if (pending && new Date(String(pending.expires_at).replace(' ', 'T')).getTime() > Date.now()) {
92
+ return response.json({ message: 'A pending invitation already exists. Resend or revoke it from the invitation list.' }, 409)
93
+ }
94
+ if (pending) {
95
+ const expired = await (trx as any)
96
+ .updateTable('team_invitations')
97
+ .set({ status: 'expired', pending_key: null, updated_at: sqlTimestamp() })
98
+ .where('id', '=', Number(pending.id))
99
+ .where('token_hash', '=', String(pending.token_hash))
100
+ .where('status', '=', 'pending')
101
+ .executeTakeFirst()
102
+ if (changedRows(expired) !== 1)
103
+ throw new TeamStateConflictError('The pending invitation changed before it could be replaced.')
104
+ }
105
+
106
+ await (trx as any)
107
+ .insertInto('team_invitations')
108
+ .values({
109
+ team_id: teamId,
110
+ email,
111
+ role,
112
+ token_hash: tokenHash,
113
+ pending_key: pendingKey,
114
+ invited_by_user_id: invitedByUserId,
115
+ status: 'pending',
116
+ delivery_status: 'pending',
117
+ expires_at: expiresAt,
118
+ uuid: randomUUID(),
119
+ })
120
+ .execute()
121
+
122
+ const invitation = await (trx as any)
123
+ .selectFrom('team_invitations')
124
+ .where('token_hash', '=', tokenHash)
125
+ .select(['id'])
126
+ .executeTakeFirst()
127
+ if (!invitation)
128
+ throw new Error('The inserted invitation could not be resolved by its token hash.')
129
+
130
+ return { id: Number(invitation.id), teamName: String(team.name) }
103
131
  })
104
- .execute()
132
+ }
133
+ catch (error) {
134
+ if (error instanceof TeamStateConflictError)
135
+ return response.json({ message: error.message }, 409)
136
+ if (isUniqueViolation(error))
137
+ return response.json({ message: 'A pending invitation already exists. Resend or revoke it from the invitation list.' }, 409)
138
+ return teamOperationalError(error, 'The invitation could not be created.', 'TeamInviteAction.create', 500)
139
+ }
105
140
 
106
- const created = await (db as any)
107
- .selectFrom('team_invitations')
108
- .where('token_hash', '=', tokenHash)
109
- .select(['id'])
110
- .executeTakeFirst()
111
- if (!created)
112
- return response.json({ message: 'The invitation could not be created.' }, 500)
141
+ if (created instanceof Response)
142
+ return created
113
143
 
114
144
  try {
115
145
  await deliverTeamInvitation({
116
- id: Number(created.id),
146
+ id: created.id,
117
147
  email,
118
- teamName: String(team.name),
148
+ teamName: created.teamName,
119
149
  role,
120
150
  token,
151
+ tokenHash,
121
152
  })
122
153
  }
123
- catch {
124
- return response.json({ message: 'Invitation created, but email delivery failed. You can retry it from the invitation list.' }, 502)
154
+ catch (error) {
155
+ return teamOperationalError(error, 'Invitation created, but email delivery failed. You can retry it from the invitation list.', 'TeamInviteAction.delivery', 502)
125
156
  }
126
157
 
127
158
  return response.json({
128
159
  invitation: {
129
- id: Number(created.id),
160
+ id: created.id,
130
161
  email,
131
162
  role,
132
163
  status: 'pending',
@@ -2,8 +2,9 @@ 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 { TeamStateConflictError, teamOperationalError } from '../../Teams/team-response'
5
6
  import { syncTeamMemberCount } from './team-member-count'
6
- import { parsePositiveId } from './team-records'
7
+ import { changedRows, parsePositiveId } from './team-records'
7
8
 
8
9
  export default new Action({
9
10
  name: 'Dashboard Team Member Destroy',
@@ -17,24 +18,42 @@ export default new Action({
17
18
  if (!teamId || !memberId)
18
19
  return response.json({ message: 'Invalid team or member id.' }, 400)
19
20
 
20
- const member = await (db as any)
21
- .selectFrom('team_members')
22
- .where('id', '=', memberId)
23
- .where('team_id', '=', teamId)
24
- .select(['id', 'role'])
25
- .executeTakeFirst()
26
- if (!member)
27
- return response.json({ message: 'Team member not found.' }, 404)
28
- if (member.role === 'owner')
29
- return response.json({ message: 'Transfer team ownership before removing the owner.' }, 409)
21
+ let result: Response | null
22
+ try {
23
+ result = await db.transaction(async (rawTrx) => {
24
+ const trx = rawTrx as unknown as typeof db
25
+ const member = await (trx as any)
26
+ .selectFrom('team_members')
27
+ .where('id', '=', memberId)
28
+ .where('team_id', '=', teamId)
29
+ .select(['id', 'role'])
30
+ .executeTakeFirst()
31
+ if (!member)
32
+ return response.json({ message: 'Team member not found.' }, 404)
33
+ if (member.role === 'owner')
34
+ return response.json({ message: 'Transfer team ownership before removing the owner.' }, 409)
30
35
 
31
- await (db as any)
32
- .deleteFrom('team_members')
33
- .where('id', '=', memberId)
34
- .where('team_id', '=', teamId)
35
- .execute()
36
+ const deleted = await (trx as any)
37
+ .deleteFrom('team_members')
38
+ .where('id', '=', memberId)
39
+ .where('team_id', '=', teamId)
40
+ .where('role', '!=', 'owner')
41
+ .executeTakeFirst()
42
+ if (changedRows(deleted) !== 1)
43
+ throw new TeamStateConflictError('The team member changed before they could be removed.')
36
44
 
37
- await syncTeamMemberCount(teamId)
45
+ await syncTeamMemberCount(teamId, trx)
46
+ return null
47
+ })
48
+ }
49
+ catch (error) {
50
+ if (error instanceof TeamStateConflictError)
51
+ return response.json({ message: error.message }, 409)
52
+ return teamOperationalError(error, 'The team member could not be removed.', 'TeamMemberDestroyAction', 500)
53
+ }
54
+
55
+ if (result)
56
+ return result
38
57
 
39
58
  return response.noContent()
40
59
  },
@@ -2,8 +2,9 @@ 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 { TeamStateConflictError, teamOperationalError } from '../../Teams/team-response'
5
6
  import { syncTeamMemberCount } from './team-member-count'
6
- import { normalizeInvitationRole, parsePositiveId, sqlTimestamp } from './team-records'
7
+ import { changedRows, normalizeInvitationRole, parsePositiveId, sqlTimestamp } from './team-records'
7
8
 
8
9
  interface UpdateInput {
9
10
  role?: unknown
@@ -22,34 +23,46 @@ export default new Action({
22
23
  if (!teamId || !memberId)
23
24
  return response.json({ message: 'Invalid team or member id.' }, 400)
24
25
 
25
- const member = await (db as any)
26
- .selectFrom('team_members')
27
- .where('id', '=', memberId)
28
- .where('team_id', '=', teamId)
29
- .select(['id', 'role', 'status'])
30
- .executeTakeFirst()
31
- if (!member)
32
- return response.json({ message: 'Team member not found.' }, 404)
33
- if (member.role === 'owner')
34
- return response.json({ message: 'Transfer team ownership before changing the owner.' }, 409)
35
-
36
26
  const input = request.all()
37
- const role = input.role === undefined ? String(member.role) : normalizeInvitationRole(input.role)
38
- const status = input.status === undefined ? String(member.status) : String(input.status).trim().toLowerCase()
39
- if (!role)
40
- return response.json({ message: 'Choose a valid member role.' }, 422)
41
- if (!['active', 'suspended'].includes(status))
42
- return response.json({ message: 'Choose a valid member status.' }, 422)
27
+ try {
28
+ return await db.transaction(async (rawTrx) => {
29
+ const trx = rawTrx as unknown as typeof db
30
+ const member = await (trx as any)
31
+ .selectFrom('team_members')
32
+ .where('id', '=', memberId)
33
+ .where('team_id', '=', teamId)
34
+ .select(['id', 'role', 'status'])
35
+ .executeTakeFirst()
36
+ if (!member)
37
+ return response.json({ message: 'Team member not found.' }, 404)
38
+ if (member.role === 'owner')
39
+ return response.json({ message: 'Transfer team ownership before changing the owner.' }, 409)
43
40
 
44
- await (db as any)
45
- .updateTable('team_members')
46
- .set({ role, status, updated_at: sqlTimestamp() })
47
- .where('id', '=', memberId)
48
- .where('team_id', '=', teamId)
49
- .execute()
41
+ const role = input.role === undefined ? String(member.role) : normalizeInvitationRole(input.role)
42
+ const status = input.status === undefined ? String(member.status) : String(input.status).trim().toLowerCase()
43
+ if (!role)
44
+ return response.json({ message: 'Choose a valid member role.' }, 422)
45
+ if (!['active', 'suspended'].includes(status))
46
+ return response.json({ message: 'Choose a valid member status.' }, 422)
50
47
 
51
- await syncTeamMemberCount(teamId)
48
+ const updated = await (trx as any)
49
+ .updateTable('team_members')
50
+ .set({ role, status, updated_at: sqlTimestamp() })
51
+ .where('id', '=', memberId)
52
+ .where('team_id', '=', teamId)
53
+ .where('role', '!=', 'owner')
54
+ .executeTakeFirst()
55
+ if (changedRows(updated) !== 1)
56
+ throw new TeamStateConflictError('The team member changed before they could be updated.')
52
57
 
53
- return { member: { id: memberId, role, status } }
58
+ await syncTeamMemberCount(teamId, trx)
59
+ return { member: { id: memberId, role, status } }
60
+ })
61
+ }
62
+ catch (error) {
63
+ if (error instanceof TeamStateConflictError)
64
+ return response.json({ message: error.message }, 409)
65
+ return teamOperationalError(error, 'The team member could not be updated.', 'TeamMemberUpdateAction', 500)
66
+ }
54
67
  },
55
68
  })
@@ -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 { teamOperationalError } from '../../Teams/team-response'
5
6
  import { invitationStatus, parsePositiveId } from './team-records'
6
7
 
7
8
  export default new Action({
@@ -15,69 +16,81 @@ export default new Action({
15
16
  if (!teamId)
16
17
  return response.json({ message: 'Invalid team id.' }, 400)
17
18
 
18
- const team = await db
19
- .selectFrom('teams')
20
- .where('id', '=', teamId)
21
- .select(['id', 'name'])
22
- .executeTakeFirst()
19
+ let team
20
+ try {
21
+ team = await db
22
+ .selectFrom('teams')
23
+ .where('id', '=', teamId)
24
+ .select(['id', 'name'])
25
+ .executeTakeFirst()
26
+ }
27
+ catch (error) {
28
+ return teamOperationalError(error, 'Team could not be loaded.', 'TeamPeopleIndexAction.team')
29
+ }
23
30
  if (!team)
24
31
  return response.json({ message: 'Team not found.' }, 404)
25
32
 
26
- const members = await (db as any)
27
- .selectFrom('team_members')
28
- .innerJoin('users', 'users.id', '=', 'team_members.user_id')
29
- .where('team_members.team_id', '=', teamId)
30
- .select([
31
- 'team_members.id as id',
32
- 'team_members.user_id as user_id',
33
- 'team_members.role as role',
34
- 'team_members.status as status',
35
- 'team_members.created_at as created_at',
36
- 'users.name as name',
37
- 'users.email as email',
33
+ try {
34
+ const [members, invitations] = await Promise.all([
35
+ (db as any)
36
+ .selectFrom('team_members')
37
+ .innerJoin('users', 'users.id', '=', 'team_members.user_id')
38
+ .where('team_members.team_id', '=', teamId)
39
+ .select([
40
+ 'team_members.id as id',
41
+ 'team_members.user_id as user_id',
42
+ 'team_members.role as role',
43
+ 'team_members.status as status',
44
+ 'team_members.created_at as created_at',
45
+ 'users.name as name',
46
+ 'users.email as email',
47
+ ])
48
+ .orderBy('users.name', 'asc')
49
+ .execute(),
50
+ (db as any)
51
+ .selectFrom('team_invitations')
52
+ .where('team_id', '=', teamId)
53
+ .where('status', '=', 'pending')
54
+ .select([
55
+ 'id',
56
+ 'email',
57
+ 'role',
58
+ 'status',
59
+ 'delivery_status',
60
+ 'expires_at',
61
+ 'created_at',
62
+ ])
63
+ .orderBy('created_at', 'desc')
64
+ .execute(),
38
65
  ])
39
- .orderBy('users.name', 'asc')
40
- .execute()
41
66
 
42
- const invitations = await (db as any)
43
- .selectFrom('team_invitations')
44
- .where('team_id', '=', teamId)
45
- .where('status', '=', 'pending')
46
- .select([
47
- 'id',
48
- 'email',
49
- 'role',
50
- 'status',
51
- 'delivery_status',
52
- 'expires_at',
53
- 'created_at',
54
- ])
55
- .orderBy('created_at', 'desc')
56
- .execute()
57
-
58
- return {
59
- team: {
60
- id: Number(team.id),
61
- name: String(team.name),
62
- },
63
- members: members.map((member: any) => ({
64
- id: Number(member.id),
65
- userId: Number(member.user_id),
66
- name: String(member.name || member.email || 'Team member'),
67
- email: String(member.email || ''),
68
- role: String(member.role || 'member'),
69
- status: String(member.status || 'active'),
70
- joinedAt: member.created_at ? String(member.created_at) : null,
71
- })),
72
- invitations: invitations.map((invitation: any) => ({
73
- id: Number(invitation.id),
74
- email: String(invitation.email),
75
- role: String(invitation.role || 'member'),
76
- status: invitationStatus(invitation.status, invitation.expires_at),
77
- deliveryStatus: String(invitation.delivery_status || 'pending'),
78
- expiresAt: String(invitation.expires_at),
79
- createdAt: invitation.created_at ? String(invitation.created_at) : null,
80
- })),
67
+ return {
68
+ team: {
69
+ id: Number(team.id),
70
+ name: String(team.name),
71
+ },
72
+ members: members.map((member: any) => ({
73
+ id: Number(member.id),
74
+ userId: Number(member.user_id),
75
+ name: String(member.name || member.email || 'Team member'),
76
+ email: String(member.email || ''),
77
+ role: String(member.role || 'member'),
78
+ status: String(member.status || 'active'),
79
+ joinedAt: member.created_at ? String(member.created_at) : null,
80
+ })),
81
+ invitations: invitations.map((invitation: any) => ({
82
+ id: Number(invitation.id),
83
+ email: String(invitation.email),
84
+ role: String(invitation.role || 'member'),
85
+ status: invitationStatus(invitation.status, invitation.expires_at),
86
+ deliveryStatus: String(invitation.delivery_status || 'pending'),
87
+ expiresAt: String(invitation.expires_at),
88
+ createdAt: invitation.created_at ? String(invitation.created_at) : null,
89
+ })),
90
+ }
91
+ }
92
+ catch (error) {
93
+ return teamOperationalError(error, 'Team members and invitations could not be loaded.', 'TeamPeopleIndexAction.people')
81
94
  }
82
95
  },
83
96
  })
@@ -1,7 +1,8 @@
1
1
  import { config } from '@stacksjs/config'
2
2
  import { db } from '@stacksjs/database'
3
3
  import { sendTeamInvitation } from '../../../Mail/TeamInvitation'
4
- import { invitationUrl, sqlTimestamp } from './team-records'
4
+ import { teamOperationalIssue } from '../../Teams/team-response'
5
+ import { changedRows, invitationUrl, sqlTimestamp } from './team-records'
5
6
 
6
7
  interface DeliveryInput {
7
8
  id: number
@@ -9,12 +10,22 @@ interface DeliveryInput {
9
10
  teamName: string
10
11
  role: string
11
12
  token: string
13
+ tokenHash: string
14
+ }
15
+
16
+ async function recordDeliveryFailure(input: DeliveryInput, message: string): Promise<void> {
17
+ try {
18
+ await markTeamInvitationDeliveryFailed(input.id, input.tokenHash, message)
19
+ }
20
+ catch (error) {
21
+ teamOperationalIssue(error, 'Invitation delivery failure could not be recorded.', 'deliverTeamInvitation.failureStatus')
22
+ }
12
23
  }
13
24
 
14
25
  export async function deliverTeamInvitation(input: DeliveryInput): Promise<void> {
15
26
  const baseUrl = String(process.env.APP_URL || config.app.url || '').trim()
16
27
  if (!baseUrl) {
17
- await markTeamInvitationDeliveryFailed(input.id, 'APP_URL is not configured.')
28
+ await recordDeliveryFailure(input, 'APP_URL is not configured.')
18
29
  throw new Error('APP_URL must be configured before invitations can be delivered.')
19
30
  }
20
31
 
@@ -26,7 +37,15 @@ export async function deliverTeamInvitation(input: DeliveryInput): Promise<void>
26
37
  invitationUrl: invitationUrl(baseUrl, input.token),
27
38
  expiresInDays: 7,
28
39
  })
29
- await (db as any)
40
+ }
41
+ catch (error) {
42
+ const message = teamOperationalIssue(error, 'Email delivery failed.', 'deliverTeamInvitation.provider')
43
+ await recordDeliveryFailure(input, message)
44
+ throw error
45
+ }
46
+
47
+ try {
48
+ const result = await (db as any)
30
49
  .updateTable('team_invitations')
31
50
  .set({
32
51
  delivery_status: 'sent',
@@ -35,16 +54,18 @@ export async function deliverTeamInvitation(input: DeliveryInput): Promise<void>
35
54
  updated_at: sqlTimestamp(),
36
55
  })
37
56
  .where('id', '=', input.id)
38
- .execute()
57
+ .where('token_hash', '=', input.tokenHash)
58
+ .executeTakeFirst()
59
+ if (changedRows(result) !== 1)
60
+ throw new Error('Invitation delivery status changed before it could be recorded.')
39
61
  }
40
62
  catch (error) {
41
- const message = error instanceof Error ? error.message : 'Email delivery failed.'
42
- await markTeamInvitationDeliveryFailed(input.id, message)
63
+ teamOperationalIssue(error, 'Invitation delivery success could not be recorded.', 'deliverTeamInvitation.successStatus')
43
64
  throw error
44
65
  }
45
66
  }
46
67
 
47
- export async function markTeamInvitationDeliveryFailed(id: number, error: string): Promise<void> {
68
+ export async function markTeamInvitationDeliveryFailed(id: number, tokenHash: string, error: string): Promise<void> {
48
69
  await (db as any)
49
70
  .updateTable('team_invitations')
50
71
  .set({
@@ -53,5 +74,6 @@ export async function markTeamInvitationDeliveryFailed(id: number, error: string
53
74
  updated_at: sqlTimestamp(),
54
75
  })
55
76
  .where('id', '=', id)
77
+ .where('token_hash', '=', tokenHash)
56
78
  .execute()
57
79
  }
@@ -1,4 +1,4 @@
1
- import { db } from '@stacksjs/database'
1
+ import { db, sql } from '@stacksjs/database'
2
2
  import { sqlTimestamp } from './team-records'
3
3
 
4
4
  export async function syncTeamMemberCount(teamId: number, connection: typeof db = db): Promise<number> {
@@ -6,9 +6,11 @@ export async function syncTeamMemberCount(teamId: number, connection: typeof db
6
6
  .selectFrom('team_members')
7
7
  .where('team_id', '=', teamId)
8
8
  .where('status', '=', 'active')
9
- .select(connection.fn.count('id').as('count'))
9
+ .select(sql`count(*) as count`)
10
10
  .executeTakeFirst()
11
11
  const count = Number(row?.count || 0)
12
+ if (!Number.isSafeInteger(count) || count < 0)
13
+ throw new TypeError('Team member count must be a non-negative integer.')
12
14
 
13
15
  await connection
14
16
  .updateTable('teams')
@@ -25,6 +25,8 @@ describe('team records', () => {
25
25
  expect(changedRows({ changes: 1 })).toBe(1)
26
26
  expect(changedRows({ affectedRows: 2 })).toBe(2)
27
27
  expect(changedRows([{ numUpdatedRows: 3n }])).toBe(3)
28
+ expect(changedRows({ numDeletedRows: 4n })).toBe(4)
29
+ expect(changedRows({ numInsertedRows: 5n })).toBe(5)
28
30
  expect(changedRows(null)).toBe(0)
29
31
  })
30
32
 
@@ -24,6 +24,8 @@ export function changedRows(result: unknown): number {
24
24
  ?? record.affectedRows
25
25
  ?? record.numAffectedRows
26
26
  ?? record.numUpdatedRows
27
+ ?? record.numDeletedRows
28
+ ?? record.numInsertedRows
27
29
  ?? 0,
28
30
  )
29
31
  }
@@ -0,0 +1,16 @@
1
+ import { response } from '@stacksjs/router'
2
+
3
+ export function dashboardOperationalError(
4
+ error: unknown,
5
+ message: string,
6
+ action: string,
7
+ status = 503,
8
+ ): Response {
9
+ console.error(`[dashboard/api] ${action} failed:`, error)
10
+ return response.json({ message }, status)
11
+ }
12
+
13
+ export function dashboardOperationalIssue(error: unknown, message: string, action: string): string {
14
+ console.error(`[dashboard/api] ${action} failed:`, error)
15
+ return message
16
+ }
@@ -15,7 +15,7 @@ export default new Action({
15
15
  const appName = config.app.name || 'our app'
16
16
  const url = config.app.url || 'https://localhost:5173'
17
17
 
18
- await mail.send({
18
+ await mail.sendOrFail({
19
19
  to,
20
20
  subject: `Welcome to ${appName}!`,
21
21
  text: `