@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.
- package/ai/skills/stacks-ai/SKILL.md +24 -1
- package/ai/skills/stacks-commerce/SKILL.md +1 -1
- package/ai/skills/stacks-config/SKILL.md +1 -1
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-dependencies/SKILL.md +35 -3
- package/ai/skills/stacks-env/SKILL.md +4 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +2 -2
- package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
- package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
- package/app/Actions/Commerce/commerce-action.test.ts +30 -1
- package/app/Actions/Commerce/commerce-action.ts +44 -0
- package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
- package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
- package/app/Actions/Dashboard/Ci/failure-notifier.ts +11 -7
- package/app/Actions/Dashboard/Ci/runner-pressure-monitor.ts +13 -7
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
- package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
- package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
- package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
- package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
- package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
- package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
- package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
- package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +7 -4
- package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +10 -7
- package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +7 -4
- package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +9 -5
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +8 -5
- package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +3 -2
- package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +7 -4
- package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -4
- package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +3 -2
- package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +7 -3
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
- package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
- package/app/Actions/Storefront/AddToCartAction.ts +4 -1
- package/app/Actions/Storefront/GetCartAction.ts +25 -10
- package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
- package/app/Actions/Storefront/_shipping.ts +63 -37
- package/app/Broadcasts/OrderShipped.ts +1 -1
- package/app/Mail/OrderConfirmation.ts +14 -7
- package/app/Models/Campaign.ts +9 -4
- package/app/Models/commerce/Cart.ts +5 -4
- package/app/Models/commerce/CartItem.ts +6 -4
- package/app/Models/commerce/Coupon.ts +9 -3
- package/app/Models/commerce/Customer.ts +2 -1
- package/app/Models/commerce/GiftCard.ts +5 -2
- package/app/Models/commerce/Order.ts +6 -5
- package/app/Models/commerce/OrderItem.ts +2 -1
- package/app/Models/commerce/ProductVariant.ts +71 -4
- package/app/Models/commerce/Transaction.ts +3 -2
- package/app/Storefront/StorefrontMoney.ts +54 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
- package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
- package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
- package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
- package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
- package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
- package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
- package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
- package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
- package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
- package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
- package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
- package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
- package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
- package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
- package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
- package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
- package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
- package/resources/components/Dashboard/Transaction/index.stx +6 -3
- package/resources/components/Storefront/CartDrawer.stx +11 -7
- package/resources/views/cart.stx +19 -23
- package/resources/views/checkout/contact.stx +12 -14
- package/resources/views/checkout/payment.stx +13 -13
- package/resources/views/checkout/shipping.stx +13 -13
- package/resources/views/cms/blocks/form.stx +50 -1
- package/resources/views/orders/[id].stx +13 -4
- package/vcs/github/workflows/buddy-bot.yml +5 -5
- package/vcs/github/workflows/ci.yml +3 -3
- 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:
|
|
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
|
-
|
|
44
|
-
expect(sale.
|
|
45
|
-
expect(sale.
|
|
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:
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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(() =>
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|