@rovela-ai/sdk 0.3.22 → 0.3.30
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/dist/admin/api/index.d.ts +4 -0
- package/dist/admin/api/index.d.ts.map +1 -1
- package/dist/admin/api/index.js +6 -0
- package/dist/admin/api/index.js.map +1 -1
- package/dist/admin/api/products-bulk.d.ts +38 -0
- package/dist/admin/api/products-bulk.d.ts.map +1 -0
- package/dist/admin/api/products-bulk.js +135 -0
- package/dist/admin/api/products-bulk.js.map +1 -0
- package/dist/admin/api/products-stats.d.ts +34 -0
- package/dist/admin/api/products-stats.d.ts.map +1 -0
- package/dist/admin/api/products-stats.js +43 -0
- package/dist/admin/api/products-stats.js.map +1 -0
- package/dist/admin/api/products.d.ts.map +1 -1
- package/dist/admin/api/products.js +56 -5
- package/dist/admin/api/products.js.map +1 -1
- package/dist/admin/api/setup-guide.d.ts +78 -0
- package/dist/admin/api/setup-guide.d.ts.map +1 -0
- package/dist/admin/api/setup-guide.js +235 -0
- package/dist/admin/api/setup-guide.js.map +1 -0
- package/dist/admin/api/stats.d.ts +3 -0
- package/dist/admin/api/stats.d.ts.map +1 -1
- package/dist/admin/api/stats.js +20 -5
- package/dist/admin/api/stats.js.map +1 -1
- package/dist/admin/components/AdminLayout.d.ts.map +1 -1
- package/dist/admin/components/AdminLayout.js +5 -2
- package/dist/admin/components/AdminLayout.js.map +1 -1
- package/dist/admin/components/AdminNav.d.ts.map +1 -1
- package/dist/admin/components/AdminNav.js +13 -0
- package/dist/admin/components/AdminNav.js.map +1 -1
- package/dist/admin/components/LowStockAlert.d.ts.map +1 -1
- package/dist/admin/components/LowStockAlert.js +1 -1
- package/dist/admin/components/LowStockAlert.js.map +1 -1
- package/dist/admin/components/OrderStatusChart.d.ts.map +1 -1
- package/dist/admin/components/OrderStatusChart.js +7 -1
- package/dist/admin/components/OrderStatusChart.js.map +1 -1
- package/dist/admin/components/PeriodSelector.d.ts +9 -0
- package/dist/admin/components/PeriodSelector.d.ts.map +1 -0
- package/dist/admin/components/PeriodSelector.js +19 -0
- package/dist/admin/components/PeriodSelector.js.map +1 -0
- package/dist/admin/components/PrimaryMetricsRow.d.ts +11 -0
- package/dist/admin/components/PrimaryMetricsRow.d.ts.map +1 -0
- package/dist/admin/components/PrimaryMetricsRow.js +73 -0
- package/dist/admin/components/PrimaryMetricsRow.js.map +1 -0
- package/dist/admin/components/ProductTable.d.ts +2 -1
- package/dist/admin/components/ProductTable.d.ts.map +1 -1
- package/dist/admin/components/ProductTable.js +284 -33
- package/dist/admin/components/ProductTable.js.map +1 -1
- package/dist/admin/components/RevenueChart.d.ts.map +1 -1
- package/dist/admin/components/RevenueChart.js +4 -1
- package/dist/admin/components/RevenueChart.js.map +1 -1
- package/dist/admin/components/SecondaryMetricsRow.d.ts +14 -0
- package/dist/admin/components/SecondaryMetricsRow.d.ts.map +1 -0
- package/dist/admin/components/SecondaryMetricsRow.js +45 -0
- package/dist/admin/components/SecondaryMetricsRow.js.map +1 -0
- package/dist/admin/components/SetupGuide.d.ts +4 -0
- package/dist/admin/components/SetupGuide.d.ts.map +1 -0
- package/dist/admin/components/SetupGuide.js +238 -0
- package/dist/admin/components/SetupGuide.js.map +1 -0
- package/dist/admin/components/index.d.ts +7 -0
- package/dist/admin/components/index.d.ts.map +1 -1
- package/dist/admin/components/index.js +4 -0
- package/dist/admin/components/index.js.map +1 -1
- package/dist/admin/hooks/index.d.ts +3 -0
- package/dist/admin/hooks/index.d.ts.map +1 -1
- package/dist/admin/hooks/index.js +2 -0
- package/dist/admin/hooks/index.js.map +1 -1
- package/dist/admin/hooks/useAdminProductMetrics.d.ts +3 -0
- package/dist/admin/hooks/useAdminProductMetrics.d.ts.map +1 -0
- package/dist/admin/hooks/useAdminProductMetrics.js +32 -0
- package/dist/admin/hooks/useAdminProductMetrics.js.map +1 -0
- package/dist/admin/hooks/useAdminProducts.d.ts.map +1 -1
- package/dist/admin/hooks/useAdminProducts.js +19 -0
- package/dist/admin/hooks/useAdminProducts.js.map +1 -1
- package/dist/admin/hooks/useAdminStats.d.ts +7 -1
- package/dist/admin/hooks/useAdminStats.d.ts.map +1 -1
- package/dist/admin/hooks/useAdminStats.js +31 -2
- package/dist/admin/hooks/useAdminStats.js.map +1 -1
- package/dist/admin/hooks/useSetupGuide.d.ts +45 -0
- package/dist/admin/hooks/useSetupGuide.d.ts.map +1 -0
- package/dist/admin/hooks/useSetupGuide.js +57 -0
- package/dist/admin/hooks/useSetupGuide.js.map +1 -0
- package/dist/admin/index.d.ts +5 -5
- package/dist/admin/index.d.ts.map +1 -1
- package/dist/admin/index.js +5 -3
- package/dist/admin/index.js.map +1 -1
- package/dist/admin/server/admin-session.d.ts +37 -0
- package/dist/admin/server/admin-session.d.ts.map +1 -1
- package/dist/admin/server/admin-session.js +43 -0
- package/dist/admin/server/admin-session.js.map +1 -1
- package/dist/admin/server/index.d.ts +1 -1
- package/dist/admin/server/index.d.ts.map +1 -1
- package/dist/admin/server/index.js +1 -1
- package/dist/admin/server/index.js.map +1 -1
- package/dist/admin/styles/admin-theme.css +634 -8
- package/dist/admin/types.d.ts +108 -0
- package/dist/admin/types.d.ts.map +1 -1
- package/dist/core/cookie-consent/CookieBanner.d.ts.map +1 -1
- package/dist/core/cookie-consent/CookieBanner.js +51 -15
- package/dist/core/cookie-consent/CookieBanner.js.map +1 -1
- package/dist/core/db/index.d.ts +2 -2
- package/dist/core/db/index.d.ts.map +1 -1
- package/dist/core/db/index.js +3 -1
- package/dist/core/db/index.js.map +1 -1
- package/dist/core/db/queries.d.ts +181 -17
- package/dist/core/db/queries.d.ts.map +1 -1
- package/dist/core/db/queries.js +398 -10
- package/dist/core/db/queries.js.map +1 -1
- package/dist/core/db/schema.d.ts +34 -3
- package/dist/core/db/schema.d.ts.map +1 -1
- package/dist/core/db/schema.js +19 -1
- package/dist/core/db/schema.js.map +1 -1
- package/dist/core/server/index.d.ts +2 -1
- package/dist/core/server/index.d.ts.map +1 -1
- package/dist/core/server/index.js +3 -1
- package/dist/core/server/index.js.map +1 -1
- package/dist/media/components/ImageGalleryUpload.d.ts.map +1 -1
- package/dist/media/components/ImageGalleryUpload.js +9 -2
- package/dist/media/components/ImageGalleryUpload.js.map +1 -1
- package/dist/products/components/ProductCard.d.ts.map +1 -1
- package/dist/products/components/ProductCard.js +8 -1
- package/dist/products/components/ProductCard.js.map +1 -1
- package/dist/products/components/ProductGallery.d.ts.map +1 -1
- package/dist/products/components/ProductGallery.js +14 -3
- package/dist/products/components/ProductGallery.js.map +1 -1
- package/package.json +13 -1
package/dist/core/db/queries.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Type-safe query helpers for e-commerce stores
|
|
5
5
|
* Each store has its own database (via Neon branches) - no tenant filtering needed
|
|
6
6
|
*/
|
|
7
|
-
import { eq, and, desc, asc, ilike, sql, inArray, isNull } from 'drizzle-orm';
|
|
7
|
+
import { eq, and, or, desc, asc, ilike, sql, inArray, isNull } from 'drizzle-orm';
|
|
8
8
|
import { getDb } from './client';
|
|
9
9
|
import * as schema from './schema';
|
|
10
10
|
/**
|
|
@@ -22,6 +22,9 @@ export async function findProducts(options = {}) {
|
|
|
22
22
|
if (options.search) {
|
|
23
23
|
conditions.push(ilike(schema.products.name, `%${options.search}%`));
|
|
24
24
|
}
|
|
25
|
+
if (options.outOfStock) {
|
|
26
|
+
conditions.push(buildOutOfStockCondition());
|
|
27
|
+
}
|
|
25
28
|
// Determine sort order
|
|
26
29
|
let orderBy;
|
|
27
30
|
switch (options.sort) {
|
|
@@ -100,12 +103,123 @@ export async function countProducts(options = {}) {
|
|
|
100
103
|
if (options.search) {
|
|
101
104
|
conditions.push(ilike(schema.products.name, `%${options.search}%`));
|
|
102
105
|
}
|
|
106
|
+
if (options.outOfStock) {
|
|
107
|
+
conditions.push(buildOutOfStockCondition());
|
|
108
|
+
}
|
|
103
109
|
const result = await db
|
|
104
110
|
.select({ count: sql `count(*)` })
|
|
105
111
|
.from(schema.products)
|
|
106
112
|
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
|
107
113
|
return Number(result[0]?.count || 0);
|
|
108
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Build the SQL condition for "effectively out of stock":
|
|
117
|
+
* - non-variant products: trackInventory=true AND inventory <= 0
|
|
118
|
+
* - variant products: no variant exists with inventory > 0
|
|
119
|
+
*
|
|
120
|
+
* Reused by both `findProducts` (the OOS chip filter) and the OOS metric card.
|
|
121
|
+
* The variant subquery uses `product_variants_product_id_idx` (already in schema)
|
|
122
|
+
* so it's an index lookup, not a scan.
|
|
123
|
+
*/
|
|
124
|
+
function buildOutOfStockCondition() {
|
|
125
|
+
return or(and(eq(schema.products.hasVariants, false), eq(schema.products.trackInventory, true), sql `${schema.products.inventory} <= 0`), and(eq(schema.products.hasVariants, true), sql `NOT EXISTS (
|
|
126
|
+
SELECT 1 FROM ${schema.productVariants} pv
|
|
127
|
+
WHERE pv.product_id = ${schema.products.id} AND pv.inventory > 0
|
|
128
|
+
)`));
|
|
129
|
+
}
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// Product Metrics & Inventory Aggregation (admin product list page)
|
|
132
|
+
// =============================================================================
|
|
133
|
+
/**
|
|
134
|
+
* For products with `hasVariants=true`, the parent `products.inventory` column
|
|
135
|
+
* is unused — inventory lives on `product_variants`. This helper returns a
|
|
136
|
+
* Map of `productId → effectiveInventory` for an arbitrary set of variant
|
|
137
|
+
* product IDs. The page-list handler calls this for the products it just
|
|
138
|
+
* returned and merges the sums into each row before responding to the client.
|
|
139
|
+
*
|
|
140
|
+
* Empty input → empty map. Single SQL query, GROUP BY product_id.
|
|
141
|
+
*/
|
|
142
|
+
export async function getInventoryByProductIds(productIds) {
|
|
143
|
+
if (productIds.length === 0)
|
|
144
|
+
return new Map();
|
|
145
|
+
const db = getDb();
|
|
146
|
+
const rows = await db
|
|
147
|
+
.select({
|
|
148
|
+
productId: schema.productVariants.productId,
|
|
149
|
+
total: sql `COALESCE(SUM(${schema.productVariants.inventory}), 0)`,
|
|
150
|
+
})
|
|
151
|
+
.from(schema.productVariants)
|
|
152
|
+
.where(inArray(schema.productVariants.productId, productIds))
|
|
153
|
+
.groupBy(schema.productVariants.productId);
|
|
154
|
+
const result = new Map();
|
|
155
|
+
for (const r of rows) {
|
|
156
|
+
result.set(r.productId, Number(r.total) || 0);
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Count of products in `status='active'`. Drives the "Active products"
|
|
162
|
+
* metric card on the admin product list page.
|
|
163
|
+
*/
|
|
164
|
+
export async function getActiveProductCount() {
|
|
165
|
+
const db = getDb();
|
|
166
|
+
const result = await db
|
|
167
|
+
.select({ count: sql `count(*)` })
|
|
168
|
+
.from(schema.products)
|
|
169
|
+
.where(eq(schema.products.status, 'active'));
|
|
170
|
+
return Number(result[0]?.count || 0);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Count of *active* products that are effectively out of stock. Excludes
|
|
174
|
+
* drafts / archived (they're irrelevant noise on the product list view).
|
|
175
|
+
*
|
|
176
|
+
* Uses the same OOS condition as the chip filter (single source of truth).
|
|
177
|
+
*/
|
|
178
|
+
export async function getOutOfStockProductCount() {
|
|
179
|
+
const db = getDb();
|
|
180
|
+
const result = await db
|
|
181
|
+
.select({ count: sql `count(*)` })
|
|
182
|
+
.from(schema.products)
|
|
183
|
+
.where(and(eq(schema.products.status, 'active'), buildOutOfStockCondition()));
|
|
184
|
+
return Number(result[0]?.count || 0);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Best-selling product over the last `days` window, by units sold.
|
|
188
|
+
*
|
|
189
|
+
* Counts only orders that actually moved revenue: `paid`, `shipped`,
|
|
190
|
+
* `delivered`. Excludes pending (might fail), cancelled, refunded, and
|
|
191
|
+
* return_requested (not yet finalized).
|
|
192
|
+
*
|
|
193
|
+
* Returns null when no qualifying orders exist in the window — the UI
|
|
194
|
+
* renders an empty state rather than a broken card.
|
|
195
|
+
*/
|
|
196
|
+
export async function getBestSellerProduct(days = 30) {
|
|
197
|
+
const db = getDb();
|
|
198
|
+
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
199
|
+
const rows = await db
|
|
200
|
+
.select({
|
|
201
|
+
id: schema.products.id,
|
|
202
|
+
name: schema.products.name,
|
|
203
|
+
unitsSold: sql `SUM(${schema.orderItems.quantity})`,
|
|
204
|
+
revenue: sql `SUM(${schema.orderItems.quantity} * ${schema.orderItems.price})`,
|
|
205
|
+
})
|
|
206
|
+
.from(schema.orderItems)
|
|
207
|
+
.innerJoin(schema.orders, eq(schema.orders.id, schema.orderItems.orderId))
|
|
208
|
+
.innerJoin(schema.products, eq(schema.products.id, schema.orderItems.productId))
|
|
209
|
+
.where(and(inArray(schema.orders.status, ['paid', 'shipped', 'delivered']), sql `${schema.orders.createdAt} >= ${since}`))
|
|
210
|
+
.groupBy(schema.products.id, schema.products.name)
|
|
211
|
+
.orderBy(desc(sql `SUM(${schema.orderItems.quantity})`))
|
|
212
|
+
.limit(1);
|
|
213
|
+
const row = rows[0];
|
|
214
|
+
if (!row)
|
|
215
|
+
return null;
|
|
216
|
+
return {
|
|
217
|
+
id: row.id,
|
|
218
|
+
name: row.name,
|
|
219
|
+
unitsSold: Number(row.unitsSold) || 0,
|
|
220
|
+
revenue: String(row.revenue ?? '0'),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
109
223
|
// =============================================================================
|
|
110
224
|
// Categories
|
|
111
225
|
// =============================================================================
|
|
@@ -296,10 +410,13 @@ export async function getStoreStats() {
|
|
|
296
410
|
.select({ count: sql `count(*)` })
|
|
297
411
|
.from(schema.products)
|
|
298
412
|
.where(eq(schema.products.status, 'active')),
|
|
299
|
-
// Total orders
|
|
413
|
+
// Total orders — same status filter as revenue so the two metrics tell
|
|
414
|
+
// the same story. Counting cancelled/refunded/pending here would inflate
|
|
415
|
+
// the number relative to revenue and confuse merchants.
|
|
300
416
|
db
|
|
301
417
|
.select({ count: sql `count(*)` })
|
|
302
|
-
.from(schema.orders)
|
|
418
|
+
.from(schema.orders)
|
|
419
|
+
.where(inArray(schema.orders.status, ['paid', 'shipped', 'delivered'])),
|
|
303
420
|
// Total customers
|
|
304
421
|
db
|
|
305
422
|
.select({ count: sql `count(*)` })
|
|
@@ -317,6 +434,137 @@ export async function getStoreStats() {
|
|
|
317
434
|
revenue: Number(revenue[0]?.total || 0),
|
|
318
435
|
};
|
|
319
436
|
}
|
|
437
|
+
// =============================================================================
|
|
438
|
+
// Period-scoped store stats (for time-window selector)
|
|
439
|
+
// =============================================================================
|
|
440
|
+
/**
|
|
441
|
+
* Same shape as `getStoreStats()` but scoped to a window of `days` ago to now.
|
|
442
|
+
* Powers the "Today / 7d / 30d / 90d" selector on the admin dashboard. Both
|
|
443
|
+
* Orders and Revenue use the same status filter (paid + shipped + delivered)
|
|
444
|
+
* so they tell the same story.
|
|
445
|
+
*
|
|
446
|
+
* Note: `products` and `customers` here mean *new* products created in the
|
|
447
|
+
* window and *new* customers registered in the window — this is what makes
|
|
448
|
+
* the period-vs-prior-period delta meaningful. For the absolute "how many
|
|
449
|
+
* active products do I have" use `getStoreStats()`.
|
|
450
|
+
*/
|
|
451
|
+
export async function getStoreStatsForPeriod(startDate, endDate) {
|
|
452
|
+
const db = getDb();
|
|
453
|
+
const [orderRow, revenueRow, productRow, customerRow] = await Promise.all([
|
|
454
|
+
db
|
|
455
|
+
.select({ count: sql `count(*)` })
|
|
456
|
+
.from(schema.orders)
|
|
457
|
+
.where(and(inArray(schema.orders.status, ['paid', 'shipped', 'delivered']), sql `${schema.orders.createdAt} >= ${startDate.toISOString()}`, sql `${schema.orders.createdAt} < ${endDate.toISOString()}`)),
|
|
458
|
+
db
|
|
459
|
+
.select({ total: sql `COALESCE(SUM(total), 0)` })
|
|
460
|
+
.from(schema.orders)
|
|
461
|
+
.where(and(inArray(schema.orders.status, ['paid', 'shipped', 'delivered']), sql `${schema.orders.createdAt} >= ${startDate.toISOString()}`, sql `${schema.orders.createdAt} < ${endDate.toISOString()}`)),
|
|
462
|
+
db
|
|
463
|
+
.select({ count: sql `count(*)` })
|
|
464
|
+
.from(schema.products)
|
|
465
|
+
.where(and(eq(schema.products.status, 'active'), sql `${schema.products.createdAt} >= ${startDate.toISOString()}`, sql `${schema.products.createdAt} < ${endDate.toISOString()}`)),
|
|
466
|
+
db
|
|
467
|
+
.select({ count: sql `count(*)` })
|
|
468
|
+
.from(schema.customers)
|
|
469
|
+
.where(and(sql `${schema.customers.createdAt} >= ${startDate.toISOString()}`, sql `${schema.customers.createdAt} < ${endDate.toISOString()}`)),
|
|
470
|
+
]);
|
|
471
|
+
return {
|
|
472
|
+
orders: Number(orderRow[0]?.count || 0),
|
|
473
|
+
revenue: Number(revenueRow[0]?.total || 0),
|
|
474
|
+
newProducts: Number(productRow[0]?.count || 0),
|
|
475
|
+
newCustomers: Number(customerRow[0]?.count || 0),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Count of refunded orders in the window. Used to compute refund rate.
|
|
480
|
+
*/
|
|
481
|
+
export async function getRefundedOrdersCount(startDate, endDate) {
|
|
482
|
+
const db = getDb();
|
|
483
|
+
const result = await db
|
|
484
|
+
.select({ count: sql `count(*)` })
|
|
485
|
+
.from(schema.orders)
|
|
486
|
+
.where(and(eq(schema.orders.status, 'refunded'), sql `${schema.orders.createdAt} >= ${startDate.toISOString()}`, sql `${schema.orders.createdAt} < ${endDate.toISOString()}`));
|
|
487
|
+
return Number(result[0]?.count || 0);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Count of all orders in the window (any status, used as the denominator
|
|
491
|
+
* for refund rate so the rate isn't artificially inflated by excluding
|
|
492
|
+
* pending/cancelled). Cancelled orders DO count here — they represent
|
|
493
|
+
* customer behavior even if they don't represent revenue.
|
|
494
|
+
*/
|
|
495
|
+
async function getTotalOrdersInPeriod(startDate, endDate) {
|
|
496
|
+
const db = getDb();
|
|
497
|
+
const result = await db
|
|
498
|
+
.select({ count: sql `count(*)` })
|
|
499
|
+
.from(schema.orders)
|
|
500
|
+
.where(and(sql `${schema.orders.createdAt} >= ${startDate.toISOString()}`, sql `${schema.orders.createdAt} < ${endDate.toISOString()}`));
|
|
501
|
+
return Number(result[0]?.count || 0);
|
|
502
|
+
}
|
|
503
|
+
function pctDelta(current, previous) {
|
|
504
|
+
if (previous === 0)
|
|
505
|
+
return null;
|
|
506
|
+
return (current - previous) / previous;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Build the full dashboard summary in a single round-trip. Runs eight
|
|
510
|
+
* parallel queries (current period + previous period × four metrics) and
|
|
511
|
+
* computes derived ratios + deltas in-process. Total wall-clock ~30–50ms
|
|
512
|
+
* on a warm Neon connection.
|
|
513
|
+
*/
|
|
514
|
+
export async function getDashboardSummary(periodDays = 30) {
|
|
515
|
+
// Anchor windows to UTC midnight so day boundaries are stable.
|
|
516
|
+
const now = new Date();
|
|
517
|
+
const todayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
518
|
+
// `endDate` is the start of tomorrow (exclusive upper bound) so today's
|
|
519
|
+
// orders are included.
|
|
520
|
+
const endDate = new Date(todayUtc);
|
|
521
|
+
endDate.setUTCDate(endDate.getUTCDate() + 1);
|
|
522
|
+
const currentStart = new Date(todayUtc);
|
|
523
|
+
currentStart.setUTCDate(currentStart.getUTCDate() - (periodDays - 1));
|
|
524
|
+
const previousEnd = new Date(currentStart);
|
|
525
|
+
const previousStart = new Date(currentStart);
|
|
526
|
+
previousStart.setUTCDate(previousStart.getUTCDate() - periodDays);
|
|
527
|
+
const [currentStats, previousStats, currentTotalOrders, previousTotalOrders, currentRefunded, previousRefunded, bestSeller,] = await Promise.all([
|
|
528
|
+
getStoreStatsForPeriod(currentStart, endDate),
|
|
529
|
+
getStoreStatsForPeriod(previousStart, previousEnd),
|
|
530
|
+
getTotalOrdersInPeriod(currentStart, endDate),
|
|
531
|
+
getTotalOrdersInPeriod(previousStart, previousEnd),
|
|
532
|
+
getRefundedOrdersCount(currentStart, endDate),
|
|
533
|
+
getRefundedOrdersCount(previousStart, previousEnd),
|
|
534
|
+
getBestSellerProduct(periodDays),
|
|
535
|
+
]);
|
|
536
|
+
const currentAov = currentStats.orders > 0 ? currentStats.revenue / currentStats.orders : 0;
|
|
537
|
+
const previousAov = previousStats.orders > 0 ? previousStats.revenue / previousStats.orders : 0;
|
|
538
|
+
const currentRefundRate = currentTotalOrders > 0 ? currentRefunded / currentTotalOrders : 0;
|
|
539
|
+
const previousRefundRate = previousTotalOrders > 0 ? previousRefunded / previousTotalOrders : 0;
|
|
540
|
+
return {
|
|
541
|
+
periodDays,
|
|
542
|
+
current: {
|
|
543
|
+
revenue: currentStats.revenue,
|
|
544
|
+
orders: currentStats.orders,
|
|
545
|
+
newCustomers: currentStats.newCustomers,
|
|
546
|
+
newProducts: currentStats.newProducts,
|
|
547
|
+
aov: currentAov,
|
|
548
|
+
refundRate: currentRefundRate,
|
|
549
|
+
bestSeller,
|
|
550
|
+
},
|
|
551
|
+
previous: {
|
|
552
|
+
revenue: previousStats.revenue,
|
|
553
|
+
orders: previousStats.orders,
|
|
554
|
+
newCustomers: previousStats.newCustomers,
|
|
555
|
+
newProducts: previousStats.newProducts,
|
|
556
|
+
aov: previousAov,
|
|
557
|
+
refundRate: previousRefundRate,
|
|
558
|
+
},
|
|
559
|
+
deltas: {
|
|
560
|
+
revenue: pctDelta(currentStats.revenue, previousStats.revenue),
|
|
561
|
+
orders: pctDelta(currentStats.orders, previousStats.orders),
|
|
562
|
+
aov: pctDelta(currentAov, previousAov),
|
|
563
|
+
refundRate: pctDelta(currentRefundRate, previousRefundRate),
|
|
564
|
+
customers: pctDelta(currentStats.newCustomers, previousStats.newCustomers),
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
320
568
|
/**
|
|
321
569
|
* Get low stock products (inventory < threshold)
|
|
322
570
|
*/
|
|
@@ -737,12 +985,24 @@ export async function countOrders(options = {}) {
|
|
|
737
985
|
return Number(result[0]?.count || 0);
|
|
738
986
|
}
|
|
739
987
|
/**
|
|
740
|
-
* Get revenue by period (for analytics)
|
|
988
|
+
* Get revenue by period (for analytics).
|
|
989
|
+
*
|
|
990
|
+
* Returns one row per calendar day in the window, **including days with zero
|
|
991
|
+
* revenue**. The SQL GROUP BY only emits rows where orders exist, which made
|
|
992
|
+
* the chart skip empty days and look discontinuous (e.g., a Friday-only seller
|
|
993
|
+
* showed 5 floating points instead of 30). Fill happens in-process, so the
|
|
994
|
+
* chart can render a continuous line without the consumer thinking about it.
|
|
995
|
+
*
|
|
996
|
+
* Date format is `YYYY-MM-DD` in UTC (matches Postgres `DATE()` output).
|
|
741
997
|
*/
|
|
742
998
|
export async function getRevenueByPeriod(days) {
|
|
743
999
|
const db = getDb();
|
|
744
|
-
|
|
745
|
-
|
|
1000
|
+
// Normalize to UTC midnight so the day boundaries match Postgres DATE()
|
|
1001
|
+
// and our in-process fill loop produces stable keys.
|
|
1002
|
+
const now = new Date();
|
|
1003
|
+
const todayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
1004
|
+
const startDate = new Date(todayUtc);
|
|
1005
|
+
startDate.setUTCDate(startDate.getUTCDate() - days);
|
|
746
1006
|
const result = await db
|
|
747
1007
|
.select({
|
|
748
1008
|
date: sql `DATE(${schema.orders.createdAt})`,
|
|
@@ -752,10 +1012,21 @@ export async function getRevenueByPeriod(days) {
|
|
|
752
1012
|
.where(and(inArray(schema.orders.status, ['paid', 'shipped', 'delivered']), sql `${schema.orders.createdAt} >= ${startDate.toISOString()}`))
|
|
753
1013
|
.groupBy(sql `DATE(${schema.orders.createdAt})`)
|
|
754
1014
|
.orderBy(sql `DATE(${schema.orders.createdAt})`);
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1015
|
+
// Index actual revenue rows by date for O(1) lookup during fill.
|
|
1016
|
+
const revenueByDate = new Map();
|
|
1017
|
+
for (const row of result) {
|
|
1018
|
+
revenueByDate.set(String(row.date), Number(row.revenue));
|
|
1019
|
+
}
|
|
1020
|
+
// Walk every day from startDate through today and emit a row for each —
|
|
1021
|
+
// empty days get revenue=0 so the chart renders a continuous line.
|
|
1022
|
+
const filled = [];
|
|
1023
|
+
const cursor = new Date(startDate);
|
|
1024
|
+
while (cursor <= todayUtc) {
|
|
1025
|
+
const key = cursor.toISOString().slice(0, 10);
|
|
1026
|
+
filled.push({ date: key, revenue: revenueByDate.get(key) ?? 0 });
|
|
1027
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
1028
|
+
}
|
|
1029
|
+
return filled;
|
|
759
1030
|
}
|
|
760
1031
|
/**
|
|
761
1032
|
* Get orders by status count (for analytics)
|
|
@@ -1403,4 +1674,121 @@ export async function findTaxRateForAddress(country, state) {
|
|
|
1403
1674
|
}
|
|
1404
1675
|
return null; // No matching tax zone
|
|
1405
1676
|
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Runtime self-heal for the `onboarding` column. Stores generated before this
|
|
1679
|
+
* column shipped don't have it; this idempotent migration adds it on first
|
|
1680
|
+
* call and caches success in-process so subsequent calls are no-ops.
|
|
1681
|
+
*
|
|
1682
|
+
* Same pattern as the analytics JSONB self-heal documented in CLAUDE.md §27.
|
|
1683
|
+
* No fleet rollout needed — the column appears on each store's first hit
|
|
1684
|
+
* to the setup-guide endpoint or the writeback hooks.
|
|
1685
|
+
*/
|
|
1686
|
+
let onboardingColumnEnsured = false;
|
|
1687
|
+
export async function ensureOnboardingColumn() {
|
|
1688
|
+
if (onboardingColumnEnsured)
|
|
1689
|
+
return;
|
|
1690
|
+
const db = getDb();
|
|
1691
|
+
try {
|
|
1692
|
+
await db.execute(sql `
|
|
1693
|
+
ALTER TABLE store_settings
|
|
1694
|
+
ADD COLUMN IF NOT EXISTS onboarding jsonb NOT NULL DEFAULT '{}'::jsonb
|
|
1695
|
+
`);
|
|
1696
|
+
onboardingColumnEnsured = true;
|
|
1697
|
+
}
|
|
1698
|
+
catch (err) {
|
|
1699
|
+
// Don't poison the cache on transient failures — try again next call.
|
|
1700
|
+
console.error('[ensureOnboardingColumn] migration failed:', err);
|
|
1701
|
+
throw err;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Read the onboarding JSONB. Returns `{}` when the row is missing or the
|
|
1706
|
+
* column hasn't been initialized yet (legacy stores). Always safe to call.
|
|
1707
|
+
*/
|
|
1708
|
+
export async function getOnboardingState() {
|
|
1709
|
+
await ensureOnboardingColumn();
|
|
1710
|
+
const db = getDb();
|
|
1711
|
+
const rows = await db
|
|
1712
|
+
.select({ onboarding: schema.storeSettings.onboarding })
|
|
1713
|
+
.from(schema.storeSettings)
|
|
1714
|
+
.limit(1);
|
|
1715
|
+
return (rows[0]?.onboarding ?? {});
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* Set or clear a single key on the onboarding JSONB. Uses `jsonb_set` so it
|
|
1719
|
+
* never clobbers other keys written by parallel requests. Safe to call
|
|
1720
|
+
* fire-and-forget from mutating API routes (products POST/PUT, settings PUT).
|
|
1721
|
+
*/
|
|
1722
|
+
export async function setOnboardingFlag(key, value) {
|
|
1723
|
+
await ensureOnboardingColumn();
|
|
1724
|
+
const db = getDb();
|
|
1725
|
+
// jsonb_set with create_missing=true so the column starts at '{}' and
|
|
1726
|
+
// gradually accretes keys. We JSON-stringify the value first so that
|
|
1727
|
+
// booleans, numbers, strings, and null all round-trip with their proper
|
|
1728
|
+
// JSONB types. Earlier versions used `to_jsonb(value::text)::jsonb`
|
|
1729
|
+
// which incorrectly produced JSONB string `"true"` for boolean true —
|
|
1730
|
+
// breaking strict-equality reads in the GET handler so the setup-guide
|
|
1731
|
+
// tasks never marked complete.
|
|
1732
|
+
const jsonValue = JSON.stringify(value);
|
|
1733
|
+
await db.execute(sql `
|
|
1734
|
+
UPDATE store_settings
|
|
1735
|
+
SET onboarding = jsonb_set(
|
|
1736
|
+
COALESCE(onboarding, '{}'::jsonb),
|
|
1737
|
+
${sql.raw(`'{${String(key)}}'`)},
|
|
1738
|
+
${jsonValue}::jsonb,
|
|
1739
|
+
true
|
|
1740
|
+
)
|
|
1741
|
+
`);
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Aggregated signal counts used by the setup-guide GET handler. One round-trip
|
|
1745
|
+
* via Promise.all instead of four sequential queries.
|
|
1746
|
+
*/
|
|
1747
|
+
export async function getSetupGuideSignals() {
|
|
1748
|
+
await ensureOnboardingColumn();
|
|
1749
|
+
const db = getDb();
|
|
1750
|
+
const [products, shipping, tax, admins, settings] = await Promise.all([
|
|
1751
|
+
db
|
|
1752
|
+
.select({ count: sql `count(*)` })
|
|
1753
|
+
.from(schema.products),
|
|
1754
|
+
db
|
|
1755
|
+
.select({ count: sql `count(*)` })
|
|
1756
|
+
.from(schema.shippingZones)
|
|
1757
|
+
.where(eq(schema.shippingZones.isActive, true)),
|
|
1758
|
+
db
|
|
1759
|
+
.select({ count: sql `count(*)` })
|
|
1760
|
+
.from(schema.taxZones)
|
|
1761
|
+
.where(eq(schema.taxZones.isActive, true)),
|
|
1762
|
+
db
|
|
1763
|
+
.select({ count: sql `count(*)` })
|
|
1764
|
+
.from(schema.storeAdmins),
|
|
1765
|
+
db
|
|
1766
|
+
.select({
|
|
1767
|
+
logoUrl: schema.storeSettings.logoUrl,
|
|
1768
|
+
// `analytics` is self-healed by the pixel cards (see CLAUDE.md §27);
|
|
1769
|
+
// we read it via raw SQL so the SDK works on stores where the
|
|
1770
|
+
// column doesn't exist yet — the COALESCE keeps that path safe.
|
|
1771
|
+
analytics: sql `COALESCE(
|
|
1772
|
+
(SELECT analytics FROM store_settings LIMIT 1),
|
|
1773
|
+
'{}'::jsonb
|
|
1774
|
+
)`,
|
|
1775
|
+
onboarding: schema.storeSettings.onboarding,
|
|
1776
|
+
})
|
|
1777
|
+
.from(schema.storeSettings)
|
|
1778
|
+
.limit(1),
|
|
1779
|
+
]);
|
|
1780
|
+
return {
|
|
1781
|
+
productCount: Number(products[0]?.count || 0),
|
|
1782
|
+
shippingZonesCount: Number(shipping[0]?.count || 0),
|
|
1783
|
+
taxZonesCount: Number(tax[0]?.count || 0),
|
|
1784
|
+
adminCount: Number(admins[0]?.count || 0),
|
|
1785
|
+
storeSettings: settings[0]
|
|
1786
|
+
? {
|
|
1787
|
+
logoUrl: settings[0].logoUrl ?? null,
|
|
1788
|
+
analytics: settings[0].analytics ?? {},
|
|
1789
|
+
onboarding: settings[0].onboarding ?? {},
|
|
1790
|
+
}
|
|
1791
|
+
: null,
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1406
1794
|
//# sourceMappingURL=queries.js.map
|