@stacksjs/defaults 0.75.37 → 0.75.40

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 (110) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +24 -1
  2. package/ai/skills/stacks-commerce/SKILL.md +1 -1
  3. package/ai/skills/stacks-config/SKILL.md +1 -1
  4. package/ai/skills/stacks-database/SKILL.md +1 -1
  5. package/ai/skills/stacks-dependencies/SKILL.md +35 -3
  6. package/ai/skills/stacks-env/SKILL.md +4 -1
  7. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  8. package/ai/skills/stacks-models/SKILL.md +2 -2
  9. package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
  10. package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
  11. package/app/Actions/Commerce/commerce-action.test.ts +30 -1
  12. package/app/Actions/Commerce/commerce-action.ts +44 -0
  13. package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
  14. package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
  15. package/app/Actions/Dashboard/Ci/failure-notifier.ts +11 -7
  16. package/app/Actions/Dashboard/Ci/runner-pressure-monitor.ts +13 -7
  17. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
  18. package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
  19. package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
  20. package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
  21. package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
  22. package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
  23. package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
  24. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
  25. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +7 -4
  26. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +10 -7
  27. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +7 -4
  28. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +9 -5
  29. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +8 -5
  30. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +3 -2
  31. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +7 -4
  32. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -4
  33. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +3 -2
  34. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +7 -3
  35. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
  36. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
  37. package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
  38. package/app/Actions/Storefront/AddToCartAction.ts +4 -1
  39. package/app/Actions/Storefront/GetCartAction.ts +25 -10
  40. package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
  41. package/app/Actions/Storefront/_shipping.ts +63 -37
  42. package/app/Broadcasts/OrderShipped.ts +1 -1
  43. package/app/Mail/OrderConfirmation.ts +14 -7
  44. package/app/Models/Campaign.ts +9 -4
  45. package/app/Models/commerce/Cart.ts +5 -4
  46. package/app/Models/commerce/CartItem.ts +6 -4
  47. package/app/Models/commerce/Coupon.ts +9 -3
  48. package/app/Models/commerce/Customer.ts +2 -1
  49. package/app/Models/commerce/GiftCard.ts +5 -2
  50. package/app/Models/commerce/Order.ts +6 -5
  51. package/app/Models/commerce/OrderItem.ts +2 -1
  52. package/app/Models/commerce/ProductVariant.ts +71 -4
  53. package/app/Models/commerce/Transaction.ts +3 -2
  54. package/app/Storefront/StorefrontMoney.ts +54 -0
  55. package/ide/vscode/package.json +1 -1
  56. package/package.json +2 -2
  57. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
  58. package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
  59. package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
  60. package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
  61. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
  62. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
  63. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
  64. package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
  65. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
  66. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
  67. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
  68. package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
  69. package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
  70. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
  71. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
  72. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
  73. package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
  74. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
  75. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
  76. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
  77. package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
  78. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
  79. package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
  80. package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
  81. package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
  82. package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
  83. package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
  84. package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
  85. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
  86. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
  87. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
  88. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
  89. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
  90. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
  91. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
  92. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
  93. package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
  94. package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
  95. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
  96. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
  97. package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
  98. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
  99. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
  100. package/resources/components/Dashboard/Transaction/index.stx +6 -3
  101. package/resources/components/Storefront/CartDrawer.stx +11 -7
  102. package/resources/views/cart.stx +19 -23
  103. package/resources/views/checkout/contact.stx +12 -14
  104. package/resources/views/checkout/payment.stx +13 -13
  105. package/resources/views/checkout/shipping.stx +13 -13
  106. package/resources/views/cms/blocks/form.stx +50 -1
  107. package/resources/views/orders/[id].stx +13 -4
  108. package/vcs/github/workflows/buddy-bot.yml +5 -5
  109. package/vcs/github/workflows/ci.yml +3 -3
  110. package/vcs/github/workflows/release.yml +1 -1
@@ -25,13 +25,13 @@ describe('dashboard commerce POS helpers', () => {
25
25
  expect(result.error).toContain('between 1 and 999')
26
26
  })
27
27
 
28
- it('recomputes server-authoritative totals from persisted prices', () => {
28
+ it('recomputes server-authoritative totals from persisted prices, in minor units', () => {
29
29
  const sale = calculateCommercePosSale([
30
30
  {
31
31
  id: '4',
32
32
  name: 'Widget',
33
33
  description: '',
34
- price: 12.5,
34
+ price: 1250,
35
35
  isAvailable: true,
36
36
  inventoryCount: 20,
37
37
  preparationTime: 4,
@@ -40,9 +40,29 @@ describe('dashboard commerce POS helpers', () => {
40
40
  categoryName: 'Unassigned category',
41
41
  },
42
42
  ], [{ productId: 4, quantity: 3, specialInstructions: '' }], 8.25)
43
- expect(sale.subtotal).toBe(37.5)
44
- expect(sale.taxAmount).toBe(3.09)
45
- expect(sale.totalAmount).toBe(40.59)
43
+ // $12.50 x 3 = $37.50; 8.25% of it is 309.375 cents, which rounds to 309.
44
+ expect(sale.lines[0]?.lineTotal).toBe(3750)
45
+ expect(sale.subtotal).toBe(3750)
46
+ expect(sale.taxAmount).toBe(309)
47
+ expect(sale.totalAmount).toBe(4059)
48
+ })
49
+
50
+ it('refuses a product whose price is not integer minor units (stacksjs/stacks#2851)', () => {
51
+ expect(() => calculateCommercePosSale([
52
+ {
53
+ id: '4',
54
+ name: 'Widget',
55
+ description: '',
56
+ price: 12.5,
57
+ isAvailable: true,
58
+ inventoryCount: 20,
59
+ preparationTime: 4,
60
+ allergens: [],
61
+ categoryId: '',
62
+ categoryName: 'Unassigned category',
63
+ },
64
+ ], [{ productId: 4, quantity: 1, specialInstructions: '' }], 0))
65
+ .toThrow('expected integer minor units')
46
66
  })
47
67
 
48
68
  it('chooses an active default tax rate', () => {
@@ -81,7 +101,7 @@ describe('dashboard commerce POS helpers', () => {
81
101
  id: '4',
82
102
  name: 'Widget',
83
103
  description: '',
84
- price: 12.5,
104
+ price: 1250,
85
105
  isAvailable: true,
86
106
  inventoryCount: 2,
87
107
  preparationTime: 4,
@@ -51,8 +51,13 @@ export class CommercePosAvailabilityError extends Error {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Amounts are integer minor units, as `products.price` is (stacksjs/stacks#2851).
56
+ * Line totals and sums of integers are already whole; tax is the one product
57
+ * that can land between two units, and it rounds to the nearest one.
58
+ */
54
59
  function money(value: number): number {
55
- return Math.round((value + Number.EPSILON) * 100) / 100
60
+ return Math.round(value)
56
61
  }
57
62
 
58
63
  function value(record: any, ...keys: string[]): unknown {
@@ -206,8 +211,8 @@ export function calculateCommercePosSale(
206
211
  const id = Number(product.id)
207
212
  if (!Number.isSafeInteger(id) || id <= 0)
208
213
  throw new TypeError(`Product id "${product.id}" is invalid.`)
209
- if (!Number.isFinite(product.price) || product.price < 0)
210
- throw new TypeError(`Product ${id} has an invalid price.`)
214
+ if (!Number.isSafeInteger(product.price) || product.price < 0)
215
+ throw new TypeError(`Product ${id} has an invalid price: expected integer minor units.`)
211
216
  if (!Number.isSafeInteger(product.inventoryCount) || product.inventoryCount < 0)
212
217
  throw new TypeError(`Product ${id} has an invalid inventory count.`)
213
218
  return [id, product] as const
@@ -1,9 +1,11 @@
1
+ import { moneyInputError, parseMoneyInput } from '@stacksjs/commerce/money'
1
2
  import { commerceTimestamp } from './commerce-record'
2
3
 
3
4
  export interface CommerceProductRecord {
4
5
  id: string
5
6
  name: string
6
7
  description: string
8
+ /** Integer minor units of the store currency (1999 is $19.99 in USD). */
7
9
  price: number
8
10
  imageUrl: string
9
11
  isAvailable: boolean
@@ -60,6 +62,73 @@ export interface ProductReviewSummary {
60
62
  averageRating: number
61
63
  }
62
64
 
65
+ /**
66
+ * The product form as the dashboard dialog holds it: every field is the text
67
+ * the person typed, including the price in MAJOR units (`"19.99"`).
68
+ */
69
+ export interface ProductFormFields {
70
+ name: string
71
+ description: string
72
+ price: string
73
+ imageUrl: string
74
+ isAvailable: boolean
75
+ inventoryCount: string
76
+ preparationTime: string
77
+ allergens: string
78
+ nutritionalInfo: string
79
+ categoryId: string
80
+ manufacturerId: string
81
+ }
82
+
83
+ /** What the dialog sends to `POST`/`PATCH /api/dashboard/commerce/products`. */
84
+ export interface ProductWritePayload {
85
+ name: string
86
+ description: string
87
+ /** Integer minor units, never a decimal (stacksjs/stacks#2851). */
88
+ price: number
89
+ imageUrl: string
90
+ isAvailable: boolean
91
+ inventoryCount: number
92
+ preparationTime: number
93
+ allergens: string
94
+ nutritionalInfo: string
95
+ categoryId: number | null
96
+ manufacturerId: number | null
97
+ }
98
+
99
+ /** One minor unit (a cent in USD): the Product model's `price` minimum. */
100
+ export const MIN_PRODUCT_PRICE = 1
101
+
102
+ /** Why a typed price cannot be saved in `currency`, or `''` when it can. */
103
+ export function productPriceInputError(price: string, currency: string): string {
104
+ return moneyInputError(price, currency, { min: MIN_PRODUCT_PRICE })
105
+ }
106
+
107
+ /**
108
+ * The dialog's fields as the payload the products API stores.
109
+ *
110
+ * The one place a typed price becomes the stored integer: `"19.99"` in USD is
111
+ * `1999`, by string arithmetic, so the dashboard writes the same unit the
112
+ * catalog importer and order totals read. Throws `PriceFormatError` for a
113
+ * price `productPriceInputError` would reject.
114
+ */
115
+ export function productWritePayload(form: ProductFormFields, currency: string): ProductWritePayload {
116
+ const allergens = form.allergens.split(',').map(item => item.trim()).filter(Boolean)
117
+ return {
118
+ name: form.name.trim(),
119
+ description: form.description.trim(),
120
+ price: parseMoneyInput(form.price, currency, { min: MIN_PRODUCT_PRICE }),
121
+ imageUrl: form.imageUrl.trim(),
122
+ isAvailable: form.isAvailable,
123
+ inventoryCount: Number(form.inventoryCount),
124
+ preparationTime: Number(form.preparationTime),
125
+ allergens: JSON.stringify(allergens),
126
+ nutritionalInfo: form.nutritionalInfo.trim() || '{}',
127
+ categoryId: form.categoryId ? Number(form.categoryId) : null,
128
+ manufacturerId: form.manufacturerId ? Number(form.manufacturerId) : null,
129
+ }
130
+ }
131
+
63
132
  function value(record: any, ...keys: string[]): unknown {
64
133
  for (const key of keys) {
65
134
  const result = typeof record?.get === 'function' ? record.get(key) : record?.[key]
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
4
  import { kanbanActionError, kanbanError } from './kanban-response'
5
5
 
6
6
  /**
@@ -33,19 +33,22 @@ export default new Action({
33
33
  try {
34
34
  await db.transaction(async (rawTrx) => {
35
35
  const qb = rawTrx as unknown as typeof db
36
+ // Postgres numbers its parameters, so the `?` these carried bound
37
+ // nothing there (stacksjs/stacks#2846).
38
+ const { param } = sqlHelpers(getDatabaseDialect())
36
39
  // Pivots + card-scoped children first — they reference cards.
37
40
  await qb.unsafe(
38
- 'DELETE FROM card_labels WHERE card_id IN (SELECT id FROM cards WHERE board_id = ?)',
41
+ `DELETE FROM card_labels WHERE card_id IN (SELECT id FROM cards WHERE board_id = ${param(1)})`,
39
42
  [id],
40
43
  ).execute()
41
44
  await qb.unsafe(
42
- 'DELETE FROM card_assignees WHERE card_id IN (SELECT id FROM cards WHERE board_id = ?)',
45
+ `DELETE FROM card_assignees WHERE card_id IN (SELECT id FROM cards WHERE board_id = ${param(1)})`,
43
46
  [id],
44
47
  ).execute()
45
48
  // Card comments use the same
46
49
  // card-scoped pattern as the pivots.
47
50
  await qb.unsafe(
48
- 'DELETE FROM card_comments WHERE card_id IN (SELECT id FROM cards WHERE board_id = ?)',
51
+ `DELETE FROM card_comments WHERE card_id IN (SELECT id FROM cards WHERE board_id = ${param(1)})`,
49
52
  [id],
50
53
  ).execute()
51
54
  // Cards (denormalised board_id avoids the column join).
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
4
  import { modelBoolean } from './kanban-model'
5
5
  import { kanbanActionError, kanbanError } from './kanban-response'
6
6
 
@@ -83,8 +83,11 @@ export default new Action({
83
83
  }
84
84
 
85
85
  try {
86
+ // Postgres numbers its parameters, so the `?` these carried bound
87
+ // nothing there (stacksjs/stacks#2846).
88
+ const { param } = sqlHelpers(getDatabaseDialect())
86
89
  const boards = await db.unsafe(
87
- 'SELECT * FROM boards WHERE id = ? LIMIT 1',
90
+ `SELECT * FROM boards WHERE id = ${param(1)} LIMIT 1`,
88
91
  [id],
89
92
  ).execute() as unknown as BoardRow[]
90
93
  const board = boards[0]
@@ -94,15 +97,15 @@ export default new Action({
94
97
 
95
98
  const [columns, cards, labels] = await Promise.all([
96
99
  db.unsafe(
97
- 'SELECT * FROM board_columns WHERE board_id = ? ORDER BY position ASC, id ASC',
100
+ `SELECT * FROM board_columns WHERE board_id = ${param(1)} ORDER BY position ASC, id ASC`,
98
101
  [id],
99
102
  ).execute() as unknown as Promise<ColumnRow[]>,
100
103
  db.unsafe(
101
- 'SELECT * FROM cards WHERE board_id = ? AND archived = false ORDER BY column_id ASC, position ASC, id ASC',
104
+ `SELECT * FROM cards WHERE board_id = ${param(1)} AND archived = false ORDER BY column_id ASC, position ASC, id ASC`,
102
105
  [id],
103
106
  ).execute() as unknown as Promise<CardRow[]>,
104
107
  db.unsafe(
105
- 'SELECT id, board_id, name, color FROM labels WHERE board_id = ? ORDER BY name ASC',
108
+ `SELECT id, board_id, name, color FROM labels WHERE board_id = ${param(1)} ORDER BY name ASC`,
106
109
  [id],
107
110
  ).execute() as unknown as Promise<LabelRow[]>,
108
111
  ])
@@ -116,7 +119,7 @@ export default new Action({
116
119
  `SELECT cl.card_id, l.id, l.name, l.color
117
120
  FROM card_labels cl
118
121
  JOIN labels l ON l.id = cl.label_id
119
- WHERE cl.card_id IN (SELECT id FROM cards WHERE board_id = ? AND archived = false)
122
+ WHERE cl.card_id IN (SELECT id FROM cards WHERE board_id = ${param(1)} AND archived = false)
120
123
  ORDER BY l.name ASC`,
121
124
  [id],
122
125
  ).execute() as Promise<Array<{ card_id: number, id: number, name: string, color: string }>>,
@@ -124,7 +127,7 @@ export default new Action({
124
127
  `SELECT ca.card_id, ca.user_id, u.name, u.email
125
128
  FROM card_assignees ca
126
129
  LEFT JOIN users u ON u.id = ca.user_id
127
- WHERE ca.card_id IN (SELECT id FROM cards WHERE board_id = ? AND archived = false)`,
130
+ WHERE ca.card_id IN (SELECT id FROM cards WHERE board_id = ${param(1)} AND archived = false)`,
128
131
  [id],
129
132
  ).execute() as Promise<Array<{ card_id: number, user_id: number, name: string | null, email: string | null }>>,
130
133
  ])
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
4
  import { kanbanActionError, kanbanError } from './kanban-response'
5
5
 
6
6
  interface SyncInput {
@@ -48,13 +48,16 @@ export default new Action({
48
48
  const uniqueUserIds = Array.from(new Set(userIds))
49
49
 
50
50
  try {
51
- const cardRows = await db.unsafe('SELECT id FROM cards WHERE id = ? LIMIT 1', [cardId]).execute() as Array<{ id: number }>
51
+ // Postgres numbers its parameters, so the `?` these carried bound
52
+ // nothing there, the dynamic IN lists included (stacksjs/stacks#2846).
53
+ const { param } = sqlHelpers(getDatabaseDialect())
54
+ const cardRows = await db.unsafe(`SELECT id FROM cards WHERE id = ${param(1)} LIMIT 1`, [cardId]).execute() as Array<{ id: number }>
52
55
  if (!cardRows?.length)
53
56
  return kanbanError('Card not found.', 404)
54
57
 
55
58
  // Validate user ids exist.
56
59
  if (uniqueUserIds.length > 0) {
57
- const placeholders = uniqueUserIds.map(() => '?').join(',')
60
+ const placeholders = uniqueUserIds.map((_, index) => param(index + 1)).join(',')
58
61
  const userRows = await db.unsafe(
59
62
  `SELECT id FROM users WHERE id IN (${placeholders})`,
60
63
  uniqueUserIds,
@@ -84,7 +87,7 @@ export default new Action({
84
87
  // Return the resolved user rows for the optimistic UI to confirm.
85
88
  let assignees: Array<{ userId: number, name: string | null, email: string | null }> = []
86
89
  if (uniqueUserIds.length > 0) {
87
- const placeholders = uniqueUserIds.map(() => '?').join(',')
90
+ const placeholders = uniqueUserIds.map((_, index) => param(index + 1)).join(',')
88
91
  const rows = await db.unsafe(
89
92
  `SELECT id, name, email FROM users WHERE id IN (${placeholders}) ORDER BY name ASC`,
90
93
  uniqueUserIds,
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
4
  import { kanbanActionError, kanbanError } from './kanban-response'
5
5
 
6
6
  interface SyncInput {
@@ -47,8 +47,11 @@ export default new Action({
47
47
 
48
48
  try {
49
49
  // Resolve the card → board so we can validate label scope.
50
+ // Postgres numbers its parameters, so the `?` these carried bound
51
+ // nothing there (stacksjs/stacks#2846).
52
+ const { param } = sqlHelpers(getDatabaseDialect())
50
53
  const cardRows = await db.unsafe(
51
- 'SELECT id, board_id FROM cards WHERE id = ? LIMIT 1',
54
+ `SELECT id, board_id FROM cards WHERE id = ${param(1)} LIMIT 1`,
52
55
  [cardId],
53
56
  ).execute() as Array<{ id: number, board_id: number }>
54
57
  const card = cardRows?.[0]
@@ -58,9 +61,10 @@ export default new Action({
58
61
  // Every label must belong to the same board (labels are
59
62
  // board-scoped through `labels.board_id`).
60
63
  if (uniqueLabelIds.length > 0) {
61
- const placeholders = uniqueLabelIds.map(() => '?').join(',')
64
+ // The IN list is numbered alongside the trailing board id.
65
+ const placeholders = uniqueLabelIds.map((_, index) => param(index + 1)).join(',')
62
66
  const labelRows = await db.unsafe(
63
- `SELECT id FROM labels WHERE id IN (${placeholders}) AND board_id = ?`,
67
+ `SELECT id FROM labels WHERE id IN (${placeholders}) AND board_id = ${param(uniqueLabelIds.length + 1)}`,
64
68
  [...uniqueLabelIds, card.board_id],
65
69
  ).execute() as Array<{ id: number }>
66
70
  if (labelRows.length !== uniqueLabelIds.length)
@@ -83,7 +87,7 @@ export default new Action({
83
87
  // confirm its in-flight state matches what the server stored.
84
88
  let labels: Array<{ id: number, name: string, color: string }> = []
85
89
  if (uniqueLabelIds.length > 0) {
86
- const placeholders = uniqueLabelIds.map(() => '?').join(',')
90
+ const placeholders = uniqueLabelIds.map((_, index) => param(index + 1)).join(',')
87
91
  labels = await db.unsafe(
88
92
  `SELECT id, name, color FROM labels WHERE id IN (${placeholders}) ORDER BY name ASC`,
89
93
  uniqueLabelIds,
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
4
  import { modelBoolean } from './kanban-model'
5
5
  import { kanbanActionError, kanbanError } from './kanban-response'
6
6
 
@@ -47,8 +47,11 @@ export default new Action({
47
47
  }
48
48
 
49
49
  try {
50
+ // Postgres numbers its parameters, so the `?` these carried bound
51
+ // nothing there (stacksjs/stacks#2846).
52
+ const { param } = sqlHelpers(getDatabaseDialect())
50
53
  const cardRows = await db.unsafe(
51
- 'SELECT * FROM cards WHERE id = ? LIMIT 1',
54
+ `SELECT * FROM cards WHERE id = ${param(1)} LIMIT 1`,
52
55
  [id],
53
56
  ).execute() as unknown as CardRow[]
54
57
  const card = cardRows?.[0]
@@ -61,7 +64,7 @@ export default new Action({
61
64
  `SELECT l.id, l.name, l.color
62
65
  FROM card_labels cl
63
66
  JOIN labels l ON l.id = cl.label_id
64
- WHERE cl.card_id = ?
67
+ WHERE cl.card_id = ${param(1)}
65
68
  ORDER BY l.name ASC`,
66
69
  [id],
67
70
  ).execute() as Promise<Array<{ id: number, name: string, color: string }>>,
@@ -69,14 +72,14 @@ export default new Action({
69
72
  `SELECT ca.user_id, ca.assigned_by_user_id, ca.created_at, u.name, u.email
70
73
  FROM card_assignees ca
71
74
  LEFT JOIN users u ON u.id = ca.user_id
72
- WHERE ca.card_id = ?`,
75
+ WHERE ca.card_id = ${param(1)}`,
73
76
  [id],
74
77
  ).execute() as Promise<Array<{ user_id: number, assigned_by_user_id: number | null, created_at: string | null, name: string | null, email: string | null }>>,
75
78
  db.unsafe(
76
79
  `SELECT cc.id, cc.uuid, cc.user_id, cc.body, cc.created_at, cc.updated_at, u.name, u.email
77
80
  FROM card_comments cc
78
81
  LEFT JOIN users u ON u.id = cc.user_id
79
- WHERE cc.card_id = ?
82
+ WHERE cc.card_id = ${param(1)}
80
83
  ORDER BY cc.created_at ASC, cc.id ASC`,
81
84
  [id],
82
85
  ).execute() as Promise<Array<{ id: number, uuid: string | null, user_id: number | null, body: string, created_at: string | null, updated_at: string | null, name: string | null, email: string | null }>>,
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
4
  import { BoardColumn, Card } from '@stacksjs/orm'
5
5
  import { kanbanActionError, kanbanError } from './kanban-response'
6
6
 
@@ -54,8 +54,9 @@ export default new Action({
54
54
  if (!Number.isFinite(boardId) || boardId <= 0)
55
55
  return kanbanError('Column is not attached to a valid board.', 409)
56
56
 
57
+ const { param } = sqlHelpers(getDatabaseDialect())
57
58
  const maxRow = await db.unsafe(
58
- 'SELECT COALESCE(MAX(position), -1) AS m FROM cards WHERE column_id = ?',
59
+ `SELECT COALESCE(MAX(position), -1) AS m FROM cards WHERE column_id = ${param(1)}`,
59
60
  [columnId],
60
61
  ).execute() as Array<{ m: number }>
61
62
  const nextPosition = (Number(maxRow?.[0]?.m ?? -1) + 1) || 0
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
4
  import { kanbanActionError, kanbanError } from './kanban-response'
5
5
 
6
6
  interface ColumnReorder {
@@ -86,8 +86,11 @@ export default new Action({
86
86
 
87
87
  try {
88
88
  // Verify all columns belong to the same board.
89
+ // Postgres numbers its parameters, so every IN list below is numbered
90
+ // rather than repeating `?` (stacksjs/stacks#2846).
91
+ const { param } = sqlHelpers(getDatabaseDialect())
89
92
  const columnIds = reorders.map(r => r.columnId)
90
- const colPlaceholders = columnIds.map(() => '?').join(',')
93
+ const colPlaceholders = columnIds.map((_, index) => param(index + 1)).join(',')
91
94
  const colRows = await db.unsafe(
92
95
  `SELECT id, board_id FROM board_columns WHERE id IN (${colPlaceholders})`,
93
96
  columnIds,
@@ -105,9 +108,9 @@ export default new Action({
105
108
  // page from moving cards in from a sibling board).
106
109
  if (allCardIds.size > 0) {
107
110
  const cardIdList = Array.from(allCardIds)
108
- const cardPlaceholders = cardIdList.map(() => '?').join(',')
111
+ const cardPlaceholders = cardIdList.map((_, index) => param(index + 1)).join(',')
109
112
  const cardRows = await db.unsafe(
110
- `SELECT id FROM cards WHERE id IN (${cardPlaceholders}) AND board_id = ?`,
113
+ `SELECT id FROM cards WHERE id IN (${cardPlaceholders}) AND board_id = ${param(cardIdList.length + 1)}`,
111
114
  [...cardIdList, boardId],
112
115
  ).execute() as Array<{ id: number }>
113
116
  if (cardRows.length !== cardIdList.length) {
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
4
  import { kanbanActionError, kanbanError } from './kanban-response'
5
5
 
6
6
  /**
@@ -29,17 +29,20 @@ export default new Action({
29
29
  try {
30
30
  await db.transaction(async (rawTrx) => {
31
31
  const qb = rawTrx as unknown as typeof db
32
+ // Postgres numbers its parameters, so the `?` these carried bound
33
+ // nothing there (stacksjs/stacks#2846).
34
+ const { param } = sqlHelpers(getDatabaseDialect())
32
35
  await qb.unsafe(
33
- 'DELETE FROM card_labels WHERE card_id IN (SELECT id FROM cards WHERE column_id = ?)',
36
+ `DELETE FROM card_labels WHERE card_id IN (SELECT id FROM cards WHERE column_id = ${param(1)})`,
34
37
  [id],
35
38
  ).execute()
36
39
  await qb.unsafe(
37
- 'DELETE FROM card_assignees WHERE card_id IN (SELECT id FROM cards WHERE column_id = ?)',
40
+ `DELETE FROM card_assignees WHERE card_id IN (SELECT id FROM cards WHERE column_id = ${param(1)})`,
38
41
  [id],
39
42
  ).execute()
40
43
  // Card comments are card-scoped children.
41
44
  await qb.unsafe(
42
- 'DELETE FROM card_comments WHERE card_id IN (SELECT id FROM cards WHERE column_id = ?)',
45
+ `DELETE FROM card_comments WHERE card_id IN (SELECT id FROM cards WHERE column_id = ${param(1)})`,
43
46
  [id],
44
47
  ).execute()
45
48
  await qb.deleteFrom('cards').where('column_id', '=', id).execute()
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
4
  import { Board, BoardColumn } from '@stacksjs/orm'
5
5
  import { kanbanActionError, kanbanError } from './kanban-response'
6
6
 
@@ -47,8 +47,9 @@ export default new Action({
47
47
  return kanbanError('Board not found.', 404)
48
48
  }
49
49
 
50
+ const { param } = sqlHelpers(getDatabaseDialect())
50
51
  const maxRow = await db.unsafe(
51
- 'SELECT COALESCE(MAX(position), -1) AS m FROM board_columns WHERE board_id = ?',
52
+ `SELECT COALESCE(MAX(position), -1) AS m FROM board_columns WHERE board_id = ${param(1)}`,
52
53
  [boardId],
53
54
  ).execute() as Array<{ m: number }>
54
55
  const nextPosition = (Number(maxRow?.[0]?.m ?? -1) + 1) || 0
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
4
  import { kanbanActionError, kanbanError } from './kanban-response'
5
5
 
6
6
  interface ReorderInput {
@@ -53,9 +53,13 @@ export default new Action({
53
53
  try {
54
54
  // Verify every column actually belongs to the named board.
55
55
  // Single round-trip via a count of rows that match both criteria.
56
- const placeholders = ids.map(() => '?').join(',')
56
+ // Postgres numbers its parameters, so the IN list has to be numbered
57
+ // alongside the trailing board id rather than repeating `?`
58
+ // (stacksjs/stacks#2846).
59
+ const { param } = sqlHelpers(getDatabaseDialect())
60
+ const placeholders = ids.map((_, index) => param(index + 1)).join(',')
57
61
  const matchRows = await db.unsafe(
58
- `SELECT COUNT(*) AS c FROM board_columns WHERE id IN (${placeholders}) AND board_id = ?`,
62
+ `SELECT COUNT(*) AS c FROM board_columns WHERE id IN (${placeholders}) AND board_id = ${param(ids.length + 1)}`,
59
63
  [...ids, boardId],
60
64
  ).execute() as Array<{ c: number }>
61
65
  const matched = Number(matchRows?.[0]?.c ?? 0)
@@ -314,3 +314,42 @@ describe('composing a recovery campaign', () => {
314
314
  expect(JSON.parse(data.segment_definition).rules[1].value).toBe(4)
315
315
  })
316
316
  })
317
+
318
+ describe('cart money is integer minor units (stacksjs/stacks#2851)', () => {
319
+ test('a 1999-cent cart is worth $19.99, and the average rounds to a whole cent', () => {
320
+ const result = normalizeAbandonedCarts(
321
+ [cart({ id: 1, total: 1999 }), cart({ id: 2, customer_id: 12, total: 1000 })],
322
+ [],
323
+ CUSTOMERS,
324
+ [],
325
+ [],
326
+ { now: NOW },
327
+ )
328
+
329
+ expect(result.records[0].value).toBe(1999)
330
+ expect(result.summary.openValue).toBe(2999)
331
+ // 1499.5 cents is not an amount anything can hold.
332
+ expect(result.summary.averageValue).toBe(1500)
333
+ })
334
+
335
+ test('the dialog\'s threshold arrives in minor units and is compared against cart totals as such', () => {
336
+ // RecoveryCampaignDialog sends parseMoneyInput('25.00', 'USD'), which is 2500.
337
+ const data = recoveryCampaignWriteData({ name: 'Recovery', subject: 'Still here', minimumValue: 2500 })
338
+ expect(JSON.parse(data.segment_definition).rules[2]).toEqual({ field: 'cart.value', operator: 'gte', value: 2500 })
339
+
340
+ const records = normalizeAbandonedCarts(
341
+ [cart({ id: 1, total: 2499 }), cart({ id: 2, customer_id: 12, total: 2500 })],
342
+ [],
343
+ CUSTOMERS,
344
+ [],
345
+ [],
346
+ { now: NOW },
347
+ ).records
348
+ expect(reachOf(records, 1, 2500)).toEqual({ carts: 1, value: 2500 })
349
+ })
350
+
351
+ test('a fractional threshold is rounded to a whole minor unit', () => {
352
+ const data = recoveryCampaignWriteData({ name: 'Recovery', subject: 'Still here', minimumValue: 2500.4 })
353
+ expect(JSON.parse(data.segment_definition).rules[2].value).toBe(2500)
354
+ })
355
+ })
@@ -34,6 +34,7 @@ export interface AbandonedCartRecord {
34
34
  itemCount: number
35
35
  /** The first few product names, for a row that says what was left behind. */
36
36
  items: string[]
37
+ /** The cart's total, in integer minor units of `currency` (1999 is $19.99). */
37
38
  value: number
38
39
  currency: string
39
40
  abandonedAt: string
@@ -48,7 +49,7 @@ export interface AbandonedCartRecord {
48
49
  export interface AbandonedCartSummary {
49
50
  /** Carts sitting abandoned or expired right now. */
50
51
  open: number
51
- /** What those carts are worth. */
52
+ /** What those carts are worth, in integer minor units. */
52
53
  openValue: number
53
54
  /** Of those, how many have already been written to. */
54
55
  contacted: number
@@ -57,6 +58,7 @@ export interface AbandonedCartSummary {
57
58
  recoveredValue: number
58
59
  /** Recovered as a share of everything that was ever chased. */
59
60
  recoveryRate: number
61
+ /** Rounded to a whole minor unit. */
60
62
  averageValue: number
61
63
  currency: string
62
64
  }
@@ -66,6 +68,7 @@ export interface RecoveryCampaignRecord {
66
68
  name: string
67
69
  status: string
68
70
  idleHours: number
71
+ /** The smallest cart total the campaign writes about, in integer minor units. */
69
72
  minimumValue: number
70
73
  sentCount: number
71
74
  openedCount: number
@@ -314,7 +317,7 @@ export function normalizeAbandonedCarts(
314
317
  recovered: recovered.length,
315
318
  recoveredValue,
316
319
  recoveryRate: chased > 0 ? recovered.length / chased * 100 : 0,
317
- averageValue: open.length > 0 ? openValue / open.length : 0,
320
+ averageValue: open.length > 0 ? Math.round(openValue / open.length) : 0,
318
321
  currency: records[0]?.currency || defaultCurrency,
319
322
  },
320
323
  campaigns,
@@ -354,6 +357,7 @@ export interface RecoveryCampaignInput {
354
357
  fromAddress: string
355
358
  emailListId: number | null
356
359
  idleHours: number
360
+ /** Integer minor units, compared against a cart's `total`. */
357
361
  minimumValue: number
358
362
  scheduledAt: string | null
359
363
  currency: string
@@ -378,7 +382,9 @@ export function recoveryCampaignWriteData(
378
382
  currency: string
379
383
  } {
380
384
  const idleHours = Math.max(1, Math.round(Number(input.idleHours)) || DEFAULT_IDLE_HOURS)
381
- const minimumValue = Math.max(0, Number(input.minimumValue) || 0)
385
+ // Minor units, like the cart totals it is compared against: a fraction of a
386
+ // cent is not a threshold anybody set.
387
+ const minimumValue = Math.max(0, Math.round(Number(input.minimumValue)) || 0)
382
388
  const emailListId = Number(input.emailListId ?? input.email_list_id)
383
389
  const scheduledAt = text(input.scheduledAt ?? input.scheduled_at).trim()
384
390