@develemit/commerce-server 0.7.0 → 0.8.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 0.8.0 (2026-11-12)
2
+
3
+ ### Features
4
+
5
+ - `DELETE /admin/products/:id` permanently deletes a draft that has never been live and has no order lines (204), along with its image files and holds; otherwise 409 `PRODUCT_NOT_DELETABLE` with `reason: 'published' | 'has_orders'`. Migration `0009` adds `commerce_products.published_at` (set when a product first goes active; backfilled for existing non-draft products), and admin products report `everPublished`.
6
+ - `orderEmailLines` returns `reference`, the short order reference to print in order emails instead of the full id.
7
+ - `GET /admin/orders?q=` accepts the short reference (with or without the leading `#`).
8
+
9
+ ### Changes
10
+
11
+ - `GET /admin/products/counts`: `live` counts active pieces with stock, and `sold` counts active pieces at zero stock, so a sold piece is no longer counted in both. Each chip's count equals the list its filter shows (`status=active&stock=in` / `status=active&stock=sold`). Upgrading shows lower Live numbers.
12
+
1
13
  ## 0.7.0 (2026-11-04)
2
14
 
3
15
  ### Features
package/README.md CHANGED
@@ -132,6 +132,9 @@ onOrderPaid: async (order) => {
132
132
  },
133
133
  ```
134
134
 
135
+ `lines.reference` is the short order reference (`#A1B2C3D4`, from
136
+ `formatOrderRef` in contracts); print it instead of the full id.
137
+
135
138
  The result also has structured `items`, `subtotal`, `discount` (absent when
136
139
  no discount applied), `shipping`, `total` and `address` (absent for pickup).
137
140
  HTML output is `<tr>` / `<p>` fragments with `& < > " '` escaped. Override
package/dist/index.d.ts CHANGED
@@ -214,6 +214,23 @@ declare const commerceSchema: {
214
214
  identity: undefined;
215
215
  generated: undefined;
216
216
  }, {}, {}>;
217
+ publishedAt: drizzle_orm_pg_core.PgColumn<{
218
+ name: "published_at";
219
+ tableName: "commerce_products";
220
+ dataType: "date";
221
+ columnType: "PgTimestamp";
222
+ data: Date;
223
+ driverParam: string;
224
+ notNull: false;
225
+ hasDefault: false;
226
+ isPrimaryKey: false;
227
+ isAutoincrement: false;
228
+ hasRuntimeDefault: false;
229
+ enumValues: undefined;
230
+ baseColumn: never;
231
+ identity: undefined;
232
+ generated: undefined;
233
+ }, {}, {}>;
217
234
  createdAt: drizzle_orm_pg_core.PgColumn<{
218
235
  name: "created_at";
219
236
  tableName: "commerce_products";
@@ -1899,6 +1916,7 @@ interface OrderEmailLinesHtml {
1899
1916
  address: string;
1900
1917
  }
1901
1918
  interface OrderEmailLines {
1919
+ reference: string;
1902
1920
  items: EmailLine[];
1903
1921
  subtotal: EmailLine;
1904
1922
  discount?: EmailLine;
package/dist/index.js CHANGED
@@ -82,6 +82,7 @@ var products = pgTable(
82
82
  quantity: integer("quantity").notNull().default(1),
83
83
  status: productStatus("status").notNull().default("draft"),
84
84
  featured: boolean("featured").notNull().default(false),
85
+ publishedAt: timestamp("published_at", { withTimezone: true }),
85
86
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
86
87
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
87
88
  },
@@ -331,9 +332,6 @@ async function setShippingRates(db, rates) {
331
332
  return listShippingRates(db);
332
333
  }
333
334
 
334
- // src/admin/images.ts
335
- import { Readable } from "stream";
336
-
337
335
  // src/admin/repo.ts
338
336
  import {
339
337
  and as and2,
@@ -438,14 +436,23 @@ async function setImageAlt(db, id, alt) {
438
436
  async function selectProductCounts(db) {
439
437
  const [row] = await db.select({
440
438
  all: sql2`count(*) filter (where ${products.status} <> 'archived')::int`,
441
- live: sql2`count(*) filter (where ${products.status} = 'active')::int`,
439
+ live: sql2`count(*) filter (where ${products.status} = 'active' and ${products.quantity} > 0)::int`,
442
440
  draft: sql2`count(*) filter (where ${products.status} = 'draft')::int`,
443
- sold: sql2`count(*) filter (where ${products.quantity} = 0)::int`,
441
+ sold: sql2`count(*) filter (where ${products.status} = 'active' and ${products.quantity} = 0)::int`,
444
442
  archived: sql2`count(*) filter (where ${products.status} = 'archived')::int`,
445
443
  incompleteDrafts: sql2`count(*) filter (where ${products.status} = 'draft' and (${products.name} = '' or ${products.priceCents} is null or ${products.category} is null))::int`
446
444
  }).from(products);
447
445
  return row;
448
446
  }
447
+ async function productHasOrderLines(db, productId) {
448
+ const rows = await db.select({ id: orderItems.id }).from(orderItems).where(eq2(orderItems.productId, productId)).limit(1);
449
+ return rows.length > 0;
450
+ }
451
+ async function deleteProductRows(db, id) {
452
+ await db.delete(inventoryHolds).where(eq2(inventoryHolds.productId, id));
453
+ await db.delete(productImages).where(eq2(productImages.productId, id));
454
+ await db.delete(products).where(eq2(products.id, id));
455
+ }
449
456
 
450
457
  // src/admin/service.ts
451
458
  import {
@@ -481,6 +488,17 @@ var ProductIncompleteError = class extends Error {
481
488
  missing;
482
489
  code = "PRODUCT_INCOMPLETE";
483
490
  };
491
+ var ProductNotDeletableError = class extends Error {
492
+ constructor(reason) {
493
+ super(
494
+ reason === "published" ? "This product has been live, so it can only be archived" : "This product is on an order, so it can only be archived"
495
+ );
496
+ this.reason = reason;
497
+ this.name = "ProductNotDeletableError";
498
+ }
499
+ reason;
500
+ code = "PRODUCT_NOT_DELETABLE";
501
+ };
484
502
 
485
503
  // src/admin/publish.ts
486
504
  function temporarySlug(id) {
@@ -529,6 +547,7 @@ function toAdminProduct({ row, images }) {
529
547
  quantity: row.quantity,
530
548
  status: row.status,
531
549
  featured: row.featured,
550
+ everPublished: row.publishedAt !== null,
532
551
  images: images.map((i) => ({
533
552
  id: i.id,
534
553
  url: i.url,
@@ -578,6 +597,7 @@ async function createAdminProduct(db, currency, input, { requirePhotoToPublish =
578
597
  const slug = uniqueSlug(base, await slugsStartingWith(tx, base));
579
598
  return insertProduct(tx, {
580
599
  ...data,
600
+ publishedAt: data.status === "active" ? /* @__PURE__ */ new Date() : void 0,
581
601
  id: newId,
582
602
  slug,
583
603
  currency: data.currency ?? currency
@@ -593,6 +613,9 @@ async function patchAdminProduct(db, id, input, { requirePhotoToPublish = true }
593
613
  assertPublishable(record, fields, photoNeeded);
594
614
  }
595
615
  const set = sold ? { ...fields, quantity: 0 } : { ...fields };
616
+ if (fields.status === "active" && !record.row.publishedAt) {
617
+ set.publishedAt = /* @__PURE__ */ new Date();
618
+ }
596
619
  const renames = fields.name && record.row.slug === temporarySlug(id);
597
620
  await db.transaction(async (tx) => {
598
621
  if (renames) {
@@ -607,6 +630,24 @@ async function getAdminProductCounts(db) {
607
630
  return selectProductCounts(db);
608
631
  }
609
632
 
633
+ // src/admin/delete.ts
634
+ async function deleteAdminProduct(db, storage, id) {
635
+ const { row, images } = await requireProduct(db, id);
636
+ if (row.status !== "draft" || row.publishedAt) {
637
+ throw new ProductNotDeletableError("published");
638
+ }
639
+ if (await productHasOrderLines(db, id)) {
640
+ throw new ProductNotDeletableError("has_orders");
641
+ }
642
+ await db.transaction((tx) => deleteProductRows(tx, id));
643
+ for (const image of images) {
644
+ if (image.storageKey) await storage.delete(image.storageKey);
645
+ }
646
+ }
647
+
648
+ // src/admin/images.ts
649
+ import { Readable } from "stream";
650
+
610
651
  // src/admin/upload.ts
611
652
  import { randomUUID as randomUUID2 } from "crypto";
612
653
  import { fileTypeFromBuffer } from "file-type";
@@ -712,6 +753,13 @@ var adminPlugin = async (app, { db, currency, authorize, storage, requirePhotoTo
712
753
  missing: err.missing
713
754
  });
714
755
  }
756
+ if (err instanceof ProductNotDeletableError) {
757
+ return reply.code(409).send({
758
+ error: err.code,
759
+ message: err.message,
760
+ reason: err.reason
761
+ });
762
+ }
715
763
  if (err instanceof InvalidCursorError || err instanceof InvalidImageError || err instanceof InvalidImageOrderError) {
716
764
  return reply.code(400).send({ error: err.code, message: err.message });
717
765
  }
@@ -752,6 +800,13 @@ var adminPlugin = async (app, { db, currency, authorize, storage, requirePhotoTo
752
800
  { requirePhotoToPublish }
753
801
  )
754
802
  );
803
+ app.delete(
804
+ "/products/:id",
805
+ async (req, reply) => {
806
+ await deleteAdminProduct(db, storage, req.params.id);
807
+ return reply.code(204).send();
808
+ }
809
+ );
755
810
  app.post(
756
811
  "/products/:id/images",
757
812
  async (req, reply) => {
@@ -1217,6 +1272,9 @@ import {
1217
1272
  shipOrderInputSchema
1218
1273
  } from "@develemit/commerce-contracts";
1219
1274
 
1275
+ // src/orders/admin-service.ts
1276
+ import { orderRefSearchTerm } from "@develemit/commerce-contracts";
1277
+
1220
1278
  // src/orders/admin-repo.ts
1221
1279
  import { and as and5, desc as desc2, eq as eq5, ilike as ilike2, lt as lt3, or as or3, sql as sql5 } from "drizzle-orm";
1222
1280
  function escapeLike2(value) {
@@ -1488,7 +1546,7 @@ function decodeOrderCursor(raw) {
1488
1546
  async function listAdminOrders(db, query) {
1489
1547
  const rows = await selectOrderPage(db, {
1490
1548
  status: query.status,
1491
- q: query.q,
1549
+ q: query.q ? orderRefSearchTerm(query.q) || void 0 : void 0,
1492
1550
  hasPendingRefund: query.hasPendingRefund,
1493
1551
  after: query.cursor ? decodeOrderCursor(query.cursor) : void 0,
1494
1552
  limit: query.limit + 1
@@ -3192,6 +3250,9 @@ function startHoldSweeper(db, { intervalMs = 6e4, onError } = {}) {
3192
3250
  }
3193
3251
 
3194
3252
  // src/emails/order-lines.ts
3253
+ import {
3254
+ formatOrderRef
3255
+ } from "@develemit/commerce-contracts";
3195
3256
  var defaultLabels = {
3196
3257
  subtotal: "Subtotal",
3197
3258
  discount: (code) => code ? `Discount (${code})` : "Discount",
@@ -3255,6 +3316,7 @@ function orderEmailLines(order, options = {}) {
3255
3316
  const address = addressLines(order);
3256
3317
  const totals = [subtotal, ...discount ? [discount] : [], shipping, total];
3257
3318
  return {
3319
+ reference: formatOrderRef(order.id),
3258
3320
  items,
3259
3321
  subtotal,
3260
3322
  discount,