@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
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abandoned carts, and the campaign that goes after them.
|
|
3
|
+
*
|
|
4
|
+
* A cart that was filled and never checked out is the most qualified audience
|
|
5
|
+
* a shop has: somebody chose the products, priced them, and stopped. Every
|
|
6
|
+
* other campaign starts by guessing what a person wants; this one already
|
|
7
|
+
* knows, and the only question is whether anybody asks them to come back.
|
|
8
|
+
*
|
|
9
|
+
* The Campaign model already carries everything a recovery campaign needs -
|
|
10
|
+
* a channel, a schedule, delivery aggregates - so this does not add a second
|
|
11
|
+
* kind of campaign beside it. What makes a campaign a recovery campaign is
|
|
12
|
+
* its `segment_definition`: the column exists to say who a campaign is for,
|
|
13
|
+
* and a recovery campaign is one that says "the people whose carts went cold".
|
|
14
|
+
* Read `isRecoverySegment` as the whole of that contract.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here writes. The functions are pure so the numbers on the dashboard
|
|
17
|
+
* can be tested without a database, which is the only way the attribution
|
|
18
|
+
* below is checkable at all.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type AbandonedCartState = 'abandoned' | 'expired' | 'recovered'
|
|
22
|
+
|
|
23
|
+
/** The marker that makes a Campaign a cart-recovery campaign. */
|
|
24
|
+
export const ABANDONED_CART_TRIGGER = 'abandoned_cart'
|
|
25
|
+
|
|
26
|
+
/** How long a cart sits untouched before it is worth chasing, in hours. */
|
|
27
|
+
export const DEFAULT_IDLE_HOURS = 4
|
|
28
|
+
|
|
29
|
+
export interface AbandonedCartRecord {
|
|
30
|
+
id: string
|
|
31
|
+
customerId: string
|
|
32
|
+
customerName: string
|
|
33
|
+
customerEmail: string
|
|
34
|
+
itemCount: number
|
|
35
|
+
/** The first few product names, for a row that says what was left behind. */
|
|
36
|
+
items: string[]
|
|
37
|
+
value: number
|
|
38
|
+
currency: string
|
|
39
|
+
abandonedAt: string
|
|
40
|
+
/** Hours since the cart was last touched. */
|
|
41
|
+
idleHours: number
|
|
42
|
+
state: AbandonedCartState
|
|
43
|
+
/** Whether a recovery campaign has already written to this customer. */
|
|
44
|
+
contacted: boolean
|
|
45
|
+
contactedAt: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AbandonedCartSummary {
|
|
49
|
+
/** Carts sitting abandoned or expired right now. */
|
|
50
|
+
open: number
|
|
51
|
+
/** What those carts are worth. */
|
|
52
|
+
openValue: number
|
|
53
|
+
/** Of those, how many have already been written to. */
|
|
54
|
+
contacted: number
|
|
55
|
+
/** Carts that were contacted and then checked out. */
|
|
56
|
+
recovered: number
|
|
57
|
+
recoveredValue: number
|
|
58
|
+
/** Recovered as a share of everything that was ever chased. */
|
|
59
|
+
recoveryRate: number
|
|
60
|
+
averageValue: number
|
|
61
|
+
currency: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RecoveryCampaignRecord {
|
|
65
|
+
id: string
|
|
66
|
+
name: string
|
|
67
|
+
status: string
|
|
68
|
+
idleHours: number
|
|
69
|
+
minimumValue: number
|
|
70
|
+
sentCount: number
|
|
71
|
+
openedCount: number
|
|
72
|
+
clickedCount: number
|
|
73
|
+
scheduledAt: string
|
|
74
|
+
sentAt: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AbandonedCartIndexPayload {
|
|
78
|
+
records: AbandonedCartRecord[]
|
|
79
|
+
summary: AbandonedCartSummary
|
|
80
|
+
campaigns: RecoveryCampaignRecord[]
|
|
81
|
+
defaultCurrency: string
|
|
82
|
+
defaultIdleHours: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function value(record: any, ...keys: string[]): unknown {
|
|
86
|
+
for (const key of keys) {
|
|
87
|
+
const result = typeof record?.get === 'function' ? record.get(key) : record?.[key]
|
|
88
|
+
if (result !== null && result !== undefined)
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function text(input: unknown): string {
|
|
95
|
+
return input === null || input === undefined ? '' : String(input)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function number(input: unknown): number {
|
|
99
|
+
const result = Number(input)
|
|
100
|
+
return Number.isFinite(result) && result >= 0 ? result : 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A timestamp as milliseconds, whichever way the driver spelled it.
|
|
105
|
+
*
|
|
106
|
+
* SQLite hands back `2026-09-01 18:04:00` and Postgres an ISO string; both
|
|
107
|
+
* have to compare against `Date.now()` or the idle hours on every row are
|
|
108
|
+
* NaN, and a NaN sorts first, which puts the least useful rows at the top.
|
|
109
|
+
*
|
|
110
|
+
* A stamp with no zone on it is read as UTC, because that is what wrote it:
|
|
111
|
+
* SQLite's CURRENT_TIMESTAMP is UTC and every driver here stores UTC. Left to
|
|
112
|
+
* `new Date()`, a bare `2026-09-01 18:04:00` is read as local instead, and a
|
|
113
|
+
* cart abandoned two hours ago in a UTC-7 browser comes back five hours in
|
|
114
|
+
* the future - which clamps to "idle 0h" and quietly empties every filter
|
|
115
|
+
* that asks for carts older than something.
|
|
116
|
+
*/
|
|
117
|
+
function moment(input: unknown): number {
|
|
118
|
+
if (!input)
|
|
119
|
+
return Number.NaN
|
|
120
|
+
if (input instanceof Date)
|
|
121
|
+
return input.getTime()
|
|
122
|
+
|
|
123
|
+
const stamp = text(input).trim().replace(' ', 'T')
|
|
124
|
+
const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(stamp) ? stamp : `${stamp}Z`
|
|
125
|
+
const parsed = new Date(zoned).getTime()
|
|
126
|
+
|
|
127
|
+
return Number.isFinite(parsed) ? parsed : Number.NaN
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hoursBetween(from: number, to: number): number {
|
|
131
|
+
if (!Number.isFinite(from) || !Number.isFinite(to))
|
|
132
|
+
return 0
|
|
133
|
+
return Math.max(0, Math.round((to - from) / 36e5 * 10) / 10)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The audience a recovery campaign is written for.
|
|
138
|
+
*
|
|
139
|
+
* Stored on the campaign rather than recomputed from one, so a campaign that
|
|
140
|
+
* was sent last month still says what it was aimed at even after the carts it
|
|
141
|
+
* targeted have converted, expired, or been swept.
|
|
142
|
+
*/
|
|
143
|
+
export function abandonedCartSegment(idleHours: number, minimumValue: number): {
|
|
144
|
+
trigger: string
|
|
145
|
+
operator: string
|
|
146
|
+
rules: Array<{ field: string, operator: string, value: unknown }>
|
|
147
|
+
} {
|
|
148
|
+
return {
|
|
149
|
+
trigger: ABANDONED_CART_TRIGGER,
|
|
150
|
+
operator: 'and',
|
|
151
|
+
rules: [
|
|
152
|
+
{ field: 'cart.state', operator: 'is', value: 'abandoned' },
|
|
153
|
+
{ field: 'cart.idleHours', operator: 'gte', value: Math.max(1, Math.round(idleHours) || DEFAULT_IDLE_HOURS) },
|
|
154
|
+
{ field: 'cart.value', operator: 'gte', value: Math.max(0, minimumValue) },
|
|
155
|
+
],
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Whether a stored `segment_definition` describes a cart-recovery audience. */
|
|
160
|
+
export function isRecoverySegment(input: unknown): boolean {
|
|
161
|
+
const parsed = parseSegment(input)
|
|
162
|
+
return parsed?.trigger === ABANDONED_CART_TRIGGER
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseSegment(input: unknown): Record<string, any> | null {
|
|
166
|
+
if (!input)
|
|
167
|
+
return null
|
|
168
|
+
if (typeof input === 'object')
|
|
169
|
+
return input as Record<string, any>
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(text(input))
|
|
172
|
+
return parsed && typeof parsed === 'object' ? parsed : null
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// A segment nobody can parse is a segment nobody can be sure of, and
|
|
176
|
+
// guessing here would silently widen who a campaign is sent to.
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ruleValue(segment: Record<string, any> | null, field: string, fallback: number): number {
|
|
182
|
+
const rules = Array.isArray(segment?.rules) ? segment!.rules : []
|
|
183
|
+
const rule = rules.find((candidate: any) => candidate?.field === field)
|
|
184
|
+
const found = Number(rule?.value)
|
|
185
|
+
return Number.isFinite(found) ? found : fallback
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function groupItems(itemRows: any[]): Map<string, { count: number, names: string[] }> {
|
|
189
|
+
const grouped = new Map<string, { count: number, names: string[] }>()
|
|
190
|
+
for (const row of itemRows) {
|
|
191
|
+
const cartId = text(value(row, 'cart_id', 'cartId'))
|
|
192
|
+
if (!cartId)
|
|
193
|
+
continue
|
|
194
|
+
const current = grouped.get(cartId) || { count: 0, names: [] }
|
|
195
|
+
current.count += Math.max(1, number(value(row, 'quantity')) || 1)
|
|
196
|
+
const name = text(value(row, 'product_name', 'productName'))
|
|
197
|
+
if (name && current.names.length < 4)
|
|
198
|
+
current.names.push(name)
|
|
199
|
+
grouped.set(cartId, current)
|
|
200
|
+
}
|
|
201
|
+
return grouped
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Who a recovery campaign has already written to, and when it first did.
|
|
206
|
+
*
|
|
207
|
+
* Keyed by the address rather than by a subscriber id: a campaign send records
|
|
208
|
+
* the recipient it actually wrote to, and that address is the only thing a
|
|
209
|
+
* cart's customer and a campaign's send have in common.
|
|
210
|
+
*/
|
|
211
|
+
function contactedRecipients(sendRows: any[], recoveryCampaignIds: Set<string>): Map<string, number> {
|
|
212
|
+
const contacted = new Map<string, number>()
|
|
213
|
+
for (const row of sendRows) {
|
|
214
|
+
if (!recoveryCampaignIds.has(text(value(row, 'campaign_id', 'campaignId'))))
|
|
215
|
+
continue
|
|
216
|
+
const recipient = text(value(row, 'recipient')).toLowerCase()
|
|
217
|
+
if (!recipient)
|
|
218
|
+
continue
|
|
219
|
+
const sentAt = moment(value(row, 'sent_at', 'sentAt') ?? value(row, 'created_at', 'createdAt'))
|
|
220
|
+
if (!Number.isFinite(sentAt))
|
|
221
|
+
continue
|
|
222
|
+
const existing = contacted.get(recipient)
|
|
223
|
+
if (existing === undefined || sentAt < existing)
|
|
224
|
+
contacted.set(recipient, sentAt)
|
|
225
|
+
}
|
|
226
|
+
return contacted
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function normalizeAbandonedCarts(
|
|
230
|
+
cartRows: any[],
|
|
231
|
+
itemRows: any[],
|
|
232
|
+
customerRows: any[],
|
|
233
|
+
campaignRows: any[],
|
|
234
|
+
sendRows: any[],
|
|
235
|
+
options: { defaultCurrency?: string, now?: Date, idleHours?: number } = {},
|
|
236
|
+
): AbandonedCartIndexPayload {
|
|
237
|
+
const defaultCurrency = (options.defaultCurrency || 'USD').toUpperCase()
|
|
238
|
+
const now = (options.now ?? new Date()).getTime()
|
|
239
|
+
const defaultIdleHours = Math.max(1, Math.round(options.idleHours ?? DEFAULT_IDLE_HOURS))
|
|
240
|
+
|
|
241
|
+
const customers = new Map(customerRows.map(row => [text(value(row, 'id')), row]))
|
|
242
|
+
const items = groupItems(itemRows)
|
|
243
|
+
|
|
244
|
+
const campaigns = campaignRows
|
|
245
|
+
.filter(row => isRecoverySegment(value(row, 'segment_definition', 'segmentDefinition')))
|
|
246
|
+
.map((row): RecoveryCampaignRecord => {
|
|
247
|
+
const segment = parseSegment(value(row, 'segment_definition', 'segmentDefinition'))
|
|
248
|
+
return {
|
|
249
|
+
id: text(value(row, 'id')),
|
|
250
|
+
name: text(value(row, 'name')),
|
|
251
|
+
status: text(value(row, 'status')) || 'draft',
|
|
252
|
+
idleHours: ruleValue(segment, 'cart.idleHours', defaultIdleHours),
|
|
253
|
+
minimumValue: ruleValue(segment, 'cart.value', 0),
|
|
254
|
+
sentCount: number(value(row, 'sent_count', 'sentCount')),
|
|
255
|
+
openedCount: number(value(row, 'opened_count', 'openedCount')),
|
|
256
|
+
clickedCount: number(value(row, 'clicked_count', 'clickedCount')),
|
|
257
|
+
scheduledAt: text(value(row, 'scheduled_at', 'scheduledAt')),
|
|
258
|
+
sentAt: text(value(row, 'sent_at', 'sentAt')),
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const contacted = contactedRecipients(sendRows, new Set(campaigns.map(campaign => campaign.id)))
|
|
263
|
+
|
|
264
|
+
const records = cartRows.map((cart): AbandonedCartRecord => {
|
|
265
|
+
const id = text(value(cart, 'id'))
|
|
266
|
+
const customerId = text(value(cart, 'customer_id', 'customerId'))
|
|
267
|
+
const customer = customers.get(customerId)
|
|
268
|
+
const email = text(value(customer, 'email'))
|
|
269
|
+
const status = text(value(cart, 'status')).toLowerCase()
|
|
270
|
+
const abandonedAtMs = moment(value(cart, 'updated_at', 'updatedAt') ?? value(cart, 'created_at', 'createdAt'))
|
|
271
|
+
const contactedAtMs = contacted.get(email.toLowerCase())
|
|
272
|
+
const grouped = items.get(id)
|
|
273
|
+
|
|
274
|
+
/*
|
|
275
|
+
* `converted` is only counted as recovered when a recovery campaign got
|
|
276
|
+
* there first. A cart somebody came back to on their own is a sale, not a
|
|
277
|
+
* campaign's, and crediting it would make every recovery campaign look
|
|
278
|
+
* like it worked.
|
|
279
|
+
*/
|
|
280
|
+
const recovered = status === 'converted'
|
|
281
|
+
&& contactedAtMs !== undefined
|
|
282
|
+
&& Number.isFinite(abandonedAtMs)
|
|
283
|
+
&& contactedAtMs <= abandonedAtMs
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
id,
|
|
287
|
+
customerId,
|
|
288
|
+
customerName: text(value(customer, 'name')) || 'Guest',
|
|
289
|
+
customerEmail: email,
|
|
290
|
+
itemCount: grouped?.count || number(value(cart, 'total_items', 'totalItems')),
|
|
291
|
+
items: grouped?.names || [],
|
|
292
|
+
value: number(value(cart, 'total')),
|
|
293
|
+
currency: text(value(cart, 'currency')).toUpperCase() || defaultCurrency,
|
|
294
|
+
abandonedAt: text(value(cart, 'updated_at', 'updatedAt') ?? value(cart, 'created_at', 'createdAt')),
|
|
295
|
+
idleHours: hoursBetween(abandonedAtMs, now),
|
|
296
|
+
state: recovered ? 'recovered' : status === 'expired' ? 'expired' : 'abandoned',
|
|
297
|
+
contacted: contactedAtMs !== undefined,
|
|
298
|
+
contactedAt: contactedAtMs === undefined ? '' : new Date(contactedAtMs).toISOString(),
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const open = records.filter(record => record.state !== 'recovered')
|
|
303
|
+
const recovered = records.filter(record => record.state === 'recovered')
|
|
304
|
+
const openValue = open.reduce((sum, record) => sum + record.value, 0)
|
|
305
|
+
const recoveredValue = recovered.reduce((sum, record) => sum + record.value, 0)
|
|
306
|
+
const chased = open.filter(record => record.contacted).length + recovered.length
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
records,
|
|
310
|
+
summary: {
|
|
311
|
+
open: open.length,
|
|
312
|
+
openValue,
|
|
313
|
+
contacted: open.filter(record => record.contacted).length,
|
|
314
|
+
recovered: recovered.length,
|
|
315
|
+
recoveredValue,
|
|
316
|
+
recoveryRate: chased > 0 ? recovered.length / chased * 100 : 0,
|
|
317
|
+
averageValue: open.length > 0 ? openValue / open.length : 0,
|
|
318
|
+
currency: records[0]?.currency || defaultCurrency,
|
|
319
|
+
},
|
|
320
|
+
campaigns,
|
|
321
|
+
defaultCurrency,
|
|
322
|
+
defaultIdleHours,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* How many carts a campaign with these rules would be written for, and what
|
|
328
|
+
* they are worth. Shown on the compose dialog so nobody schedules a send to
|
|
329
|
+
* nobody, or to everybody.
|
|
330
|
+
*/
|
|
331
|
+
export function reachOf(
|
|
332
|
+
records: AbandonedCartRecord[],
|
|
333
|
+
idleHours: number,
|
|
334
|
+
minimumValue: number,
|
|
335
|
+
): { carts: number, value: number } {
|
|
336
|
+
const matching = records.filter(record =>
|
|
337
|
+
record.state === 'abandoned'
|
|
338
|
+
&& record.customerEmail !== ''
|
|
339
|
+
&& record.idleHours >= idleHours
|
|
340
|
+
&& record.value >= minimumValue)
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
carts: matching.length,
|
|
344
|
+
value: matching.reduce((sum, record) => sum + record.value, 0),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface RecoveryCampaignInput {
|
|
349
|
+
name: string
|
|
350
|
+
subject: string
|
|
351
|
+
template: string
|
|
352
|
+
text: string
|
|
353
|
+
fromName: string
|
|
354
|
+
fromAddress: string
|
|
355
|
+
emailListId: number | null
|
|
356
|
+
idleHours: number
|
|
357
|
+
minimumValue: number
|
|
358
|
+
scheduledAt: string | null
|
|
359
|
+
currency: string
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function recoveryCampaignWriteData(
|
|
363
|
+
input: Record<string, unknown>,
|
|
364
|
+
defaultCurrency = 'USD',
|
|
365
|
+
): {
|
|
366
|
+
name: string
|
|
367
|
+
description: string
|
|
368
|
+
type: 'email'
|
|
369
|
+
status: 'draft' | 'scheduled'
|
|
370
|
+
subject: string
|
|
371
|
+
template: string
|
|
372
|
+
text: string | null
|
|
373
|
+
from_name: string | null
|
|
374
|
+
from_address: string | null
|
|
375
|
+
email_list_id: number | null
|
|
376
|
+
segment_definition: string
|
|
377
|
+
scheduled_at: string | null
|
|
378
|
+
currency: string
|
|
379
|
+
} {
|
|
380
|
+
const idleHours = Math.max(1, Math.round(Number(input.idleHours)) || DEFAULT_IDLE_HOURS)
|
|
381
|
+
const minimumValue = Math.max(0, Number(input.minimumValue) || 0)
|
|
382
|
+
const emailListId = Number(input.emailListId ?? input.email_list_id)
|
|
383
|
+
const scheduledAt = text(input.scheduledAt ?? input.scheduled_at).trim()
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
name: text(input.name).trim(),
|
|
387
|
+
description: `Recovery campaign for carts idle ${idleHours}h or more.`,
|
|
388
|
+
type: 'email',
|
|
389
|
+
status: scheduledAt ? 'scheduled' : 'draft',
|
|
390
|
+
subject: text(input.subject).trim(),
|
|
391
|
+
template: text(input.template).trim() || 'abandoned-cart',
|
|
392
|
+
text: text(input.text).trim() || null,
|
|
393
|
+
from_name: text(input.fromName ?? input.from_name).trim() || null,
|
|
394
|
+
from_address: text(input.fromAddress ?? input.from_address).trim() || null,
|
|
395
|
+
email_list_id: Number.isInteger(emailListId) && emailListId > 0 ? emailListId : null,
|
|
396
|
+
segment_definition: JSON.stringify(abandonedCartSegment(idleHours, minimumValue)),
|
|
397
|
+
scheduled_at: scheduledAt || null,
|
|
398
|
+
currency: text(input.currency).trim().toUpperCase() || defaultCurrency,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function validateRecoveryCampaign(
|
|
403
|
+
data: ReturnType<typeof recoveryCampaignWriteData>,
|
|
404
|
+
now = new Date(),
|
|
405
|
+
): string {
|
|
406
|
+
if (data.name.trim().length < 3)
|
|
407
|
+
return 'Recovery campaign names must contain at least 3 characters.'
|
|
408
|
+
if (!data.subject)
|
|
409
|
+
return 'A recovery campaign needs a subject line.'
|
|
410
|
+
|
|
411
|
+
/*
|
|
412
|
+
* The whole point of this campaign is that it writes to people who left a
|
|
413
|
+
* cart, and the address it writes to comes from the cart's customer. A
|
|
414
|
+
* campaign aimed at an email list would write to the list instead, which is
|
|
415
|
+
* a newsletter with a misleading name.
|
|
416
|
+
*/
|
|
417
|
+
if (data.email_list_id)
|
|
418
|
+
return 'Recovery campaigns take their audience from abandoned carts, not from an email list.'
|
|
419
|
+
|
|
420
|
+
if (data.status === 'scheduled') {
|
|
421
|
+
const scheduledAt = new Date(String(data.scheduled_at).replace(' ', 'T')).getTime()
|
|
422
|
+
if (!Number.isFinite(scheduledAt))
|
|
423
|
+
return 'Enter a valid send time.'
|
|
424
|
+
if (scheduledAt <= now.getTime())
|
|
425
|
+
return 'A recovery campaign has to be scheduled for the future.'
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return ''
|
|
429
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { DashboardHqDriverOptions, DashboardProviderName, DashboardProviderSelection } from '@stacksjs/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The seam between a dashboard section and the data behind it.
|
|
5
|
+
*
|
|
6
|
+
* Every section in the dashboard used to reach for a local store directly:
|
|
7
|
+
* the Logs page queried the `logs` table, the Errors pages called into
|
|
8
|
+
* `@stacksjs/commerce`, and web Analytics aggregated the `Request` model. That
|
|
9
|
+
* works exactly once, for the application the dashboard is running inside. It
|
|
10
|
+
* cannot show the logs of a fleet, and it gave the hosted products (loghq,
|
|
11
|
+
* bughq, analyticshq) nowhere to plug in.
|
|
12
|
+
*
|
|
13
|
+
* This module resolves which provider a domain reads from. `local` is the
|
|
14
|
+
* default and is byte-for-byte the behaviour that shipped before the seam
|
|
15
|
+
* existed, including how it fails: a local provider is never wrapped, so a
|
|
16
|
+
* database outage still surfaces the same operational error it always did.
|
|
17
|
+
* Anything else is remote, and remote is wrapped, because a section whose
|
|
18
|
+
* backend is unreachable has to render empty with a reason rather than take
|
|
19
|
+
* the page down.
|
|
20
|
+
*
|
|
21
|
+
* Read paths only. No provider writes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The dashboard sections that resolve a provider. */
|
|
25
|
+
export type DashboardProviderDomain = 'logs' | 'errors' | 'analytics'
|
|
26
|
+
|
|
27
|
+
const PROVIDER_NAMES: readonly DashboardProviderName[] = ['local', 'hq']
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Why a section has no data.
|
|
31
|
+
*
|
|
32
|
+
* Carried on the payload beside the empty collections rather than thrown, so
|
|
33
|
+
* the section renders its own empty state instead of an error page. The string
|
|
34
|
+
* is written for whoever is looking at the dashboard, not for a log file.
|
|
35
|
+
*/
|
|
36
|
+
export interface DashboardProviderUnavailable {
|
|
37
|
+
unavailable: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResolvedDashboardDriver {
|
|
41
|
+
name: DashboardProviderName
|
|
42
|
+
options: DashboardHqDriverOptions
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const resolved = new Map<DashboardProviderDomain, ResolvedDashboardDriver>()
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Forgets every memoized driver choice.
|
|
49
|
+
*
|
|
50
|
+
* Resolution is cached per domain so a request does not re-read config on
|
|
51
|
+
* every query. Tests that walk more than one configuration in a single process
|
|
52
|
+
* need that cache cleared between cases, and clearing it is much cheaper than
|
|
53
|
+
* `mock.module`, which replaces the shared module instance for every other
|
|
54
|
+
* test file in the run and is not undone by `mock.restore()`.
|
|
55
|
+
*/
|
|
56
|
+
export function resetDashboardProviders(): void {
|
|
57
|
+
resolved.clear()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readSelection(section: unknown, domain: DashboardProviderDomain): DashboardProviderSelection {
|
|
61
|
+
if (!section || typeof section !== 'object')
|
|
62
|
+
return {}
|
|
63
|
+
const providers = (section as { providers?: unknown }).providers
|
|
64
|
+
if (!providers || typeof providers !== 'object')
|
|
65
|
+
return {}
|
|
66
|
+
const selection = (providers as Record<string, unknown>)[domain]
|
|
67
|
+
return selection && typeof selection === 'object' ? selection as DashboardProviderSelection : {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function selectDriverName(value: unknown, domain: DashboardProviderDomain): DashboardProviderName {
|
|
71
|
+
if (value === undefined || value === null || value === '')
|
|
72
|
+
return 'local'
|
|
73
|
+
|
|
74
|
+
switch (value) {
|
|
75
|
+
case 'local':
|
|
76
|
+
return 'local'
|
|
77
|
+
case 'hq':
|
|
78
|
+
return 'hq'
|
|
79
|
+
default:
|
|
80
|
+
// `satisfies DashboardConfig` is erased at runtime, and config files are
|
|
81
|
+
// ordinary TypeScript an app can hand-edit, so an unknown name reaches
|
|
82
|
+
// here as a plain string. Falling back to local keeps the section
|
|
83
|
+
// rendering; throwing would take it down over a typo.
|
|
84
|
+
console.warn(
|
|
85
|
+
`[dashboard/api] config.dashboard.providers.${domain}.driver is `
|
|
86
|
+
+ `"${String(value)}", which is not a known provider. Reading local data instead. `
|
|
87
|
+
+ `Available providers: ${PROVIDER_NAMES.join(', ')}.`,
|
|
88
|
+
)
|
|
89
|
+
return 'local'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reads a domain's provider choice from config.
|
|
95
|
+
*
|
|
96
|
+
* Config is read through `overridesReady`. A read that happens before the
|
|
97
|
+
* application's own config files have been layered in returns the framework
|
|
98
|
+
* defaults with no error at all, which would silently resolve `local` in an
|
|
99
|
+
* application that had asked for something else.
|
|
100
|
+
*/
|
|
101
|
+
export async function resolveDashboardDriver(domain: DashboardProviderDomain): Promise<ResolvedDashboardDriver> {
|
|
102
|
+
const cached = resolved.get(domain)
|
|
103
|
+
if (cached)
|
|
104
|
+
return cached
|
|
105
|
+
|
|
106
|
+
let selection: DashboardProviderSelection = {}
|
|
107
|
+
try {
|
|
108
|
+
const { config, overridesReady } = await import('@stacksjs/config')
|
|
109
|
+
// Rejection means boot could not read the application's config at all.
|
|
110
|
+
// The framework defaults are still a usable answer, and they are local.
|
|
111
|
+
await overridesReady.catch(() => {})
|
|
112
|
+
selection = readSelection(config.dashboard, domain)
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
console.warn(`[dashboard/api] dashboard provider config for ${domain} could not be read, using local:`, error)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const driver: ResolvedDashboardDriver = {
|
|
119
|
+
name: selectDriverName(selection.driver, domain),
|
|
120
|
+
options: selection.drivers?.hq ?? {},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
resolved.set(domain, driver)
|
|
124
|
+
return driver
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Runs a remote provider so that it cannot take a section down.
|
|
129
|
+
*
|
|
130
|
+
* Three ways a remote read goes wrong, all of which land on the same empty
|
|
131
|
+
* payload plus a reason: it rejects, it never answers, or it answers with
|
|
132
|
+
* something that is not the shape the section renders. The last one matters
|
|
133
|
+
* as much as the first two, because a payload that is merely missing its
|
|
134
|
+
* array reaches the component and fails there instead, which is a blank page
|
|
135
|
+
* rather than an empty state.
|
|
136
|
+
*/
|
|
137
|
+
export async function readThroughProvider<T>(
|
|
138
|
+
domain: DashboardProviderDomain,
|
|
139
|
+
empty: (reason: string) => T,
|
|
140
|
+
read: () => Promise<T>,
|
|
141
|
+
isValid: (payload: unknown) => boolean,
|
|
142
|
+
): Promise<T> {
|
|
143
|
+
try {
|
|
144
|
+
const payload = await read()
|
|
145
|
+
if (!isValid(payload)) {
|
|
146
|
+
console.error(`[dashboard/api] the ${domain} provider answered with an unexpected shape`)
|
|
147
|
+
return empty('This section could not be read from its provider.')
|
|
148
|
+
}
|
|
149
|
+
return payload
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
console.error(`[dashboard/api] the ${domain} provider could not be read:`, error)
|
|
153
|
+
return empty('This section could not be reached. It is configured to read from a hosted provider.')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The reason an `hq` provider has no data yet.
|
|
159
|
+
*
|
|
160
|
+
* Both published SDKs are ingest clients. `@loghq/stacks` and `@bughq/stacks`
|
|
161
|
+
* send records and expose no function that reads any back, and a project's
|
|
162
|
+
* ingest key grants no read access by design. The hosted read APIs do exist,
|
|
163
|
+
* but they authenticate a person rather than a machine, so wiring one needs a
|
|
164
|
+
* product decision about service credentials before any code here can help.
|
|
165
|
+
*
|
|
166
|
+
* Selecting `hq` today therefore yields an empty section that says so, which
|
|
167
|
+
* is the honest answer and keeps the degrade path real rather than theoretical.
|
|
168
|
+
*/
|
|
169
|
+
export const HQ_READ_UNAVAILABLE
|
|
170
|
+
= 'No data. The hosted provider has no read API yet, so this section cannot be filled from it.'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { errors } from '@stacksjs/commerce'
|
|
3
2
|
import { response } from '@stacksjs/router'
|
|
3
|
+
import { readErrorsByGroup } from './error-provider'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'Error Group',
|
|
@@ -14,8 +14,6 @@ export default new Action({
|
|
|
14
14
|
return response.json({ error: 'Both type and message are required' }, 400)
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return response.json({ data: results })
|
|
17
|
+
return response.json(await readErrorsByGroup(type, message))
|
|
20
18
|
},
|
|
21
19
|
})
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { errors } from '@stacksjs/commerce'
|
|
3
2
|
import { response } from '@stacksjs/router'
|
|
3
|
+
import { readGroupedErrors } from './error-provider'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'Error Index',
|
|
7
7
|
description: 'Fetch all grouped errors (Sentry-like aggregation)',
|
|
8
8
|
method: 'GET',
|
|
9
9
|
async handle() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return response.json({ data: results })
|
|
10
|
+
return response.json(await readGroupedErrors())
|
|
13
11
|
},
|
|
14
12
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { errors } from '@stacksjs/commerce'
|
|
3
2
|
import { response } from '@stacksjs/router'
|
|
3
|
+
import { readErrorById } from './error-provider'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'Error Show',
|
|
@@ -13,12 +13,12 @@ export default new Action({
|
|
|
13
13
|
if (id === null)
|
|
14
14
|
return response.json({ error: 'A numeric error id is required' }, 422)
|
|
15
15
|
|
|
16
|
-
const result = await
|
|
16
|
+
const result = await readErrorById(id)
|
|
17
17
|
|
|
18
|
-
if (!result) {
|
|
18
|
+
if (!result.data) {
|
|
19
19
|
return response.json({ error: 'Error not found' }, 404)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
return response.json(
|
|
22
|
+
return response.json(result)
|
|
23
23
|
},
|
|
24
24
|
})
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { errors } from '@stacksjs/commerce'
|
|
3
2
|
import { response } from '@stacksjs/router'
|
|
3
|
+
import { readErrorStats } from './error-provider'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'Error Stats',
|
|
7
7
|
description: 'Fetch error statistics',
|
|
8
8
|
method: 'GET',
|
|
9
9
|
async handle() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return response.json({ data: stats })
|
|
10
|
+
return response.json(await readErrorStats())
|
|
13
11
|
},
|
|
14
12
|
})
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { errors } from '@stacksjs/commerce'
|
|
3
2
|
import { response } from '@stacksjs/router'
|
|
3
|
+
import { readErrorTimeline } from './error-provider'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'Error Timeline',
|
|
7
7
|
description: 'Fetch error timeline (hourly counts for last 24 hours)',
|
|
8
8
|
method: 'GET',
|
|
9
9
|
async handle() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return response.json({ data: timeline })
|
|
10
|
+
return response.json(await readErrorTimeline())
|
|
13
11
|
},
|
|
14
12
|
})
|