@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.
Files changed (60) hide show
  1. package/app/Actions/Dashboard/Marketing/AbandonedCartCampaignAction.ts +51 -0
  2. package/app/Actions/Dashboard/Marketing/AbandonedCartIndexAction.ts +85 -0
  3. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +316 -0
  4. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +429 -0
  5. package/app/Models/AnalyticsEvent.ts +5 -0
  6. package/app/Models/Content/Menu.ts +5 -1
  7. package/app/Models/Content/MenuItem.ts +6 -1
  8. package/app/Models/Content/Page.ts +5 -1
  9. package/app/Models/Content/Post.ts +5 -1
  10. package/app/Models/Content/Redirect.ts +5 -1
  11. package/app/Models/EmailIdempotency.ts +5 -0
  12. package/app/Models/EmailList.ts +6 -0
  13. package/app/Models/EmailSuppression.ts +5 -0
  14. package/app/Models/EmailWebhookEvent.ts +5 -0
  15. package/app/Models/Forms/Form.ts +5 -1
  16. package/app/Models/Forms/FormField.ts +6 -1
  17. package/app/Models/MailPreference.ts +5 -0
  18. package/app/Models/Request.ts +5 -0
  19. package/app/Models/SiteDomain.ts +5 -1
  20. package/app/Models/Tag.ts +6 -0
  21. package/app/Models/Team.ts +6 -1
  22. package/app/Models/User.ts +6 -1
  23. package/app/Models/commerce/Auction.ts +6 -0
  24. package/app/Models/commerce/AuctionItem.ts +6 -0
  25. package/app/Models/commerce/Cart.ts +6 -1
  26. package/app/Models/commerce/CartItem.ts +6 -1
  27. package/app/Models/commerce/Category.ts +6 -0
  28. package/app/Models/commerce/Coupon.ts +6 -0
  29. package/app/Models/commerce/DeliveryRoute.ts +6 -1
  30. package/app/Models/commerce/DeliveryStop.ts +6 -1
  31. package/app/Models/commerce/GiftCard.ts +6 -1
  32. package/app/Models/commerce/LicenseKey.ts +6 -1
  33. package/app/Models/commerce/LoyaltyReward.ts +6 -0
  34. package/app/Models/commerce/Manufacturer.ts +6 -0
  35. package/app/Models/commerce/Order.ts +6 -1
  36. package/app/Models/commerce/Payment.ts +6 -1
  37. package/app/Models/commerce/PrintDevice.ts +6 -0
  38. package/app/Models/commerce/Product.ts +6 -0
  39. package/app/Models/commerce/ProductUnit.ts +6 -0
  40. package/app/Models/commerce/ProductVariant.ts +6 -0
  41. package/app/Models/commerce/Review.ts +6 -1
  42. package/app/Models/commerce/ShippingMethod.ts +6 -0
  43. package/app/Models/commerce/ShippingRate.ts +6 -0
  44. package/app/Models/commerce/ShippingZone.ts +6 -0
  45. package/app/Models/commerce/TaxRate.ts +6 -0
  46. package/app/Models/commerce/Transaction.ts +6 -1
  47. package/app/Models/commerce/WaitlistProduct.ts +6 -1
  48. package/app/Models/commerce/WaitlistRestaurant.ts +6 -1
  49. package/app/Models/realtime/Websocket.ts +5 -0
  50. package/ide/vscode/package.json +1 -1
  51. package/package.json +2 -2
  52. package/project/storage/framework/tsconfig.app.json +4 -1
  53. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +240 -0
  54. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +96 -0
  55. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +145 -0
  56. package/resources/functions/dashboard/sidebar.ts +1 -0
  57. package/resources/views/cms/blocks/form.stx +92 -1
  58. package/routes/dashboard-api.ts +5 -0
  59. package/routes/forms.ts +46 -0
  60. 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: {
@@ -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: {
@@ -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: {
@@ -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,
@@ -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,