@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.js CHANGED
@@ -397,7 +397,7 @@ var init_paid_order_erp = __esm({
397
397
  });
398
398
 
399
399
  // src/api/crud.ts
400
- import { ILike, Like, MoreThan } from "typeorm";
400
+ import { Brackets, ILike, MoreThan } from "typeorm";
401
401
 
402
402
  // src/plugins/erp/erp-contact-sync.ts
403
403
  init_erp_queue();
@@ -710,14 +710,38 @@ function createCrudHandler(dataSource, entityMap, options) {
710
710
  const repo = dataSource.getRepository(entity);
711
711
  const typeFilter = searchParams.get("type");
712
712
  const columnNames = new Set(repo.metadata.columns.map((c) => c.propertyName));
713
+ if (resource === "media") {
714
+ const qb = repo.createQueryBuilder("m");
715
+ const parentIdParam = searchParams.get("parentId");
716
+ if (parentIdParam != null && parentIdParam !== "") {
717
+ const n = Number(parentIdParam);
718
+ if (!Number.isFinite(n)) return json({ error: "Invalid parentId" }, { status: 400 });
719
+ qb.where("m.parentId = :pid", { pid: n });
720
+ } else {
721
+ qb.where("m.parentId IS NULL");
722
+ }
723
+ if (search && typeof search === "string" && search.trim()) {
724
+ qb.andWhere("m.filename ILIKE :search", { search: `%${search.trim()}%` });
725
+ }
726
+ if (typeFilter) {
727
+ qb.andWhere(
728
+ new Brackets((sq) => {
729
+ sq.where("m.kind = :folderKind", { folderKind: "folder" }).orWhere("m.mimeType LIKE :mtp", {
730
+ mtp: `${typeFilter}/%`
731
+ });
732
+ })
733
+ );
734
+ }
735
+ const allowedSort = ["filename", "createdAt", "id"];
736
+ const sf = allowedSort.includes(sortFieldRaw) ? sortFieldRaw : "filename";
737
+ const so = sortOrder === "DESC" ? "DESC" : "ASC";
738
+ qb.orderBy("CASE WHEN m.kind = :fk THEN 0 ELSE 1 END", "ASC").addOrderBy(`m.${sf}`, so).setParameter("fk", "folder").skip(skip).take(limit);
739
+ const [data2, total2] = await qb.getManyAndCount();
740
+ return json({ total: total2, page, limit, totalPages: Math.ceil(total2 / limit), data: data2 });
741
+ }
713
742
  const sortField = columnNames.has(sortFieldRaw) ? sortFieldRaw : "createdAt";
714
743
  let where = {};
715
- if (resource === "media") {
716
- const mediaWhere = {};
717
- if (search) mediaWhere.filename = ILike(`%${search}%`);
718
- if (typeFilter) mediaWhere.mimeType = Like(`${typeFilter}/%`);
719
- where = Object.keys(mediaWhere).length > 0 ? mediaWhere : {};
720
- } else if (search) {
744
+ if (search) {
721
745
  where = buildSearchWhereClause(repo, search);
722
746
  }
723
747
  const intFilterKeys = ["productId", "attributeId", "taxId"];
@@ -757,6 +781,38 @@ function createCrudHandler(dataSource, entityMap, options) {
757
781
  if (!body || typeof body !== "object" || Object.keys(body).length === 0) {
758
782
  return json({ error: "Invalid request payload" }, { status: 400 });
759
783
  }
784
+ if (resource === "media") {
785
+ const b = body;
786
+ const kind = b.kind === "folder" ? "folder" : "file";
787
+ b.kind = kind;
788
+ const fn = String(b.filename ?? "").trim().slice(0, 255);
789
+ if (!fn) return json({ error: "filename required" }, { status: 400 });
790
+ b.filename = fn;
791
+ let pid = null;
792
+ if (b.parentId != null && b.parentId !== "") {
793
+ const n = Number(b.parentId);
794
+ if (!Number.isFinite(n)) return json({ error: "Invalid parentId" }, { status: 400 });
795
+ pid = n;
796
+ }
797
+ b.parentId = pid;
798
+ const mediaRepo = dataSource.getRepository(entityMap.media);
799
+ if (pid != null) {
800
+ const parent = await mediaRepo.findOne({ where: { id: pid } });
801
+ if (!parent || parent.kind !== "folder") {
802
+ return json({ error: "parent must be a folder" }, { status: 400 });
803
+ }
804
+ }
805
+ if (kind === "folder") {
806
+ b.url = null;
807
+ b.mimeType = "inode/directory";
808
+ b.size = 0;
809
+ } else {
810
+ if (!b.url || typeof b.url !== "string") return json({ error: "url required for files" }, { status: 400 });
811
+ if (!b.mimeType || typeof b.mimeType !== "string") {
812
+ b.mimeType = "application/octet-stream";
813
+ }
814
+ }
815
+ }
760
816
  const repo = dataSource.getRepository(entity);
761
817
  sanitizeBodyForEntity(repo, body);
762
818
  const created = await repo.save(repo.create(body));
@@ -1029,6 +1085,11 @@ function createCrudByIdHandler(dataSource, entityMap, options) {
1029
1085
  return updated2 ? json(updated2) : json({ message: "Not found" }, { status: 404 });
1030
1086
  }
1031
1087
  const updatePayload = rawBody && typeof rawBody === "object" ? pickColumnUpdates(repo, rawBody) : {};
1088
+ if (resource === "media") {
1089
+ const u = updatePayload;
1090
+ delete u.parentId;
1091
+ delete u.kind;
1092
+ }
1032
1093
  if (Object.keys(updatePayload).length > 0) {
1033
1094
  sanitizeBodyForEntity(repo, updatePayload);
1034
1095
  await repo.update(numericId, updatePayload);
@@ -1205,7 +1266,7 @@ function createUserAuthApiRouter(config) {
1205
1266
  // src/api/cms-handlers.ts
1206
1267
  init_email_queue();
1207
1268
  init_erp_queue();
1208
- import { MoreThanOrEqual, ILike as ILike2 } from "typeorm";
1269
+ import { MoreThanOrEqual, ILike as ILike2, In } from "typeorm";
1209
1270
 
1210
1271
  // src/plugins/captcha/assert.ts
1211
1272
  async function assertCaptchaOk(getCms, body, req, json) {
@@ -1223,6 +1284,194 @@ async function assertCaptchaOk(getCms, body, req, json) {
1223
1284
  return json({ error: result.message }, { status: result.status });
1224
1285
  }
1225
1286
 
1287
+ // src/lib/media-folder-path.ts
1288
+ function sanitizeMediaFolderPath(input) {
1289
+ if (input == null) return "";
1290
+ if (typeof input !== "string") return "";
1291
+ const segments = input.replace(/\\/g, "/").split("/").map((s) => s.trim()).filter(Boolean).filter((s) => s !== ".." && s !== ".");
1292
+ const joined = segments.join("/");
1293
+ return joined.length > 512 ? joined.slice(0, 512) : joined;
1294
+ }
1295
+ function sanitizeStorageSegment(name) {
1296
+ const s = name.replace(/[/\\]/g, "-").trim().slice(0, 255);
1297
+ return s || "item";
1298
+ }
1299
+
1300
+ // src/lib/media-parent-path.ts
1301
+ async function relativePathFromMediaParentId(dataSource, entityMap, parentId) {
1302
+ if (parentId == null) return "";
1303
+ const repo = dataSource.getRepository(entityMap.media);
1304
+ const segments = [];
1305
+ let id = parentId;
1306
+ for (let d = 0; d < 64 && id != null; d++) {
1307
+ const row = await repo.findOne({ where: { id } });
1308
+ if (!row) break;
1309
+ const m = row;
1310
+ if (m.kind !== "folder") break;
1311
+ segments.unshift(sanitizeStorageSegment(m.filename));
1312
+ id = m.parentId ?? null;
1313
+ }
1314
+ return segments.join("/");
1315
+ }
1316
+
1317
+ // src/lib/media-zip-extract.ts
1318
+ import { IsNull as IsNull2 } from "typeorm";
1319
+ var ZIP_MIME_TYPES = /* @__PURE__ */ new Set(["application/zip", "application/x-zip-compressed"]);
1320
+ var MAX_ENTRIES = 2e3;
1321
+ var MAX_TOTAL_UNCOMPRESSED = 80 * 1024 * 1024;
1322
+ function isZipMedia(mime, filename) {
1323
+ if (mime && ZIP_MIME_TYPES.has(mime)) return true;
1324
+ return filename.toLowerCase().endsWith(".zip");
1325
+ }
1326
+ async function readBufferFromPublicUrl(url) {
1327
+ if (url.startsWith("http://") || url.startsWith("https://")) {
1328
+ const r = await fetch(url);
1329
+ if (!r.ok) throw new Error("Failed to download file");
1330
+ return Buffer.from(await r.arrayBuffer());
1331
+ }
1332
+ if (url.startsWith("/")) {
1333
+ const { readFile } = await import("fs/promises");
1334
+ const { join } = await import("path");
1335
+ const rel = url.replace(/^\/+/, "");
1336
+ return readFile(join(process.cwd(), "public", rel));
1337
+ }
1338
+ throw new Error("Unsupported media URL");
1339
+ }
1340
+ function sanitizeZipPath(entryName) {
1341
+ const norm = entryName.replace(/\\/g, "/").split("/").filter(Boolean);
1342
+ for (const seg of norm) {
1343
+ if (seg === ".." || seg === ".") return null;
1344
+ }
1345
+ return norm;
1346
+ }
1347
+ function shouldSkipEntry(parts) {
1348
+ if (parts[0] === "__MACOSX") return true;
1349
+ const last = parts[parts.length - 1];
1350
+ if (last === ".DS_Store") return true;
1351
+ return false;
1352
+ }
1353
+ function guessMimeType(fileName) {
1354
+ const lower = fileName.toLowerCase();
1355
+ if (lower.endsWith(".png")) return "image/png";
1356
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
1357
+ if (lower.endsWith(".gif")) return "image/gif";
1358
+ if (lower.endsWith(".webp")) return "image/webp";
1359
+ if (lower.endsWith(".svg")) return "image/svg+xml";
1360
+ if (lower.endsWith(".pdf")) return "application/pdf";
1361
+ if (lower.endsWith(".txt")) return "text/plain";
1362
+ if (lower.endsWith(".json")) return "application/json";
1363
+ if (lower.endsWith(".zip")) return "application/zip";
1364
+ return "application/octet-stream";
1365
+ }
1366
+ async function findOrCreateFolder(dataSource, entityMap, parentId, name) {
1367
+ const safe = sanitizeStorageSegment(name);
1368
+ const repo = dataSource.getRepository(entityMap.media);
1369
+ const where = parentId == null ? { kind: "folder", filename: safe, parentId: IsNull2() } : { kind: "folder", filename: safe, parentId };
1370
+ const existing = await repo.findOne({ where });
1371
+ if (existing) return existing.id;
1372
+ const row = await repo.save(
1373
+ repo.create({
1374
+ kind: "folder",
1375
+ parentId,
1376
+ filename: safe,
1377
+ url: null,
1378
+ mimeType: "inode/directory",
1379
+ size: 0,
1380
+ alt: null,
1381
+ isPublic: false,
1382
+ deleted: false
1383
+ })
1384
+ );
1385
+ return row.id;
1386
+ }
1387
+ async function ensureFolderChain(dataSource, entityMap, rootParentId, pathSegments) {
1388
+ let pid = rootParentId;
1389
+ for (const seg of pathSegments) {
1390
+ if (!seg) continue;
1391
+ pid = await findOrCreateFolder(dataSource, entityMap, pid, seg);
1392
+ }
1393
+ return pid;
1394
+ }
1395
+ async function extractZipMediaIntoParentTree(opts) {
1396
+ const { dataSource, entityMap, zipMediaRow } = opts;
1397
+ const row = zipMediaRow;
1398
+ if (row.kind !== "file" || !row.url) throw new Error("Not a file");
1399
+ if (!isZipMedia(row.mimeType, row.filename)) throw new Error("Not a zip archive");
1400
+ const buffer = await readBufferFromPublicUrl(row.url);
1401
+ const { default: AdmZip } = await import("adm-zip");
1402
+ const zip = new AdmZip(buffer);
1403
+ const entries = zip.getEntries();
1404
+ if (entries.length > MAX_ENTRIES) throw new Error(`Too many zip entries (max ${MAX_ENTRIES})`);
1405
+ const rootParentId = row.parentId;
1406
+ const items = [];
1407
+ let totalUncompressed = 0;
1408
+ for (const e of entries) {
1409
+ const raw = e.entryName;
1410
+ const parts = sanitizeZipPath(raw);
1411
+ if (!parts || shouldSkipEntry(parts)) continue;
1412
+ const isDir = e.isDirectory || /\/$/.test(raw);
1413
+ let data = null;
1414
+ if (!isDir) {
1415
+ data = e.getData();
1416
+ totalUncompressed += data.length;
1417
+ if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
1418
+ throw new Error(`Uncompressed content exceeds limit (${MAX_TOTAL_UNCOMPRESSED} bytes)`);
1419
+ }
1420
+ }
1421
+ items.push({ parts, isDir, data });
1422
+ }
1423
+ items.sort((a, b) => {
1424
+ const da = a.parts.length;
1425
+ const db = b.parts.length;
1426
+ if (da !== db) return da - db;
1427
+ return a.parts.join("/").localeCompare(b.parts.join("/"));
1428
+ });
1429
+ let files = 0;
1430
+ let folderEntries = 0;
1431
+ const repo = dataSource.getRepository(entityMap.media);
1432
+ for (const it of items) {
1433
+ if (it.isDir) {
1434
+ await ensureFolderChain(dataSource, entityMap, rootParentId, it.parts);
1435
+ folderEntries++;
1436
+ continue;
1437
+ }
1438
+ const fileName = it.parts[it.parts.length - 1];
1439
+ const dirParts = it.parts.slice(0, -1);
1440
+ const parentFolderId = await ensureFolderChain(dataSource, entityMap, rootParentId, dirParts);
1441
+ const buf = it.data;
1442
+ const relBase = await relativePathFromMediaParentId(dataSource, entityMap, parentFolderId);
1443
+ const relativeUnderUploads = relBase ? `${relBase}/${fileName}` : fileName;
1444
+ const contentType = guessMimeType(fileName);
1445
+ let publicUrl;
1446
+ if (opts.storage) {
1447
+ publicUrl = await opts.storage.upload(buf, `uploads/${relativeUnderUploads}`, contentType);
1448
+ } else {
1449
+ const fs = await import("fs/promises");
1450
+ const pathMod = await import("path");
1451
+ const dir = pathMod.join(process.cwd(), opts.localUploadDir);
1452
+ const filePath = pathMod.join(dir, relativeUnderUploads);
1453
+ await fs.mkdir(pathMod.dirname(filePath), { recursive: true });
1454
+ await fs.writeFile(filePath, buf);
1455
+ publicUrl = `/${opts.localUploadDir.replace(/^\/+/, "").replace(/\\/g, "/")}/${relativeUnderUploads.replace(/\\/g, "/")}`;
1456
+ }
1457
+ await repo.save(
1458
+ repo.create({
1459
+ kind: "file",
1460
+ parentId: parentFolderId,
1461
+ filename: fileName,
1462
+ url: publicUrl,
1463
+ mimeType: contentType,
1464
+ size: buf.length,
1465
+ alt: null,
1466
+ isPublic: false,
1467
+ deleted: false
1468
+ })
1469
+ );
1470
+ files++;
1471
+ }
1472
+ return { files, folderEntries };
1473
+ }
1474
+
1226
1475
  // src/api/cms-handlers.ts
1227
1476
  function createDashboardStatsHandler(config) {
1228
1477
  const { dataSource, entityMap, json, requireAuth, requirePermission, requireEntityPermission } = config;
@@ -1240,26 +1489,209 @@ function createDashboardStatsHandler(config) {
1240
1489
  try {
1241
1490
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
1242
1491
  const repo = (name) => entityMap[name] ? dataSource.getRepository(entityMap[name]) : void 0;
1243
- const [contactsCount, formsCount, formSubmissionsCount, usersCount, blogsCount, recentContacts, recentSubmissions] = await Promise.all([
1492
+ const [contactsCount, formsCount, formSubmissionsCount, usersCount, blogsCount, recentContacts, recentSubmissions, contactTypeRows] = await Promise.all([
1244
1493
  repo("contacts")?.count() ?? 0,
1245
1494
  repo("forms")?.count({ where: { deleted: false } }) ?? 0,
1246
1495
  repo("form_submissions")?.count() ?? 0,
1247
1496
  repo("users")?.count({ where: { deleted: false } }) ?? 0,
1248
1497
  repo("blogs")?.count({ where: { deleted: false } }) ?? 0,
1249
1498
  repo("contacts")?.count({ where: { createdAt: MoreThanOrEqual(sevenDaysAgo) } }) ?? 0,
1250
- repo("form_submissions")?.count({ where: { createdAt: MoreThanOrEqual(sevenDaysAgo) } }) ?? 0
1499
+ repo("form_submissions")?.count({ where: { createdAt: MoreThanOrEqual(sevenDaysAgo) } }) ?? 0,
1500
+ 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() ?? []
1251
1501
  ]);
1252
1502
  return json({
1253
1503
  contacts: { total: contactsCount, recent: recentContacts },
1254
1504
  forms: { total: formsCount, submissions: formSubmissionsCount, recentSubmissions },
1255
1505
  users: usersCount,
1256
- blogs: blogsCount
1506
+ blogs: blogsCount,
1507
+ contactTypes: (contactTypeRows ?? []).map((row) => ({
1508
+ type: row.type || "unknown",
1509
+ count: Number(row.count || 0)
1510
+ }))
1257
1511
  });
1258
1512
  } catch (err) {
1259
1513
  return json({ error: "Failed to fetch dashboard stats" }, { status: 500 });
1260
1514
  }
1261
1515
  };
1262
1516
  }
1517
+ function toNum(v) {
1518
+ const n = typeof v === "number" ? v : Number(v ?? 0);
1519
+ return Number.isFinite(n) ? n : 0;
1520
+ }
1521
+ function toIsoDate(d) {
1522
+ return d.toISOString().slice(0, 10);
1523
+ }
1524
+ function createEcommerceAnalyticsHandler(config) {
1525
+ const { dataSource, entityMap, json, requireAuth, requireEntityPermission } = config;
1526
+ return async function GET(req) {
1527
+ const authErr = await requireAuth(req);
1528
+ if (authErr) return authErr;
1529
+ if (requireEntityPermission) {
1530
+ const pe = await requireEntityPermission(req, "analytics", "read");
1531
+ if (pe) return pe;
1532
+ }
1533
+ if (!entityMap.orders || !entityMap.order_items || !entityMap.payments || !entityMap.products) {
1534
+ return json({ error: "Store analytics unavailable" }, { status: 404 });
1535
+ }
1536
+ try {
1537
+ const url = new URL(req.url);
1538
+ const rawDays = parseInt(url.searchParams.get("days") || "30", 10);
1539
+ const days = Number.isFinite(rawDays) ? Math.min(365, Math.max(7, rawDays)) : 30;
1540
+ const end = /* @__PURE__ */ new Date();
1541
+ const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1e3);
1542
+ const orderRepo = dataSource.getRepository(entityMap.orders);
1543
+ const paymentRepo = dataSource.getRepository(entityMap.payments);
1544
+ const itemRepo = dataSource.getRepository(entityMap.order_items);
1545
+ const productRepo = dataSource.getRepository(entityMap.products);
1546
+ const [salesOrders, returnOrders, replacementOrders, payments, products] = await Promise.all([
1547
+ orderRepo.find({
1548
+ where: { deleted: false, createdAt: MoreThanOrEqual(start), orderKind: "sale", status: In(["confirmed", "processing", "completed"]) },
1549
+ select: ["id", "contactId", "createdAt", "subtotal", "discount", "tax", "total", "status"]
1550
+ }),
1551
+ orderRepo.find({
1552
+ where: { deleted: false, createdAt: MoreThanOrEqual(start), orderKind: "return" },
1553
+ select: ["id", "createdAt", "total"]
1554
+ }),
1555
+ orderRepo.find({
1556
+ where: { deleted: false, createdAt: MoreThanOrEqual(start), orderKind: "replacement" },
1557
+ select: ["id", "createdAt", "total"]
1558
+ }),
1559
+ paymentRepo.find({
1560
+ where: { deleted: false, createdAt: MoreThanOrEqual(start) },
1561
+ select: ["id", "status", "method", "amount", "createdAt"]
1562
+ }),
1563
+ productRepo.find({
1564
+ where: { deleted: false },
1565
+ select: ["id", "name", "quantity"]
1566
+ })
1567
+ ]);
1568
+ const saleOrderIds = salesOrders.map((o) => o.id);
1569
+ const orderItems = saleOrderIds.length ? await itemRepo.find({
1570
+ where: { orderId: In(saleOrderIds) },
1571
+ select: ["id", "orderId", "productId", "quantity", "total"]
1572
+ }) : [];
1573
+ const grossSales = salesOrders.reduce((sum, o) => sum + toNum(o.subtotal), 0);
1574
+ const discounts = salesOrders.reduce((sum, o) => sum + toNum(o.discount), 0);
1575
+ const taxes = salesOrders.reduce((sum, o) => sum + toNum(o.tax), 0);
1576
+ const returnsValue = returnOrders.reduce((sum, o) => sum + toNum(o.total), 0);
1577
+ const replacementsValue = replacementOrders.reduce((sum, o) => sum + toNum(o.total), 0);
1578
+ const netSales = grossSales - discounts - returnsValue;
1579
+ const ordersCount = salesOrders.length;
1580
+ const aov = ordersCount > 0 ? netSales / ordersCount : 0;
1581
+ const returnRate = ordersCount > 0 ? returnOrders.length / ordersCount * 100 : 0;
1582
+ const salesByDate = /* @__PURE__ */ new Map();
1583
+ const returnsByDate = /* @__PURE__ */ new Map();
1584
+ for (const o of salesOrders) {
1585
+ const key = toIsoDate(new Date(o.createdAt));
1586
+ const row = salesByDate.get(key) ?? { value: 0, orders: 0 };
1587
+ row.value += toNum(o.total);
1588
+ row.orders += 1;
1589
+ salesByDate.set(key, row);
1590
+ }
1591
+ for (const o of returnOrders) {
1592
+ const key = toIsoDate(new Date(o.createdAt));
1593
+ const row = returnsByDate.get(key) ?? { value: 0, count: 0 };
1594
+ row.value += toNum(o.total);
1595
+ row.count += 1;
1596
+ returnsByDate.set(key, row);
1597
+ }
1598
+ const salesOverTime = [];
1599
+ const returnsTrend = [];
1600
+ for (let i = days - 1; i >= 0; i--) {
1601
+ const d = new Date(end.getTime() - i * 24 * 60 * 60 * 1e3);
1602
+ const key = toIsoDate(d);
1603
+ const sales = salesByDate.get(key) ?? { value: 0, orders: 0 };
1604
+ const returns = returnsByDate.get(key) ?? { value: 0, count: 0 };
1605
+ salesOverTime.push({ date: key, value: Number(sales.value.toFixed(2)), orders: sales.orders });
1606
+ returnsTrend.push({ date: key, value: Number(returns.value.toFixed(2)), count: returns.count });
1607
+ }
1608
+ const productNameMap = /* @__PURE__ */ new Map();
1609
+ for (const p of products) productNameMap.set(Number(p.id), (p.name || `Product #${p.id}`).trim());
1610
+ const productAgg = /* @__PURE__ */ new Map();
1611
+ for (const item of orderItems) {
1612
+ const productId = Number(item.productId);
1613
+ const productName = productNameMap.get(productId) || `Product #${productId}`;
1614
+ const row = productAgg.get(productId) ?? { name: productName, units: 0, sales: 0 };
1615
+ row.units += toNum(item.quantity);
1616
+ row.sales += toNum(item.total);
1617
+ productAgg.set(productId, row);
1618
+ }
1619
+ 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)) }));
1620
+ const allSaleOrderContactIds = Array.from(new Set(salesOrders.map((o) => Number(o.contactId)).filter((n) => Number.isInteger(n) && n > 0)));
1621
+ 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() : [];
1622
+ const countMap = /* @__PURE__ */ new Map();
1623
+ for (const c of allTimeCounts) countMap.set(Number(c.contactId), Number(c.total));
1624
+ const purchasingCustomers = allSaleOrderContactIds.length;
1625
+ const returningCustomers = allSaleOrderContactIds.filter((id) => (countMap.get(id) ?? 0) > 1).length;
1626
+ const newCustomers = Math.max(0, purchasingCustomers - returningCustomers);
1627
+ const returningCustomerRate = purchasingCustomers > 0 ? returningCustomers / purchasingCustomers * 100 : 0;
1628
+ const totalPayments = payments.length;
1629
+ const completedPayments = payments.filter((p) => p.status === "completed").length;
1630
+ const failedPayments = payments.filter((p) => p.status === "failed").length;
1631
+ const paymentSuccessRate = totalPayments > 0 ? completedPayments / totalPayments * 100 : 0;
1632
+ const paymentMethodMap = /* @__PURE__ */ new Map();
1633
+ for (const p of payments) {
1634
+ const method = (p.method || "unknown").toLowerCase();
1635
+ const row = paymentMethodMap.get(method) ?? { method, count: 0, amount: 0 };
1636
+ row.count += 1;
1637
+ row.amount += toNum(p.amount);
1638
+ paymentMethodMap.set(method, row);
1639
+ }
1640
+ const paymentMethods = Array.from(paymentMethodMap.values()).sort((a, b) => b.count - a.count).map((p) => ({ ...p, amount: Number(p.amount.toFixed(2)) }));
1641
+ const totalInventory = products.reduce((sum, p) => sum + toNum(p.quantity), 0);
1642
+ const outOfStockCount = products.filter((p) => toNum(p.quantity) <= 0).length;
1643
+ const lowStockCount = products.filter((p) => toNum(p.quantity) > 0 && toNum(p.quantity) <= 5).length;
1644
+ const inventoryRisk = {
1645
+ outOfStockCount,
1646
+ lowStockCount,
1647
+ totalInventory
1648
+ };
1649
+ return json({
1650
+ rangeDays: days,
1651
+ kpis: {
1652
+ netSales: Number(netSales.toFixed(2)),
1653
+ grossSales: Number(grossSales.toFixed(2)),
1654
+ ordersPlaced: ordersCount,
1655
+ averageOrderValue: Number(aov.toFixed(2)),
1656
+ returningCustomerRate: Number(returningCustomerRate.toFixed(2)),
1657
+ returnRate: Number(returnRate.toFixed(2)),
1658
+ returnValue: Number(returnsValue.toFixed(2)),
1659
+ discounts: Number(discounts.toFixed(2)),
1660
+ taxes: Number(taxes.toFixed(2)),
1661
+ paymentSuccessRate: Number(paymentSuccessRate.toFixed(2))
1662
+ },
1663
+ salesOverTime,
1664
+ topProducts,
1665
+ customerMix: {
1666
+ newCustomers,
1667
+ returningCustomers,
1668
+ repeatPurchaseRate: Number(returningCustomerRate.toFixed(2))
1669
+ },
1670
+ returnsTrend,
1671
+ paymentPerformance: {
1672
+ successCount: completedPayments,
1673
+ failedCount: failedPayments,
1674
+ successRate: Number(paymentSuccessRate.toFixed(2)),
1675
+ methods: paymentMethods
1676
+ },
1677
+ conversionProxy: {
1678
+ sessions: 0,
1679
+ checkoutStarted: 0,
1680
+ ordersPlaced: ordersCount
1681
+ },
1682
+ salesBreakdown: {
1683
+ sales: { count: ordersCount, value: Number(grossSales.toFixed(2)) },
1684
+ returns: { count: returnOrders.length, value: Number(returnsValue.toFixed(2)) },
1685
+ replacements: { count: replacementOrders.length, value: Number(replacementsValue.toFixed(2)) }
1686
+ },
1687
+ geoPerformance: [],
1688
+ inventoryRisk
1689
+ });
1690
+ } catch {
1691
+ return json({ error: "Failed to fetch ecommerce analytics" }, { status: 500 });
1692
+ }
1693
+ };
1694
+ }
1263
1695
  function createAnalyticsHandlers(config) {
1264
1696
  const { json, getAnalyticsData, getPropertyId, getPermissions } = config;
1265
1697
  return {
@@ -1291,8 +1723,27 @@ function createAnalyticsHandlers(config) {
1291
1723
  };
1292
1724
  }
1293
1725
  function createUploadHandler(config) {
1294
- const { json, requireAuth, requireEntityPermission, storage, localUploadDir = "public/uploads", allowedTypes, maxSizeBytes = 10 * 1024 * 1024 } = config;
1295
- const allowed = allowedTypes ?? ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "text/plain"];
1726
+ const {
1727
+ json,
1728
+ requireAuth,
1729
+ requireEntityPermission,
1730
+ storage,
1731
+ localUploadDir = "public/uploads",
1732
+ allowedTypes,
1733
+ maxSizeBytes = 10 * 1024 * 1024,
1734
+ dataSource,
1735
+ entityMap
1736
+ } = config;
1737
+ const allowed = allowedTypes ?? [
1738
+ "image/jpeg",
1739
+ "image/png",
1740
+ "image/gif",
1741
+ "image/webp",
1742
+ "application/pdf",
1743
+ "text/plain",
1744
+ "application/zip",
1745
+ "application/x-zip-compressed"
1746
+ ];
1296
1747
  return async function POST(req) {
1297
1748
  const authErr = await requireAuth(req);
1298
1749
  if (authErr) return authErr;
@@ -1305,28 +1756,92 @@ function createUploadHandler(config) {
1305
1756
  const file = formData.get("file");
1306
1757
  if (!file) return json({ error: "No file uploaded" }, { status: 400 });
1307
1758
  if (!allowed.includes(file.type)) return json({ error: "File type not allowed" }, { status: 400 });
1308
- if (file.size > maxSizeBytes) return json({ error: "File size exceeds limit" }, { status: 400 });
1759
+ const defaultMax = 10 * 1024 * 1024;
1760
+ const maxZipBytes = 80 * 1024 * 1024;
1761
+ const baseMax = maxSizeBytes ?? defaultMax;
1762
+ const effectiveMax = file.type === "application/zip" || file.type === "application/x-zip-compressed" ? Math.max(baseMax, maxZipBytes) : baseMax;
1763
+ if (file.size > effectiveMax) return json({ error: "File size exceeds limit" }, { status: 400 });
1764
+ const parentRaw = formData.get("parentId");
1765
+ let parentId = null;
1766
+ if (parentRaw != null && String(parentRaw).trim() !== "") {
1767
+ const n = Number(parentRaw);
1768
+ if (!Number.isFinite(n)) return json({ error: "Invalid parentId" }, { status: 400 });
1769
+ parentId = n;
1770
+ }
1771
+ let folder = "";
1772
+ if (parentId != null) {
1773
+ if (!dataSource || !entityMap?.media) {
1774
+ return json({ error: "Upload handler needs dataSource and entityMap for folder uploads" }, { status: 400 });
1775
+ }
1776
+ const repo = dataSource.getRepository(entityMap.media);
1777
+ const p = await repo.findOne({ where: { id: parentId } });
1778
+ if (!p || p.kind !== "folder") {
1779
+ return json({ error: "parent must be a folder" }, { status: 400 });
1780
+ }
1781
+ folder = await relativePathFromMediaParentId(dataSource, entityMap, parentId);
1782
+ } else {
1783
+ const folderRawLegacy = formData.get("folder") ?? formData.get("folderPath");
1784
+ if (folderRawLegacy && typeof folderRawLegacy === "string" && folderRawLegacy.trim()) {
1785
+ folder = sanitizeMediaFolderPath(folderRawLegacy);
1786
+ }
1787
+ }
1309
1788
  const buffer = Buffer.from(await file.arrayBuffer());
1310
1789
  const fileName = `${Date.now()}-${file.name}`;
1311
1790
  const contentType = file.type || "application/octet-stream";
1791
+ const relativeUnderUploads = folder ? `${folder}/${fileName}` : fileName;
1312
1792
  const raw = typeof storage === "function" ? storage() : storage;
1313
1793
  const storageService = raw instanceof Promise ? await raw : raw;
1314
1794
  if (storageService) {
1315
- const fileUrl = await storageService.upload(buffer, `uploads/${fileName}`, contentType);
1316
- return json({ filePath: fileUrl });
1795
+ const fileUrl = await storageService.upload(buffer, `uploads/${relativeUnderUploads}`, contentType);
1796
+ return json({ filePath: fileUrl, parentId });
1317
1797
  }
1318
1798
  const fs = await import("fs/promises");
1319
1799
  const path = await import("path");
1320
1800
  const dir = path.join(process.cwd(), localUploadDir);
1321
- await fs.mkdir(dir, { recursive: true });
1322
- const filePath = path.join(dir, fileName);
1801
+ const filePath = path.join(dir, relativeUnderUploads);
1802
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1323
1803
  await fs.writeFile(filePath, buffer);
1324
- return json({ filePath: `/${localUploadDir.replace(/^\/+/, "").replace(/\\/g, "/")}/${fileName}` });
1804
+ const urlRel = `${localUploadDir.replace(/^\/+/, "").replace(/\\/g, "/")}/${relativeUnderUploads.replace(/\\/g, "/")}`;
1805
+ return json({ filePath: `/${urlRel}`, parentId });
1325
1806
  } catch (err) {
1326
1807
  return json({ error: "File upload failed" }, { status: 500 });
1327
1808
  }
1328
1809
  };
1329
1810
  }
1811
+ function createMediaZipExtractHandler(config) {
1812
+ const { json, requireAuth, requireEntityPermission, storage, localUploadDir = "public/uploads", dataSource, entityMap } = config;
1813
+ return async function POST(_req, zipMediaId) {
1814
+ const authErr = await requireAuth(_req);
1815
+ if (authErr) return authErr;
1816
+ if (requireEntityPermission) {
1817
+ const pe = await requireEntityPermission(_req, "media", "create");
1818
+ if (pe) return pe;
1819
+ }
1820
+ if (!dataSource || !entityMap?.media) {
1821
+ return json({ error: "Media extract requires dataSource and entityMap" }, { status: 500 });
1822
+ }
1823
+ const id = Number(zipMediaId);
1824
+ if (!Number.isFinite(id)) return json({ error: "Invalid id" }, { status: 400 });
1825
+ const repo = dataSource.getRepository(entityMap.media);
1826
+ const row = await repo.findOne({ where: { id } });
1827
+ if (!row) return json({ error: "Not found" }, { status: 404 });
1828
+ try {
1829
+ const raw = typeof storage === "function" ? storage() : storage;
1830
+ const storageService = raw instanceof Promise ? await raw : raw;
1831
+ const result = await extractZipMediaIntoParentTree({
1832
+ dataSource,
1833
+ entityMap,
1834
+ zipMediaRow: row,
1835
+ storage: storageService,
1836
+ localUploadDir
1837
+ });
1838
+ return json({ ok: true, ...result });
1839
+ } catch (e) {
1840
+ const msg = e instanceof Error ? e.message : "Extract failed";
1841
+ return json({ error: msg }, { status: 400 });
1842
+ }
1843
+ };
1844
+ }
1330
1845
  function createBlogBySlugHandler(config) {
1331
1846
  const { dataSource, entityMap, json } = config;
1332
1847
  return async function GET(_req, slug) {
@@ -2468,6 +2983,7 @@ function createCmsApiHandler(config) {
2468
2983
  getCms,
2469
2984
  userAuth: userAuthConfig,
2470
2985
  dashboard,
2986
+ ecommerceAnalytics,
2471
2987
  analytics: analyticsConfig,
2472
2988
  upload,
2473
2989
  blogBySlug,
@@ -2534,8 +3050,28 @@ function createCmsApiHandler(config) {
2534
3050
  });
2535
3051
  const userAuthRouter = userAuth ? createUserAuthApiRouter(userAuth) : null;
2536
3052
  const dashboardGet = dashboard ? createDashboardStatsHandler(mergePerm(dashboard) ?? dashboard) : null;
3053
+ const ecommerceAnalyticsResolved = mergePerm(
3054
+ ecommerceAnalytics ?? {
3055
+ dataSource,
3056
+ entityMap,
3057
+ json: config.json,
3058
+ requireAuth: config.requireAuth
3059
+ }
3060
+ ) ?? {
3061
+ dataSource,
3062
+ entityMap,
3063
+ json: config.json,
3064
+ requireAuth: config.requireAuth
3065
+ };
3066
+ const ecommerceAnalyticsGet = createEcommerceAnalyticsHandler(ecommerceAnalyticsResolved);
2537
3067
  const analyticsHandlers = analytics ? createAnalyticsHandlers(analytics) : null;
2538
- const uploadPost = upload ? createUploadHandler(mergePerm(upload) ?? upload) : null;
3068
+ const uploadMerged = upload ? {
3069
+ ...mergePerm(upload) ?? upload,
3070
+ dataSource: upload.dataSource ?? dataSource,
3071
+ entityMap: upload.entityMap ?? entityMap
3072
+ } : null;
3073
+ const uploadPost = uploadMerged ? createUploadHandler(uploadMerged) : null;
3074
+ const zipExtractPost = uploadMerged ? createMediaZipExtractHandler(uploadMerged) : null;
2539
3075
  const blogBySlugGet = blogBySlug ? createBlogBySlugHandler(blogBySlug) : null;
2540
3076
  const formBySlugGet = formBySlug ? createFormBySlugHandler(formBySlug) : null;
2541
3077
  const formSaveHandlers = formSaveConfig ? createFormSaveHandlers(mergePerm(formSaveConfig) ?? formSaveConfig) : null;
@@ -2584,6 +3120,11 @@ function createCmsApiHandler(config) {
2584
3120
  if (path[0] === "dashboard" && path[1] === "stats" && path.length === 2 && method === "GET" && dashboardGet) {
2585
3121
  return dashboardGet(req);
2586
3122
  }
3123
+ if (path[0] === "dashboard" && path[1] === "ecommerce" && path.length === 2 && method === "GET" && ecommerceAnalyticsGet) {
3124
+ const g = await analyticsGate();
3125
+ if (g) return g;
3126
+ return ecommerceAnalyticsGet(req);
3127
+ }
2587
3128
  if (path[0] === "analytics" && analyticsHandlers) {
2588
3129
  if (path.length === 1 && method === "GET") {
2589
3130
  const g = await analyticsGate();
@@ -2602,6 +3143,9 @@ function createCmsApiHandler(config) {
2602
3143
  }
2603
3144
  }
2604
3145
  if (path[0] === "upload" && path.length === 1 && method === "POST" && uploadPost) return uploadPost(req);
3146
+ if (path[0] === "media" && path[1] === "extract" && path.length === 3 && method === "POST" && zipExtractPost) {
3147
+ return zipExtractPost(req, path[2]);
3148
+ }
2605
3149
  if (path[0] === "blogs" && path[1] === "slug" && path.length === 3 && method === "GET" && blogBySlugGet) {
2606
3150
  return blogBySlugGet(req, path[2]);
2607
3151
  }
@@ -2739,7 +3283,7 @@ function createCmsApiHandler(config) {
2739
3283
  }
2740
3284
 
2741
3285
  // src/api/storefront-handlers.ts
2742
- import { In, IsNull as IsNull3 } from "typeorm";
3286
+ import { In as In2, IsNull as IsNull4 } from "typeorm";
2743
3287
 
2744
3288
  // src/lib/is-valid-signup-email.ts
2745
3289
  var MAX_EMAIL = 254;
@@ -2999,7 +3543,7 @@ async function queueSms(cms, payload) {
2999
3543
 
3000
3544
  // src/lib/otp-challenge.ts
3001
3545
  import { createHmac, randomInt, timingSafeEqual } from "crypto";
3002
- import { IsNull as IsNull2, MoreThan as MoreThan2 } from "typeorm";
3546
+ import { IsNull as IsNull3, MoreThan as MoreThan2 } from "typeorm";
3003
3547
  var OTP_TTL_MS = 10 * 60 * 1e3;
3004
3548
  var MAX_SENDS_PER_HOUR = 5;
3005
3549
  var MAX_VERIFY_ATTEMPTS = 8;
@@ -3047,7 +3591,7 @@ async function createOtpChallenge(dataSource, entityMap, input) {
3047
3591
  await repo.delete({
3048
3592
  purpose,
3049
3593
  identifier,
3050
- consumedAt: IsNull2()
3594
+ consumedAt: IsNull3()
3051
3595
  });
3052
3596
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
3053
3597
  const codeHash = hashOtpCode(code, purpose, identifier, pepper);
@@ -3068,7 +3612,7 @@ async function verifyAndConsumeOtpChallenge(dataSource, entityMap, input) {
3068
3612
  const { purpose, identifier, code, pepper } = input;
3069
3613
  const repo = dataSource.getRepository(entityMap.otp_challenges);
3070
3614
  const row = await repo.findOne({
3071
- where: { purpose, identifier, consumedAt: IsNull2() },
3615
+ where: { purpose, identifier, consumedAt: IsNull3() },
3072
3616
  order: { id: "DESC" }
3073
3617
  });
3074
3618
  if (!row) {
@@ -3302,7 +3846,7 @@ function createStorefrontApiHandler(config) {
3302
3846
  const u = await userRepo().findOne({ where: { id: userId } });
3303
3847
  if (!u) return null;
3304
3848
  const unclaimed = await contactRepo().findOne({
3305
- where: { email: u.email, userId: IsNull3(), deleted: false }
3849
+ where: { email: u.email, userId: IsNull4(), deleted: false }
3306
3850
  });
3307
3851
  if (unclaimed) {
3308
3852
  await contactRepo().update(unclaimed.id, { userId });
@@ -4343,7 +4887,7 @@ function createStorefrontApiHandler(config) {
4343
4887
  const previewByOrder = {};
4344
4888
  if (orderIds.length) {
4345
4889
  const oItems = await orderItemRepo().find({
4346
- where: { orderId: In(orderIds) },
4890
+ where: { orderId: In2(orderIds) },
4347
4891
  relations: ["product"],
4348
4892
  order: { id: "ASC" }
4349
4893
  });
@@ -4475,9 +5019,11 @@ export {
4475
5019
  createCrudByIdHandler,
4476
5020
  createCrudHandler,
4477
5021
  createDashboardStatsHandler,
5022
+ createEcommerceAnalyticsHandler,
4478
5023
  createForgotPasswordHandler,
4479
5024
  createFormBySlugHandler,
4480
5025
  createInviteAcceptHandler,
5026
+ createMediaZipExtractHandler,
4481
5027
  createSetPasswordHandler,
4482
5028
  createSettingsApiHandlers,
4483
5029
  createStorefrontApiHandler,