@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.
- package/ai/skills/stacks-email/SKILL.md +38 -1
- package/app/Actions/ContactAction.ts +2 -2
- package/app/Actions/Dashboard/Analytics/EventAnalyticsAction.ts +2 -3
- package/app/Actions/Dashboard/Analytics/MarketingAnalyticsAction.ts +2 -3
- package/app/Actions/Dashboard/Analytics/SalesAnalyticsAction.ts +2 -3
- package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +9 -3
- package/app/Actions/Dashboard/Auth/MeAction.ts +4 -7
- package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +2 -3
- package/app/Actions/Dashboard/Cloud/CloudIndexAction.ts +8 -1
- package/app/Actions/Dashboard/Cloud/ServerlessIndexAction.ts +15 -8
- package/app/Actions/Dashboard/Commerce/CommerceCategoriesAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceCouponsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceDashboardAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceGiftCardsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePaymentsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +75 -61
- package/app/Actions/Dashboard/Commerce/CommercePrintDevicesAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +3 -4
- package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceTaxesAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/DigitalDeliveryIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/DriverIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/LicenseKeyIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/LicenseKeyOptionsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ManufacturerIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ProductUnitIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ProductVariantIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ProductWaitlistIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/RestaurantWaitlistIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ReviewIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ShippingMethodIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ShippingRateIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ShippingZoneIndexAction.ts +2 -4
- package/app/Actions/Dashboard/DashboardActivityAction.ts +2 -5
- package/app/Actions/Dashboard/Data/ActivityIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Data/SubscriberIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Data/TeamIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Data/UserIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +23 -19
- package/app/Actions/Dashboard/Deployments/GetAverageDeploymentTime.ts +8 -2
- package/app/Actions/Dashboard/Deployments/GetDeployScript.ts +3 -6
- package/app/Actions/Dashboard/Deployments/GetDeployment.ts +4 -5
- package/app/Actions/Dashboard/Deployments/GetDeploymentCount.ts +7 -1
- package/app/Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput.ts +10 -9
- package/app/Actions/Dashboard/Deployments/GetDeployments.ts +2 -4
- package/app/Actions/Dashboard/Deployments/GetRecentDeployments.ts +8 -2
- package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +18 -5
- package/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Email/CapturedMailShowAction.ts +3 -4
- package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +15 -14
- package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +2 -8
- package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Jobs/JobShowAction.ts +13 -8
- package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -4
- package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/UsersListAction.ts +2 -3
- package/app/Actions/Dashboard/Kanban/kanban-response.ts +5 -0
- package/app/Actions/Dashboard/Library/CreateComponent.ts +15 -6
- package/app/Actions/Dashboard/Library/CreateFunction.ts +15 -6
- package/app/Actions/Dashboard/Library/DependencyIndexAction.ts +88 -77
- package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +21 -15
- package/app/Actions/Dashboard/Library/GetComponents.ts +14 -7
- package/app/Actions/Dashboard/Library/GetDownloadCount.ts +14 -5
- package/app/Actions/Dashboard/Library/GetFunctions.ts +12 -5
- package/app/Actions/Dashboard/Library/GetReleaseCount.ts +14 -5
- package/app/Actions/Dashboard/Library/GetReleases.ts +17 -10
- package/app/Actions/Dashboard/Library/PackageIndexAction.ts +14 -7
- package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +7 -5
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +82 -174
- package/app/Actions/Dashboard/Models/ModelStoreAction.ts +6 -4
- package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +9 -7
- package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +47 -17
- package/app/Actions/Dashboard/Models/model-write.ts +20 -5
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +46 -36
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +14 -6
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryOverviewAction.ts +96 -84
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryRetryAction.ts +34 -21
- package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +2 -4
- package/app/Actions/Dashboard/Queries/QueryShowAction.ts +2 -3
- package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +2 -4
- package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +2 -4
- package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +2 -2
- package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +2 -2
- package/app/Actions/Dashboard/Rbac/PermissionsIndexAction.ts +2 -3
- package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +2 -2
- package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +2 -2
- package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +2 -6
- package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +2 -2
- package/app/Actions/Dashboard/Rbac/RolesIndexAction.ts +4 -6
- package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +2 -2
- package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +2 -9
- package/app/Actions/Dashboard/Rbac/UsersListAction.ts +2 -3
- package/app/Actions/Dashboard/Rbac/rbac-response.ts +10 -0
- package/app/Actions/Dashboard/Realtime/RealtimeStatsAction.ts +2 -4
- package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +68 -55
- package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +34 -16
- package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +52 -29
- package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +102 -71
- package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +36 -17
- package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +39 -26
- package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +71 -58
- package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +29 -7
- package/app/Actions/Dashboard/Teams/team-member-count.ts +4 -2
- package/app/Actions/Dashboard/Teams/team-records.test.ts +2 -0
- package/app/Actions/Dashboard/Teams/team-records.ts +2 -0
- package/app/Actions/Dashboard/dashboard-response.ts +16 -0
- package/app/Actions/SendWelcomeEmail.ts +1 -1
- package/app/Actions/Teams/AcceptInvitationAction.ts +74 -51
- package/app/Actions/Teams/ShowInvitationAction.ts +21 -14
- package/app/Actions/Teams/team-response.ts +23 -0
- package/app/Mail/OrderConfirmation.ts +1 -1
- package/app/Mail/SubscriptionConfirmation.ts +1 -1
- package/app/Mail/TeamInvitation.ts +1 -1
- package/app/Models/EmailIdempotency.ts +64 -0
- package/app/Models/EmailSuppression.ts +52 -0
- package/app/Models/EmailWebhookEvent.ts +51 -0
- package/app/Models/TeamInvitation.ts +13 -0
- package/functions/data.ts +0 -1
- package/functions/jobs.ts +0 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/resources/components/Dashboard/Data/DataRecords.stx +0 -4
- package/resources/components/Dashboard/Jobs/JobDetail.stx +3 -2
- package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +7 -13
- package/resources/components/Dashboard/Models/ModelsOverview.stx +1 -1
- package/views/dashboard/.discovered-models.json +30 -0
- 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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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:
|
|
146
|
+
id: created.id,
|
|
117
147
|
email,
|
|
118
|
-
teamName:
|
|
148
|
+
teamName: created.teamName,
|
|
119
149
|
role,
|
|
120
150
|
token,
|
|
151
|
+
tokenHash,
|
|
121
152
|
})
|
|
122
153
|
}
|
|
123
|
-
catch {
|
|
124
|
-
return
|
|
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:
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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(
|
|
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
|
|
|
@@ -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
|
+
}
|