@stacksjs/defaults 0.70.354 → 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 (214) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +17 -2
  2. package/ai/skills/stacks-browse/scripts/browse.ts +207 -6
  3. package/ai/skills/stacks-email/SKILL.md +73 -16
  4. package/app/Actions/ContactAction.ts +2 -2
  5. package/app/Actions/Dashboard/Analytics/EventAnalyticsAction.ts +2 -3
  6. package/app/Actions/Dashboard/Analytics/MarketingAnalyticsAction.ts +2 -3
  7. package/app/Actions/Dashboard/Analytics/SalesAnalyticsAction.ts +2 -3
  8. package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +9 -3
  9. package/app/Actions/Dashboard/Auth/MeAction.ts +12 -14
  10. package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +2 -3
  11. package/app/Actions/Dashboard/Ci/RepoRunJobsAction.ts +10 -15
  12. package/app/Actions/Dashboard/Ci/RepoRunsAction.ts +12 -14
  13. package/app/Actions/Dashboard/Ci/RunnerHistoryAction.ts +11 -10
  14. package/app/Actions/Dashboard/Ci/StatusAction.ts +2 -11
  15. package/app/Actions/Dashboard/Cloud/CloudIndexAction.ts +8 -1
  16. package/app/Actions/Dashboard/Cloud/ServerlessIndexAction.ts +15 -8
  17. package/app/Actions/Dashboard/Commerce/CommerceCategoriesAction.ts +2 -4
  18. package/app/Actions/Dashboard/Commerce/CommerceCouponsAction.ts +2 -4
  19. package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +2 -4
  20. package/app/Actions/Dashboard/Commerce/CommerceDashboardAction.ts +2 -4
  21. package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +2 -4
  22. package/app/Actions/Dashboard/Commerce/CommerceGiftCardsAction.ts +2 -4
  23. package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +2 -4
  24. package/app/Actions/Dashboard/Commerce/CommercePaymentsAction.ts +2 -4
  25. package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +2 -4
  26. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +76 -61
  27. package/app/Actions/Dashboard/Commerce/CommercePrintDevicesAction.ts +2 -4
  28. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +4 -7
  29. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +4 -4
  30. package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +2 -4
  31. package/app/Actions/Dashboard/Commerce/CommerceTaxesAction.ts +2 -4
  32. package/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts +2 -4
  33. package/app/Actions/Dashboard/Commerce/DigitalDeliveryIndexAction.ts +2 -4
  34. package/app/Actions/Dashboard/Commerce/DriverIndexAction.ts +2 -4
  35. package/app/Actions/Dashboard/Commerce/LicenseKeyIndexAction.ts +2 -4
  36. package/app/Actions/Dashboard/Commerce/LicenseKeyOptionsAction.ts +2 -4
  37. package/app/Actions/Dashboard/Commerce/ManufacturerIndexAction.ts +2 -4
  38. package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +1 -0
  39. package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -0
  40. package/app/Actions/Dashboard/Commerce/ProductUnitIndexAction.ts +2 -4
  41. package/app/Actions/Dashboard/Commerce/ProductVariantIndexAction.ts +2 -4
  42. package/app/Actions/Dashboard/Commerce/ProductWaitlistIndexAction.ts +2 -4
  43. package/app/Actions/Dashboard/Commerce/RestaurantWaitlistIndexAction.ts +2 -4
  44. package/app/Actions/Dashboard/Commerce/ReviewIndexAction.ts +2 -4
  45. package/app/Actions/Dashboard/Commerce/ShippingMethodIndexAction.ts +2 -4
  46. package/app/Actions/Dashboard/Commerce/ShippingRateIndexAction.ts +2 -4
  47. package/app/Actions/Dashboard/Commerce/ShippingZoneIndexAction.ts +2 -4
  48. package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -0
  49. package/app/Actions/Dashboard/DashboardActivityAction.ts +2 -5
  50. package/app/Actions/Dashboard/Data/ActivityIndexAction.ts +2 -5
  51. package/app/Actions/Dashboard/Data/SubscriberIndexAction.ts +2 -5
  52. package/app/Actions/Dashboard/Data/TeamIndexAction.ts +2 -5
  53. package/app/Actions/Dashboard/Data/UserIndexAction.ts +2 -5
  54. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +26 -21
  55. package/app/Actions/Dashboard/Deployments/GetAverageDeploymentTime.ts +8 -2
  56. package/app/Actions/Dashboard/Deployments/GetDeployScript.ts +3 -6
  57. package/app/Actions/Dashboard/Deployments/GetDeployment.ts +5 -5
  58. package/app/Actions/Dashboard/Deployments/GetDeploymentCount.ts +7 -1
  59. package/app/Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput.ts +10 -9
  60. package/app/Actions/Dashboard/Deployments/GetDeployments.ts +2 -4
  61. package/app/Actions/Dashboard/Deployments/GetRecentDeployments.ts +8 -2
  62. package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +21 -7
  63. package/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +2 -4
  64. package/app/Actions/Dashboard/Email/CapturedMailShowAction.ts +3 -4
  65. package/app/Actions/Dashboard/Email/InboxActivityAction.ts +28 -16
  66. package/app/Actions/Dashboard/Email/InboxAttachmentDownloadAction.ts +45 -0
  67. package/app/Actions/Dashboard/Email/InboxDeleteAction.ts +11 -6
  68. package/app/Actions/Dashboard/Email/InboxIndexAction.ts +5 -7
  69. package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +11 -6
  70. package/app/Actions/Dashboard/Email/InboxMarkUnreadAction.ts +11 -6
  71. package/app/Actions/Dashboard/Email/InboxPreferenceShowAction.ts +10 -4
  72. package/app/Actions/Dashboard/Email/InboxPreferenceUpdateAction.ts +8 -9
  73. package/app/Actions/Dashboard/Email/InboxShowAction.ts +7 -7
  74. package/app/Actions/Dashboard/Email/InboxStatsAction.ts +5 -7
  75. package/app/Actions/Dashboard/Email/inbox-request.ts +33 -0
  76. package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +15 -14
  77. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +4 -11
  78. package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +3 -6
  79. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +9 -18
  80. package/app/Actions/Dashboard/Jobs/JobRetryAction.ts +4 -3
  81. package/app/Actions/Dashboard/Jobs/JobShowAction.ts +16 -11
  82. package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -4
  83. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +7 -8
  84. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +11 -19
  85. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +6 -6
  86. package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +7 -8
  87. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +3 -5
  88. package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +6 -6
  89. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +13 -12
  90. package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +12 -15
  91. package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +21 -46
  92. package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +43 -0
  93. package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +7 -8
  94. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +8 -9
  95. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +6 -7
  96. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +9 -9
  97. package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +7 -8
  98. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +6 -6
  99. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -8
  100. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +6 -6
  101. package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +7 -8
  102. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +6 -6
  103. package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +6 -7
  104. package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +6 -6
  105. package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +7 -8
  106. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +3 -4
  107. package/app/Actions/Dashboard/Kanban/kanban-comment.ts +14 -0
  108. package/app/Actions/Dashboard/Kanban/kanban-model.test.ts +36 -0
  109. package/app/Actions/Dashboard/Kanban/kanban-model.ts +14 -7
  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 +11 -8
  122. package/app/Actions/Dashboard/Models/ModelShowAction.ts +97 -192
  123. package/app/Actions/Dashboard/Models/ModelStoreAction.ts +14 -12
  124. package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +18 -16
  125. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +50 -19
  126. package/app/Actions/Dashboard/Models/model-api.ts +27 -0
  127. package/app/Actions/Dashboard/Models/model-write.ts +22 -15
  128. package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +49 -40
  129. package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +20 -10
  130. package/app/Actions/Dashboard/Notifications/NotificationDeliveryOverviewAction.ts +96 -84
  131. package/app/Actions/Dashboard/Notifications/NotificationDeliveryRetryAction.ts +34 -21
  132. package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +2 -4
  133. package/app/Actions/Dashboard/Queries/QueryShowAction.ts +3 -3
  134. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +2 -4
  135. package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +2 -4
  136. package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +6 -6
  137. package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +5 -4
  138. package/app/Actions/Dashboard/Rbac/PermissionsIndexAction.ts +2 -3
  139. package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +6 -6
  140. package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +6 -6
  141. package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +6 -9
  142. package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +5 -4
  143. package/app/Actions/Dashboard/Rbac/RolesIndexAction.ts +4 -6
  144. package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +6 -7
  145. package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +6 -13
  146. package/app/Actions/Dashboard/Rbac/UsersListAction.ts +2 -3
  147. package/app/Actions/Dashboard/Rbac/rbac-response.ts +10 -0
  148. package/app/Actions/Dashboard/Realtime/RealtimeStatsAction.ts +2 -4
  149. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +69 -55
  150. package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +34 -16
  151. package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +52 -29
  152. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +104 -73
  153. package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +36 -17
  154. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +41 -28
  155. package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +71 -58
  156. package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +29 -7
  157. package/app/Actions/Dashboard/Teams/team-member-count.ts +4 -2
  158. package/app/Actions/Dashboard/Teams/team-records.test.ts +2 -0
  159. package/app/Actions/Dashboard/Teams/team-records.ts +2 -0
  160. package/app/Actions/Dashboard/dashboard-request.ts +13 -0
  161. package/app/Actions/Dashboard/dashboard-response.ts +16 -0
  162. package/app/Actions/SendWelcomeEmail.ts +1 -1
  163. package/app/Actions/Teams/AcceptInvitationAction.ts +74 -51
  164. package/app/Actions/Teams/ShowInvitationAction.ts +21 -14
  165. package/app/Actions/Teams/team-response.ts +23 -0
  166. package/app/Mail/OrderConfirmation.ts +1 -1
  167. package/app/Mail/SubscriptionConfirmation.ts +1 -1
  168. package/app/Mail/TeamInvitation.ts +1 -1
  169. package/app/Models/CardComment.ts +3 -4
  170. package/app/Models/EmailIdempotency.ts +64 -0
  171. package/app/Models/EmailSuppression.ts +52 -0
  172. package/app/Models/EmailWebhookEvent.ts +51 -0
  173. package/app/Models/TeamInvitation.ts +13 -0
  174. package/functions/dashboard-api.ts +51 -10
  175. package/functions/data.ts +0 -1
  176. package/functions/inbox.test.ts +2 -0
  177. package/functions/inbox.ts +27 -2
  178. package/functions/jobs.ts +0 -1
  179. package/functions/public-application-url.test.ts +13 -0
  180. package/functions/public-application-url.ts +27 -0
  181. package/ide/vscode/package.json +1 -1
  182. package/package.json +1 -1
  183. package/resources/components/Dashboard/Auth/AccessTokens.stx +15 -0
  184. package/resources/components/Dashboard/Ci/CiDashboard.stx +24 -6
  185. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +1 -1
  186. package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +8 -7
  187. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +8 -7
  188. package/resources/components/Dashboard/Content/BlogDashboard.stx +14 -2
  189. package/resources/components/Dashboard/Content/PostsDashboard.stx +7 -1
  190. package/resources/components/Dashboard/Data/DataRecords.stx +0 -4
  191. package/resources/components/Dashboard/Deployments/DeploymentDetail.stx +16 -2
  192. package/resources/components/Dashboard/Email/EmailNavigation.stx +5 -5
  193. package/resources/components/Dashboard/Email/EmailSidebar.stx +1 -1
  194. package/resources/components/Dashboard/Email/InboxAttachmentList.stx +53 -0
  195. package/resources/components/Dashboard/Email/InboxDashboard.stx +5 -4
  196. package/resources/components/Dashboard/Email/InboxMessageDetail.stx +6 -4
  197. package/resources/components/Dashboard/Jobs/JobDetail.stx +3 -2
  198. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +63 -6
  199. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +3 -6
  200. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +4 -4
  201. package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +7 -13
  202. package/resources/components/Dashboard/Models/ModelsOverview.stx +1 -1
  203. package/resources/components/Dashboard/NotFoundDashboard.stx +2 -5
  204. package/resources/components/Dashboard/Queries/QueryDashboard.stx +6 -2
  205. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +4 -4
  206. package/resources/components/Dashboard/UI/EmptyState.stx +2 -2
  207. package/resources/components/Dashboard/UI/PageLayout.stx +3 -3
  208. package/resources/functions/dashboard/sidebar.ts +4 -4
  209. package/routes/dashboard-api.ts +8 -20
  210. package/views/dashboard/.discovered-models.json +54 -0
  211. package/views/dashboard/layouts/default.stx +19 -16
  212. package/views/dashboard/stores/auth.ts +15 -14
  213. package/views/dashboard/stores/ci.ts +13 -7
  214. package/views/dashboard/stores/kanban.ts +48 -11
@@ -1,9 +1,12 @@
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 } from '@stacksjs/orm'
4
5
  import { response } from '@stacksjs/router'
6
+ import { teamOperationalError } from '../../Teams/team-response'
5
7
  import { deliverTeamInvitation } from './team-invitation-delivery'
6
8
  import {
9
+ changedRows,
7
10
  generateInvitationToken,
8
11
  hashInvitationToken,
9
12
  invitationExpiresAt,
@@ -23,40 +26,59 @@ export default new Action({
23
26
  if (!teamId || !invitationId)
24
27
  return response.json({ message: 'Invalid team or invitation id.' }, 400)
25
28
 
26
- const invitation = await (db as any)
27
- .selectFrom('team_invitations')
28
- .innerJoin('teams', 'teams.id', '=', 'team_invitations.team_id')
29
- .where('team_invitations.id', '=', invitationId)
30
- .where('team_invitations.team_id', '=', teamId)
31
- .select([
32
- 'team_invitations.id as id',
33
- 'team_invitations.email as email',
34
- 'team_invitations.role as role',
35
- 'team_invitations.status as status',
36
- 'teams.name as team_name',
37
- ])
38
- .executeTakeFirst()
29
+ let invitation
30
+ try {
31
+ invitation = await (db as any)
32
+ .selectFrom('team_invitations')
33
+ .innerJoin('teams', 'teams.id', '=', 'team_invitations.team_id')
34
+ .where('team_invitations.id', '=', invitationId)
35
+ .where('team_invitations.team_id', '=', teamId)
36
+ .select([
37
+ 'team_invitations.id as id',
38
+ 'team_invitations.email as email',
39
+ 'team_invitations.role as role',
40
+ 'team_invitations.status as status',
41
+ 'team_invitations.token_hash as token_hash',
42
+ 'teams.name as team_name',
43
+ ])
44
+ .executeTakeFirst()
45
+ }
46
+ catch (error) {
47
+ return teamOperationalError(error, 'Invitation could not be loaded.', 'TeamInvitationResendAction.lookup', 500)
48
+ }
39
49
  if (!invitation)
40
50
  return response.json({ message: 'Invitation not found.' }, 404)
41
51
  if (!['pending', 'expired'].includes(String(invitation.status)))
42
52
  return response.json({ message: 'Only pending or expired invitations can be resent.' }, 409)
43
53
 
44
54
  const token = generateInvitationToken()
55
+ const tokenHash = hashInvitationToken(token)
45
56
  const expiresAt = invitationExpiresAt()
46
- await (db as any)
47
- .updateTable('team_invitations')
48
- .set({
49
- token_hash: hashInvitationToken(token),
50
- status: 'pending',
51
- delivery_status: 'pending',
52
- delivery_error: null,
53
- expires_at: expiresAt,
54
- delivered_at: null,
55
- updated_at: sqlTimestamp(),
56
- })
57
- .where('id', '=', invitationId)
58
- .where('team_id', '=', teamId)
59
- .execute()
57
+ try {
58
+ const result = await (db as any)
59
+ .updateTable('team_invitations')
60
+ .set({
61
+ token_hash: tokenHash,
62
+ pending_key: `${teamId}:${String(invitation.email).trim().toLowerCase()}`,
63
+ status: 'pending',
64
+ delivery_status: 'pending',
65
+ delivery_error: null,
66
+ expires_at: expiresAt,
67
+ delivered_at: null,
68
+ updated_at: sqlTimestamp(),
69
+ })
70
+ .where('id', '=', invitationId)
71
+ .where('team_id', '=', teamId)
72
+ .where('token_hash', '=', String(invitation.token_hash))
73
+ .executeTakeFirst()
74
+ if (changedRows(result) !== 1)
75
+ return response.json({ message: 'The invitation changed before it could be resent.' }, 409)
76
+ }
77
+ catch (error) {
78
+ if (isUniqueViolation(error))
79
+ return response.json({ message: 'Another pending invitation already exists for this email.' }, 409)
80
+ return teamOperationalError(error, 'Invitation could not be refreshed.', 'TeamInvitationResendAction.update', 500)
81
+ }
60
82
 
61
83
  try {
62
84
  await deliverTeamInvitation({
@@ -65,10 +87,11 @@ export default new Action({
65
87
  teamName: String(invitation.team_name),
66
88
  role: String(invitation.role),
67
89
  token,
90
+ tokenHash,
68
91
  })
69
92
  }
70
- catch {
71
- return response.json({ message: 'The invitation was refreshed, but email delivery failed.' }, 502)
93
+ catch (error) {
94
+ return teamOperationalError(error, 'The invitation was refreshed, but email delivery failed.', 'TeamInvitationResendAction.delivery', 502)
72
95
  }
73
96
 
74
97
  return {
@@ -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,
@@ -25,12 +28,12 @@ export default new Action({
25
28
  method: 'POST',
26
29
  apiResponse: true,
27
30
 
28
- async handle(request: RequestInstance) {
31
+ async handle(request: RequestInstance<InviteInput>) {
29
32
  const teamId = parsePositiveId(request.getParam('id'))
30
33
  if (!teamId)
31
34
  return response.json({ message: 'Invalid team id.' }, 400)
32
35
 
33
- const input = (request as any).jsonBody as InviteInput | undefined ?? {}
36
+ const input = request.all()
34
37
  const email = normalizeInvitationEmail(input.email)
35
38
  const role = normalizeInvitationRole(input.role ?? 'member')
36
39
  if (!email)
@@ -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 = (request as any).user ?? (request as any)._authenticatedUser ?? null
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
@@ -16,40 +17,52 @@ export default new Action({
16
17
  method: 'PATCH',
17
18
  apiResponse: true,
18
19
 
19
- async handle(request: RequestInstance) {
20
+ async handle(request: RequestInstance<UpdateInput>) {
20
21
  const teamId = parsePositiveId(request.getParam('id'))
21
22
  const memberId = parsePositiveId(request.getParam('memberId'))
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)
26
+ const input = request.all()
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)
35
40
 
36
- const input = (request as any).jsonBody as UpdateInput | undefined ?? {}
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)
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)
43
47
 
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()
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.')
50
57
 
51
- await syncTeamMemberCount(teamId)
52
-
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
  })