@stacksjs/defaults 0.74.1 → 0.74.3
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/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/Models/AnalyticsEvent.ts +5 -0
- package/app/Models/Content/Menu.ts +5 -1
- package/app/Models/Content/MenuItem.ts +6 -1
- package/app/Models/Content/Page.ts +5 -1
- package/app/Models/Content/Post.ts +5 -1
- package/app/Models/Content/Redirect.ts +5 -1
- package/app/Models/EmailIdempotency.ts +5 -0
- package/app/Models/EmailList.ts +6 -0
- package/app/Models/EmailSuppression.ts +5 -0
- package/app/Models/EmailWebhookEvent.ts +5 -0
- package/app/Models/Forms/Form.ts +5 -1
- package/app/Models/Forms/FormField.ts +6 -1
- package/app/Models/MailPreference.ts +5 -0
- package/app/Models/Request.ts +5 -0
- package/app/Models/SiteDomain.ts +5 -1
- package/app/Models/Tag.ts +6 -0
- package/app/Models/Team.ts +6 -1
- package/app/Models/User.ts +6 -1
- package/app/Models/commerce/Auction.ts +6 -0
- package/app/Models/commerce/AuctionItem.ts +6 -0
- package/app/Models/commerce/Cart.ts +6 -1
- package/app/Models/commerce/CartItem.ts +6 -1
- package/app/Models/commerce/Category.ts +6 -0
- package/app/Models/commerce/Coupon.ts +6 -0
- package/app/Models/commerce/DeliveryRoute.ts +6 -1
- package/app/Models/commerce/DeliveryStop.ts +6 -1
- package/app/Models/commerce/GiftCard.ts +6 -1
- package/app/Models/commerce/LicenseKey.ts +6 -1
- package/app/Models/commerce/LoyaltyReward.ts +6 -0
- package/app/Models/commerce/Manufacturer.ts +6 -0
- package/app/Models/commerce/Order.ts +6 -1
- package/app/Models/commerce/Payment.ts +6 -1
- package/app/Models/commerce/PrintDevice.ts +6 -0
- package/app/Models/commerce/Product.ts +6 -0
- package/app/Models/commerce/ProductUnit.ts +6 -0
- package/app/Models/commerce/ProductVariant.ts +6 -0
- package/app/Models/commerce/Review.ts +6 -1
- package/app/Models/commerce/ShippingMethod.ts +6 -0
- package/app/Models/commerce/ShippingRate.ts +6 -0
- package/app/Models/commerce/ShippingZone.ts +6 -0
- package/app/Models/commerce/TaxRate.ts +6 -0
- package/app/Models/commerce/Transaction.ts +6 -1
- package/app/Models/commerce/WaitlistProduct.ts +6 -1
- package/app/Models/commerce/WaitlistRestaurant.ts +6 -1
- package/app/Models/realtime/Websocket.ts +5 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/project/storage/framework/tsconfig.app.json +4 -1
- 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/resources/views/cms/blocks/form.stx +92 -1
- package/routes/dashboard-api.ts +5 -0
- package/routes/forms.ts +46 -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
|
+
}
|
|
@@ -7,6 +7,11 @@ export default defineModel({
|
|
|
7
7
|
primaryKey: 'id',
|
|
8
8
|
autoIncrement: true,
|
|
9
9
|
|
|
10
|
+
// An infrastructure table: rows are written by the system, not on behalf of a
|
|
11
|
+
// caller, so no row has a per-caller owner to scope by. Writes are gated by
|
|
12
|
+
// `middleware` instead. Declared rather than left silent (stacksjs/stacks#2375).
|
|
13
|
+
ownership: false,
|
|
14
|
+
|
|
10
15
|
traits: {
|
|
11
16
|
useUuid: true,
|
|
12
17
|
useTimestamps: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineModel } from '@stacksjs/orm'
|
|
1
|
+
import { defineModel, siteOwnership } from '@stacksjs/orm'
|
|
2
2
|
import { schema } from '@stacksjs/validation'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -20,6 +20,10 @@ export default defineModel({
|
|
|
20
20
|
},
|
|
21
21
|
],
|
|
22
22
|
|
|
23
|
+
// Owned through the site, which belongs to a team, so the owner is the set of
|
|
24
|
+
// site ids the caller's team owns rather than a single id (stacksjs/stacks#2375).
|
|
25
|
+
ownership: siteOwnership(),
|
|
26
|
+
|
|
23
27
|
traits: {
|
|
24
28
|
useTimestamps: true,
|
|
25
29
|
useApi: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineModel } from '@stacksjs/orm'
|
|
1
|
+
import { defineModel, parentOwnership } from '@stacksjs/orm'
|
|
2
2
|
import { schema } from '@stacksjs/validation'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -11,6 +11,11 @@ export default defineModel({
|
|
|
11
11
|
primaryKey: 'id',
|
|
12
12
|
autoIncrement: true,
|
|
13
13
|
|
|
14
|
+
// No owner of its own: these rows are owned by whoever owns the menu's site, and so its team
|
|
15
|
+
// (stacksjs/stacks#2375). Resolved through the parent so it follows any change
|
|
16
|
+
// to how Menu decides ownership.
|
|
17
|
+
ownership: parentOwnership('Menu', 'menu_id'),
|
|
18
|
+
|
|
14
19
|
traits: {
|
|
15
20
|
useTimestamps: true,
|
|
16
21
|
useApi: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineModel } from '@stacksjs/orm'
|
|
1
|
+
import { defineModel, siteOwnership } from '@stacksjs/orm'
|
|
2
2
|
import { schema } from '@stacksjs/validation'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -28,6 +28,10 @@ export default defineModel({
|
|
|
28
28
|
},
|
|
29
29
|
],
|
|
30
30
|
|
|
31
|
+
// Owned through the site, which belongs to a team, so the owner is the set of
|
|
32
|
+
// site ids the caller's team owns rather than a single id (stacksjs/stacks#2375).
|
|
33
|
+
ownership: siteOwnership(),
|
|
34
|
+
|
|
31
35
|
traits: {
|
|
32
36
|
useUuid: true,
|
|
33
37
|
useTimestamps: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineModel } from '@stacksjs/orm'
|
|
1
|
+
import { defineModel, siteOwnership } from '@stacksjs/orm'
|
|
2
2
|
import { schema } from '@stacksjs/validation'
|
|
3
3
|
|
|
4
4
|
export default defineModel({
|
|
@@ -7,6 +7,10 @@ export default defineModel({
|
|
|
7
7
|
primaryKey: 'id',
|
|
8
8
|
autoIncrement: true,
|
|
9
9
|
|
|
10
|
+
// Owned through the site, which belongs to a team, so the owner is the set of
|
|
11
|
+
// site ids the caller's team owns rather than a single id (stacksjs/stacks#2375).
|
|
12
|
+
ownership: siteOwnership(),
|
|
13
|
+
|
|
10
14
|
traits: {
|
|
11
15
|
useUuid: true,
|
|
12
16
|
useTimestamps: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineModel } from '@stacksjs/orm'
|
|
1
|
+
import { defineModel, siteOwnership } from '@stacksjs/orm'
|
|
2
2
|
import { schema } from '@stacksjs/validation'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -21,6 +21,10 @@ export default defineModel({
|
|
|
21
21
|
},
|
|
22
22
|
],
|
|
23
23
|
|
|
24
|
+
// Owned through the site, which belongs to a team, so the owner is the set of
|
|
25
|
+
// site ids the caller's team owns rather than a single id (stacksjs/stacks#2375).
|
|
26
|
+
ownership: siteOwnership(),
|
|
27
|
+
|
|
24
28
|
traits: {
|
|
25
29
|
useTimestamps: true,
|
|
26
30
|
useApi: {
|
|
@@ -7,6 +7,11 @@ export default defineModel({
|
|
|
7
7
|
primaryKey: 'id',
|
|
8
8
|
autoIncrement: true,
|
|
9
9
|
|
|
10
|
+
// An infrastructure table: rows are written by the system, not on behalf of a
|
|
11
|
+
// caller, so no row has a per-caller owner to scope by. Writes are gated by
|
|
12
|
+
// `middleware` instead. Declared rather than left silent (stacksjs/stacks#2375).
|
|
13
|
+
ownership: false,
|
|
14
|
+
|
|
10
15
|
traits: {
|
|
11
16
|
useTimestamps: true,
|
|
12
17
|
useApi: {
|
package/app/Models/EmailList.ts
CHANGED
|
@@ -8,6 +8,12 @@ export default defineModel({
|
|
|
8
8
|
autoIncrement: true,
|
|
9
9
|
hasMany: ['EmailListSubscriber', 'Campaign', 'CampaignSend'],
|
|
10
10
|
|
|
11
|
+
// A reference table: no row here has a per-caller owner, so there is nothing
|
|
12
|
+
// to scope by and writes are an administrative concern gated by `middleware`.
|
|
13
|
+
// Declared rather than left silent so `security.api.rowScoping: 'deny'` can
|
|
14
|
+
// tell "considered" from "nobody thought about it" (stacksjs/stacks#2375).
|
|
15
|
+
ownership: false,
|
|
16
|
+
|
|
11
17
|
traits: {
|
|
12
18
|
useUuid: true,
|
|
13
19
|
useTimestamps: true,
|
|
@@ -15,6 +15,11 @@ export default defineModel({
|
|
|
15
15
|
},
|
|
16
16
|
],
|
|
17
17
|
|
|
18
|
+
// An infrastructure table: rows are written by the system, not on behalf of a
|
|
19
|
+
// caller, so no row has a per-caller owner to scope by. Writes are gated by
|
|
20
|
+
// `middleware` instead. Declared rather than left silent (stacksjs/stacks#2375).
|
|
21
|
+
ownership: false,
|
|
22
|
+
|
|
18
23
|
traits: {
|
|
19
24
|
useTimestamps: true,
|
|
20
25
|
useApi: {
|
|
@@ -15,6 +15,11 @@ export default defineModel({
|
|
|
15
15
|
},
|
|
16
16
|
],
|
|
17
17
|
|
|
18
|
+
// An infrastructure table: rows are written by the system, not on behalf of a
|
|
19
|
+
// caller, so no row has a per-caller owner to scope by. Writes are gated by
|
|
20
|
+
// `middleware` instead. Declared rather than left silent (stacksjs/stacks#2375).
|
|
21
|
+
ownership: false,
|
|
22
|
+
|
|
18
23
|
traits: {
|
|
19
24
|
useTimestamps: true,
|
|
20
25
|
useApi: {
|
package/app/Models/Forms/Form.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineModel } from '@stacksjs/orm'
|
|
1
|
+
import { defineModel, siteOwnership } from '@stacksjs/orm'
|
|
2
2
|
import { schema } from '@stacksjs/validation'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -23,6 +23,10 @@ export default defineModel({
|
|
|
23
23
|
},
|
|
24
24
|
],
|
|
25
25
|
|
|
26
|
+
// Owned through the site, which belongs to a team, so the owner is the set of
|
|
27
|
+
// site ids the caller's team owns rather than a single id (stacksjs/stacks#2375).
|
|
28
|
+
ownership: siteOwnership(),
|
|
29
|
+
|
|
26
30
|
traits: {
|
|
27
31
|
useUuid: true,
|
|
28
32
|
useTimestamps: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineModel } from '@stacksjs/orm'
|
|
1
|
+
import { defineModel, parentOwnership } from '@stacksjs/orm'
|
|
2
2
|
import { schema } from '@stacksjs/validation'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -21,6 +21,11 @@ export default defineModel({
|
|
|
21
21
|
},
|
|
22
22
|
],
|
|
23
23
|
|
|
24
|
+
// No owner of its own: these rows are owned by whoever owns the form's site, and so its team
|
|
25
|
+
// (stacksjs/stacks#2375). Resolved through the parent so it follows any change
|
|
26
|
+
// to how Form decides ownership.
|
|
27
|
+
ownership: parentOwnership('Form', 'form_id'),
|
|
28
|
+
|
|
24
29
|
traits: {
|
|
25
30
|
useTimestamps: true,
|
|
26
31
|
useApi: {
|
|
@@ -7,6 +7,11 @@ export default defineModel({
|
|
|
7
7
|
primaryKey: 'id',
|
|
8
8
|
autoIncrement: true,
|
|
9
9
|
|
|
10
|
+
// An infrastructure table: rows are written by the system, not on behalf of a
|
|
11
|
+
// caller, so no row has a per-caller owner to scope by. Writes are gated by
|
|
12
|
+
// `middleware` instead. Declared rather than left silent (stacksjs/stacks#2375).
|
|
13
|
+
ownership: false,
|
|
14
|
+
|
|
10
15
|
traits: {
|
|
11
16
|
useTimestamps: true,
|
|
12
17
|
useApi: {
|
package/app/Models/Request.ts
CHANGED
|
@@ -22,6 +22,11 @@ export default defineModel({
|
|
|
22
22
|
},
|
|
23
23
|
],
|
|
24
24
|
|
|
25
|
+
// An infrastructure table: rows are written by the system, not on behalf of a
|
|
26
|
+
// caller, so no row has a per-caller owner to scope by. Writes are gated by
|
|
27
|
+
// `middleware` instead. Declared rather than left silent (stacksjs/stacks#2375).
|
|
28
|
+
ownership: false,
|
|
29
|
+
|
|
25
30
|
traits: {
|
|
26
31
|
useTimestamps: true,
|
|
27
32
|
useSoftDeletes: true,
|
package/app/Models/SiteDomain.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineModel } from '@stacksjs/orm'
|
|
1
|
+
import { defineModel, siteOwnership } from '@stacksjs/orm'
|
|
2
2
|
import { schema } from '@stacksjs/validation'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -14,6 +14,10 @@ export default defineModel({
|
|
|
14
14
|
primaryKey: 'id',
|
|
15
15
|
autoIncrement: true,
|
|
16
16
|
|
|
17
|
+
// Owned through the site, which belongs to a team, so the owner is the set of
|
|
18
|
+
// site ids the caller's team owns rather than a single id (stacksjs/stacks#2375).
|
|
19
|
+
ownership: siteOwnership(),
|
|
20
|
+
|
|
17
21
|
traits: {
|
|
18
22
|
useTimestamps: true,
|
|
19
23
|
observe: true, // domain changes clear the host-resolution cache
|
package/app/Models/Tag.ts
CHANGED
|
@@ -25,6 +25,12 @@ export default defineModel({
|
|
|
25
25
|
primaryKey: 'id',
|
|
26
26
|
autoIncrement: true,
|
|
27
27
|
|
|
28
|
+
// A reference table: no row here has a per-caller owner, so there is nothing
|
|
29
|
+
// to scope by and writes are an administrative concern gated by `middleware`.
|
|
30
|
+
// Declared rather than left silent so `security.api.rowScoping: 'deny'` can
|
|
31
|
+
// tell "considered" from "nobody thought about it" (stacksjs/stacks#2375).
|
|
32
|
+
ownership: false,
|
|
33
|
+
|
|
28
34
|
traits: {
|
|
29
35
|
useUuid: true,
|
|
30
36
|
useTimestamps: true,
|