Compare commits

...

2 Commits

Author SHA1 Message Date
seb
fc7afd1be8 u 2026-07-27 01:14:36 +02:00
seb
c27ad52e2a u 2026-07-27 00:13:47 +02:00
21 changed files with 1271 additions and 13 deletions

View File

@@ -1,3 +1,6 @@
# Demo catalog (skips MSSQL; requires `npm run demo:generate` first)
DEMO_MODE=false
# HTTPS POS server
PORT=4443
AUTH_TOKEN=df40ad2067954646abb0499548a52241

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ scripts/s3-backup/data/
scripts/s3-backup/tmp/
scripts/s3-backup/certs/
scripts/minimal-db/data/
demo/

13
API.md
View File

@@ -37,6 +37,19 @@ See [productSync.md](productSync.md) for the cursor / row-version model in detai
---
## Demo mode
Node can serve a generated in-memory catalog without MSSQL.
1. Generate artifacts once: `npm run demo:generate`
Writes `demo/catalog.json` and `demo/images/<hash>.jpg` (≥1000 products, 34 category levels, variants, real photos).
2. Set `DEMO_MODE=true` in `.env` (see `.env.example`).
3. Start the server as usual (`npm start`). Pairing and all sync endpoints work; orders are logged and return synthetic `OK` results.
Demo mode is **opt-in only** — a failed MSSQL connection does not enable it.
---
## `GET /v1/client` — Pairing
Discovers the server and completes pairing with a 6-digit code.

View File

@@ -11,8 +11,11 @@
"qr": "node scripts/create-pairing-qr.mjs",
"backup:s3": "node scripts/s3-backup/backup.mjs",
"backup:s3:quick": "node scripts/s3-backup/backup.mjs --skip-trust",
"restore:s3": "node scripts/s3-backup/restore.mjs",
"restore:s3:quick": "node scripts/s3-backup/restore.mjs --skip-trust",
"db:minimal": "node scripts/create-minimal-db.mjs",
"db:minimal:extract": "node scripts/create-minimal-db.mjs extract",
"demo:generate": "node scripts/generate-demo-catalog.mjs",
"start": "node --watch server.js",
"test:client": "node test-client.js"
},

View File

@@ -0,0 +1,781 @@
#!/usr/bin/env node
/**
* Generates demo/catalog.json + demo/images/*.jpg for DEMO_MODE.
* Downloads real photos from LoremFlickr matched to category / product keywords
* (Picsum fallback). Variant siblings share a base photo and get a light tint.
*
* Usage: node scripts/generate-demo-catalog.mjs
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const DEMO_DIR = path.join(ROOT, 'demo');
const IMAGES_DIR = path.join(DEMO_DIR, 'images');
const IMAGE_SIZE = 480;
const JPEG_QUALITY = 65;
const DOWNLOAD_CONCURRENCY = 8;
const TARGET_PRODUCTS = 1100;
const COLORS = ['Red', 'Blue', 'Green', 'Black', 'White', 'Grey', 'Navy', 'Beige'];
const MATERIALS = ['Oak', 'Pine', 'Steel', 'Aluminium', 'Cotton', 'Leather', 'Plastic', 'Bamboo'];
const PACKAGE_SIZES = ['250g', '500g', '1kg', '2kg', '5kg', '10pcs', '20pcs', '50pcs'];
const TINTS = {
Red: { r: 220, g: 60, b: 60 },
Blue: { r: 50, g: 90, b: 200 },
Green: { r: 40, g: 150, b: 70 },
Black: { r: 30, g: 30, b: 30 },
White: { r: 230, g: 230, b: 230 },
Grey: { r: 120, g: 120, b: 120 },
Navy: { r: 20, g: 40, b: 90 },
Beige: { r: 210, g: 190, b: 150 },
};
/** Flickr-friendly tags for catalog subjects (category / product type names). */
const KEYWORD_ALIASES = {
'Home & Living': 'livingroom,interior,home',
Furniture: 'furniture,home',
Seating: 'seating,chair',
Armchairs: 'armchair,chair',
Sofas: 'sofa,couch',
Stools: 'stool,chair',
Tables: 'table,furniture',
'Coffee Tables': 'coffee,table',
'Side Tables': 'side,table',
'Dining Tables': 'dining,table',
Storage: 'storage,cabinet',
Shelves: 'shelf,bookshelf',
Cabinets: 'cabinet,cupboard',
Decor: 'decor,interior',
Lighting: 'lamp,lighting',
'Floor Lamps': 'floor,lamp',
'Table Lamps': 'table,lamp',
Pendants: 'pendant,lamp',
Textiles: 'textile,fabric',
Cushions: 'cushion,pillow',
Throws: 'blanket,throw',
Rugs: 'rug,carpet',
WallArt: 'wallart,painting',
Prints: 'poster,print',
Mirrors: 'mirror,reflection',
Office: 'office,workspace',
Desks: 'desk,office',
Standing: 'standing,desk',
'Electric Desks': 'standing,desk',
'Manual Desks': 'desk,office',
Sitting: 'desk,office',
'Compact Desks': 'desk,workspace',
'Executive Desks': 'desk,office',
Chairs: 'office,chair',
Ergonomic: 'ergonomic,chair',
'Mesh Chairs': 'office,chair',
'Leather Chairs': 'leather,chair',
Guest: 'guest,chair',
'Stacking Chairs': 'stacking,chair',
'Visitor Chairs': 'office,chair',
Supplies: 'office,supplies',
Paper: 'paper,stationery',
'A4 Paper': 'paper,stack',
Notebooks: 'notebook,journal',
Writing: 'pen,writing',
Pens: 'pen,fountain',
Markers: 'marker,pen',
Organizers: 'desk,organizer',
Trays: 'tray,desk',
'File Boxes': 'archive,box',
Outdoor: 'outdoor,garden',
Garden: 'garden,outdoors',
Tools: 'garden,tools',
'Hand Tools': 'hand,tools',
'Power Tools': 'power,tools',
Planters: 'planter,pot',
'Ceramic Pots': 'ceramic,pot',
'Hanging Baskets': 'hanging,basket',
Benches: 'bench,park',
Loungers: 'lounger,sunbed',
Sports: 'sports,fitness',
Fitness: 'fitness,gym',
Weights: 'dumbbell,weights',
Mats: 'yoga,mat',
Bands: 'resistance,band',
Recreation: 'recreation,sport',
Balls: 'ball,sport',
Rackets: 'tennis,racket',
Kitchen: 'kitchen,cooking',
Cookware: 'cookware,kitchen',
Pots: 'cooking,pot',
'Sauce Pans': 'saucepan,pot',
'Stock Pots': 'stockpot,pot',
Pans: 'frying,pan',
'Frying Pans': 'frying,pan',
Woks: 'wok,pan',
Bakeware: 'bakeware,baking',
'Baking Trays': 'baking,tray',
'Cake Tins': 'cake,tin',
Tableware: 'tableware,dishes',
Plates: 'plate,dish',
'Dinner Plates': 'dinner,plate',
'Side Plates': 'plate,dish',
Drinkware: 'drinkware,cup',
Mugs: 'mug,coffee',
Glasses: 'glass,drink',
Cutlery: 'cutlery,silverware',
'Fork Sets': 'fork,cutlery',
'Knife Sets': 'knife,cutlery',
Food: 'food,grocery',
DryGoods: 'grocery,food',
Pasta: 'pasta,noodles',
Rice: 'rice,grain',
Beans: 'beans,legume',
Beverages: 'beverage,drink',
Coffee: 'coffee,beans',
Tea: 'tea,cup',
Juice: 'juice,orange',
Snacks: 'snack,food',
Nuts: 'nuts,almond',
Bars: 'granola,bar',
};
const PRODUCT_STEMS = [
'Classic',
'Premium',
'Essential',
'Urban',
'Nordic',
'Studio',
'Heritage',
'Compact',
'Pro',
'Lite',
'Max',
'Basic',
];
const STEM_RE = new RegExp(`^(${PRODUCT_STEMS.join('|')})\\s+`, 'i');
/** Department → groups → subgroups → leaves (34 levels). */
const TREE = {
'Home & Living': {
Furniture: {
Seating: ['Armchairs', 'Sofas', 'Stools'],
Tables: ['Coffee Tables', 'Side Tables', 'Dining Tables'],
Storage: ['Shelves', 'Cabinets'],
},
Decor: {
Lighting: ['Floor Lamps', 'Table Lamps', 'Pendants'],
Textiles: ['Cushions', 'Throws', 'Rugs'],
WallArt: ['Prints', 'Mirrors'],
},
},
Office: {
Desks: {
Standing: ['Electric Desks', 'Manual Desks'],
Sitting: ['Compact Desks', 'Executive Desks'],
},
Chairs: {
Ergonomic: ['Mesh Chairs', 'Leather Chairs'],
Guest: ['Stacking Chairs', 'Visitor Chairs'],
},
Supplies: {
Paper: ['A4 Paper', 'Notebooks'],
Writing: ['Pens', 'Markers'],
Organizers: ['Trays', 'File Boxes'],
},
},
Outdoor: {
Garden: {
Tools: ['Hand Tools', 'Power Tools'],
Planters: ['Ceramic Pots', 'Hanging Baskets'],
Furniture: ['Benches', 'Loungers'],
},
Sports: {
Fitness: ['Weights', 'Mats', 'Bands'],
Recreation: ['Balls', 'Rackets'],
},
},
Kitchen: {
Cookware: {
Pots: ['Sauce Pans', 'Stock Pots'],
Pans: ['Frying Pans', 'Woks'],
Bakeware: ['Baking Trays', 'Cake Tins'],
},
Tableware: {
Plates: ['Dinner Plates', 'Side Plates'],
Drinkware: ['Mugs', 'Glasses'],
Cutlery: ['Fork Sets', 'Knife Sets'],
},
Food: {
DryGoods: ['Pasta', 'Rice', 'Beans'],
Beverages: ['Coffee', 'Tea', 'Juice'],
Snacks: ['Nuts', 'Bars'],
},
},
};
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function hashBuffer(buf) {
return crypto.createHash('sha256').update(buf).digest('hex');
}
function padSku(n) {
return `DEMO-${String(n).padStart(5, '0')}`;
}
function barcodeFor(n) {
return `200${String(n).padStart(10, '0')}`;
}
function formatDateTime(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function grossPrice(net, taxRate) {
return (Number(net) * (1 + Number(taxRate) / 100)).toFixed(2);
}
function pick(arr, i) {
return arr[i % arr.length];
}
function variantCombos(axes) {
const keys = Object.keys(axes);
if (keys.length === 0) {
return [{}];
}
let combos = [{}];
for (const key of keys) {
const next = [];
for (const base of combos) {
for (const value of axes[key]) {
next.push({ ...base, [key]: value });
}
}
combos = next;
}
return combos;
}
function formatVariants(combo) {
return Object.entries(combo)
.map(([k, v]) => `${k}: ${v}`)
.join(' | ');
}
async function mapPool(items, concurrency, fn) {
const results = new Array(items.length);
let index = 0;
async function worker() {
while (index < items.length) {
const i = index++;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
return results;
}
function subjectFromProductName(name) {
return String(name || '')
.replace(/\s*\([^)]*\)\s*$/, '')
.replace(STEM_RE, '')
.replace(/\s+Featured\s+\d+$/i, '')
.replace(/\s+Item$/i, '')
.replace(/\s+Starter Kit\s+\d+$/i, '')
.replace(/\s+Kit$/i, '')
.trim();
}
function keywordsForSubject(subject) {
if (!subject) {
return 'product';
}
if (KEYWORD_ALIASES[subject]) {
return KEYWORD_ALIASES[subject];
}
const cleaned = subjectFromProductName(subject);
if (KEYWORD_ALIASES[cleaned]) {
return KEYWORD_ALIASES[cleaned];
}
const tags = cleaned
.replace(/&/g, ' ')
.split(/[\s/_-]+/)
.map((w) => w.toLowerCase().replace(/[^a-z0-9]/g, ''))
.filter((w) => w.length > 2 && !['the', 'and', 'set', 'sets'].includes(w))
.slice(0, 3);
return tags.length > 0 ? tags.join(',') : 'product';
}
function lockFromSeed(seed) {
const hex = crypto.createHash('sha1').update(String(seed)).digest('hex').slice(0, 8);
return Number.parseInt(hex, 16) % 1_000_000_000 || 1;
}
async function fetchBuffer(url, retries = 4) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetch(url, {
redirect: 'follow',
headers: { 'User-Agent': 'jtlsrv-demo-catalog/1.0' },
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length < 1000) {
throw new Error('image too small');
}
await sharp(buf).metadata();
return buf;
} catch (err) {
if (attempt === retries - 1) {
throw err;
}
await sleep(250 * (attempt + 1));
}
}
throw new Error('unreachable');
}
const rawImageCache = new Map();
async function fetchMatchingImage(keywords, lock) {
const cacheKey = `${keywords}|${lock}`;
if (rawImageCache.has(cacheKey)) {
return rawImageCache.get(cacheKey);
}
const pathTags = String(keywords)
.split(',')
.map((tag) => encodeURIComponent(tag.trim()))
.filter(Boolean)
.join(',');
try {
const flickrUrl = `https://loremflickr.com/${IMAGE_SIZE}/${IMAGE_SIZE}/${pathTags}?lock=${lock}`;
const buf = await fetchBuffer(flickrUrl);
rawImageCache.set(cacheKey, buf);
return buf;
} catch (err) {
console.warn(` loremflickr miss (${keywords}): ${err.message}; falling back to picsum`);
}
const picsumUrl = `https://picsum.photos/seed/${encodeURIComponent(`${keywords}-${lock}`)}/${IMAGE_SIZE}/${IMAGE_SIZE}.jpg`;
const buf = await fetchBuffer(picsumUrl);
rawImageCache.set(cacheKey, buf);
return buf;
}
async function processImage(raw, { tintKey = null, label = null } = {}) {
let pipeline = sharp(raw).resize(IMAGE_SIZE, IMAGE_SIZE, { fit: 'cover' });
if (tintKey && TINTS[tintKey]) {
const { r, g, b } = TINTS[tintKey];
const overlay = await sharp({
create: {
width: IMAGE_SIZE,
height: IMAGE_SIZE,
channels: 4,
background: { r, g, b, alpha: 0.28 },
},
})
.png()
.toBuffer();
pipeline = sharp(await pipeline.toBuffer()).composite([{ input: overlay, blend: 'over' }]);
}
if (label) {
const safe = String(label)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.slice(0, 40);
const svg = Buffer.from(`
<svg width="${IMAGE_SIZE}" height="${IMAGE_SIZE}">
<rect x="0" y="${IMAGE_SIZE - 48}" width="${IMAGE_SIZE}" height="48" fill="rgba(0,0,0,0.45)"/>
<text x="16" y="${IMAGE_SIZE - 18}" font-family="sans-serif" font-size="22" fill="white">${safe}</text>
</svg>
`);
pipeline = sharp(await pipeline.toBuffer()).composite([{ input: svg, blend: 'over' }]);
}
return pipeline.jpeg({ quality: JPEG_QUALITY, mozjpeg: true }).toBuffer();
}
async function saveImage(jpegBuffer) {
const hash = hashBuffer(jpegBuffer);
const filePath = path.join(IMAGES_DIR, `${hash}.jpg`);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, jpegBuffer);
}
return hash;
}
function buildCategoryTree() {
const categories = [];
let nextId = 1;
let sort = 0;
let lastChanged = 1;
function add(name, pid, depth) {
const id = nextId++;
categories.push({
_id: String(id),
name,
pid: pid === null ? '0' : String(pid),
sort: String(++sort),
lastChanged: String(lastChanged++),
imghash: null,
imgsrc: null,
discounts: [],
depth,
});
return id;
}
for (const [dept, groups] of Object.entries(TREE)) {
const deptId = add(dept, null, 1);
for (const [group, subgroups] of Object.entries(groups)) {
const groupId = add(group, deptId, 2);
for (const [subgroup, leaves] of Object.entries(subgroups)) {
const subgroupId = add(subgroup, groupId, 3);
for (const leaf of leaves) {
add(leaf, subgroupId, 4);
}
}
}
}
return categories;
}
function chooseAxes(leafName, familyIndex) {
const foodish = /Pasta|Rice|Beans|Coffee|Tea|Juice|Nuts|Bars|Paper|Notebooks/.test(leafName);
const furnitureish = /Chair|Sofa|Table|Desk|Shelf|Cabinet|Bench|Lounger|Lamp|Armchair|Stool/.test(leafName);
const mode = familyIndex % 5;
if (foodish || mode === 0) {
return { Size: PACKAGE_SIZES.slice(0, 4 + (familyIndex % 3)) };
}
if (furnitureish || mode === 1) {
return {
Color: COLORS.slice(0, 3 + (familyIndex % 3)),
Material: MATERIALS.slice(0, 2 + (familyIndex % 2)),
};
}
if (mode === 2) {
return { Color: COLORS.slice(0, 4 + (familyIndex % 3)) };
}
if (mode === 3) {
return { Material: MATERIALS.slice(0, 3 + (familyIndex % 3)) };
}
return {
Color: COLORS.slice(0, 2 + (familyIndex % 2)),
Size: PACKAGE_SIZES.slice(0, 2 + (familyIndex % 2)),
};
}
const MIN_PRODUCTS_PER_CATEGORY = 5;
function buildProducts(categories, customerGroupIds) {
const products = [];
const composites = [];
let nextId = 1;
let lastChanged = 1;
let familyIndex = 0;
const createdAt = formatDateTime(new Date('2024-01-15T10:00:00Z'));
const taxRate = 19;
const leafCategories = categories.filter((c) => c.depth === 4);
function pricesFor(net) {
const base = grossPrice(net, taxRate);
const wholesaleNet = Number(net) * 0.85;
return customerGroupIds.map((customerGroupId, i) => ({
customerGroupId: String(customerGroupId),
customerId: '0',
price: i === 0 ? base : grossPrice(wholesaleNet, taxRate),
quantity: '0',
}));
}
function pushProduct(partial) {
const id = nextId++;
const net = partial.netPrice ?? 9.99 + (id % 80) * 1.25;
const product = {
_id: String(id),
sku: padSku(id),
barcode: barcodeFor(id),
name: partial.name,
tax_rate: String(taxRate),
price: grossPrice(net, taxRate),
created_at: createdAt,
lastChanged: String(lastChanged++),
categories_id: partial.categoryId,
categories: [{ categoryId: partial.categoryId }],
prices: pricesFor(net),
is_parent: partial.is_parent ?? '0',
parent: partial.parent ?? '0',
variants: partial.variants ?? '',
isCompositeProduct: partial.isCompositeProduct ?? '0',
attributes: [],
imghash: null,
imgsrc: null,
_tintKey: partial.tintKey ?? null,
_label: partial.label ?? null,
_seed: partial.seed,
};
products.push(product);
return product;
}
function countByCategory() {
const counts = new Map();
for (const product of products) {
const id = product.categories_id;
counts.set(id, (counts.get(id) || 0) + 1);
}
return counts;
}
// Variant families on leaf categories (majority of catalog)
for (const leaf of leafCategories) {
const familiesPerLeaf = 2 + (Number(leaf._id) % 3); // 24 families
for (let f = 0; f < familiesPerLeaf; f++) {
familyIndex++;
const stem = pick(PRODUCT_STEMS, familyIndex);
const baseName = `${stem} ${leaf.name}`;
const axes = chooseAxes(leaf.name, familyIndex);
const combos = variantCombos(axes).slice(0, 8);
const parent = pushProduct({
name: baseName,
categoryId: leaf._id,
is_parent: '1',
parent: '0',
variants: '',
seed: `parent-${leaf._id}-${f}`,
netPrice: 15 + (familyIndex % 40),
});
for (let c = 0; c < combos.length; c++) {
const combo = combos[c];
const tintKey = combo.Color || null;
pushProduct({
name: `${baseName} (${formatVariants(combo)})`,
categoryId: leaf._id,
is_parent: '0',
parent: parent._id,
variants: formatVariants(combo),
tintKey,
label: formatVariants(combo),
seed: `var-${leaf._id}-${f}-${c}`,
netPrice: 15 + (familyIndex % 40) + c * 0.5,
});
}
if (products.length >= TARGET_PRODUCTS) {
break;
}
}
if (products.length >= TARGET_PRODUCTS) {
break;
}
}
// Dedicated simple kit products for BOM (not variant parents/children)
const kitLeaf = leafCategories[0];
for (let i = 0; i < 8; i++) {
const kit = pushProduct({
name: `${pick(PRODUCT_STEMS, i + 7)} Starter Kit ${i + 1}`,
categoryId: kitLeaf._id,
isCompositeProduct: '1',
seed: `kit-${i}`,
netPrice: 49 + i * 5,
});
const components = products
.filter((p) => p.is_parent === '0' && p.parent !== '0' && p._id !== kit._id)
.slice(i * 3, i * 3 + 3);
for (const comp of components) {
composites.push({
productId: kit._id,
productIdComponent: comp._id,
quantity: (1 + (i % 3)).toFixed(2),
lastChanged: kit.lastChanged,
});
}
}
// Fill remaining with simple products across leaves
let simpleIndex = 0;
while (products.length < TARGET_PRODUCTS) {
const leaf = leafCategories[simpleIndex % leafCategories.length];
const stem = pick(PRODUCT_STEMS, simpleIndex + 3);
pushProduct({
name: `${stem} ${leaf.name} Item`,
categoryId: leaf._id,
seed: `simple-${leaf._id}-${simpleIndex}`,
netPrice: 4.5 + (simpleIndex % 50),
});
simpleIndex++;
}
// Every category (including top-level / intermediate) gets ≥5 products
let ensureIndex = 0;
for (const category of categories) {
const counts = countByCategory();
const have = counts.get(category._id) || 0;
for (let i = have; i < MIN_PRODUCTS_PER_CATEGORY; i++) {
const stem = pick(PRODUCT_STEMS, ensureIndex + i);
pushProduct({
name: `${stem} ${category.name} Featured ${i + 1}`,
categoryId: category._id,
seed: `ensure-${category._id}-${i}`,
netPrice: 8 + ((ensureIndex + i) % 40),
});
}
ensureIndex++;
}
return { products, composites };
}
async function main() {
console.log('Generating demo catalog…');
fs.mkdirSync(IMAGES_DIR, { recursive: true });
// Clear previous images
for (const file of fs.readdirSync(IMAGES_DIR)) {
fs.unlinkSync(path.join(IMAGES_DIR, file));
}
const categories = buildCategoryTree();
const leafCategories = categories.filter((c) => c.depth === 4);
console.log(`Categories: ${categories.length} (leaves: ${leafCategories.length})`);
const customerGroups = [
{
customerGroupId: '1',
name: 'Standard',
standard: '1',
discountPercent: '0.00',
lastChanged: '1',
},
{
customerGroupId: '2',
name: 'Wholesale',
standard: '0',
discountPercent: '10.00',
lastChanged: '2',
},
];
const { products, composites } = buildProducts(
categories,
customerGroups.map((g) => Number(g.customerGroupId))
);
const perCategory = new Map();
for (const product of products) {
perCategory.set(product.categories_id, (perCategory.get(product.categories_id) || 0) + 1);
}
const minPerCategory = Math.min(...categories.map((c) => perCategory.get(c._id) || 0));
console.log(
`Products: ${products.length} (composites links: ${composites.length}, min per category: ${minPerCategory})`
);
const categoryById = new Map(categories.map((c) => [c._id, c]));
const imageJobs = [
...categories.map((c) => ({
kind: 'category',
ref: c,
keywords: keywordsForSubject(c.name),
lock: lockFromSeed(`cat-${c._id}`),
tintKey: null,
label: c.name,
})),
...products.map((p) => {
const category = categoryById.get(p.categories_id);
const subject =
p.isCompositeProduct === '1'
? 'gift basket'
: category?.name || subjectFromProductName(p.name);
const lockSeed = p.parent !== '0' ? `parent-${p.parent}` : `product-${p._id}`;
return {
kind: 'product',
ref: p,
keywords: keywordsForSubject(subject),
lock: lockFromSeed(lockSeed),
tintKey: p._tintKey,
label: p.is_parent === '1' ? p.name : p._label,
};
}),
];
console.log(`Downloading / processing ${imageJobs.length} images (keyword-matched via LoremFlickr)…`);
let done = 0;
await mapPool(imageJobs, DOWNLOAD_CONCURRENCY, async (job) => {
const raw = await fetchMatchingImage(job.keywords, job.lock);
const jpeg = await processImage(raw, { tintKey: job.tintKey, label: job.label });
const hash = await saveImage(jpeg);
job.ref.imghash = hash;
job.ref.imgsrc = hash;
done++;
if (done % 50 === 0 || done === imageJobs.length) {
console.log(` images ${done}/${imageJobs.length} (unique fetches: ${rawImageCache.size})`);
}
});
// Strip generator-only fields
for (const p of products) {
delete p._tintKey;
delete p._label;
delete p._seed;
}
for (const c of categories) {
delete c.depth;
}
const deletedEntities = [
{
entityId: '999001',
entityType: '1',
lastChanged: '1',
},
];
const catalog = {
version: 1,
generatedAt: new Date().toISOString(),
customerGroups,
categories,
products,
composites,
deletedEntities,
maxOrderId: 0,
};
const catalogPath = path.join(DEMO_DIR, 'catalog.json');
fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2));
const imageCount = fs.readdirSync(IMAGES_DIR).length;
console.log(`Wrote ${catalogPath}`);
console.log(`Images on disk: ${imageCount}`);
console.log('Done.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -16,9 +16,12 @@ First run installs the CA into the `mssql` Docker container (`/var/opt/mssql/sec
|---------|-------------|
| `npm run backup:s3` | Start S3 endpoint + backup `MSSQL_DATABASE` from `.env` |
| `npm run backup:s3:quick` | Same, but skip PAL CA install/restart (after first setup) |
| `npm run restore:s3` | Start S3 endpoint + restore latest `.bak` for `MSSQL_DATABASE` |
| `npm run restore:s3:quick` | Same, but skip PAL CA install/restart (after first setup) |
| `node scripts/s3-backup/backup.mjs --all` | Backup `eazybusiness` and `Mandant_3` |
| `node scripts/s3-backup/backup.mjs --server-only` | Run endpoint only |
| `npm run backup:s3 -- --skip-trust` | Skip CA install (npm needs `--` before script args) |
| `npm run restore:s3:quick -- <file.bak>` | Restore a specific backup file |
## Layout
@@ -26,6 +29,7 @@ First run installs the CA into the `mssql` Docker container (`/var/opt/mssql/sec
scripts/s3-backup/
server.mjs S3-compatible HTTPS server (SigV4, multipart upload)
backup.mjs Orchestrator: trust CA → start server → sqlcmd BACKUP
restore.mjs Orchestrator: trust CA → start server → sqlcmd RESTORE
config.mjs Host, port, credentials
ensure-certs.mjs TLS certs + Docker MSSQL PAL trust
sigv4.mjs AWS Signature V4 verification

View File

@@ -0,0 +1,335 @@
#!/usr/bin/env node
import { fork, spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import {
ACCESS_KEY,
BUCKET,
DATA_DIR,
HOST,
PORT,
SECRET_KEY,
SERVER_CERT,
s3BaseUrl,
} from './config.mjs';
import { caTrustStatus, ensureCerts, installCaTrust } from './ensure-certs.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
dotenv.config({ path: path.join(root, '.env') });
const argv = process.argv.slice(2);
const args = new Set(argv.filter((a) => a.startsWith('--')));
const positional = argv.filter((a) => !a.startsWith('--'));
const serverOnly = args.has('--server-only');
const skipTrust = args.has('--skip-trust');
const useLatest = args.has('--latest');
const replace = !args.has('--no-replace');
const databaseArg = argv.find((a, i) => argv[i - 1] === '--database');
const defaultDb = process.env.MSSQL_DATABASE || 'eazybusiness';
const database = databaseArg || defaultDb;
function sqlcmd(query) {
const server = process.env.MSSQL_SERVER || 'localhost';
const port = process.env.MSSQL_PORT || '1433';
const user = process.env.MSSQL_USER || 'sa';
const password = process.env.MSSQL_PASSWORD || '';
return new Promise((resolve, reject) => {
const child = spawn(
'sqlcmd',
['-W', '-C', '-S', `${server},${port}`, '-U', user, '-P', password, '-Q', query],
{ encoding: 'utf8' }
);
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk) => {
stdout += chunk;
process.stdout.write(chunk);
});
child.stderr?.on('data', (chunk) => {
stderr += chunk;
process.stderr.write(chunk);
});
child.on('close', (code) => {
const output = `${stdout}${stderr}`;
if (code !== 0 || /^\s*Msg \d+,/m.test(output)) {
reject(new Error(output.trim() || 'sqlcmd failed'));
} else {
resolve(stdout);
}
});
child.on('error', reject);
});
}
function listBackups(prefix) {
if (!fs.existsSync(DATA_DIR)) return [];
return fs
.readdirSync(DATA_DIR)
.filter((name) => name.endsWith('.bak') && name.startsWith(`${prefix}-`))
.map((name) => ({
name,
path: path.join(DATA_DIR, name),
mtime: fs.statSync(path.join(DATA_DIR, name)).mtimeMs,
}))
.sort((a, b) => b.mtime - a.mtime);
}
function resolveBackupFile() {
if (positional.length > 1) {
throw new Error(`Expected at most one backup file, got: ${positional.join(', ')}`);
}
if (positional.length === 1) {
const input = positional[0];
if (path.isAbsolute(input) || input.includes('/')) {
const resolved = path.resolve(input);
if (!fs.existsSync(resolved)) {
throw new Error(`Backup file not found: ${resolved}`);
}
const base = path.basename(resolved);
const target = path.join(DATA_DIR, base);
if (resolved !== target) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.copyFileSync(resolved, target);
console.log(`Copied ${resolved} -> ${target}`);
}
return base;
}
const onDisk = path.join(DATA_DIR, input);
if (!fs.existsSync(onDisk)) {
throw new Error(`Backup file not found: ${onDisk}`);
}
return input;
}
if (useLatest || positional.length === 0) {
const matches = listBackups(database);
if (matches.length === 0) {
throw new Error(`No backups found for ${database} in ${DATA_DIR}`);
}
console.log(`Using latest backup: ${matches[0].name}`);
return matches[0].name;
}
throw new Error('Specify a backup file or pass --latest');
}
function databaseFromBackup(file) {
const match = path.basename(file).match(/^(.+)-\d{4}-\d{2}-\d{2}T/);
return match ? match[1] : database;
}
async function isServerUp() {
const probeHost = /^\d+\./.test(HOST) ? '127.0.0.1' : HOST;
return new Promise((resolve) => {
const opts = {
host: probeHost,
port: PORT,
path: '/',
method: 'GET',
rejectUnauthorized: false,
};
if (!/^\d+\./.test(HOST)) {
opts.servername = HOST;
}
const req = https.request(opts, (res) => {
res.resume();
resolve(res.statusCode === 403 || res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.setTimeout(1000, () => {
req.destroy();
resolve(false);
});
req.end();
});
}
async function waitForServer() {
for (let i = 0; i < 40; i++) {
if (await isServerUp()) return;
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(`S3 endpoint did not start on https://${HOST}:${PORT}`);
}
function pidOnPort(port) {
const result = spawnSync('ss', ['-tlnp'], { encoding: 'utf8' });
const match = result.stdout?.match(new RegExp(`:${port}\\s+.*?pid=(\\d+)`));
return match ? Number(match[1]) : null;
}
async function stopPortListener(port) {
const stale = pidOnPort(port);
if (!stale) return;
try {
process.kill(stale);
} catch {
spawnSync('fuser', ['-k', `${port}/tcp`], { stdio: 'pipe' });
}
await new Promise((r) => setTimeout(r, 200));
}
async function ensureServerProcess() {
await stopPortListener(PORT);
return startServerProcess();
}
function ensureCredential() {
const cred = s3BaseUrl();
return sqlcmd(`
IF NOT EXISTS (SELECT 1 FROM sys.credentials WHERE name = N'${cred}')
BEGIN
CREATE CREDENTIAL [${cred}]
WITH IDENTITY = 'S3 Access Key',
SECRET = '${ACCESS_KEY}:${SECRET_KEY}';
END
`);
}
async function restoreDatabase(db, file) {
const url = `${s3BaseUrl()}/${file}`;
const onDisk = path.join(DATA_DIR, file);
if (!fs.existsSync(onDisk)) {
throw new Error(`Backup file missing on disk: ${onDisk}`);
}
const mb = (fs.statSync(onDisk).size / (1024 * 1024)).toFixed(1);
console.log(`Restoring ${db} <- ${url} (${mb} MB)`);
const replaceClause = replace ? ', REPLACE' : '';
await sqlcmd(`
IF DB_ID(N'${db}') IS NOT NULL
BEGIN
ALTER DATABASE [${db}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
END
RESTORE DATABASE [${db}]
FROM URL = '${url}'
WITH STATS = 10, MAXTRANSFERSIZE = 20971520${replaceClause};
ALTER DATABASE [${db}] SET MULTI_USER;
`);
}
function waitForSql() {
for (let i = 0; i < 60; i++) {
const result = spawnSync(
'sqlcmd',
[
'-W',
'-C',
'-S',
`${process.env.MSSQL_SERVER || 'localhost'},${process.env.MSSQL_PORT || '1433'}`,
'-U',
process.env.MSSQL_USER || 'sa',
'-P',
process.env.MSSQL_PASSWORD || '',
'-Q',
'SELECT 1',
],
{ encoding: 'utf8' }
);
if (result.status === 0 && !/Msg \d+,/.test(result.stdout || '')) {
return;
}
spawnSync('sleep', ['2']);
}
throw new Error('MSSQL did not become ready');
}
function startServerProcess() {
const child = fork(new URL('./server.mjs', import.meta.url), {
env: { ...process.env, S3_BACKUP_CHILD: '1' },
stdio: 'inherit',
});
return child;
}
async function main() {
if (skipTrust) {
const status = caTrustStatus();
if (!status.inSync) {
throw new Error(
'PAL CA is out of sync with scripts/s3-backup/certs/ca.pem. Run: npm run backup:s3'
);
}
console.log('Skipping PAL CA install (--skip-trust)');
if (!fs.existsSync(SERVER_CERT)) {
throw new Error('No TLS certs found. Run: npm run backup:s3');
}
} else {
ensureCerts(HOST);
const trust = installCaTrust();
if (!trust.ok) {
console.warn('Could not install CA into MSSQL container. Run:');
console.warn(' docker exec -u root mssql mkdir -p /var/opt/mssql/security/ca-certificates');
console.warn(' docker cp scripts/s3-backup/certs/ca.pem mssql:/var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem');
console.warn(' docker exec -u root mssql chown mssql:mssql /var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem');
console.warn(' docker restart mssql');
} else {
console.log('Installed S3 CA into MSSQL PAL trust store');
if (trust.restarted) {
console.log('Waiting for MSSQL to restart...');
waitForSql();
}
}
}
const serverChild = await ensureServerProcess();
try {
await waitForServer();
if (serverOnly) {
console.log('Server running (--server-only). Ctrl+C to stop.');
await new Promise((resolve) => serverChild.on('exit', resolve));
return;
}
const file = resolveBackupFile();
const db = databaseArg || databaseFromBackup(file);
await ensureCredential();
await restoreDatabase(db, file);
console.log(`\nRestored ${db} from ${file}`);
} finally {
if (!serverOnly) {
serverChild.kill();
await stopPortListener(PORT);
}
}
}
if (args.has('--help')) {
console.log(`Usage: node scripts/s3-backup/restore.mjs [file.bak] [options]
Starts the local S3-compatible HTTPS endpoint and restores MSSQL from a .bak on disk.
Arguments:
file.bak Backup filename in data/${BUCKET}/, or a path to copy from
Options:
--latest Use newest backup matching --database (default if no file given)
--database <name> Target database (default: MSSQL_DATABASE or name parsed from file)
--no-replace Do not pass REPLACE to RESTORE
--server-only Start endpoint only, no restore
--skip-trust Skip installing CA cert into system trust store
Examples:
npm run restore:s3:quick
npm run restore:s3:quick -- eazybusiness-2026-07-23T19-02-44-903Z.bak
npm run restore:s3:quick -- --database eazybusiness --latest
Reads MSSQL_* from .env in repo root.
Backups are read from scripts/s3-backup/data/${BUCKET}/
`);
process.exit(0);
}
main().catch((err) => {
console.error(err.message || err);
process.exit(1);
});

View File

@@ -214,13 +214,36 @@ async function handle(req, res) {
if (!fs.existsSync(file)) {
return send(res, 404, xml('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
}
const data = fs.readFileSync(file);
const stat = fs.statSync(file);
const etag = etagFor(fs.readFileSync(file));
const range = req.headers.range;
if (range) {
const match = /^bytes=(\d+)-(\d*)$/i.exec(range);
if (match) {
const start = Number(match[1]);
const end = match[2] ? Number(match[2]) : stat.size - 1;
if (start >= stat.size || end < start) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
return res.end();
}
const length = end - start + 1;
res.writeHead(206, {
'Content-Type': 'application/octet-stream',
'Content-Length': length,
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Accept-Ranges': 'bytes',
ETag: etag,
});
return fs.createReadStream(file, { start, end }).pipe(res);
}
}
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': data.length,
ETag: etagFor(data),
'Content-Length': stat.size,
'Accept-Ranges': 'bytes',
ETag: etag,
});
return res.end(data);
return fs.createReadStream(file).pipe(res);
}
if (req.method === 'HEAD' && key) {
@@ -231,6 +254,7 @@ async function handle(req, res) {
const stat = fs.statSync(file);
return send(res, 200, '', {
'Content-Length': stat.size,
'Accept-Ranges': 'bytes',
ETag: etagFor(fs.readFileSync(file)),
});
}

View File

@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url';
import 'dotenv/config';
import { connectDb, closeDb } from './src/db.js';
import { isDemoMode } from './src/demo/mode.js';
import { loadDemoCatalog } from './src/demo/store.js';
import { createJtlPosServer } from './src/jtl-server.js';
import { createPairingStore } from './src/pairing.js';
import { closeOrderLog } from './src/order-log.js';
@@ -113,6 +115,13 @@ const httpsServer = https.createServer(
);
async function start() {
if (isDemoMode()) {
const stats = await loadDemoCatalog();
logger.success(
`DEMO_MODE: loaded catalog (${stats.products} products, ${stats.categories} categories, ${stats.customerGroups} customer groups, ${stats.composites} composite links)`
);
logger.info('MSSQL is skipped while DEMO_MODE=true');
} else {
try {
const pool = await connectDb();
logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`);
@@ -122,6 +131,7 @@ async function start() {
logger.warn(`MSSQL connection skipped: ${err.message}`);
logger.warn('POS handshake will still work; sync from database is not available yet.');
}
}
httpsServer.listen(PORT, '0.0.0.0', () => {
logger.success(`HTTPS POS server listening on https://0.0.0.0:${PORT}`);

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCategoryCount } from '../demo/store.js';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
@@ -12,6 +14,10 @@ WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree)
`;
export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
if (isDemoMode()) {
return getDemoCategoryCount({ cursor });
}
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.query(CATEGORY_COUNT_SQL);

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCategoryList } from '../demo/store.js';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
@@ -26,6 +28,10 @@ ORDER BY lastChanged ASC;
`;
export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) {
if (isDemoMode()) {
return getDemoCategoryList({ cursor, limit });
}
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.input('limit', sql.Int, limit)

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCompositeProductCount } from '../demo/store.js';
import { getPool } from '../db.js';
import { getActiveShopId } from '../shop.js';
@@ -16,6 +18,10 @@ WHERE a.kStueckliste <> 0
`;
export async function getCompositeProductCount({ cursor = 0 } = {}) {
if (isDemoMode()) {
return getDemoCompositeProductCount({ cursor });
}
const result = await getPool()
.request()
.input('cursor', sql.BigInt, cursor)

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCompositeProductList } from '../demo/store.js';
import { getPool } from '../db.js';
import { getActiveShopId } from '../shop.js';
@@ -21,6 +23,10 @@ ORDER BY lastChanged ASC;
`;
export async function getCompositeProductList({ cursor = 0, limit = 100 } = {}) {
if (isDemoMode()) {
return getDemoCompositeProductList({ cursor, limit });
}
const result = await getPool()
.request()
.input('cursor', sql.BigInt, cursor)

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { createDemoOrder } from '../demo/store.js';
import { getPool } from '../db.js';
import { getActiveShopId, getActiveShopSubshopId } from '../shop.js';
import { deliverOrder } from './delivery/index.js';
@@ -653,6 +655,10 @@ async function insertPayment(transaction, kAuftrag, payment, order, orderDate, z
}
export async function createOrder(order) {
if (isDemoMode()) {
return createDemoOrder(order);
}
const kPosAuftrag = Number.parseInt(order.externalId, 10);
const externalOrderNumber = order.externalOrderNumber || '';
if (Number.isInteger(kPosAuftrag) && kPosAuftrag > 0) {

View File

@@ -1,4 +1,10 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import {
getDemoCustomerGroupCount,
getDemoCustomerGroupIds,
getDemoCustomerGroupList,
} from '../demo/store.js';
import { getPool } from '../db.js';
const CUSTOMER_GROUP_IDS_SQL = `
@@ -26,11 +32,19 @@ WHERE CONVERT(BIGINT, bRowversion) > @cursor;
`;
export async function getCustomerGroupIds() {
if (isDemoMode()) {
return getDemoCustomerGroupIds();
}
const result = await getPool().request().query(CUSTOMER_GROUP_IDS_SQL);
return result.recordset.map((row) => row.kKundenGruppe);
}
export async function getCustomerGroupList({ cursor = 0 } = {}) {
if (isDemoMode()) {
return getDemoCustomerGroupList({ cursor });
}
const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_LIST_SQL);
return result.recordset.map((row) => ({
@@ -43,6 +57,10 @@ export async function getCustomerGroupList({ cursor = 0 } = {}) {
}
export async function getCustomerGroupCount({ cursor = 0 } = {}) {
if (isDemoMode()) {
return getDemoCustomerGroupCount({ cursor });
}
const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_COUNT_SQL);
return result.recordset[0]?.CustomerGroupCount ?? 0;
}

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoDeletedEntityCount } from '../demo/store.js';
import { getPool } from '../db.js';
const DELETED_ENTITY_COUNT_SQL = `
@@ -8,6 +10,10 @@ WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > @cursor;
`;
export async function getDeletedEntityCount({ cursor = 0 } = {}) {
if (isDemoMode()) {
return getDemoDeletedEntityCount({ cursor });
}
const result = await getPool()
.request()
.input('cursor', sql.BigInt, cursor)

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoDeletedEntityList } from '../demo/store.js';
import { getPool } from '../db.js';
const DELETED_ENTITY_LIST_SQL = `
@@ -12,6 +14,10 @@ ORDER BY lastChanged ASC;
`;
export async function getDeletedEntityList({ cursor = 0, limit = 600 } = {}) {
if (isDemoMode()) {
return getDemoDeletedEntityList({ cursor, limit });
}
const result = await getPool()
.request()
.input('cursor', sql.BigInt, cursor)

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoImageByHash } from '../demo/store.js';
import { getPool } from '../db.js';
const IMAGE_BY_HASH_SQL = `
@@ -24,6 +26,10 @@ function contentTypeFor(cQuelle) {
}
export async function getImageByHash(hash, size) {
if (isDemoMode()) {
return getDemoImageByHash(hash);
}
const result = await getPool().request().input('hash', sql.NVarChar, hash).query(IMAGE_BY_HASH_SQL);
const row = result.recordset[0];

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoMaxOrderIdCount } from '../demo/store.js';
import { getPool } from '../db.js';
import { getActiveShopSubshopId } from '../shop.js';
@@ -9,6 +11,10 @@ WHERE kShopSubShop = @kShopSubShop;
`;
export async function getMaxOrderIdCount() {
if (isDemoMode()) {
return getDemoMaxOrderIdCount();
}
const kShopSubShop = getActiveShopSubshopId();
if (!kShopSubShop) {
return 0;

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoProductCount } from '../demo/store.js';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
@@ -22,6 +24,10 @@ WHERE a.cAktiv = 'Y'
`;
export async function getProductCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
if (isDemoMode()) {
return getDemoProductCount({ cursor });
}
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.query(PRODUCT_COUNT_SQL);

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoProductList } from '../demo/store.js';
import { getPool } from '../db.js';
import { getCustomerGroupIds } from './customer-groups.js';
import { getProductAttributes } from './product-attributes.js';
@@ -93,6 +95,10 @@ function grossPrice(netPrice, taxRate) {
}
export async function getProductList({ cursor = 0, limit = 20 } = {}) {
if (isDemoMode()) {
return getDemoProductList({ cursor, limit });
}
const pool = getPool();
const [productResult, customerGroupIds] = await Promise.all([