@infuro/cms-core 1.0.15 → 1.0.16

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/api.cjs CHANGED
@@ -428,9 +428,11 @@ __export(api_exports, {
428
428
  createCrudByIdHandler: () => createCrudByIdHandler,
429
429
  createCrudHandler: () => createCrudHandler,
430
430
  createDashboardStatsHandler: () => createDashboardStatsHandler,
431
+ createEcommerceAnalyticsHandler: () => createEcommerceAnalyticsHandler,
431
432
  createForgotPasswordHandler: () => createForgotPasswordHandler,
432
433
  createFormBySlugHandler: () => createFormBySlugHandler,
433
434
  createInviteAcceptHandler: () => createInviteAcceptHandler,
435
+ createMediaZipExtractHandler: () => createMediaZipExtractHandler,
434
436
  createSetPasswordHandler: () => createSetPasswordHandler,
435
437
  createSettingsApiHandlers: () => createSettingsApiHandlers,
436
438
  createStorefrontApiHandler: () => createStorefrontApiHandler,
@@ -757,14 +759,38 @@ function createCrudHandler(dataSource, entityMap, options) {
757
759
  const repo = dataSource.getRepository(entity);
758
760
  const typeFilter = searchParams.get("type");
759
761
  const columnNames = new Set(repo.metadata.columns.map((c) => c.propertyName));
762
+ if (resource === "media") {
763
+ const qb = repo.createQueryBuilder("m");
764
+ const parentIdParam = searchParams.get("parentId");
765
+ if (parentIdParam != null && parentIdParam !== "") {
766
+ const n = Number(parentIdParam);
767
+ if (!Number.isFinite(n)) return json({ error: "Invalid parentId" }, { status: 400 });
768
+ qb.where("m.parentId = :pid", { pid: n });
769
+ } else {
770
+ qb.where("m.parentId IS NULL");
771
+ }
772
+ if (search && typeof search === "string" && search.trim()) {
773
+ qb.andWhere("m.filename ILIKE :search", { search: `%${search.trim()}%` });
774
+ }
775
+ if (typeFilter) {
776
+ qb.andWhere(
777
+ new import_typeorm.Brackets((sq) => {
778
+ sq.where("m.kind = :folderKind", { folderKind: "folder" }).orWhere("m.mimeType LIKE :mtp", {
779
+ mtp: `${typeFilter}/%`
780
+ });
781
+ })
782
+ );
783
+ }
784
+ const allowedSort = ["filename", "createdAt", "id"];
785
+ const sf = allowedSort.includes(sortFieldRaw) ? sortFieldRaw : "filename";
786
+ const so = sortOrder === "DESC" ? "DESC" : "ASC";
787
+ qb.orderBy("CASE WHEN m.kind = :fk THEN 0 ELSE 1 END", "ASC").addOrderBy(`m.${sf}`, so).setParameter("fk", "folder").skip(skip).take(limit);
788
+ const [data2, total2] = await qb.getManyAndCount();
789
+ return json({ total: total2, page, limit, totalPages: Math.ceil(total2 / limit), data: data2 });
790
+ }
760
791
  const sortField = columnNames.has(sortFieldRaw) ? sortFieldRaw : "createdAt";
761
792
  let where = {};
762
- if (resource === "media") {
763
- const mediaWhere = {};
764
- if (search) mediaWhere.filename = (0, import_typeorm.ILike)(`%${search}%`);
765
- if (typeFilter) mediaWhere.mimeType = (0, import_typeorm.Like)(`${typeFilter}/%`);
766
- where = Object.keys(mediaWhere).length > 0 ? mediaWhere : {};
767
- } else if (search) {
793
+ if (search) {
768
794
  where = buildSearchWhereClause(repo, search);
769
795
  }
770
796
  const intFilterKeys = ["productId", "attributeId", "taxId"];
@@ -804,6 +830,38 @@ function createCrudHandler(dataSource, entityMap, options) {
804
830
  if (!body || typeof body !== "object" || Object.keys(body).length === 0) {
805
831
  return json({ error: "Invalid request payload" }, { status: 400 });
806
832
  }
833
+ if (resource === "media") {
834
+ const b = body;
835
+ const kind = b.kind === "folder" ? "folder" : "file";
836
+ b.kind = kind;
837
+ const fn = String(b.filename ?? "").trim().slice(0, 255);
838
+ if (!fn) return json({ error: "filename required" }, { status: 400 });
839
+ b.filename = fn;
840
+ let pid = null;
841
+ if (b.parentId != null && b.parentId !== "") {
842
+ const n = Number(b.parentId);
843
+ if (!Number.isFinite(n)) return json({ error: "Invalid parentId" }, { status: 400 });
844
+ pid = n;
845
+ }
846
+ b.parentId = pid;
847
+ const mediaRepo = dataSource.getRepository(entityMap.media);
848
+ if (pid != null) {
849
+ const parent = await mediaRepo.findOne({ where: { id: pid } });
850
+ if (!parent || parent.kind !== "folder") {
851
+ return json({ error: "parent must be a folder" }, { status: 400 });
852
+ }
853
+ }
854
+ if (kind === "folder") {
855
+ b.url = null;
856
+ b.mimeType = "inode/directory";
857
+ b.size = 0;
858
+ } else {
859
+ if (!b.url || typeof b.url !== "string") return json({ error: "url required for files" }, { status: 400 });
860
+ if (!b.mimeType || typeof b.mimeType !== "string") {
861
+ b.mimeType = "application/octet-stream";
862
+ }
863
+ }
864
+ }
807
865
  const repo = dataSource.getRepository(entity);
808
866
  sanitizeBodyForEntity(repo, body);
809
867
  const created = await repo.save(repo.create(body));
@@ -1076,6 +1134,11 @@ function createCrudByIdHandler(dataSource, entityMap, options) {
1076
1134
  return updated2 ? json(updated2) : json({ message: "Not found" }, { status: 404 });
1077
1135
  }
1078
1136
  const updatePayload = rawBody && typeof rawBody === "object" ? pickColumnUpdates(repo, rawBody) : {};
1137
+ if (resource === "media") {
1138
+ const u = updatePayload;
1139
+ delete u.parentId;
1140
+ delete u.kind;
1141
+ }
1079
1142
  if (Object.keys(updatePayload).length > 0) {
1080
1143
  sanitizeBodyForEntity(repo, updatePayload);
1081
1144
  await repo.update(numericId, updatePayload);
@@ -1250,7 +1313,7 @@ function createUserAuthApiRouter(config) {
1250
1313
  }
1251
1314
 
1252
1315
  // src/api/cms-handlers.ts
1253
- var import_typeorm3 = require("typeorm");
1316
+ var import_typeorm4 = require("typeorm");
1254
1317
  init_email_queue();
1255
1318
  init_erp_queue();
1256
1319
 
@@ -1270,6 +1333,194 @@ async function assertCaptchaOk(getCms, body, req, json) {
1270
1333
  return json({ error: result.message }, { status: result.status });
1271
1334
  }
1272
1335
 
1336
+ // src/lib/media-folder-path.ts
1337
+ function sanitizeMediaFolderPath(input) {
1338
+ if (input == null) return "";
1339
+ if (typeof input !== "string") return "";
1340
+ const segments = input.replace(/\\/g, "/").split("/").map((s) => s.trim()).filter(Boolean).filter((s) => s !== ".." && s !== ".");
1341
+ const joined = segments.join("/");
1342
+ return joined.length > 512 ? joined.slice(0, 512) : joined;
1343
+ }
1344
+ function sanitizeStorageSegment(name) {
1345
+ const s = name.replace(/[/\\]/g, "-").trim().slice(0, 255);
1346
+ return s || "item";
1347
+ }
1348
+
1349
+ // src/lib/media-parent-path.ts
1350
+ async function relativePathFromMediaParentId(dataSource, entityMap, parentId) {
1351
+ if (parentId == null) return "";
1352
+ const repo = dataSource.getRepository(entityMap.media);
1353
+ const segments = [];
1354
+ let id = parentId;
1355
+ for (let d = 0; d < 64 && id != null; d++) {
1356
+ const row = await repo.findOne({ where: { id } });
1357
+ if (!row) break;
1358
+ const m = row;
1359
+ if (m.kind !== "folder") break;
1360
+ segments.unshift(sanitizeStorageSegment(m.filename));
1361
+ id = m.parentId ?? null;
1362
+ }
1363
+ return segments.join("/");
1364
+ }
1365
+
1366
+ // src/lib/media-zip-extract.ts
1367
+ var import_typeorm3 = require("typeorm");
1368
+ var ZIP_MIME_TYPES = /* @__PURE__ */ new Set(["application/zip", "application/x-zip-compressed"]);
1369
+ var MAX_ENTRIES = 2e3;
1370
+ var MAX_TOTAL_UNCOMPRESSED = 80 * 1024 * 1024;
1371
+ function isZipMedia(mime, filename) {
1372
+ if (mime && ZIP_MIME_TYPES.has(mime)) return true;
1373
+ return filename.toLowerCase().endsWith(".zip");
1374
+ }
1375
+ async function readBufferFromPublicUrl(url) {
1376
+ if (url.startsWith("http://") || url.startsWith("https://")) {
1377
+ const r = await fetch(url);
1378
+ if (!r.ok) throw new Error("Failed to download file");
1379
+ return Buffer.from(await r.arrayBuffer());
1380
+ }
1381
+ if (url.startsWith("/")) {
1382
+ const { readFile } = await import("fs/promises");
1383
+ const { join } = await import("path");
1384
+ const rel = url.replace(/^\/+/, "");
1385
+ return readFile(join(process.cwd(), "public", rel));
1386
+ }
1387
+ throw new Error("Unsupported media URL");
1388
+ }
1389
+ function sanitizeZipPath(entryName) {
1390
+ const norm = entryName.replace(/\\/g, "/").split("/").filter(Boolean);
1391
+ for (const seg of norm) {
1392
+ if (seg === ".." || seg === ".") return null;
1393
+ }
1394
+ return norm;
1395
+ }
1396
+ function shouldSkipEntry(parts) {
1397
+ if (parts[0] === "__MACOSX") return true;
1398
+ const last = parts[parts.length - 1];
1399
+ if (last === ".DS_Store") return true;
1400
+ return false;
1401
+ }
1402
+ function guessMimeType(fileName) {
1403
+ const lower = fileName.toLowerCase();
1404
+ if (lower.endsWith(".png")) return "image/png";
1405
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
1406
+ if (lower.endsWith(".gif")) return "image/gif";
1407
+ if (lower.endsWith(".webp")) return "image/webp";
1408
+ if (lower.endsWith(".svg")) return "image/svg+xml";
1409
+ if (lower.endsWith(".pdf")) return "application/pdf";
1410
+ if (lower.endsWith(".txt")) return "text/plain";
1411
+ if (lower.endsWith(".json")) return "application/json";
1412
+ if (lower.endsWith(".zip")) return "application/zip";
1413
+ return "application/octet-stream";
1414
+ }
1415
+ async function findOrCreateFolder(dataSource, entityMap, parentId, name) {
1416
+ const safe = sanitizeStorageSegment(name);
1417
+ const repo = dataSource.getRepository(entityMap.media);
1418
+ const where = parentId == null ? { kind: "folder", filename: safe, parentId: (0, import_typeorm3.IsNull)() } : { kind: "folder", filename: safe, parentId };
1419
+ const existing = await repo.findOne({ where });
1420
+ if (existing) return existing.id;
1421
+ const row = await repo.save(
1422
+ repo.create({
1423
+ kind: "folder",
1424
+ parentId,
1425
+ filename: safe,
1426
+ url: null,
1427
+ mimeType: "inode/directory",
1428
+ size: 0,
1429
+ alt: null,
1430
+ isPublic: false,
1431
+ deleted: false
1432
+ })
1433
+ );
1434
+ return row.id;
1435
+ }
1436
+ async function ensureFolderChain(dataSource, entityMap, rootParentId, pathSegments) {
1437
+ let pid = rootParentId;
1438
+ for (const seg of pathSegments) {
1439
+ if (!seg) continue;
1440
+ pid = await findOrCreateFolder(dataSource, entityMap, pid, seg);
1441
+ }
1442
+ return pid;
1443
+ }
1444
+ async function extractZipMediaIntoParentTree(opts) {
1445
+ const { dataSource, entityMap, zipMediaRow } = opts;
1446
+ const row = zipMediaRow;
1447
+ if (row.kind !== "file" || !row.url) throw new Error("Not a file");
1448
+ if (!isZipMedia(row.mimeType, row.filename)) throw new Error("Not a zip archive");
1449
+ const buffer = await readBufferFromPublicUrl(row.url);
1450
+ const { default: AdmZip } = await import("adm-zip");
1451
+ const zip = new AdmZip(buffer);
1452
+ const entries = zip.getEntries();
1453
+ if (entries.length > MAX_ENTRIES) throw new Error(`Too many zip entries (max ${MAX_ENTRIES})`);
1454
+ const rootParentId = row.parentId;
1455
+ const items = [];
1456
+ let totalUncompressed = 0;
1457
+ for (const e of entries) {
1458
+ const raw = e.entryName;
1459
+ const parts = sanitizeZipPath(raw);
1460
+ if (!parts || shouldSkipEntry(parts)) continue;
1461
+ const isDir = e.isDirectory || /\/$/.test(raw);
1462
+ let data = null;
1463
+ if (!isDir) {
1464
+ data = e.getData();
1465
+ totalUncompressed += data.length;
1466
+ if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
1467
+ throw new Error(`Uncompressed content exceeds limit (${MAX_TOTAL_UNCOMPRESSED} bytes)`);
1468
+ }
1469
+ }
1470
+ items.push({ parts, isDir, data });
1471
+ }
1472
+ items.sort((a, b) => {
1473
+ const da = a.parts.length;
1474
+ const db = b.parts.length;
1475
+ if (da !== db) return da - db;
1476
+ return a.parts.join("/").localeCompare(b.parts.join("/"));
1477
+ });
1478
+ let files = 0;
1479
+ let folderEntries = 0;
1480
+ const repo = dataSource.getRepository(entityMap.media);
1481
+ for (const it of items) {
1482
+ if (it.isDir) {
1483
+ await ensureFolderChain(dataSource, entityMap, rootParentId, it.parts);
1484
+ folderEntries++;
1485
+ continue;
1486
+ }
1487
+ const fileName = it.parts[it.parts.length - 1];
1488
+ const dirParts = it.parts.slice(0, -1);
1489
+ const parentFolderId = await ensureFolderChain(dataSource, entityMap, rootParentId, dirParts);
1490
+ const buf = it.data;
1491
+ const relBase = await relativePathFromMediaParentId(dataSource, entityMap, parentFolderId);
1492
+ const relativeUnderUploads = relBase ? `${relBase}/${fileName}` : fileName;
1493
+ const contentType = guessMimeType(fileName);
1494
+ let publicUrl;
1495
+ if (opts.storage) {
1496
+ publicUrl = await opts.storage.upload(buf, `uploads/${relativeUnderUploads}`, contentType);
1497
+ } else {
1498
+ const fs = await import("fs/promises");
1499
+ const pathMod = await import("path");
1500
+ const dir = pathMod.join(process.cwd(), opts.localUploadDir);
1501
+ const filePath = pathMod.join(dir, relativeUnderUploads);
1502
+ await fs.mkdir(pathMod.dirname(filePath), { recursive: true });
1503
+ await fs.writeFile(filePath, buf);
1504
+ publicUrl = `/${opts.localUploadDir.replace(/^\/+/, "").replace(/\\/g, "/")}/${relativeUnderUploads.replace(/\\/g, "/")}`;
1505
+ }
1506
+ await repo.save(
1507
+ repo.create({
1508
+ kind: "file",
1509
+ parentId: parentFolderId,
1510
+ filename: fileName,
1511
+ url: publicUrl,
1512
+ mimeType: contentType,
1513
+ size: buf.length,
1514
+ alt: null,
1515
+ isPublic: false,
1516
+ deleted: false
1517
+ })
1518
+ );
1519
+ files++;
1520
+ }
1521
+ return { files, folderEntries };
1522
+ }
1523
+
1273
1524
  // src/api/cms-handlers.ts
1274
1525
  function createDashboardStatsHandler(config) {
1275
1526
  const { dataSource, entityMap, json, requireAuth, requirePermission, requireEntityPermission } = config;
@@ -1287,26 +1538,209 @@ function createDashboardStatsHandler(config) {
1287
1538
  try {
1288
1539
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
1289
1540
  const repo = (name) => entityMap[name] ? dataSource.getRepository(entityMap[name]) : void 0;
1290
- const [contactsCount, formsCount, formSubmissionsCount, usersCount, blogsCount, recentContacts, recentSubmissions] = await Promise.all([
1541
+ const [contactsCount, formsCount, formSubmissionsCount, usersCount, blogsCount, recentContacts, recentSubmissions, contactTypeRows] = await Promise.all([
1291
1542
  repo("contacts")?.count() ?? 0,
1292
1543
  repo("forms")?.count({ where: { deleted: false } }) ?? 0,
1293
1544
  repo("form_submissions")?.count() ?? 0,
1294
1545
  repo("users")?.count({ where: { deleted: false } }) ?? 0,
1295
1546
  repo("blogs")?.count({ where: { deleted: false } }) ?? 0,
1296
- repo("contacts")?.count({ where: { createdAt: (0, import_typeorm3.MoreThanOrEqual)(sevenDaysAgo) } }) ?? 0,
1297
- repo("form_submissions")?.count({ where: { createdAt: (0, import_typeorm3.MoreThanOrEqual)(sevenDaysAgo) } }) ?? 0
1547
+ repo("contacts")?.count({ where: { createdAt: (0, import_typeorm4.MoreThanOrEqual)(sevenDaysAgo) } }) ?? 0,
1548
+ repo("form_submissions")?.count({ where: { createdAt: (0, import_typeorm4.MoreThanOrEqual)(sevenDaysAgo) } }) ?? 0,
1549
+ repo("contacts")?.createQueryBuilder("c").select("COALESCE(NULLIF(TRIM(c.type), ''), 'unknown')", "type").addSelect("COUNT(*)", "count").where("c.deleted = :deleted", { deleted: false }).groupBy("COALESCE(NULLIF(TRIM(c.type), ''), 'unknown')").getRawMany() ?? []
1298
1550
  ]);
1299
1551
  return json({
1300
1552
  contacts: { total: contactsCount, recent: recentContacts },
1301
1553
  forms: { total: formsCount, submissions: formSubmissionsCount, recentSubmissions },
1302
1554
  users: usersCount,
1303
- blogs: blogsCount
1555
+ blogs: blogsCount,
1556
+ contactTypes: (contactTypeRows ?? []).map((row) => ({
1557
+ type: row.type || "unknown",
1558
+ count: Number(row.count || 0)
1559
+ }))
1304
1560
  });
1305
1561
  } catch (err) {
1306
1562
  return json({ error: "Failed to fetch dashboard stats" }, { status: 500 });
1307
1563
  }
1308
1564
  };
1309
1565
  }
1566
+ function toNum(v) {
1567
+ const n = typeof v === "number" ? v : Number(v ?? 0);
1568
+ return Number.isFinite(n) ? n : 0;
1569
+ }
1570
+ function toIsoDate(d) {
1571
+ return d.toISOString().slice(0, 10);
1572
+ }
1573
+ function createEcommerceAnalyticsHandler(config) {
1574
+ const { dataSource, entityMap, json, requireAuth, requireEntityPermission } = config;
1575
+ return async function GET(req) {
1576
+ const authErr = await requireAuth(req);
1577
+ if (authErr) return authErr;
1578
+ if (requireEntityPermission) {
1579
+ const pe = await requireEntityPermission(req, "analytics", "read");
1580
+ if (pe) return pe;
1581
+ }
1582
+ if (!entityMap.orders || !entityMap.order_items || !entityMap.payments || !entityMap.products) {
1583
+ return json({ error: "Store analytics unavailable" }, { status: 404 });
1584
+ }
1585
+ try {
1586
+ const url = new URL(req.url);
1587
+ const rawDays = parseInt(url.searchParams.get("days") || "30", 10);
1588
+ const days = Number.isFinite(rawDays) ? Math.min(365, Math.max(7, rawDays)) : 30;
1589
+ const end = /* @__PURE__ */ new Date();
1590
+ const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1e3);
1591
+ const orderRepo = dataSource.getRepository(entityMap.orders);
1592
+ const paymentRepo = dataSource.getRepository(entityMap.payments);
1593
+ const itemRepo = dataSource.getRepository(entityMap.order_items);
1594
+ const productRepo = dataSource.getRepository(entityMap.products);
1595
+ const [salesOrders, returnOrders, replacementOrders, payments, products] = await Promise.all([
1596
+ orderRepo.find({
1597
+ where: { deleted: false, createdAt: (0, import_typeorm4.MoreThanOrEqual)(start), orderKind: "sale", status: (0, import_typeorm4.In)(["confirmed", "processing", "completed"]) },
1598
+ select: ["id", "contactId", "createdAt", "subtotal", "discount", "tax", "total", "status"]
1599
+ }),
1600
+ orderRepo.find({
1601
+ where: { deleted: false, createdAt: (0, import_typeorm4.MoreThanOrEqual)(start), orderKind: "return" },
1602
+ select: ["id", "createdAt", "total"]
1603
+ }),
1604
+ orderRepo.find({
1605
+ where: { deleted: false, createdAt: (0, import_typeorm4.MoreThanOrEqual)(start), orderKind: "replacement" },
1606
+ select: ["id", "createdAt", "total"]
1607
+ }),
1608
+ paymentRepo.find({
1609
+ where: { deleted: false, createdAt: (0, import_typeorm4.MoreThanOrEqual)(start) },
1610
+ select: ["id", "status", "method", "amount", "createdAt"]
1611
+ }),
1612
+ productRepo.find({
1613
+ where: { deleted: false },
1614
+ select: ["id", "name", "quantity"]
1615
+ })
1616
+ ]);
1617
+ const saleOrderIds = salesOrders.map((o) => o.id);
1618
+ const orderItems = saleOrderIds.length ? await itemRepo.find({
1619
+ where: { orderId: (0, import_typeorm4.In)(saleOrderIds) },
1620
+ select: ["id", "orderId", "productId", "quantity", "total"]
1621
+ }) : [];
1622
+ const grossSales = salesOrders.reduce((sum, o) => sum + toNum(o.subtotal), 0);
1623
+ const discounts = salesOrders.reduce((sum, o) => sum + toNum(o.discount), 0);
1624
+ const taxes = salesOrders.reduce((sum, o) => sum + toNum(o.tax), 0);
1625
+ const returnsValue = returnOrders.reduce((sum, o) => sum + toNum(o.total), 0);
1626
+ const replacementsValue = replacementOrders.reduce((sum, o) => sum + toNum(o.total), 0);
1627
+ const netSales = grossSales - discounts - returnsValue;
1628
+ const ordersCount = salesOrders.length;
1629
+ const aov = ordersCount > 0 ? netSales / ordersCount : 0;
1630
+ const returnRate = ordersCount > 0 ? returnOrders.length / ordersCount * 100 : 0;
1631
+ const salesByDate = /* @__PURE__ */ new Map();
1632
+ const returnsByDate = /* @__PURE__ */ new Map();
1633
+ for (const o of salesOrders) {
1634
+ const key = toIsoDate(new Date(o.createdAt));
1635
+ const row = salesByDate.get(key) ?? { value: 0, orders: 0 };
1636
+ row.value += toNum(o.total);
1637
+ row.orders += 1;
1638
+ salesByDate.set(key, row);
1639
+ }
1640
+ for (const o of returnOrders) {
1641
+ const key = toIsoDate(new Date(o.createdAt));
1642
+ const row = returnsByDate.get(key) ?? { value: 0, count: 0 };
1643
+ row.value += toNum(o.total);
1644
+ row.count += 1;
1645
+ returnsByDate.set(key, row);
1646
+ }
1647
+ const salesOverTime = [];
1648
+ const returnsTrend = [];
1649
+ for (let i = days - 1; i >= 0; i--) {
1650
+ const d = new Date(end.getTime() - i * 24 * 60 * 60 * 1e3);
1651
+ const key = toIsoDate(d);
1652
+ const sales = salesByDate.get(key) ?? { value: 0, orders: 0 };
1653
+ const returns = returnsByDate.get(key) ?? { value: 0, count: 0 };
1654
+ salesOverTime.push({ date: key, value: Number(sales.value.toFixed(2)), orders: sales.orders });
1655
+ returnsTrend.push({ date: key, value: Number(returns.value.toFixed(2)), count: returns.count });
1656
+ }
1657
+ const productNameMap = /* @__PURE__ */ new Map();
1658
+ for (const p of products) productNameMap.set(Number(p.id), (p.name || `Product #${p.id}`).trim());
1659
+ const productAgg = /* @__PURE__ */ new Map();
1660
+ for (const item of orderItems) {
1661
+ const productId = Number(item.productId);
1662
+ const productName = productNameMap.get(productId) || `Product #${productId}`;
1663
+ const row = productAgg.get(productId) ?? { name: productName, units: 0, sales: 0 };
1664
+ row.units += toNum(item.quantity);
1665
+ row.sales += toNum(item.total);
1666
+ productAgg.set(productId, row);
1667
+ }
1668
+ const topProducts = Array.from(productAgg.values()).sort((a, b) => b.sales - a.sales).slice(0, 5).map((p) => ({ ...p, sales: Number(p.sales.toFixed(2)) }));
1669
+ const allSaleOrderContactIds = Array.from(new Set(salesOrders.map((o) => Number(o.contactId)).filter((n) => Number.isInteger(n) && n > 0)));
1670
+ const allTimeCounts = allSaleOrderContactIds.length ? await orderRepo.createQueryBuilder("o").select("o.contactId", "contactId").addSelect("COUNT(*)", "total").where("o.deleted = :deleted", { deleted: false }).andWhere("o.orderKind = :orderKind", { orderKind: "sale" }).andWhere("o.contactId IN (:...contactIds)", { contactIds: allSaleOrderContactIds }).groupBy("o.contactId").getRawMany() : [];
1671
+ const countMap = /* @__PURE__ */ new Map();
1672
+ for (const c of allTimeCounts) countMap.set(Number(c.contactId), Number(c.total));
1673
+ const purchasingCustomers = allSaleOrderContactIds.length;
1674
+ const returningCustomers = allSaleOrderContactIds.filter((id) => (countMap.get(id) ?? 0) > 1).length;
1675
+ const newCustomers = Math.max(0, purchasingCustomers - returningCustomers);
1676
+ const returningCustomerRate = purchasingCustomers > 0 ? returningCustomers / purchasingCustomers * 100 : 0;
1677
+ const totalPayments = payments.length;
1678
+ const completedPayments = payments.filter((p) => p.status === "completed").length;
1679
+ const failedPayments = payments.filter((p) => p.status === "failed").length;
1680
+ const paymentSuccessRate = totalPayments > 0 ? completedPayments / totalPayments * 100 : 0;
1681
+ const paymentMethodMap = /* @__PURE__ */ new Map();
1682
+ for (const p of payments) {
1683
+ const method = (p.method || "unknown").toLowerCase();
1684
+ const row = paymentMethodMap.get(method) ?? { method, count: 0, amount: 0 };
1685
+ row.count += 1;
1686
+ row.amount += toNum(p.amount);
1687
+ paymentMethodMap.set(method, row);
1688
+ }
1689
+ const paymentMethods = Array.from(paymentMethodMap.values()).sort((a, b) => b.count - a.count).map((p) => ({ ...p, amount: Number(p.amount.toFixed(2)) }));
1690
+ const totalInventory = products.reduce((sum, p) => sum + toNum(p.quantity), 0);
1691
+ const outOfStockCount = products.filter((p) => toNum(p.quantity) <= 0).length;
1692
+ const lowStockCount = products.filter((p) => toNum(p.quantity) > 0 && toNum(p.quantity) <= 5).length;
1693
+ const inventoryRisk = {
1694
+ outOfStockCount,
1695
+ lowStockCount,
1696
+ totalInventory
1697
+ };
1698
+ return json({
1699
+ rangeDays: days,
1700
+ kpis: {
1701
+ netSales: Number(netSales.toFixed(2)),
1702
+ grossSales: Number(grossSales.toFixed(2)),
1703
+ ordersPlaced: ordersCount,
1704
+ averageOrderValue: Number(aov.toFixed(2)),
1705
+ returningCustomerRate: Number(returningCustomerRate.toFixed(2)),
1706
+ returnRate: Number(returnRate.toFixed(2)),
1707
+ returnValue: Number(returnsValue.toFixed(2)),
1708
+ discounts: Number(discounts.toFixed(2)),
1709
+ taxes: Number(taxes.toFixed(2)),
1710
+ paymentSuccessRate: Number(paymentSuccessRate.toFixed(2))
1711
+ },
1712
+ salesOverTime,
1713
+ topProducts,
1714
+ customerMix: {
1715
+ newCustomers,
1716
+ returningCustomers,
1717
+ repeatPurchaseRate: Number(returningCustomerRate.toFixed(2))
1718
+ },
1719
+ returnsTrend,
1720
+ paymentPerformance: {
1721
+ successCount: completedPayments,
1722
+ failedCount: failedPayments,
1723
+ successRate: Number(paymentSuccessRate.toFixed(2)),
1724
+ methods: paymentMethods
1725
+ },
1726
+ conversionProxy: {
1727
+ sessions: 0,
1728
+ checkoutStarted: 0,
1729
+ ordersPlaced: ordersCount
1730
+ },
1731
+ salesBreakdown: {
1732
+ sales: { count: ordersCount, value: Number(grossSales.toFixed(2)) },
1733
+ returns: { count: returnOrders.length, value: Number(returnsValue.toFixed(2)) },
1734
+ replacements: { count: replacementOrders.length, value: Number(replacementsValue.toFixed(2)) }
1735
+ },
1736
+ geoPerformance: [],
1737
+ inventoryRisk
1738
+ });
1739
+ } catch {
1740
+ return json({ error: "Failed to fetch ecommerce analytics" }, { status: 500 });
1741
+ }
1742
+ };
1743
+ }
1310
1744
  function createAnalyticsHandlers(config) {
1311
1745
  const { json, getAnalyticsData, getPropertyId, getPermissions } = config;
1312
1746
  return {
@@ -1338,8 +1772,27 @@ function createAnalyticsHandlers(config) {
1338
1772
  };
1339
1773
  }
1340
1774
  function createUploadHandler(config) {
1341
- const { json, requireAuth, requireEntityPermission, storage, localUploadDir = "public/uploads", allowedTypes, maxSizeBytes = 10 * 1024 * 1024 } = config;
1342
- const allowed = allowedTypes ?? ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "text/plain"];
1775
+ const {
1776
+ json,
1777
+ requireAuth,
1778
+ requireEntityPermission,
1779
+ storage,
1780
+ localUploadDir = "public/uploads",
1781
+ allowedTypes,
1782
+ maxSizeBytes = 10 * 1024 * 1024,
1783
+ dataSource,
1784
+ entityMap
1785
+ } = config;
1786
+ const allowed = allowedTypes ?? [
1787
+ "image/jpeg",
1788
+ "image/png",
1789
+ "image/gif",
1790
+ "image/webp",
1791
+ "application/pdf",
1792
+ "text/plain",
1793
+ "application/zip",
1794
+ "application/x-zip-compressed"
1795
+ ];
1343
1796
  return async function POST(req) {
1344
1797
  const authErr = await requireAuth(req);
1345
1798
  if (authErr) return authErr;
@@ -1352,28 +1805,92 @@ function createUploadHandler(config) {
1352
1805
  const file = formData.get("file");
1353
1806
  if (!file) return json({ error: "No file uploaded" }, { status: 400 });
1354
1807
  if (!allowed.includes(file.type)) return json({ error: "File type not allowed" }, { status: 400 });
1355
- if (file.size > maxSizeBytes) return json({ error: "File size exceeds limit" }, { status: 400 });
1808
+ const defaultMax = 10 * 1024 * 1024;
1809
+ const maxZipBytes = 80 * 1024 * 1024;
1810
+ const baseMax = maxSizeBytes ?? defaultMax;
1811
+ const effectiveMax = file.type === "application/zip" || file.type === "application/x-zip-compressed" ? Math.max(baseMax, maxZipBytes) : baseMax;
1812
+ if (file.size > effectiveMax) return json({ error: "File size exceeds limit" }, { status: 400 });
1813
+ const parentRaw = formData.get("parentId");
1814
+ let parentId = null;
1815
+ if (parentRaw != null && String(parentRaw).trim() !== "") {
1816
+ const n = Number(parentRaw);
1817
+ if (!Number.isFinite(n)) return json({ error: "Invalid parentId" }, { status: 400 });
1818
+ parentId = n;
1819
+ }
1820
+ let folder = "";
1821
+ if (parentId != null) {
1822
+ if (!dataSource || !entityMap?.media) {
1823
+ return json({ error: "Upload handler needs dataSource and entityMap for folder uploads" }, { status: 400 });
1824
+ }
1825
+ const repo = dataSource.getRepository(entityMap.media);
1826
+ const p = await repo.findOne({ where: { id: parentId } });
1827
+ if (!p || p.kind !== "folder") {
1828
+ return json({ error: "parent must be a folder" }, { status: 400 });
1829
+ }
1830
+ folder = await relativePathFromMediaParentId(dataSource, entityMap, parentId);
1831
+ } else {
1832
+ const folderRawLegacy = formData.get("folder") ?? formData.get("folderPath");
1833
+ if (folderRawLegacy && typeof folderRawLegacy === "string" && folderRawLegacy.trim()) {
1834
+ folder = sanitizeMediaFolderPath(folderRawLegacy);
1835
+ }
1836
+ }
1356
1837
  const buffer = Buffer.from(await file.arrayBuffer());
1357
1838
  const fileName = `${Date.now()}-${file.name}`;
1358
1839
  const contentType = file.type || "application/octet-stream";
1840
+ const relativeUnderUploads = folder ? `${folder}/${fileName}` : fileName;
1359
1841
  const raw = typeof storage === "function" ? storage() : storage;
1360
1842
  const storageService = raw instanceof Promise ? await raw : raw;
1361
1843
  if (storageService) {
1362
- const fileUrl = await storageService.upload(buffer, `uploads/${fileName}`, contentType);
1363
- return json({ filePath: fileUrl });
1844
+ const fileUrl = await storageService.upload(buffer, `uploads/${relativeUnderUploads}`, contentType);
1845
+ return json({ filePath: fileUrl, parentId });
1364
1846
  }
1365
1847
  const fs = await import("fs/promises");
1366
1848
  const path = await import("path");
1367
1849
  const dir = path.join(process.cwd(), localUploadDir);
1368
- await fs.mkdir(dir, { recursive: true });
1369
- const filePath = path.join(dir, fileName);
1850
+ const filePath = path.join(dir, relativeUnderUploads);
1851
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1370
1852
  await fs.writeFile(filePath, buffer);
1371
- return json({ filePath: `/${localUploadDir.replace(/^\/+/, "").replace(/\\/g, "/")}/${fileName}` });
1853
+ const urlRel = `${localUploadDir.replace(/^\/+/, "").replace(/\\/g, "/")}/${relativeUnderUploads.replace(/\\/g, "/")}`;
1854
+ return json({ filePath: `/${urlRel}`, parentId });
1372
1855
  } catch (err) {
1373
1856
  return json({ error: "File upload failed" }, { status: 500 });
1374
1857
  }
1375
1858
  };
1376
1859
  }
1860
+ function createMediaZipExtractHandler(config) {
1861
+ const { json, requireAuth, requireEntityPermission, storage, localUploadDir = "public/uploads", dataSource, entityMap } = config;
1862
+ return async function POST(_req, zipMediaId) {
1863
+ const authErr = await requireAuth(_req);
1864
+ if (authErr) return authErr;
1865
+ if (requireEntityPermission) {
1866
+ const pe = await requireEntityPermission(_req, "media", "create");
1867
+ if (pe) return pe;
1868
+ }
1869
+ if (!dataSource || !entityMap?.media) {
1870
+ return json({ error: "Media extract requires dataSource and entityMap" }, { status: 500 });
1871
+ }
1872
+ const id = Number(zipMediaId);
1873
+ if (!Number.isFinite(id)) return json({ error: "Invalid id" }, { status: 400 });
1874
+ const repo = dataSource.getRepository(entityMap.media);
1875
+ const row = await repo.findOne({ where: { id } });
1876
+ if (!row) return json({ error: "Not found" }, { status: 404 });
1877
+ try {
1878
+ const raw = typeof storage === "function" ? storage() : storage;
1879
+ const storageService = raw instanceof Promise ? await raw : raw;
1880
+ const result = await extractZipMediaIntoParentTree({
1881
+ dataSource,
1882
+ entityMap,
1883
+ zipMediaRow: row,
1884
+ storage: storageService,
1885
+ localUploadDir
1886
+ });
1887
+ return json({ ok: true, ...result });
1888
+ } catch (e) {
1889
+ const msg = e instanceof Error ? e.message : "Extract failed";
1890
+ return json({ error: msg }, { status: 400 });
1891
+ }
1892
+ };
1893
+ }
1377
1894
  function createBlogBySlugHandler(config) {
1378
1895
  const { dataSource, entityMap, json } = config;
1379
1896
  return async function GET(_req, slug) {
@@ -1786,7 +2303,7 @@ function createUsersApiHandlers(config) {
1786
2303
  const sortField = url.searchParams.get("sortField") || "createdAt";
1787
2304
  const sortOrder = url.searchParams.get("sortOrder") === "desc" ? "DESC" : "ASC";
1788
2305
  const search = url.searchParams.get("search");
1789
- const where = search ? [{ name: (0, import_typeorm3.ILike)(`%${search}%`) }, { email: (0, import_typeorm3.ILike)(`%${search}%`) }] : {};
2306
+ const where = search ? [{ name: (0, import_typeorm4.ILike)(`%${search}%`) }, { email: (0, import_typeorm4.ILike)(`%${search}%`) }] : {};
1790
2307
  const [data, total] = await userRepo().findAndCount({
1791
2308
  skip,
1792
2309
  take: limit,
@@ -2146,7 +2663,7 @@ function createChatHandlers(config) {
2146
2663
  if (contextParts.length === 0) {
2147
2664
  const terms = getQueryTerms(message);
2148
2665
  if (terms.length > 0) {
2149
- const conditions = terms.map((t) => ({ content: (0, import_typeorm3.ILike)(`%${t}%`) }));
2666
+ const conditions = terms.map((t) => ({ content: (0, import_typeorm4.ILike)(`%${t}%`) }));
2150
2667
  const chunks = await chunkRepo().find({
2151
2668
  where: conditions,
2152
2669
  take: KB_CHUNK_LIMIT,
@@ -2515,6 +3032,7 @@ function createCmsApiHandler(config) {
2515
3032
  getCms,
2516
3033
  userAuth: userAuthConfig,
2517
3034
  dashboard,
3035
+ ecommerceAnalytics,
2518
3036
  analytics: analyticsConfig,
2519
3037
  upload,
2520
3038
  blogBySlug,
@@ -2581,8 +3099,28 @@ function createCmsApiHandler(config) {
2581
3099
  });
2582
3100
  const userAuthRouter = userAuth ? createUserAuthApiRouter(userAuth) : null;
2583
3101
  const dashboardGet = dashboard ? createDashboardStatsHandler(mergePerm(dashboard) ?? dashboard) : null;
3102
+ const ecommerceAnalyticsResolved = mergePerm(
3103
+ ecommerceAnalytics ?? {
3104
+ dataSource,
3105
+ entityMap,
3106
+ json: config.json,
3107
+ requireAuth: config.requireAuth
3108
+ }
3109
+ ) ?? {
3110
+ dataSource,
3111
+ entityMap,
3112
+ json: config.json,
3113
+ requireAuth: config.requireAuth
3114
+ };
3115
+ const ecommerceAnalyticsGet = createEcommerceAnalyticsHandler(ecommerceAnalyticsResolved);
2584
3116
  const analyticsHandlers = analytics ? createAnalyticsHandlers(analytics) : null;
2585
- const uploadPost = upload ? createUploadHandler(mergePerm(upload) ?? upload) : null;
3117
+ const uploadMerged = upload ? {
3118
+ ...mergePerm(upload) ?? upload,
3119
+ dataSource: upload.dataSource ?? dataSource,
3120
+ entityMap: upload.entityMap ?? entityMap
3121
+ } : null;
3122
+ const uploadPost = uploadMerged ? createUploadHandler(uploadMerged) : null;
3123
+ const zipExtractPost = uploadMerged ? createMediaZipExtractHandler(uploadMerged) : null;
2586
3124
  const blogBySlugGet = blogBySlug ? createBlogBySlugHandler(blogBySlug) : null;
2587
3125
  const formBySlugGet = formBySlug ? createFormBySlugHandler(formBySlug) : null;
2588
3126
  const formSaveHandlers = formSaveConfig ? createFormSaveHandlers(mergePerm(formSaveConfig) ?? formSaveConfig) : null;
@@ -2631,6 +3169,11 @@ function createCmsApiHandler(config) {
2631
3169
  if (path[0] === "dashboard" && path[1] === "stats" && path.length === 2 && method === "GET" && dashboardGet) {
2632
3170
  return dashboardGet(req);
2633
3171
  }
3172
+ if (path[0] === "dashboard" && path[1] === "ecommerce" && path.length === 2 && method === "GET" && ecommerceAnalyticsGet) {
3173
+ const g = await analyticsGate();
3174
+ if (g) return g;
3175
+ return ecommerceAnalyticsGet(req);
3176
+ }
2634
3177
  if (path[0] === "analytics" && analyticsHandlers) {
2635
3178
  if (path.length === 1 && method === "GET") {
2636
3179
  const g = await analyticsGate();
@@ -2649,6 +3192,9 @@ function createCmsApiHandler(config) {
2649
3192
  }
2650
3193
  }
2651
3194
  if (path[0] === "upload" && path.length === 1 && method === "POST" && uploadPost) return uploadPost(req);
3195
+ if (path[0] === "media" && path[1] === "extract" && path.length === 3 && method === "POST" && zipExtractPost) {
3196
+ return zipExtractPost(req, path[2]);
3197
+ }
2652
3198
  if (path[0] === "blogs" && path[1] === "slug" && path.length === 3 && method === "GET" && blogBySlugGet) {
2653
3199
  return blogBySlugGet(req, path[2]);
2654
3200
  }
@@ -2786,7 +3332,7 @@ function createCmsApiHandler(config) {
2786
3332
  }
2787
3333
 
2788
3334
  // src/api/storefront-handlers.ts
2789
- var import_typeorm5 = require("typeorm");
3335
+ var import_typeorm6 = require("typeorm");
2790
3336
 
2791
3337
  // src/lib/is-valid-signup-email.ts
2792
3338
  var MAX_EMAIL = 254;
@@ -3046,7 +3592,7 @@ async function queueSms(cms, payload) {
3046
3592
 
3047
3593
  // src/lib/otp-challenge.ts
3048
3594
  var import_crypto = require("crypto");
3049
- var import_typeorm4 = require("typeorm");
3595
+ var import_typeorm5 = require("typeorm");
3050
3596
  var OTP_TTL_MS = 10 * 60 * 1e3;
3051
3597
  var MAX_SENDS_PER_HOUR = 5;
3052
3598
  var MAX_VERIFY_ATTEMPTS = 8;
@@ -3080,7 +3626,7 @@ function normalizePhoneE164(raw, defaultCountryCode) {
3080
3626
  async function countRecentOtpSends(dataSource, entityMap, purpose, identifier, since) {
3081
3627
  const repo = dataSource.getRepository(entityMap.otp_challenges);
3082
3628
  return repo.count({
3083
- where: { purpose, identifier, createdAt: (0, import_typeorm4.MoreThan)(since) }
3629
+ where: { purpose, identifier, createdAt: (0, import_typeorm5.MoreThan)(since) }
3084
3630
  });
3085
3631
  }
3086
3632
  async function createOtpChallenge(dataSource, entityMap, input) {
@@ -3094,7 +3640,7 @@ async function createOtpChallenge(dataSource, entityMap, input) {
3094
3640
  await repo.delete({
3095
3641
  purpose,
3096
3642
  identifier,
3097
- consumedAt: (0, import_typeorm4.IsNull)()
3643
+ consumedAt: (0, import_typeorm5.IsNull)()
3098
3644
  });
3099
3645
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
3100
3646
  const codeHash = hashOtpCode(code, purpose, identifier, pepper);
@@ -3115,7 +3661,7 @@ async function verifyAndConsumeOtpChallenge(dataSource, entityMap, input) {
3115
3661
  const { purpose, identifier, code, pepper } = input;
3116
3662
  const repo = dataSource.getRepository(entityMap.otp_challenges);
3117
3663
  const row = await repo.findOne({
3118
- where: { purpose, identifier, consumedAt: (0, import_typeorm4.IsNull)() },
3664
+ where: { purpose, identifier, consumedAt: (0, import_typeorm5.IsNull)() },
3119
3665
  order: { id: "DESC" }
3120
3666
  });
3121
3667
  if (!row) {
@@ -3349,7 +3895,7 @@ function createStorefrontApiHandler(config) {
3349
3895
  const u = await userRepo().findOne({ where: { id: userId } });
3350
3896
  if (!u) return null;
3351
3897
  const unclaimed = await contactRepo().findOne({
3352
- where: { email: u.email, userId: (0, import_typeorm5.IsNull)(), deleted: false }
3898
+ where: { email: u.email, userId: (0, import_typeorm6.IsNull)(), deleted: false }
3353
3899
  });
3354
3900
  if (unclaimed) {
3355
3901
  await contactRepo().update(unclaimed.id, { userId });
@@ -4390,7 +4936,7 @@ function createStorefrontApiHandler(config) {
4390
4936
  const previewByOrder = {};
4391
4937
  if (orderIds.length) {
4392
4938
  const oItems = await orderItemRepo().find({
4393
- where: { orderId: (0, import_typeorm5.In)(orderIds) },
4939
+ where: { orderId: (0, import_typeorm6.In)(orderIds) },
4394
4940
  relations: ["product"],
4395
4941
  order: { id: "ASC" }
4396
4942
  });
@@ -4523,9 +5069,11 @@ function createStorefrontApiHandler(config) {
4523
5069
  createCrudByIdHandler,
4524
5070
  createCrudHandler,
4525
5071
  createDashboardStatsHandler,
5072
+ createEcommerceAnalyticsHandler,
4526
5073
  createForgotPasswordHandler,
4527
5074
  createFormBySlugHandler,
4528
5075
  createInviteAcceptHandler,
5076
+ createMediaZipExtractHandler,
4529
5077
  createSetPasswordHandler,
4530
5078
  createSettingsApiHandlers,
4531
5079
  createStorefrontApiHandler,