@base44/app-plugin-commerce 0.1.4 → 0.1.5
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/README.md +4 -4
- package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +11 -131
- package/base44/functions/commerce/seed-store/entry.ts +39 -167
- package/base44/functions/commerce/seed-store/seed-catalog.ts +717 -0
- package/base44/shared/commerce/catalog.ts +134 -0
- package/package.json +1 -1
- package/scripts/install.js +5 -6
- package/skills/commerce/SKILL.md +3 -2
- package/skills/commerce/docs/api-admin.md +6 -2
- package/skills/commerce/installation-guidelines.md +4 -4
- package/skills/commerce/post-installation.md +202 -78
- package/skills/commerce/references/reviews.md +20 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
|
|
|
7
7
|
## What's included
|
|
8
8
|
|
|
9
9
|
- **24 entities** — Products (a product sells variants when it carries attributes; no type field), variations, categories, tags, attributes + values, reviews, orders (embedded line/shipping/tax/fee/coupon lines), order notes, refunds, coupons, customers, tax classes/rates, shipping zones/methods, payment gateways, store settings, webhooks + deliveries, carts, download permissions, email log.
|
|
10
|
-
- **16 backend functions** — 9 admin (`commerce/admin-products`, `commerce/admin-orders`, `commerce/admin-refunds`, `commerce/admin-coupons`, `commerce/admin-customers`, `commerce/admin-reviews`, `commerce/admin-webhooks`, `commerce/admin-reports`, `commerce/admin-tools`), 4 storefront (`commerce/storefront-catalog`, `commerce/storefront-cart`, `commerce/storefront-checkout`, `commerce/storefront-account`), 2 payment (`commerce/payments`, `commerce/payment-webhook`), and an idempotent `commerce/seed-store
|
|
10
|
+
- **16 backend functions** — 9 admin (`commerce/admin-products`, `commerce/admin-orders`, `commerce/admin-refunds`, `commerce/admin-coupons`, `commerce/admin-customers`, `commerce/admin-reviews`, `commerce/admin-webhooks`, `commerce/admin-reports`, `commerce/admin-tools`), 4 storefront (`commerce/storefront-catalog`, `commerce/storefront-cart`, `commerce/storefront-checkout`, `commerce/storefront-account`), 2 payment (`commerce/payments`, `commerce/payment-webhook`), and an idempotent `commerce/seed-store` — one call seeds the business defaults **and the whole catalog** (products with attributes in, variants/categories/taxonomy created internally).
|
|
11
11
|
- **Online card payments, implemented** — hosted payment page, payment links for unpaid orders, two idempotent confirmation paths (customer return + signed webhook) and refunds through the provider. Wired to **Stripe** out of the box behind a provider-neutral payment utility, so the store takes cards as soon as the connector is connected — no charge flow to write — and moving to another provider means implementing one adapter. See [`skills/commerce/references/online-payments.md`](./skills/commerce/references/online-payments.md).
|
|
12
12
|
- **Shared commerce engine** (`base44/shared/commerce/`) — totals, tax, shipping, coupons, stock, order lifecycle, webhook dispatch (HMAC-signed), emails, payments utility + Stripe adapter, plus static country/currency/continent data.
|
|
13
13
|
- **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated.
|
|
@@ -38,7 +38,7 @@ base44-commerce-template/
|
|
|
38
38
|
│ │ know the store natively
|
|
39
39
|
│ ├── SKILL.md the map: short overview + links to everything below
|
|
40
40
|
│ ├── installation-guidelines.md installing into an app (scripted or manual)
|
|
41
|
-
│ ├── post-installation.md embedding the admin pages
|
|
41
|
+
│ ├── post-installation.md embedding the admin pages, seeding, storefront quick start
|
|
42
42
|
│ ├── references/ per-topic guides (product rendering, product page & variants,
|
|
43
43
|
│ │ Stripe, scheduled work, emails, webhooks, media & downloads,
|
|
44
44
|
│ │ limits, security)
|
|
@@ -86,7 +86,7 @@ From your existing Base44 app:
|
|
|
86
86
|
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
87
87
|
```
|
|
88
88
|
6. **Grant yourself the `admin` role** (Base44 dashboard → users, or `users.inviteUser(email, "admin")`). The admin UI refuses non-admins.
|
|
89
|
-
7. **Seed the store.** Either open `/store-admin` and click **Initialize store defaults** on the first-run setup screen, or call `commerce/seed-store` directly — it creates the settings groups, gateways, tax classes and a fallback shipping zone, plus
|
|
89
|
+
7. **Seed the store.** Either open `/store-admin` and click **Initialize store defaults** on the first-run setup screen, or call `commerce/seed-store` directly — it creates the settings groups, gateways, tax classes and a fallback shipping zone, plus the catalog: pass `products` (whole products with attributes — variants, categories and taxonomy are created internally) or `with_sample_data: true` for the generic demo. Either way pass `store_name` (the app's name) — it is required on a first seed and becomes both the email subject prefix and the sender name. Once the `general` settings group exists the store counts as ready and the first-run screen stops appearing. Payload reference and a worked example: [`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §2.
|
|
90
90
|
|
|
91
91
|
## Quick start (Base44 MCP / hosted apps)
|
|
92
92
|
|
|
@@ -94,7 +94,7 @@ If you build on Base44's hosted platform, use the Base44 agent/MCP to write the
|
|
|
94
94
|
|
|
95
95
|
1. Copy this whole repo into the target app at `examples/commerce/` (e.g. download + extract a tarball with `run_command`), then run `node examples/commerce/scripts/install.js` via `run_command` — or use `write_file` to copy every file under `base44/` and `src/commerce/` individually (use `list_directory`/`read_file` to adapt to the app's actual layout — e.g. the `@/api/base44Client` path and your router file).
|
|
96
96
|
2. Wait for the app to build (`get_app_status`), then confirm entities exist (`list_entity_schemas`).
|
|
97
|
-
3. Grant your user the `admin` role, then
|
|
97
|
+
3. Grant your user the `admin` role, then seed the store's data — one `commerce/seed-store` call takes the whole catalog via `products`, or `with_sample_data: true` for the demo catalog; leave both out for defaults only, or skip the call for the admin's first-run **Initialize store defaults** screen ([`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §2).
|
|
98
98
|
|
|
99
99
|
## What's NOT included
|
|
100
100
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"name": "StoreAdmin",
|
|
12
12
|
"description": "Store administration copilot for the commerce template: manage products, orders, refunds, coupons, customers, reviews, reports and maintenance.",
|
|
13
|
-
"instructions": "You are StoreAdmin, the store administration copilot for this shop's back office. You help store operators inspect and manage the store: products, orders, refunds, coupons, customers, reviews, webhooks, reports, and maintenance.\n\n## How you access the store\nEvery tool takes a JSON body of the form {\"action\": \"<action>\", ...payload} (exception: commerce/seed-store takes {store_name, with_sample_data?} with no action key — store_name is REQUIRED on a first seed) and responds {success, data} or {success:false, error, code}. Search/list actions return {rows, has_next} using limit+skip pagination (there are no total counts). Use search actions for free-text lookups (product name, customer email, coupon code, order number).\n\nStore configuration (settings, tax rates, shipping zones, payment gateways, webhook definitions) is not editable through your tools — see \"Sending the operator to a screen\" below.\n\n## Sending the operator to a screen\nSome configuration is only editable in the admin UI. When one of those is asked for, say plainly that you cannot change it from chat, name the screen, and give a link the operator can click — never just \"do it manually\", and never imply you tried and failed. Do not blame permissions or a security error: the reason is simply that the UI is the only place that configuration is edited.\n\nLinks use the `admin:` scheme with a path from the table below — `[Settings → Tax](admin:settings/tax)`. The chat resolves that to wherever the admin is mounted, so never write `/store-admin/...` yourself.\n\n| Ask | Screen | Link |\n|---|---|---|\n| Currency and price formatting, payment return path | Settings → General | admin:settings/general |\n| Catalog defaults, review settings | Settings → Products | admin:settings/products |\n| Stock thresholds, hold minutes, inventory recipient | Settings → Inventory | admin:settings/inventory |\n| Tax classes, tax rates, price display | Settings → Tax | admin:settings/tax |\n| Shipping zones, methods, shipping classes | Settings → Shipping | admin:settings/shipping |\n| Enabling a gateway, offline bank details | Settings → Payments | admin:settings/payments |\n| Store name (subjects + sender), admin notification recipients, per-email overrides | Settings → Emails | admin:settings/emails |\n| Creating or deleting a webhook (you CAN test and redeliver) | Settings → Webhooks | admin:settings/webhooks |\n\nExample: \"Tax rates aren't something I can change from here — they live in the store's tax settings. Open [Settings → Tax](admin:settings/tax) to add the rate, then tell me and I'll re-check the order's totals.\"\n\nCatalog taxonomy is the opposite: you CAN create categories, tags, attributes and attribute values yourself with commerce/admin-products save-term. Never send the operator to a screen to create one — link to admin:products/categories if they want to review or reorder categories by hand. Tags and attributes have no screen of their own: attributes are edited in the Attributes section of a product's Price & Inventory tab, tags in the Tags card of the product sidebar — so link to the product (admin:products) instead.\n\n## Product variants — be careful\nA product that carries attributes is sold through its variants (there is no product type), and every variant can differ in attributes (e.g. size/color), SKU, price and stock.\n- NEVER pick a variation automatically. When an order line, stock change, or download grant involves a product that carries attributes, first fetch its variations with commerce/storefront-catalog {\"action\":\"get-product\",\"id\":...} (returns {product, variations}), present them in a table (attributes, SKU, price, stock status), and ask the operator which variation to use — then include that variation_id in the item spec.\n- If the operator already named an exact variation (by SKU or full attribute combination), match it against the fetched variations and confirm the match in your reply; if the description is partial or matches more than one variation, ask.\n- Order item specs for commerce/admin-orders create/update are {product_id, variation_id?, quantity, price_override?} — variation_id is REQUIRED for a product with attributes.\n- The same applies to commerce/admin-products set-stock (pass variation_id to change a variation's stock, not the parent's).\n\n## Behavior\n- Be concise and operational. Confirm before destructive or irreversible operations (delete, refund, bulk-status, prune, clear-abandoned-carts) by restating what will happen and asking the user to confirm — unless the user's message already explicitly confirms it.\n- When showing lists or reports, format them as GitHub-flavored markdown tables (| col | col | with a |---| separator row). Keep tables ≤ 8 columns; prefer the most decision-relevant fields (name/number, status, total, date). Format money with the store currency.\n- After a mutation, report exactly what changed (ids, statuses, totals) and surface any error/code verbatim.\n- If a request is ambiguous (which order? which product?), search first and present the candidates in a table, then ask.\n- For store health questions, start with commerce/admin-tools {\"action\":\"status\"} and commerce/admin-reports {\"action\":\"summary\"}.\n- Payments: for an unpaid order paid online, commerce/payments create-link {order_id} gives a payment page link to send the customer, and verify {order_id} re-checks whether the money arrived. If no payment provider is connected, say \"no payment provider is connected\" and that connecting one enables card payments — don't name or troubleshoot a specific provider. Never invent a payment link or claim an order is paid without verifying.\n- You act with store-operator privileges; do not attempt to weaken or bypass access controls, and never expose secrets (webhook secrets, tokens).",
|
|
13
|
+
"instructions": "You are StoreAdmin, the store administration copilot for this shop's back office. You help store operators inspect and manage the store: products, orders, refunds, coupons, customers, reviews, webhooks, reports, and maintenance.\n\n## How you access the store\nEvery tool takes a JSON body of the form {\"action\": \"<action>\", ...payload} (exception: commerce/seed-store takes {store_name, with_sample_data?, products?, coupons?, tax_rates?} with no action key — store_name is REQUIRED on a first seed, and products can bootstrap a whole catalog in one call, variants included; see skills/commerce/docs/api-admin.md) and responds {success, data} or {success:false, error, code}. Search/list actions return {rows, has_next} using limit+skip pagination (there are no total counts). Use search actions for free-text lookups (product name, customer email, coupon code, order number).\n\nStore configuration (settings, tax rates, shipping zones, payment gateways, webhook definitions) is not editable through your tools — see \"Sending the operator to a screen\" below.\n\n## Sending the operator to a screen\nSome configuration is only editable in the admin UI. When one of those is asked for, say plainly that you cannot change it from chat, name the screen, and give a link the operator can click — never just \"do it manually\", and never imply you tried and failed. Do not blame permissions or a security error: the reason is simply that the UI is the only place that configuration is edited.\n\nLinks use the `admin:` scheme with a path from the table below — `[Settings → Tax](admin:settings/tax)`. The chat resolves that to wherever the admin is mounted, so never write `/store-admin/...` yourself.\n\n| Ask | Screen | Link |\n|---|---|---|\n| Currency and price formatting, payment return path | Settings → General | admin:settings/general |\n| Catalog defaults, review settings | Settings → Products | admin:settings/products |\n| Stock thresholds, hold minutes, inventory recipient | Settings → Inventory | admin:settings/inventory |\n| Tax classes, tax rates, price display | Settings → Tax | admin:settings/tax |\n| Shipping zones, methods, shipping classes | Settings → Shipping | admin:settings/shipping |\n| Enabling a gateway, offline bank details | Settings → Payments | admin:settings/payments |\n| Store name (subjects + sender), admin notification recipients, per-email overrides | Settings → Emails | admin:settings/emails |\n| Creating or deleting a webhook (you CAN test and redeliver) | Settings → Webhooks | admin:settings/webhooks |\n\nExample: \"Tax rates aren't something I can change from here — they live in the store's tax settings. Open [Settings → Tax](admin:settings/tax) to add the rate, then tell me and I'll re-check the order's totals.\"\n\nCatalog taxonomy is the opposite: you CAN create categories, tags, attributes and attribute values yourself with commerce/admin-products save-term. Never send the operator to a screen to create one — link to admin:products/categories if they want to review or reorder categories by hand. Tags and attributes have no screen of their own: attributes are edited in the Attributes section of a product's Price & Inventory tab, tags in the Tags card of the product sidebar — so link to the product (admin:products) instead.\n\n## Product variants — be careful\nA product that carries attributes is sold through its variants (there is no product type), and every variant can differ in attributes (e.g. size/color), SKU, price and stock.\n- NEVER pick a variation automatically. When an order line, stock change, or download grant involves a product that carries attributes, first fetch its variations with commerce/storefront-catalog {\"action\":\"get-product\",\"id\":...} (returns {product, variations}), present them in a table (attributes, SKU, price, stock status), and ask the operator which variation to use — then include that variation_id in the item spec.\n- If the operator already named an exact variation (by SKU or full attribute combination), match it against the fetched variations and confirm the match in your reply; if the description is partial or matches more than one variation, ask.\n- Order item specs for commerce/admin-orders create/update are {product_id, variation_id?, quantity, price_override?} — variation_id is REQUIRED for a product with attributes.\n- The same applies to commerce/admin-products set-stock (pass variation_id to change a variation's stock, not the parent's).\n\n## Behavior\n- Be concise and operational. Confirm before destructive or irreversible operations (delete, refund, bulk-status, prune, clear-abandoned-carts) by restating what will happen and asking the user to confirm — unless the user's message already explicitly confirms it.\n- When showing lists or reports, format them as GitHub-flavored markdown tables (| col | col | with a |---| separator row). Keep tables ≤ 8 columns; prefer the most decision-relevant fields (name/number, status, total, date). Format money with the store currency.\n- After a mutation, report exactly what changed (ids, statuses, totals) and surface any error/code verbatim.\n- If a request is ambiguous (which order? which product?), search first and present the candidates in a table, then ask.\n- For store health questions, start with commerce/admin-tools {\"action\":\"status\"} and commerce/admin-reports {\"action\":\"summary\"}.\n- Payments: for an unpaid order paid online, commerce/payments create-link {order_id} gives a payment page link to send the customer, and verify {order_id} re-checks whether the money arrived. If no payment provider is connected, say \"no payment provider is connected\" and that connecting one enables card payments — don't name or troubleshoot a specific provider. Never invent a payment link or claim an order is paid without verifying.\n- You act with store-operator privileges; do not attempt to weaken or bypass access controls, and never expose secrets (webhook secrets, tokens).",
|
|
14
14
|
"tool_configs": [
|
|
15
15
|
{
|
|
16
16
|
"function_name": "commerce/admin-products",
|
|
@@ -14,7 +14,17 @@ import { isVariable } from "../../../shared/commerce/products.ts";
|
|
|
14
14
|
import { sendStockEmail } from "../../../shared/commerce/emails.ts";
|
|
15
15
|
import { dispatch } from "../../../shared/commerce/webhooks.ts";
|
|
16
16
|
import { pageSlice, scanAll, textMatch } from "../../../shared/commerce/scan.ts";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
adjustTermCounts,
|
|
19
|
+
assertUniqueSku,
|
|
20
|
+
bumpCount,
|
|
21
|
+
derivePricing,
|
|
22
|
+
deriveStock,
|
|
23
|
+
ensureUniqueCode,
|
|
24
|
+
ensureUniqueSlug,
|
|
25
|
+
rollUpParent,
|
|
26
|
+
slugify,
|
|
27
|
+
} from "../../../shared/commerce/catalog.ts";
|
|
18
28
|
|
|
19
29
|
const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
|
|
20
30
|
const fail = (status: number, error: string, code?: string) =>
|
|
@@ -56,87 +66,6 @@ Deno.serve(async (req) => {
|
|
|
56
66
|
}
|
|
57
67
|
});
|
|
58
68
|
|
|
59
|
-
// ── derivations ──────────────────────────────────────────────────────────────
|
|
60
|
-
|
|
61
|
-
function slugify(name: string): string {
|
|
62
|
-
return String(name || "")
|
|
63
|
-
.toLowerCase()
|
|
64
|
-
.trim()
|
|
65
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
66
|
-
.replace(/^-+|-+$/g, "") || "product";
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** price/on_sale from the sale window. Mutates rec. */
|
|
70
|
-
function derivePricing(rec: any): void {
|
|
71
|
-
const now = Date.now();
|
|
72
|
-
const from = rec.date_on_sale_from ? new Date(rec.date_on_sale_from).getTime() : -Infinity;
|
|
73
|
-
const to = rec.date_on_sale_to ? new Date(rec.date_on_sale_to).getTime() : Infinity;
|
|
74
|
-
const saleActive = rec.sale_price != null && rec.sale_price !== "" && now >= from && now <= to;
|
|
75
|
-
rec.on_sale = !!saleActive;
|
|
76
|
-
const effective = saleActive ? rec.sale_price : rec.regular_price;
|
|
77
|
-
if (effective != null && effective !== "") rec.price = round2(Number(effective));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** stock_status derivation when stock is managed. Mutates rec. */
|
|
81
|
-
function deriveStock(rec: any, outThreshold: number, managed: boolean): void {
|
|
82
|
-
if (managed) {
|
|
83
|
-
rec.stock_status = deriveStockStatus(
|
|
84
|
-
Number(rec.stock_quantity ?? 0),
|
|
85
|
-
rec.backorders ?? "no",
|
|
86
|
-
outThreshold,
|
|
87
|
-
);
|
|
88
|
-
} else if (!rec.stock_status) {
|
|
89
|
-
rec.stock_status = "instock";
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ── uniqueness ───────────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
async function ensureUniqueSlug(
|
|
96
|
-
sr: any,
|
|
97
|
-
slug: string,
|
|
98
|
-
selfId?: string,
|
|
99
|
-
entity = "commerce.Product",
|
|
100
|
-
scope?: Record<string, any>,
|
|
101
|
-
): Promise<string> {
|
|
102
|
-
let candidate = slug;
|
|
103
|
-
for (let i = 2; i < 100; i++) {
|
|
104
|
-
const hits = (await sr.entities[entity].filter({ ...(scope ?? {}), slug: candidate }, undefined, 2)) ?? [];
|
|
105
|
-
if (!hits.some((p: any) => p.id !== selfId)) return candidate;
|
|
106
|
-
candidate = `${slug}-${i}`;
|
|
107
|
-
}
|
|
108
|
-
return `${slug}-${crypto.randomUUID().slice(0, 6)}`;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** SKU must be unique across products AND variations. Throws duplicate_sku. */
|
|
112
|
-
async function assertUniqueSku(sr: any, sku: string, opts: { productId?: string; variationId?: string }): Promise<void> {
|
|
113
|
-
if (!sku) return;
|
|
114
|
-
const prods = (await sr.entities["commerce.Product"].filter({ sku }, undefined, 2)) ?? [];
|
|
115
|
-
if (prods.some((p: any) => p.id !== opts.productId)) {
|
|
116
|
-
throw new HttpError(409, `SKU "${sku}" is already in use by another product.`, "duplicate_sku");
|
|
117
|
-
}
|
|
118
|
-
const vars = (await sr.entities["commerce.ProductVariation"].filter({ sku }, undefined, 2)) ?? [];
|
|
119
|
-
if (vars.some((v: any) => v.id !== opts.variationId)) {
|
|
120
|
-
throw new HttpError(409, `SKU "${sku}" is already in use by a product variation.`, "duplicate_sku");
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ── taxonomy counts ──────────────────────────────────────────────────────────
|
|
125
|
-
|
|
126
|
-
async function adjustTermCounts(sr: any, entity: string, prevIds: string[], nextIds: string[]): Promise<void> {
|
|
127
|
-
const added = nextIds.filter((id) => !prevIds.includes(id));
|
|
128
|
-
const removed = prevIds.filter((id) => !nextIds.includes(id));
|
|
129
|
-
for (const id of added) await bumpCount(sr, entity, id, +1);
|
|
130
|
-
for (const id of removed) await bumpCount(sr, entity, id, -1);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function bumpCount(sr: any, entity: string, id: string, delta: number): Promise<void> {
|
|
134
|
-
try {
|
|
135
|
-
const rec = await sr.entities[entity].get(id);
|
|
136
|
-
if (rec) await sr.entities[entity].update(id, { count: Math.max(0, (rec.count ?? 0) + delta) });
|
|
137
|
-
} catch { /* stale reference — recount-terms repairs */ }
|
|
138
|
-
}
|
|
139
|
-
|
|
140
69
|
// ── save ─────────────────────────────────────────────────────────────────────
|
|
141
70
|
|
|
142
71
|
async function save(sr: any, payload: any): Promise<any> {
|
|
@@ -214,44 +143,6 @@ async function diffVariations(sr: any, parent: any, incoming: any[], outThreshol
|
|
|
214
143
|
return out;
|
|
215
144
|
}
|
|
216
145
|
|
|
217
|
-
/**
|
|
218
|
-
* Roll a variant parent's stock and price up from its variations.
|
|
219
|
-
*
|
|
220
|
-
* Price matters beyond display: `list-products` sorts and filters on
|
|
221
|
-
* `product.price`, so a parent left empty sorts as free and drops out of every
|
|
222
|
-
* price filter. Deriving it removes a number nobody could keep in sync by hand —
|
|
223
|
-
* the parent is simply the cheapest thing a customer can actually buy.
|
|
224
|
-
*/
|
|
225
|
-
async function rollUpParent(sr: any, parent: any, variations: any[]): Promise<void> {
|
|
226
|
-
if (!isVariable(parent)) return;
|
|
227
|
-
const live = variations.filter((v) => (v.status ?? "publish") === "publish");
|
|
228
|
-
const patch: Record<string, any> = {};
|
|
229
|
-
|
|
230
|
-
// manage_stock on the parent means the merchant tracks one pooled quantity,
|
|
231
|
-
// and with it off an explicit stock_status is their own choice to keep.
|
|
232
|
-
if (!parent.manage_stock) {
|
|
233
|
-
const status = live.length && live.every((v) => v.stock_status === "outofstock")
|
|
234
|
-
? "outofstock"
|
|
235
|
-
: "instock";
|
|
236
|
-
if (status !== parent.stock_status) patch.stock_status = status;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const cheapest = (key: string): number | null => {
|
|
240
|
-
const values = live.map((v) => Number(v[key])).filter((n) => Number.isFinite(n));
|
|
241
|
-
return values.length ? round2(Math.min(...values)) : null;
|
|
242
|
-
};
|
|
243
|
-
const regular = cheapest("regular_price");
|
|
244
|
-
const price = cheapest("price") ?? regular;
|
|
245
|
-
const onSale = live.some((v) => !!v.on_sale);
|
|
246
|
-
if (regular !== (parent.regular_price ?? null)) patch.regular_price = regular;
|
|
247
|
-
if (price !== (parent.price ?? null)) patch.price = price;
|
|
248
|
-
if (onSale !== !!parent.on_sale) patch.on_sale = onSale;
|
|
249
|
-
|
|
250
|
-
if (!Object.keys(patch).length) return;
|
|
251
|
-
await sr.entities["commerce.Product"].update(parent.id, patch);
|
|
252
|
-
Object.assign(parent, patch);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
146
|
// ── delete / batch / duplicate ───────────────────────────────────────────────
|
|
256
147
|
|
|
257
148
|
async function remove(sr: any, id: string): Promise<any> {
|
|
@@ -474,17 +365,6 @@ async function saveTerm(sr: any, payload: any): Promise<any> {
|
|
|
474
365
|
return await sr.entities[entity].create(withCount);
|
|
475
366
|
}
|
|
476
367
|
|
|
477
|
-
/** `code` is what a storefront filter URL carries, so it has to be unique. */
|
|
478
|
-
async function ensureUniqueCode(sr: any, code: string, selfId?: string): Promise<string> {
|
|
479
|
-
let candidate = code;
|
|
480
|
-
for (let i = 2; i < 100; i++) {
|
|
481
|
-
const hits = (await sr.entities["commerce.ProductAttribute"].filter({ code: candidate }, undefined, 2)) ?? [];
|
|
482
|
-
if (!hits.some((a: any) => a.id !== selfId)) return candidate;
|
|
483
|
-
candidate = `${code}-${i}`;
|
|
484
|
-
}
|
|
485
|
-
return `${code}-${crypto.randomUUID().slice(0, 6)}`;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
368
|
/** Rewrite a renamed value across every product and variant that used it. */
|
|
489
369
|
async function renameAttributeValue(sr: any, attributeId: string, from: string, to: string): Promise<void> {
|
|
490
370
|
const products = await scanAll(sr.entities["commerce.Product"]);
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* commerce/seed-store — idempotent store initialization.
|
|
3
3
|
*
|
|
4
|
-
* Flow: (1)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (3)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Flow: (1) validate any catalog payload (pure — 400 invalid_payload with
|
|
5
|
+
* per-field errors before anything runs), (2) canary schema validation (probe
|
|
6
|
+
* create+delete per entity; abort with 422 schema_incompatible and write
|
|
7
|
+
* nothing on any failure), (3) seed required defaults (settings groups,
|
|
8
|
+
* gateways, tax classes, fallback zone), (4) the catalog: either the caller's
|
|
9
|
+
* `products`/`coupons`/`tax_rates` (taxonomy get-or-created by name,
|
|
10
|
+
* variations generated from `attributes` when not given, per-product skip when
|
|
11
|
+
* the sku/slug already exists) or the generic demo catalog
|
|
12
|
+
* (with_sample_data=true, only when the store has zero products) — with
|
|
13
|
+
* best-effort rollback on mid-failure. See seed-catalog.ts for the pipeline.
|
|
11
14
|
*
|
|
12
|
-
* Body: { with_sample_data
|
|
15
|
+
* Body: { store_name?, with_sample_data?, products?, coupons?, tax_rates? }
|
|
16
|
+
* — with_sample_data cannot be combined with the catalog keys.
|
|
13
17
|
*
|
|
14
18
|
* `store_name` is required on a first seed: a function's env is only
|
|
15
19
|
* BASE44_APP_ID, so it cannot read the app's name, and subjects need one.
|
|
16
20
|
*/
|
|
17
21
|
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
18
22
|
import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
|
|
19
|
-
import { round2 } from "../../../shared/commerce/money.ts";
|
|
20
23
|
import {
|
|
21
24
|
GATEWAY_DEFAULTS,
|
|
22
25
|
REST_OF_WORLD_EXAMPLE_METHOD,
|
|
@@ -24,14 +27,7 @@ import {
|
|
|
24
27
|
SETTINGS_DEFAULTS,
|
|
25
28
|
TAX_CLASS_DEFAULTS,
|
|
26
29
|
} from "./defaults.ts";
|
|
27
|
-
import {
|
|
28
|
-
SAMPLE_ATTRIBUTE_TERMS,
|
|
29
|
-
SAMPLE_ATTRIBUTES,
|
|
30
|
-
SAMPLE_CATEGORIES,
|
|
31
|
-
SAMPLE_COUPONS,
|
|
32
|
-
SAMPLE_PRODUCTS,
|
|
33
|
-
SAMPLE_TAX_RATES,
|
|
34
|
-
} from "./sample-data.ts";
|
|
30
|
+
import { CatalogPayloadError, normalizeCatalogPayload, sampleCatalog, seedCatalog } from "./seed-catalog.ts";
|
|
35
31
|
|
|
36
32
|
const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
|
|
37
33
|
const fail = (status: number, error: string, code?: string, extra?: Record<string, unknown>) =>
|
|
@@ -46,8 +42,18 @@ Deno.serve(async (req) => {
|
|
|
46
42
|
const withSample = !!body.with_sample_data;
|
|
47
43
|
const storeName = String(body.store_name ?? "").trim();
|
|
48
44
|
|
|
45
|
+
// ── 0. normalize the catalog payload — pure, so bad input fails before
|
|
46
|
+
// canaries or any write ─────────────────────────────────────────────
|
|
47
|
+
let catalogSpec = null;
|
|
48
|
+
try {
|
|
49
|
+
catalogSpec = normalizeCatalogPayload(body);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e instanceof CatalogPayloadError) return fail(400, e.message, "invalid_payload", { errors: e.errors });
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
// ── 1. canary schema validation — abort before writing anything real ────
|
|
50
|
-
const canaryErrors = await runCanaries(sr, withSample);
|
|
56
|
+
const canaryErrors = await runCanaries(sr, withSample || !!catalogSpec);
|
|
51
57
|
if (canaryErrors.length) {
|
|
52
58
|
return fail(422,
|
|
53
59
|
"Entity schemas have been modified and are incompatible with the seeder.",
|
|
@@ -115,21 +121,27 @@ Deno.serve(async (req) => {
|
|
|
115
121
|
seeded.zone_methods++;
|
|
116
122
|
}
|
|
117
123
|
|
|
118
|
-
// ── 3.
|
|
119
|
-
let sampleResult: Record<string,
|
|
124
|
+
// ── 3. the catalog: caller-supplied, or the demo sample ──────────────────
|
|
125
|
+
let sampleResult: Record<string, any> | null = null;
|
|
120
126
|
if (withSample) {
|
|
121
127
|
const anyProduct = (await sr.entities["commerce.Product"].list(undefined, 1)) ?? [];
|
|
122
128
|
if (!anyProduct.length) {
|
|
123
|
-
sampleResult = await
|
|
129
|
+
sampleResult = await seedCatalog(sr, sampleCatalog(), { skipExisting: false, errorCode: "sample_seed_failed" });
|
|
124
130
|
}
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
let catalogResult: Record<string, any> | null = null;
|
|
134
|
+
if (catalogSpec) {
|
|
135
|
+
catalogResult = await seedCatalog(sr, catalogSpec, { skipExisting: true, errorCode: "catalog_seed_failed" });
|
|
136
|
+
}
|
|
137
|
+
|
|
127
138
|
const currentName = String(
|
|
128
139
|
(existingSettings.find((r: any) => r.group_id === "emails")?.values?.store_name ?? storeName) || storeName,
|
|
129
140
|
);
|
|
130
141
|
return ok({
|
|
131
142
|
seeded,
|
|
132
143
|
sample_data: sampleResult ?? false,
|
|
144
|
+
catalog: catalogResult,
|
|
133
145
|
store_name: { value: storeNameAction === "kept_existing" ? currentName : storeName, action: storeNameAction },
|
|
134
146
|
});
|
|
135
147
|
} catch (e) {
|
|
@@ -142,7 +154,7 @@ Deno.serve(async (req) => {
|
|
|
142
154
|
// ── canaries ─────────────────────────────────────────────────────────────────
|
|
143
155
|
|
|
144
156
|
/** Minimal-valid probe records per entity the seeder writes. */
|
|
145
|
-
function canarySpecs(
|
|
157
|
+
function canarySpecs(needsCatalog: boolean): Array<{ entity: string; record: Record<string, any> }> {
|
|
146
158
|
const base = [
|
|
147
159
|
{ entity: "commerce.StoreSettings", record: { group_id: "general", values: { __canary: true } } },
|
|
148
160
|
{ entity: "commerce.PaymentGateway", record: { slug: "__canary", title: "Canary", enabled: false, order: 999, settings: {} } },
|
|
@@ -150,8 +162,9 @@ function canarySpecs(withSample: boolean): Array<{ entity: string; record: Recor
|
|
|
150
162
|
{ entity: "commerce.ShippingZone", record: { name: "__canary", order: 998, locations: [] } },
|
|
151
163
|
{ entity: "commerce.ShippingZoneMethod", record: { zone_id: "__canary", method_id: "flat_rate", title: "Canary", enabled: false, order: 0, settings: { cost: 0 } } },
|
|
152
164
|
];
|
|
153
|
-
const
|
|
165
|
+
const catalog = [
|
|
154
166
|
{ entity: "commerce.ProductCategory", record: { name: "__canary", slug: "__canary", count: 0 } },
|
|
167
|
+
{ entity: "commerce.ProductTag", record: { name: "__canary", count: 0 } },
|
|
155
168
|
{ entity: "commerce.ProductAttribute", record: { name: "__canary", code: "__canary", order: 0 } },
|
|
156
169
|
{ entity: "commerce.ProductAttributeTerm", record: { attribute_id: "__canary", name: "__canary", order: 0 } },
|
|
157
170
|
{ entity: "commerce.Product", record: { name: "__canary", status: "draft", regular_price: 1, price: 1 } },
|
|
@@ -159,12 +172,12 @@ function canarySpecs(withSample: boolean): Array<{ entity: string; record: Recor
|
|
|
159
172
|
{ entity: "commerce.Coupon", record: { code: "__canary", discount_type: "percent", amount: 1, usage_count: 0, used_by: [] } },
|
|
160
173
|
{ entity: "commerce.TaxRate", record: { country: "ZZ", rate: 1, name: "__canary", priority: 1, compound: false, shipping: true, tax_class: "standard" } },
|
|
161
174
|
];
|
|
162
|
-
return
|
|
175
|
+
return needsCatalog ? [...base, ...catalog] : base;
|
|
163
176
|
}
|
|
164
177
|
|
|
165
|
-
async function runCanaries(sr: any,
|
|
178
|
+
async function runCanaries(sr: any, needsCatalog: boolean): Promise<Array<{ entity: string; error: string }>> {
|
|
166
179
|
const errors: Array<{ entity: string; error: string }> = [];
|
|
167
|
-
for (const spec of canarySpecs(
|
|
180
|
+
for (const spec of canarySpecs(needsCatalog)) {
|
|
168
181
|
let created: any = null;
|
|
169
182
|
try {
|
|
170
183
|
created = await sr.entities[spec.entity].create(spec.record);
|
|
@@ -179,144 +192,3 @@ async function runCanaries(sr: any, withSample: boolean): Promise<Array<{ entity
|
|
|
179
192
|
return errors;
|
|
180
193
|
}
|
|
181
194
|
|
|
182
|
-
// ── sample catalog ───────────────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
/** price/on_sale/stock_status derivation for seeded products (mirrors commerce/admin-products). */
|
|
185
|
-
function deriveSeedProduct(p: any): any {
|
|
186
|
-
const out = { ...p };
|
|
187
|
-
out.on_sale = out.sale_price != null;
|
|
188
|
-
const effective = out.on_sale ? out.sale_price : out.regular_price;
|
|
189
|
-
if (effective != null) out.price = round2(Number(effective));
|
|
190
|
-
if (out.manage_stock) {
|
|
191
|
-
const qty = Number(out.stock_quantity ?? 0);
|
|
192
|
-
out.stock_status = qty > 0 ? "instock" : (out.backorders && out.backorders !== "no" ? "onbackorder" : "outofstock");
|
|
193
|
-
} else if (!out.stock_status) {
|
|
194
|
-
out.stock_status = "instock";
|
|
195
|
-
}
|
|
196
|
-
return out;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function seedSampleData(sr: any): Promise<Record<string, number>> {
|
|
200
|
-
// rollback ledger — delete in reverse order on failure
|
|
201
|
-
const created: Array<{ entity: string; id: string }> = [];
|
|
202
|
-
const track = async (entity: string, record: Record<string, any>) => {
|
|
203
|
-
const rec = await sr.entities[entity].create(record);
|
|
204
|
-
created.push({ entity, id: rec.id });
|
|
205
|
-
return rec;
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
try {
|
|
209
|
-
// categories
|
|
210
|
-
const categoryBySlug: Record<string, any> = {};
|
|
211
|
-
for (const cat of SAMPLE_CATEGORIES) {
|
|
212
|
-
categoryBySlug[cat.slug] = await track("commerce.ProductCategory", cat);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// attributes + values
|
|
216
|
-
const attributeByCode: Record<string, any> = {};
|
|
217
|
-
for (const attr of SAMPLE_ATTRIBUTES) {
|
|
218
|
-
const rec = await track("commerce.ProductAttribute", attr);
|
|
219
|
-
attributeByCode[attr.code] = rec;
|
|
220
|
-
for (const value of SAMPLE_ATTRIBUTE_TERMS[attr.code] ?? []) {
|
|
221
|
-
await track("commerce.ProductAttributeTerm", { ...value, attribute_id: rec.id, count: 0 });
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const productByKey: Record<string, any> = {};
|
|
226
|
-
let variationCount = 0;
|
|
227
|
-
const specs = SAMPLE_PRODUCTS;
|
|
228
|
-
|
|
229
|
-
for (const spec of specs) {
|
|
230
|
-
const { key, categories, attributes, default_attributes, variations, options: _o, ...fields } = spec;
|
|
231
|
-
const record: any = deriveSeedProduct({
|
|
232
|
-
...fields,
|
|
233
|
-
slug: fields.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
|
|
234
|
-
category_ids: (categories ?? []).map((slug: string) => categoryBySlug[slug]?.id).filter(Boolean),
|
|
235
|
-
tag_ids: [],
|
|
236
|
-
images: fields.images ?? [],
|
|
237
|
-
meta_data: [],
|
|
238
|
-
total_sales: 0,
|
|
239
|
-
});
|
|
240
|
-
if (attributes) {
|
|
241
|
-
record.attributes = attributes.map((a: any, i: number) => ({
|
|
242
|
-
attribute_id: attributeByCode[a.code]?.id ?? "",
|
|
243
|
-
name: attributeByCode[a.code]?.name ?? a.code,
|
|
244
|
-
position: i,
|
|
245
|
-
options: a.options ?? [],
|
|
246
|
-
}));
|
|
247
|
-
}
|
|
248
|
-
if (default_attributes) {
|
|
249
|
-
record.default_attributes = default_attributes.map((d: any) => ({
|
|
250
|
-
attribute_id: attributeByCode[d.code]?.id ?? "",
|
|
251
|
-
name: attributeByCode[d.code]?.name ?? d.code,
|
|
252
|
-
option: d.option,
|
|
253
|
-
}));
|
|
254
|
-
}
|
|
255
|
-
const product = await track("commerce.Product", record);
|
|
256
|
-
productByKey[key] = product;
|
|
257
|
-
|
|
258
|
-
const seededVariations: any[] = [];
|
|
259
|
-
for (const v of variations ?? []) {
|
|
260
|
-
const attrs = Object.entries(v.options).map(([code, option]) => ({
|
|
261
|
-
attribute_id: attributeByCode[code]?.id ?? "",
|
|
262
|
-
name: attributeByCode[code]?.name ?? code,
|
|
263
|
-
option,
|
|
264
|
-
}));
|
|
265
|
-
seededVariations.push(await track("commerce.ProductVariation", deriveSeedProduct({
|
|
266
|
-
product_id: product.id,
|
|
267
|
-
attributes: attrs,
|
|
268
|
-
status: "publish",
|
|
269
|
-
sku: `${record.sku}-${Object.values(v.options).join("-").toUpperCase()}`,
|
|
270
|
-
regular_price: v.regular_price,
|
|
271
|
-
...(v.sale_price != null ? { sale_price: v.sale_price } : {}),
|
|
272
|
-
manage_stock: "yes",
|
|
273
|
-
stock_quantity: v.stock_quantity,
|
|
274
|
-
backorders: v.backorders ?? "no",
|
|
275
|
-
...(v.image ? { image: v.image } : {}),
|
|
276
|
-
})));
|
|
277
|
-
variationCount++;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// The parent's price is the cheapest variant. commerce/admin-products
|
|
281
|
-
// derives this on save, but the seeder writes entities directly — without
|
|
282
|
-
// this a seeded variant product has no price, so it sorts as free and
|
|
283
|
-
// drops out of every price filter.
|
|
284
|
-
if (seededVariations.length) {
|
|
285
|
-
const cheapest = (key: string) => {
|
|
286
|
-
const values = seededVariations.map((v) => Number(v[key])).filter((n) => Number.isFinite(n));
|
|
287
|
-
return values.length ? round2(Math.min(...values)) : null;
|
|
288
|
-
};
|
|
289
|
-
const regular = cheapest("regular_price");
|
|
290
|
-
await sr.entities["commerce.Product"].update(product.id, {
|
|
291
|
-
regular_price: regular,
|
|
292
|
-
price: cheapest("price") ?? regular,
|
|
293
|
-
on_sale: seededVariations.some((v) => !!v.on_sale),
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// taxonomy counts for the seeded catalog
|
|
299
|
-
for (const cat of Object.values(categoryBySlug) as any[]) {
|
|
300
|
-
const count = specs.filter((s) => (s.categories ?? []).some((slug: string) => categoryBySlug[slug]?.id === cat.id)).length;
|
|
301
|
-
await sr.entities["commerce.ProductCategory"].update(cat.id, { count });
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
for (const coupon of SAMPLE_COUPONS) await track("commerce.Coupon", coupon);
|
|
305
|
-
for (const rate of SAMPLE_TAX_RATES) await track("commerce.TaxRate", rate);
|
|
306
|
-
|
|
307
|
-
return {
|
|
308
|
-
categories: SAMPLE_CATEGORIES.length,
|
|
309
|
-
attributes: SAMPLE_ATTRIBUTES.length,
|
|
310
|
-
products: SAMPLE_PRODUCTS.length,
|
|
311
|
-
variations: variationCount,
|
|
312
|
-
coupons: SAMPLE_COUPONS.length,
|
|
313
|
-
tax_rates: SAMPLE_TAX_RATES.length,
|
|
314
|
-
};
|
|
315
|
-
} catch (e) {
|
|
316
|
-
// best-effort rollback, newest first
|
|
317
|
-
for (const { entity, id } of created.reverse()) {
|
|
318
|
-
try { await sr.entities[entity].delete(id); } catch { /* leave orphans; commerce/admin-tools can clean */ }
|
|
319
|
-
}
|
|
320
|
-
throw new HttpError(500, `Sample data seeding failed and was rolled back: ${(e as Error).message}`, "sample_seed_failed");
|
|
321
|
-
}
|
|
322
|
-
}
|