@stacksjs/defaults 0.74.2 → 0.74.4
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-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-buddy/SKILL.md +53 -3
- package/ai/skills/stacks-cloud/SKILL.md +83 -11
- package/ai/skills/stacks-commerce/SKILL.md +1 -1
- package/ai/skills/stacks-composables/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-deploy/SKILL.md +97 -24
- package/ai/skills/stacks-orm/SKILL.md +1 -1
- package/ai/skills/stacks-types/SKILL.md +1 -1
- package/ai/skills/stacks-writing-for-agents/SKILL.md +1 -1
- package/app/Actions/Buddy/CommandsAction.ts +1 -10
- package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +2 -7
- package/app/Actions/Dashboard/Analytics/web-analytics-provider.ts +103 -0
- package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +24 -84
- package/app/Actions/Dashboard/Infrastructure/log-provider.ts +189 -0
- package/app/Actions/Dashboard/Marketing/AbandonedCartCampaignAction.ts +51 -0
- package/app/Actions/Dashboard/Marketing/AbandonedCartIndexAction.ts +85 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +316 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +429 -0
- package/app/Actions/Dashboard/dashboard-provider.ts +170 -0
- package/app/Actions/Monitoring/ErrorGroupAction.ts +2 -4
- package/app/Actions/Monitoring/ErrorIndexAction.ts +2 -4
- package/app/Actions/Monitoring/ErrorShowAction.ts +4 -4
- package/app/Actions/Monitoring/ErrorStatsAction.ts +2 -4
- package/app/Actions/Monitoring/ErrorTimelineAction.ts +2 -4
- package/app/Actions/Monitoring/error-provider.ts +130 -0
- package/app/Models/EmailIdempotency.ts +4 -1
- package/app/Models/EmailSuppression.ts +4 -1
- package/app/Models/EmailWebhookEvent.ts +4 -1
- package/app/Models/Request.ts +4 -1
- package/functions/api-url.test.ts +61 -0
- package/functions/api-url.ts +19 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +240 -0
- package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +96 -0
- package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +145 -0
- package/resources/functions/dashboard/sidebar.ts +1 -0
- package/routes/dashboard-api.ts +5 -0
- package/views/dashboard/marketing/abandoned-carts/index.stx +10 -0
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import type { DashboardLogQuery, DashboardLogRange } from './log-provider'
|
|
2
3
|
import { Action } from '@stacksjs/actions'
|
|
3
|
-
import { db } from '@stacksjs/database'
|
|
4
4
|
import { dashboardRequestValue } from '../dashboard-request'
|
|
5
5
|
import { dashboardOperationalError } from '../dashboard-response'
|
|
6
|
-
import { DASHBOARD_LOG_TYPES
|
|
7
|
-
|
|
8
|
-
const RANGES = ['1', '7', '30', '90', 'all'] as const
|
|
6
|
+
import { DASHBOARD_LOG_TYPES } from './log-dashboard'
|
|
7
|
+
import { DASHBOARD_LOG_RANGES, readDashboardLogs } from './log-provider'
|
|
9
8
|
|
|
10
9
|
function queryValue(request: RequestInstance, key: string): string {
|
|
11
10
|
return dashboardRequestValue(request, key)
|
|
12
11
|
}
|
|
13
12
|
|
|
13
|
+
/** Reads the filters off the request. Unknown values fall back rather than fail. */
|
|
14
|
+
function logQuery(request: RequestInstance): DashboardLogQuery {
|
|
15
|
+
const requestedType = queryValue(request, 'type').toLowerCase()
|
|
16
|
+
const requestedRange = queryValue(request, 'range').toLowerCase()
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
page: Math.max(1, Number.parseInt(queryValue(request, 'page') || '1', 10) || 1),
|
|
20
|
+
perPage: Math.min(100, Math.max(10, Number.parseInt(queryValue(request, 'per_page') || '25', 10) || 25)),
|
|
21
|
+
search: queryValue(request, 'search'),
|
|
22
|
+
type: DASHBOARD_LOG_TYPES.includes(requestedType as typeof DASHBOARD_LOG_TYPES[number])
|
|
23
|
+
? requestedType
|
|
24
|
+
: '',
|
|
25
|
+
source: queryValue(request, 'source').toLowerCase(),
|
|
26
|
+
project: queryValue(request, 'project'),
|
|
27
|
+
range: DASHBOARD_LOG_RANGES.includes(requestedRange as DashboardLogRange)
|
|
28
|
+
? requestedRange as DashboardLogRange
|
|
29
|
+
: '30',
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
export default new Action({
|
|
15
34
|
name: 'LogIndexAction',
|
|
16
35
|
description: 'Returns filtered, paginated Log model records for the dashboard.',
|
|
@@ -19,86 +38,7 @@ export default new Action({
|
|
|
19
38
|
|
|
20
39
|
async handle(request: RequestInstance) {
|
|
21
40
|
try {
|
|
22
|
-
|
|
23
|
-
const perPage = Math.min(100, Math.max(10, Number.parseInt(queryValue(request, 'per_page') || '25', 10) || 25))
|
|
24
|
-
const search = queryValue(request, 'search')
|
|
25
|
-
const requestedType = queryValue(request, 'type').toLowerCase()
|
|
26
|
-
const type = DASHBOARD_LOG_TYPES.includes(requestedType as typeof DASHBOARD_LOG_TYPES[number])
|
|
27
|
-
? requestedType
|
|
28
|
-
: ''
|
|
29
|
-
const source = queryValue(request, 'source').toLowerCase()
|
|
30
|
-
const project = queryValue(request, 'project')
|
|
31
|
-
const requestedRange = queryValue(request, 'range').toLowerCase()
|
|
32
|
-
const range = RANGES.includes(requestedRange as typeof RANGES[number]) ? requestedRange : '30'
|
|
33
|
-
const cutoff = range === 'all' ? 0 : Date.now() - Number(range) * 86400000
|
|
34
|
-
const searchPattern = `%${search}%`
|
|
35
|
-
|
|
36
|
-
const applyFilters = <T extends {
|
|
37
|
-
where: (...args: any[]) => T
|
|
38
|
-
whereAny: (columns: string[], operator: string, value: unknown) => T
|
|
39
|
-
}>(query: T): T => {
|
|
40
|
-
let filtered = query
|
|
41
|
-
if (cutoff)
|
|
42
|
-
filtered = filtered.where('timestamp', '>=', cutoff)
|
|
43
|
-
if (type)
|
|
44
|
-
filtered = filtered.where('type', '=', type)
|
|
45
|
-
if (source)
|
|
46
|
-
filtered = filtered.where('source', '=', source)
|
|
47
|
-
if (project)
|
|
48
|
-
filtered = filtered.where('project', '=', project)
|
|
49
|
-
if (search)
|
|
50
|
-
filtered = filtered.whereAny(['message', 'project', 'file', 'stacktrace'], 'like', searchPattern)
|
|
51
|
-
return filtered
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const [countRow, typeRows, sourceRows, projectRows] = await Promise.all([
|
|
55
|
-
applyFilters(db.selectFrom('logs').select(db.fn.count('id').as('count')))
|
|
56
|
-
.executeTakeFirst() as Promise<{ count: number | string } | undefined>,
|
|
57
|
-
applyFilters(db.selectFrom('logs').select(['type', db.fn.count('id').as('count')]))
|
|
58
|
-
.groupBy('type')
|
|
59
|
-
.execute() as Promise<Array<{ type: string, count: number | string }>>,
|
|
60
|
-
db.selectFrom('logs').select('source').distinct().orderBy('source', 'asc').limit(100).execute() as Promise<Array<{ source: string }>>,
|
|
61
|
-
db.selectFrom('logs').select('project').distinct().orderBy('project', 'asc').limit(500).execute() as Promise<Array<{ project: string }>>,
|
|
62
|
-
])
|
|
63
|
-
|
|
64
|
-
const total = Number(countRow?.count || 0)
|
|
65
|
-
const totalPages = Math.max(1, Math.ceil(total / perPage))
|
|
66
|
-
const page = Math.min(requestedPage, totalPages)
|
|
67
|
-
const rows = await applyFilters(db.selectFrom('logs').select([
|
|
68
|
-
'id',
|
|
69
|
-
'timestamp',
|
|
70
|
-
'type',
|
|
71
|
-
'source',
|
|
72
|
-
'message',
|
|
73
|
-
'project',
|
|
74
|
-
'stacktrace',
|
|
75
|
-
'file',
|
|
76
|
-
'created_at',
|
|
77
|
-
'updated_at',
|
|
78
|
-
]))
|
|
79
|
-
.orderBy('timestamp', 'desc')
|
|
80
|
-
.orderBy('id', 'desc')
|
|
81
|
-
.limit(perPage)
|
|
82
|
-
.offset((page - 1) * perPage)
|
|
83
|
-
.execute()
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
logs: rows.map(normalizeDashboardLog),
|
|
87
|
-
summary: summarizeDashboardLogTypes(typeRows, total),
|
|
88
|
-
pagination: {
|
|
89
|
-
page,
|
|
90
|
-
perPage,
|
|
91
|
-
total,
|
|
92
|
-
totalPages,
|
|
93
|
-
},
|
|
94
|
-
options: {
|
|
95
|
-
sources: sourceRows.map(row => row.source).filter(Boolean),
|
|
96
|
-
projects: projectRows.map(row => row.project).filter(Boolean),
|
|
97
|
-
types: [...DASHBOARD_LOG_TYPES],
|
|
98
|
-
ranges: [...RANGES],
|
|
99
|
-
},
|
|
100
|
-
generatedAt: new Date().toISOString(),
|
|
101
|
-
}
|
|
41
|
+
return await readDashboardLogs(logQuery(request))
|
|
102
42
|
}
|
|
103
43
|
catch (error) {
|
|
104
44
|
return dashboardOperationalError(error, 'Logs could not be loaded.', 'LogIndexAction')
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { DashboardProviderUnavailable } from '../dashboard-provider'
|
|
2
|
+
import type { DashboardLogRecord, DashboardLogSummary } from './log-dashboard'
|
|
3
|
+
import { db } from '@stacksjs/database'
|
|
4
|
+
import { HQ_READ_UNAVAILABLE, readThroughProvider, resolveDashboardDriver } from '../dashboard-provider'
|
|
5
|
+
import { DASHBOARD_LOG_TYPES, normalizeDashboardLog, summarizeDashboardLogTypes } from './log-dashboard'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the Logs section reads from.
|
|
9
|
+
*
|
|
10
|
+
* The local provider is the query that used to sit inside `LogIndexAction`,
|
|
11
|
+
* moved without changing a line of its logic: the same five statements against
|
|
12
|
+
* the `logs` table, the same filters, the same ordering, and the same clamp of
|
|
13
|
+
* a requested page down to the last page that exists.
|
|
14
|
+
*
|
|
15
|
+
* The payload deliberately reuses `DashboardLogRecord` and
|
|
16
|
+
* `DashboardLogSummary` from `log-dashboard.ts`. Three stx components import
|
|
17
|
+
* those same types, so a provider that returns the wrong shape is a compile
|
|
18
|
+
* error against the declaration the components themselves consume rather than
|
|
19
|
+
* a surprise at runtime.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const DASHBOARD_LOG_RANGES = ['1', '7', '30', '90', 'all'] as const
|
|
23
|
+
|
|
24
|
+
export type DashboardLogRange = typeof DASHBOARD_LOG_RANGES[number]
|
|
25
|
+
|
|
26
|
+
export interface DashboardLogQuery {
|
|
27
|
+
page: number
|
|
28
|
+
perPage: number
|
|
29
|
+
search: string
|
|
30
|
+
/** One of {@link DASHBOARD_LOG_TYPES}, or empty for every level. */
|
|
31
|
+
type: string
|
|
32
|
+
source: string
|
|
33
|
+
project: string
|
|
34
|
+
range: DashboardLogRange
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DashboardLogPagination {
|
|
38
|
+
page: number
|
|
39
|
+
perPage: number
|
|
40
|
+
total: number
|
|
41
|
+
totalPages: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DashboardLogOptions {
|
|
45
|
+
sources: string[]
|
|
46
|
+
projects: string[]
|
|
47
|
+
types: string[]
|
|
48
|
+
ranges: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DashboardLogPayload extends Partial<DashboardProviderUnavailable> {
|
|
52
|
+
logs: DashboardLogRecord[]
|
|
53
|
+
summary: DashboardLogSummary
|
|
54
|
+
pagination: DashboardLogPagination
|
|
55
|
+
options: DashboardLogOptions
|
|
56
|
+
generatedAt: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DashboardLogsProvider {
|
|
60
|
+
logs: (query: DashboardLogQuery) => Promise<DashboardLogPayload>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An empty page of logs, carrying the reason there is nothing to show. */
|
|
64
|
+
export function emptyLogPayload(reason: string, query?: DashboardLogQuery): DashboardLogPayload {
|
|
65
|
+
return {
|
|
66
|
+
logs: [],
|
|
67
|
+
summary: { total: 0, error: 0, warning: 0, info: 0, success: 0 },
|
|
68
|
+
pagination: {
|
|
69
|
+
page: 1,
|
|
70
|
+
perPage: query?.perPage ?? 25,
|
|
71
|
+
total: 0,
|
|
72
|
+
totalPages: 1,
|
|
73
|
+
},
|
|
74
|
+
options: {
|
|
75
|
+
sources: [],
|
|
76
|
+
projects: [],
|
|
77
|
+
types: [...DASHBOARD_LOG_TYPES],
|
|
78
|
+
ranges: [...DASHBOARD_LOG_RANGES],
|
|
79
|
+
},
|
|
80
|
+
generatedAt: new Date().toISOString(),
|
|
81
|
+
unavailable: reason,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isLogPayload(payload: unknown): boolean {
|
|
86
|
+
if (!payload || typeof payload !== 'object')
|
|
87
|
+
return false
|
|
88
|
+
const candidate = payload as Partial<DashboardLogPayload>
|
|
89
|
+
return Array.isArray(candidate.logs)
|
|
90
|
+
&& Boolean(candidate.summary)
|
|
91
|
+
&& Boolean(candidate.pagination)
|
|
92
|
+
&& Boolean(candidate.options)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* This application's own `logs` table.
|
|
97
|
+
*
|
|
98
|
+
* Not wrapped in a catch. A failure here is a local database failure, and the
|
|
99
|
+
* action has always answered that with its operational error, which is the
|
|
100
|
+
* behaviour every existing caller and test expects.
|
|
101
|
+
*/
|
|
102
|
+
export const localLogsProvider: DashboardLogsProvider = {
|
|
103
|
+
async logs(query: DashboardLogQuery): Promise<DashboardLogPayload> {
|
|
104
|
+
const cutoff = query.range === 'all' ? 0 : Date.now() - Number(query.range) * 86400000
|
|
105
|
+
const searchPattern = `%${query.search}%`
|
|
106
|
+
|
|
107
|
+
const applyFilters = <T extends {
|
|
108
|
+
where: (...args: any[]) => T
|
|
109
|
+
whereAny: (columns: string[], operator: string, value: unknown) => T
|
|
110
|
+
}>(builder: T): T => {
|
|
111
|
+
let filtered = builder
|
|
112
|
+
if (cutoff)
|
|
113
|
+
filtered = filtered.where('timestamp', '>=', cutoff)
|
|
114
|
+
if (query.type)
|
|
115
|
+
filtered = filtered.where('type', '=', query.type)
|
|
116
|
+
if (query.source)
|
|
117
|
+
filtered = filtered.where('source', '=', query.source)
|
|
118
|
+
if (query.project)
|
|
119
|
+
filtered = filtered.where('project', '=', query.project)
|
|
120
|
+
if (query.search)
|
|
121
|
+
filtered = filtered.whereAny(['message', 'project', 'file', 'stacktrace'], 'like', searchPattern)
|
|
122
|
+
return filtered
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const [countRow, typeRows, sourceRows, projectRows] = await Promise.all([
|
|
126
|
+
applyFilters(db.selectFrom('logs').select(db.fn.count('id').as('count')))
|
|
127
|
+
.executeTakeFirst() as Promise<{ count: number | string } | undefined>,
|
|
128
|
+
applyFilters(db.selectFrom('logs').select(['type', db.fn.count('id').as('count')]))
|
|
129
|
+
.groupBy('type')
|
|
130
|
+
.execute() as Promise<Array<{ type: string, count: number | string }>>,
|
|
131
|
+
db.selectFrom('logs').select('source').distinct().orderBy('source', 'asc').limit(100).execute() as Promise<Array<{ source: string }>>,
|
|
132
|
+
db.selectFrom('logs').select('project').distinct().orderBy('project', 'asc').limit(500).execute() as Promise<Array<{ project: string }>>,
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
const total = Number(countRow?.count || 0)
|
|
136
|
+
const totalPages = Math.max(1, Math.ceil(total / query.perPage))
|
|
137
|
+
const page = Math.min(query.page, totalPages)
|
|
138
|
+
const rows = await applyFilters(db.selectFrom('logs').select([
|
|
139
|
+
'id',
|
|
140
|
+
'timestamp',
|
|
141
|
+
'type',
|
|
142
|
+
'source',
|
|
143
|
+
'message',
|
|
144
|
+
'project',
|
|
145
|
+
'stacktrace',
|
|
146
|
+
'file',
|
|
147
|
+
'created_at',
|
|
148
|
+
'updated_at',
|
|
149
|
+
]))
|
|
150
|
+
.orderBy('timestamp', 'desc')
|
|
151
|
+
.orderBy('id', 'desc')
|
|
152
|
+
.limit(query.perPage)
|
|
153
|
+
.offset((page - 1) * query.perPage)
|
|
154
|
+
.execute()
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
logs: rows.map(normalizeDashboardLog),
|
|
158
|
+
summary: summarizeDashboardLogTypes(typeRows, total),
|
|
159
|
+
pagination: {
|
|
160
|
+
page,
|
|
161
|
+
perPage: query.perPage,
|
|
162
|
+
total,
|
|
163
|
+
totalPages,
|
|
164
|
+
},
|
|
165
|
+
options: {
|
|
166
|
+
sources: sourceRows.map(row => row.source).filter(Boolean),
|
|
167
|
+
projects: projectRows.map(row => row.project).filter(Boolean),
|
|
168
|
+
types: [...DASHBOARD_LOG_TYPES],
|
|
169
|
+
ranges: [...DASHBOARD_LOG_RANGES],
|
|
170
|
+
},
|
|
171
|
+
generatedAt: new Date().toISOString(),
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Reads the Logs section through whichever provider config selects. */
|
|
177
|
+
export async function readDashboardLogs(query: DashboardLogQuery): Promise<DashboardLogPayload> {
|
|
178
|
+
const driver = await resolveDashboardDriver('logs')
|
|
179
|
+
|
|
180
|
+
if (driver.name === 'local')
|
|
181
|
+
return localLogsProvider.logs(query)
|
|
182
|
+
|
|
183
|
+
return readThroughProvider(
|
|
184
|
+
'logs',
|
|
185
|
+
reason => emptyLogPayload(reason, query),
|
|
186
|
+
async () => emptyLogPayload(HQ_READ_UNAVAILABLE, query),
|
|
187
|
+
isLogPayload,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { Campaign } from '@stacksjs/orm'
|
|
5
|
+
import { response } from '@stacksjs/router'
|
|
6
|
+
import { recoveryCampaignWriteData, validateRecoveryCampaign } from './abandoned-cart-records'
|
|
7
|
+
import { marketingModelError } from './marketing-response'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Write the campaign that goes after the cold carts.
|
|
11
|
+
*
|
|
12
|
+
* It is an ordinary Campaign row - the send pipeline, the delivery reporting
|
|
13
|
+
* and the campaigns screen all work on it unchanged. What makes it a recovery
|
|
14
|
+
* campaign is the segment stored on it, which names the trigger and the rules
|
|
15
|
+
* it was written with, so the campaign still says who it was for long after
|
|
16
|
+
* those carts have converted or been swept.
|
|
17
|
+
*
|
|
18
|
+
* Created as a draft, or scheduled if a time was given. Nothing sends from
|
|
19
|
+
* here: delivery goes through `CampaignSendAction` like every other campaign,
|
|
20
|
+
* so there is one place where mail actually leaves.
|
|
21
|
+
*/
|
|
22
|
+
export default new Action({
|
|
23
|
+
name: 'AbandonedCartCampaignAction',
|
|
24
|
+
description: 'Creates a cart-recovery campaign aimed at customers who left a cart behind.',
|
|
25
|
+
method: 'POST',
|
|
26
|
+
model: Campaign,
|
|
27
|
+
|
|
28
|
+
async handle(request: RequestInstance) {
|
|
29
|
+
const data = recoveryCampaignWriteData(
|
|
30
|
+
await request.all(),
|
|
31
|
+
String(config.commerce?.currency || 'USD').toUpperCase(),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const validationError = validateRecoveryCampaign(data)
|
|
35
|
+
if (validationError)
|
|
36
|
+
return response.json({ message: validationError }, 422)
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const campaign = await Campaign.create({
|
|
40
|
+
...data,
|
|
41
|
+
audience_size: 0,
|
|
42
|
+
sent_count: 0,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return response.json({ id: campaign.get('id') }, 201)
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
return marketingModelError(error, 'Recovery campaign could not be created.', 'AbandonedCartCampaignAction')
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { config } from '@stacksjs/config'
|
|
3
|
+
import { db } from '@stacksjs/database'
|
|
4
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
|
+
import { normalizeAbandonedCarts } from './abandoned-cart-records'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every cart that was filled and then left, and whatever has been done about it.
|
|
9
|
+
*
|
|
10
|
+
* Read straight from `carts` rather than from a rollup: the number a shop acts
|
|
11
|
+
* on has to be the number in the table, and a cached one drifts the moment
|
|
12
|
+
* somebody checks out.
|
|
13
|
+
*
|
|
14
|
+
* `converted` carts are fetched alongside the cold ones because recovery is
|
|
15
|
+
* only measurable as the difference between them - a dashboard that shows
|
|
16
|
+
* only what is still abandoned can say how much money is sitting there and
|
|
17
|
+
* never how much of it came back.
|
|
18
|
+
*/
|
|
19
|
+
export default new Action({
|
|
20
|
+
name: 'AbandonedCartIndexAction',
|
|
21
|
+
description: 'Returns abandoned carts, their recovery campaigns, and what those campaigns brought back.',
|
|
22
|
+
method: 'GET',
|
|
23
|
+
apiResponse: true,
|
|
24
|
+
|
|
25
|
+
async handle() {
|
|
26
|
+
try {
|
|
27
|
+
const carts = await db
|
|
28
|
+
.selectFrom('carts')
|
|
29
|
+
.selectAll()
|
|
30
|
+
.where('status', 'in', ['abandoned', 'expired', 'converted'])
|
|
31
|
+
.orderBy('updated_at', 'desc')
|
|
32
|
+
.limit(500)
|
|
33
|
+
.execute()
|
|
34
|
+
|
|
35
|
+
const cartIds = carts.map(cart => Number(cart.id)).filter(Number.isFinite)
|
|
36
|
+
const customerIds = [...new Set(
|
|
37
|
+
carts.map(cart => Number(cart.customer_id)).filter(id => Number.isFinite(id) && id > 0),
|
|
38
|
+
)]
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* Both of these are `in` lookups on the carts already fetched, so an
|
|
42
|
+
* empty page asks nothing rather than selecting every row in the table
|
|
43
|
+
* behind an `in ()` that some drivers read as "no filter".
|
|
44
|
+
*/
|
|
45
|
+
const [items, customers, campaigns] = await Promise.all([
|
|
46
|
+
cartIds.length > 0
|
|
47
|
+
? db.selectFrom('cart_items')
|
|
48
|
+
.select(['cart_id', 'quantity', 'product_name'])
|
|
49
|
+
.where('cart_id', 'in', cartIds)
|
|
50
|
+
.execute()
|
|
51
|
+
: Promise.resolve([]),
|
|
52
|
+
customerIds.length > 0
|
|
53
|
+
? db.selectFrom('customers')
|
|
54
|
+
.select(['id', 'name', 'email'])
|
|
55
|
+
.where('id', 'in', customerIds)
|
|
56
|
+
.execute()
|
|
57
|
+
: Promise.resolve([]),
|
|
58
|
+
db.selectFrom('campaigns')
|
|
59
|
+
.selectAll()
|
|
60
|
+
.whereNotNull('segment_definition')
|
|
61
|
+
.orderBy('id', 'desc')
|
|
62
|
+
.limit(200)
|
|
63
|
+
.execute(),
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
// Only the sends belonging to those campaigns matter, and there is no
|
|
67
|
+
// point reading a newsletter's hundred thousand rows to find out.
|
|
68
|
+
const campaignIds = campaigns.map(campaign => Number(campaign.id)).filter(Number.isFinite)
|
|
69
|
+
const sends = campaignIds.length > 0
|
|
70
|
+
? await db.selectFrom('campaign_sends')
|
|
71
|
+
.select(['campaign_id', 'recipient', 'sent_at', 'created_at'])
|
|
72
|
+
.where('campaign_id', 'in', campaignIds)
|
|
73
|
+
.where('status', 'in', ['sent', 'delivered'])
|
|
74
|
+
.execute()
|
|
75
|
+
: []
|
|
76
|
+
|
|
77
|
+
return normalizeAbandonedCarts(carts, items, customers, campaigns, sends, {
|
|
78
|
+
defaultCurrency: String(config.commerce?.currency || 'USD').toUpperCase(),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return dashboardOperationalError(error, 'Abandoned carts could not be loaded.', 'AbandonedCartIndexAction')
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
})
|