@withwiz/coupon 0.1.0 → 0.2.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/README.md CHANGED
@@ -250,6 +250,28 @@ never touch tenantId again.
250
250
  no dropped rows.
251
251
  - No `$executeRawUnsafe` / `$queryRawUnsafe` is used anywhere in this
252
252
  package.
253
+ - **`userId` must come from your authenticated session, never from the
254
+ request body.** Eligibility (`allowedUserIds`), `maxRedemptionsPerUser` and
255
+ the duplicate-order guard all key on it. A coupon that sets
256
+ `maxRedemptionsPerUser` refuses anonymous `validate()` / `redeem()` calls
257
+ with `ValidationError`, so the cap cannot be bypassed by omitting the user.
258
+ - The idempotency index is `(couponId, redeemedByUserId, orderRef)`. Postgres
259
+ treats `NULL` as distinct, so **pass `orderRef` on every redeem** if you
260
+ need double-submit protection; without it, a coupon that has no
261
+ `maxRedemptionsPerUser` can be redeemed repeatedly by the same user.
262
+ - Caller-supplied strings are bounded: ids and cursors 64 chars, user / actor
263
+ ids and `orderRef` 128 chars, `metadata` 16 KiB of JSON, eligibility lists
264
+ 1,000 entries of 128 chars. Oversized input throws `ValidationError`
265
+ before it reaches the database.
266
+ - Only a genuine unique-constraint conflict (Prisma `P2002`) is translated
267
+ into `CouponAlreadyRedeemedError` / "code already exists". Any other
268
+ database failure propagates unchanged, so the rejection audit log never
269
+ records a connection error as `ALREADY_REDEEMED`. The original Prisma
270
+ error is attached as `error.cause`, never placed in `error.details`.
271
+ - Generated codes use a cryptographic RNG (`nanoid`) over a 31-character
272
+ alphabet. The default length of 8 gives roughly 2^40 combinations, which is
273
+ adequate **only behind rate limiting** on your `validate` / `redeem`
274
+ endpoints; raise `codeLength` for high-value or long-lived coupons.
253
275
 
254
276
  ## Development
255
277
 
package/dist/index.cjs CHANGED
@@ -55,7 +55,23 @@ var LIMITS = {
55
55
  PAGINATION_DEFAULT_LIMIT: 50,
56
56
  PAGINATION_MAX_LIMIT: 200,
57
57
  NAME_MAX_LENGTH: 200,
58
- CURRENCY_LENGTH: 3
58
+ CURRENCY_LENGTH: 3,
59
+ /**
60
+ * [L] Caller-supplied identifier caps. Ids (coupon / campaign / cursor) are
61
+ * bounded by the audit log's `resourceId VarChar(64)`; `orderRef` by the
62
+ * redemption table's `VarChar(128)`. User / actor ids share the same bound
63
+ * so an unbounded string can never reach a Json or text column unchecked.
64
+ */
65
+ ID_MAX_LENGTH: 64,
66
+ USER_ID_MAX_LENGTH: 128,
67
+ ORDER_REF_MAX_LENGTH: 128,
68
+ /** [L] Upper bound on serialized `metadata` (bytes of JSON text). */
69
+ METADATA_MAX_BYTES: 16384,
70
+ /** [L] Per-list entry cap and per-entry length cap for eligibility rules. */
71
+ ELIGIBILITY_MAX_ENTRIES: 1e3,
72
+ ELIGIBILITY_ENTRY_MAX_LENGTH: 128,
73
+ /** [L] Length cap for `context.plan / productId / categoryId`. */
74
+ CONTEXT_VALUE_MAX_LENGTH: 128
59
75
  };
60
76
  var AUDIT_ACTION = {
61
77
  COUPON_CREATED: "coupon.created",
@@ -617,6 +633,40 @@ function lockCouponRow(tx, table, couponId, tenantId) {
617
633
  });
618
634
  return tx.$queryRaw(strings, couponId, tenantId);
619
635
  }
636
+ function assertOptionalString(name, value, max) {
637
+ if (value === void 0 || value === null) return;
638
+ if (typeof value !== "string" || value.length === 0) {
639
+ throw new ValidationError(`${name} must be a non-empty string`);
640
+ }
641
+ if (value.length > max) {
642
+ throw new ValidationError(`${name} length exceeds max (${max})`);
643
+ }
644
+ }
645
+ function assertRequiredString(name, value, max) {
646
+ if (value === void 0 || value === null) {
647
+ throw new ValidationError(`${name} is required`);
648
+ }
649
+ assertOptionalString(name, value, max);
650
+ }
651
+ function assertContext(context) {
652
+ if (context === void 0 || context === null) return;
653
+ if (typeof context !== "object" || Array.isArray(context)) {
654
+ throw new ValidationError("context must be a plain object");
655
+ }
656
+ const c = context;
657
+ assertOptionalString("context.plan", c.plan, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
658
+ assertOptionalString("context.productId", c.productId, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
659
+ assertOptionalString("context.categoryId", c.categoryId, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
660
+ }
661
+ function assertCouponStatus(status) {
662
+ if (status === void 0) return;
663
+ const allowed = Object.values(COUPON_STATUS);
664
+ if (typeof status !== "string" || !allowed.includes(status)) {
665
+ throw new ValidationError(
666
+ `status must be one of ${allowed.join(", ")}`
667
+ );
668
+ }
669
+ }
620
670
  function assertTenantId(value) {
621
671
  if (typeof value !== "string" || value.length === 0) {
622
672
  throw new ValidationError("tenantId is required");
@@ -678,6 +728,17 @@ function assertMetadata(meta) {
678
728
  );
679
729
  }
680
730
  }
731
+ let serialized;
732
+ try {
733
+ serialized = JSON.stringify(rec);
734
+ } catch {
735
+ throw new ValidationError("metadata must be JSON-serializable");
736
+ }
737
+ if (Buffer.byteLength(serialized, "utf8") > LIMITS.METADATA_MAX_BYTES) {
738
+ throw new ValidationError(
739
+ `metadata exceeds max size (${LIMITS.METADATA_MAX_BYTES} bytes)`
740
+ );
741
+ }
681
742
  return rec;
682
743
  }
683
744
  function assertPaginationLimit(limit) {
@@ -729,6 +790,16 @@ function assertEligibility(rules) {
729
790
  `eligibility.${key} must be an array of non-empty strings`
730
791
  );
731
792
  }
793
+ if (v.length > LIMITS.ELIGIBILITY_MAX_ENTRIES) {
794
+ throw new ValidationError(
795
+ `eligibility.${key} exceeds max entries (${LIMITS.ELIGIBILITY_MAX_ENTRIES})`
796
+ );
797
+ }
798
+ if (v.some((x) => x.length > LIMITS.ELIGIBILITY_ENTRY_MAX_LENGTH)) {
799
+ throw new ValidationError(
800
+ `eligibility.${key} entry exceeds max length (${LIMITS.ELIGIBILITY_ENTRY_MAX_LENGTH})`
801
+ );
802
+ }
732
803
  out[key] = v;
733
804
  }
734
805
  return out;
@@ -745,6 +816,9 @@ function validateCreateCouponInput(input) {
745
816
  validateDiscountPolicy(input.discount);
746
817
  assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
747
818
  assertMetadata(input.metadata);
819
+ assertCouponStatus(input.status);
820
+ assertOptionalString("campaignId", input.campaignId, LIMITS.ID_MAX_LENGTH);
821
+ assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
748
822
  if (input.maxRedemptions !== void 0 && input.maxRedemptions !== null) {
749
823
  if (typeof input.maxRedemptions !== "number" || !Number.isInteger(input.maxRedemptions) || input.maxRedemptions <= 0) {
750
824
  throw new ValidationError("maxRedemptions must be a positive integer or null");
@@ -780,14 +854,16 @@ function validateIssueBulkInput(input) {
780
854
  validateDiscountPolicy(input.discount);
781
855
  assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
782
856
  assertMetadata(input.metadata);
857
+ assertOptionalString("campaignId", input.campaignId, LIMITS.ID_MAX_LENGTH);
858
+ assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
783
859
  }
784
860
  function validateIssueCouponInput(input) {
785
861
  if (!input || typeof input !== "object") {
786
862
  throw new ValidationError("input must be an object");
787
863
  }
788
- if (typeof input.couponId !== "string" || input.couponId.length === 0) {
789
- throw new ValidationError("couponId is required");
790
- }
864
+ assertRequiredString("couponId", input.couponId, LIMITS.ID_MAX_LENGTH);
865
+ assertOptionalString("issuedToUserId", input.issuedToUserId, LIMITS.USER_ID_MAX_LENGTH);
866
+ assertOptionalString("issuedBy", input.issuedBy, LIMITS.USER_ID_MAX_LENGTH);
791
867
  assertMetadata(input.metadata);
792
868
  }
793
869
  function validateValidateInput(input) {
@@ -797,6 +873,8 @@ function validateValidateInput(input) {
797
873
  if (typeof input.code !== "string" || input.code.length === 0) {
798
874
  throw new ValidationError("code is required");
799
875
  }
876
+ assertOptionalString("userId", input.userId, LIMITS.USER_ID_MAX_LENGTH);
877
+ assertContext(input.context);
800
878
  if (input.subtotal !== void 0) {
801
879
  if (typeof input.subtotal !== "number" || !Number.isInteger(input.subtotal) || input.subtotal < 0) {
802
880
  throw new ValidationError("subtotal must be a non-negative integer");
@@ -817,6 +895,9 @@ function validateRedeemInput(input) {
817
895
  throw new ValidationError("subtotal must be a non-negative integer");
818
896
  }
819
897
  assertCurrency(input.currency);
898
+ assertOptionalString("userId", input.userId, LIMITS.USER_ID_MAX_LENGTH);
899
+ assertOptionalString("orderRef", input.orderRef, LIMITS.ORDER_REF_MAX_LENGTH);
900
+ assertContext(input.context);
820
901
  assertMetadata(input.metadata);
821
902
  }
822
903
  function validateCreateCampaignInput(input) {
@@ -834,6 +915,7 @@ function validateCreateCampaignInput(input) {
834
915
  validateDiscountPolicy(input.discount);
835
916
  assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
836
917
  assertMetadata(input.metadata);
918
+ assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
837
919
  if (input.maxCoupons !== void 0 && input.maxCoupons !== null) {
838
920
  if (typeof input.maxCoupons !== "number" || !Number.isInteger(input.maxCoupons) || input.maxCoupons <= 0) {
839
921
  throw new ValidationError(
@@ -867,6 +949,9 @@ zod.z.discriminatedUnion("kind", [
867
949
  function w(v) {
868
950
  return v;
869
951
  }
952
+ function isUniqueViolation(err) {
953
+ return typeof err === "object" && err !== null && err.code === "P2002";
954
+ }
870
955
  var NOOP_LOGGER = {
871
956
  debug() {
872
957
  },
@@ -912,6 +997,13 @@ async function safeEmit(handler, logger, event) {
912
997
  });
913
998
  }
914
999
  }
1000
+ function assertUserIdForPerUserLimit(userId) {
1001
+ if (typeof userId !== "string" || userId.length === 0) {
1002
+ throw new ValidationError(
1003
+ "userId is required: this coupon enforces maxRedemptionsPerUser"
1004
+ );
1005
+ }
1006
+ }
915
1007
  async function resolveLazyStatus(delegates, tx, coupon, now) {
916
1008
  if (coupon.status === COUPON_STATUS.ARCHIVED) {
917
1009
  return { kind: "reject", reason: "ARCHIVED", coupon };
@@ -1003,20 +1095,21 @@ function createCouponClient(config) {
1003
1095
  const created = await delegates.coupon(tx).create({ data: row });
1004
1096
  return rowToCoupon(created);
1005
1097
  } catch (err) {
1098
+ if (!isUniqueViolation(err)) throw err;
1006
1099
  lastError = err;
1007
1100
  if (baseCode) {
1008
1101
  throw new ValidationError(
1009
1102
  `coupon code already exists in tenant`,
1010
- { prismaError: err instanceof Error ? err.message : String(err) },
1011
- { tenantId, couponCode: baseCode }
1103
+ void 0,
1104
+ { tenantId, couponCode: baseCode, cause: err }
1012
1105
  );
1013
1106
  }
1014
1107
  }
1015
1108
  }
1016
1109
  throw new ValidationError(
1017
1110
  `failed to create coupon after ${maxAttempts} attempts (unique code collision)`,
1018
- { lastError: lastError instanceof Error ? lastError.message : String(lastError) },
1019
- { tenantId }
1111
+ void 0,
1112
+ { tenantId, cause: lastError }
1020
1113
  );
1021
1114
  }
1022
1115
  const coupons = {
@@ -1121,15 +1214,25 @@ function createCouponClient(config) {
1121
1214
  let skipped = 0;
1122
1215
  let totalAttempts = 0;
1123
1216
  await prisma.$transaction(async (tx) => {
1217
+ const seen = /* @__PURE__ */ new Set();
1124
1218
  for (let i = 0; i < input.count; i++) {
1125
1219
  let placed = null;
1126
1220
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1127
1221
  totalAttempts++;
1128
- const code = cfg.codeGenerator({ length: codeLength });
1222
+ const canonical = canonicalizeCode(
1223
+ cfg.codeGenerator({ length: codeLength })
1224
+ );
1225
+ const collided = seen.has(canonical) || await delegates.coupon(tx).findFirst({
1226
+ where: { tenantId, code: canonical },
1227
+ select: { id: true }
1228
+ }) !== null;
1229
+ if (collided) {
1230
+ if (attempt === maxAttempts) skipped++;
1231
+ continue;
1232
+ }
1233
+ let row;
1129
1234
  try {
1130
- sanitizeCode(code);
1131
- const canonical = canonicalizeCode(code);
1132
- const row = await delegates.coupon(tx).create({
1235
+ row = await delegates.coupon(tx).create({
1133
1236
  data: {
1134
1237
  id: cfg.idGenerator(),
1135
1238
  tenantId,
@@ -1145,13 +1248,17 @@ function createCouponClient(config) {
1145
1248
  metadata: w(metadata ?? null)
1146
1249
  }
1147
1250
  });
1148
- placed = rowToCoupon(row);
1149
- break;
1150
1251
  } catch (err) {
1151
- if (attempt === maxAttempts) {
1152
- skipped++;
1153
- }
1252
+ if (!isUniqueViolation(err)) throw err;
1253
+ throw new ValidationError(
1254
+ "bulk issuance aborted: coupon code collided with a concurrent insert",
1255
+ void 0,
1256
+ { tenantId, couponCode: canonical, cause: err }
1257
+ );
1154
1258
  }
1259
+ seen.add(canonical);
1260
+ placed = rowToCoupon(row);
1261
+ break;
1155
1262
  }
1156
1263
  if (placed) {
1157
1264
  created.push(placed);
@@ -1244,9 +1351,7 @@ function createCouponClient(config) {
1244
1351
  },
1245
1352
  async get(args) {
1246
1353
  const tenantId = tid(args.tenantId);
1247
- if (!args.id || typeof args.id !== "string") {
1248
- throw new ValidationError("id is required");
1249
- }
1354
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1250
1355
  const row = await delegates.coupon(prisma).findFirst({
1251
1356
  where: { id: args.id, tenantId }
1252
1357
  });
@@ -1255,6 +1360,8 @@ function createCouponClient(config) {
1255
1360
  async list(args) {
1256
1361
  const tenantId = tid(args.tenantId);
1257
1362
  const limit = assertPaginationLimit(args.limit);
1363
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
1364
+ assertOptionalString("campaignId", args.campaignId, LIMITS.ID_MAX_LENGTH);
1258
1365
  const where = { tenantId };
1259
1366
  if (args.campaignId) where.campaignId = args.campaignId;
1260
1367
  if (args.status) where.status = args.status;
@@ -1269,6 +1376,8 @@ function createCouponClient(config) {
1269
1376
  },
1270
1377
  async pause(args) {
1271
1378
  const tenantId = tid(args.tenantId);
1379
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1380
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1272
1381
  const now = cfg.now();
1273
1382
  const coupon = await prisma.$transaction(async (tx) => {
1274
1383
  const row = await delegates.coupon(tx).findFirst({
@@ -1302,6 +1411,8 @@ function createCouponClient(config) {
1302
1411
  },
1303
1412
  async resume(args) {
1304
1413
  const tenantId = tid(args.tenantId);
1414
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1415
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1305
1416
  const now = cfg.now();
1306
1417
  const coupon = await prisma.$transaction(async (tx) => {
1307
1418
  const row = await delegates.coupon(tx).findFirst({
@@ -1335,6 +1446,8 @@ function createCouponClient(config) {
1335
1446
  },
1336
1447
  async archive(args) {
1337
1448
  const tenantId = tid(args.tenantId);
1449
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1450
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1338
1451
  const now = cfg.now();
1339
1452
  const coupon = await prisma.$transaction(async (tx) => {
1340
1453
  const row = await delegates.coupon(tx).findFirst({
@@ -1392,7 +1505,8 @@ function createCouponClient(config) {
1392
1505
  if (!elig.ok) {
1393
1506
  return { ok: false, reason: elig.reason };
1394
1507
  }
1395
- if (coupon.maxRedemptionsPerUser !== null && input.userId) {
1508
+ if (coupon.maxRedemptionsPerUser !== null) {
1509
+ assertUserIdForPerUserLimit(input.userId);
1396
1510
  const count = await delegates.couponRedemption(tx).count({
1397
1511
  where: {
1398
1512
  tenantId,
@@ -1476,7 +1590,8 @@ function createCouponClient(config) {
1476
1590
  if (!elig.ok) {
1477
1591
  throw eligibilityReasonToError(elig.reason);
1478
1592
  }
1479
- if (coupon.maxRedemptionsPerUser !== null && input.userId) {
1593
+ if (coupon.maxRedemptionsPerUser !== null) {
1594
+ assertUserIdForPerUserLimit(input.userId);
1480
1595
  const userCount = await delegates.couponRedemption(tx).count({
1481
1596
  where: {
1482
1597
  tenantId,
@@ -1550,6 +1665,7 @@ function createCouponClient(config) {
1550
1665
  });
1551
1666
  redemption = rowToRedemption(inserted);
1552
1667
  } catch (err) {
1668
+ if (!isUniqueViolation(err)) throw err;
1553
1669
  throw new CouponAlreadyRedeemedError(void 0, {
1554
1670
  tenantId,
1555
1671
  couponId: coupon.id,
@@ -1557,7 +1673,7 @@ function createCouponClient(config) {
1557
1673
  cause: err
1558
1674
  });
1559
1675
  }
1560
- if (coupon.maxRedemptionsPerUser !== null && input.userId) {
1676
+ if (coupon.maxRedemptionsPerUser !== null) {
1561
1677
  const userCountFinal = await delegates.couponRedemption(tx).count({
1562
1678
  where: {
1563
1679
  tenantId,
@@ -1657,20 +1773,30 @@ function createCouponClient(config) {
1657
1773
  const metadata = assertMetadata(input.metadata);
1658
1774
  const now = cfg.now();
1659
1775
  const campaign = await prisma.$transaction(async (tx) => {
1660
- const row = await delegates.campaign(tx).create({
1661
- data: {
1662
- id: cfg.idGenerator(),
1663
- tenantId,
1664
- name: input.name,
1665
- status: CAMPAIGN_STATUS.ACTIVE,
1666
- discount: w(input.discount),
1667
- eligibility: w(eligibility),
1668
- startsAt: input.startsAt ?? null,
1669
- endsAt: input.endsAt ?? null,
1670
- maxCoupons: input.maxCoupons ?? null,
1671
- metadata: w(metadata ?? null)
1672
- }
1673
- });
1776
+ let row;
1777
+ try {
1778
+ row = await delegates.campaign(tx).create({
1779
+ data: {
1780
+ id: cfg.idGenerator(),
1781
+ tenantId,
1782
+ name: input.name,
1783
+ status: CAMPAIGN_STATUS.ACTIVE,
1784
+ discount: w(input.discount),
1785
+ eligibility: w(eligibility),
1786
+ startsAt: input.startsAt ?? null,
1787
+ endsAt: input.endsAt ?? null,
1788
+ maxCoupons: input.maxCoupons ?? null,
1789
+ metadata: w(metadata ?? null)
1790
+ }
1791
+ });
1792
+ } catch (err) {
1793
+ if (!isUniqueViolation(err)) throw err;
1794
+ throw new ValidationError(
1795
+ "campaign name already exists in tenant",
1796
+ void 0,
1797
+ { tenantId, cause: err }
1798
+ );
1799
+ }
1674
1800
  const campaign2 = rowToCampaign(row);
1675
1801
  await writeAudit(delegates.couponAuditLog(tx), {
1676
1802
  tenantId,
@@ -1692,7 +1818,7 @@ function createCouponClient(config) {
1692
1818
  },
1693
1819
  async get(args) {
1694
1820
  const tenantId = tid(args.tenantId);
1695
- if (!args.id) throw new ValidationError("id is required");
1821
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1696
1822
  const row = await delegates.campaign(prisma).findFirst({
1697
1823
  where: { id: args.id, tenantId }
1698
1824
  });
@@ -1701,6 +1827,7 @@ function createCouponClient(config) {
1701
1827
  async list(args) {
1702
1828
  const tenantId = tid(args.tenantId);
1703
1829
  const limit = assertPaginationLimit(args.limit);
1830
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
1704
1831
  const where = { tenantId };
1705
1832
  if (args.status) where.status = args.status;
1706
1833
  return paginate(
@@ -1714,9 +1841,8 @@ function createCouponClient(config) {
1714
1841
  },
1715
1842
  async issue(args) {
1716
1843
  if (!args || typeof args !== "object") throw new ValidationError("args required");
1717
- if (typeof args.campaignId !== "string" || !args.campaignId) {
1718
- throw new ValidationError("campaignId is required");
1719
- }
1844
+ assertRequiredString("campaignId", args.campaignId, LIMITS.ID_MAX_LENGTH);
1845
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1720
1846
  if (typeof args.count !== "number" || !Number.isInteger(args.count) || args.count <= 0 || args.count > LIMITS.BULK_MAX_COUNT) {
1721
1847
  throw new ValidationError(
1722
1848
  `count must be a positive integer <= ${LIMITS.BULK_MAX_COUNT}`
@@ -1751,7 +1877,7 @@ function createCouponClient(config) {
1751
1877
  },
1752
1878
  async summary(args) {
1753
1879
  const tenantId = tid(args.tenantId);
1754
- if (!args.id) throw new ValidationError("id is required");
1880
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1755
1881
  const campRow = await delegates.campaign(prisma).findFirst({
1756
1882
  where: { id: args.id, tenantId }
1757
1883
  });
@@ -1804,7 +1930,8 @@ function createCouponClient(config) {
1804
1930
  },
1805
1931
  async close(args) {
1806
1932
  const tenantId = tid(args.tenantId);
1807
- if (!args.id) throw new ValidationError("id is required");
1933
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1934
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1808
1935
  const now = cfg.now();
1809
1936
  const campaign = await prisma.$transaction(async (tx) => {
1810
1937
  const row = await delegates.campaign(tx).findFirst({
@@ -1842,6 +1969,9 @@ function createCouponClient(config) {
1842
1969
  async listIssuances(args) {
1843
1970
  const tenantId = tid(args.tenantId);
1844
1971
  const limit = assertPaginationLimit(args.limit);
1972
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
1973
+ assertOptionalString("couponId", args.couponId, LIMITS.ID_MAX_LENGTH);
1974
+ assertOptionalString("issuedToUserId", args.issuedToUserId, LIMITS.USER_ID_MAX_LENGTH);
1845
1975
  const where = { tenantId };
1846
1976
  if (args.couponId) where.couponId = args.couponId;
1847
1977
  if (args.issuedToUserId) where.issuedToUserId = args.issuedToUserId;
@@ -1857,6 +1987,9 @@ function createCouponClient(config) {
1857
1987
  async listRedemptions(args) {
1858
1988
  const tenantId = tid(args.tenantId);
1859
1989
  const limit = assertPaginationLimit(args.limit);
1990
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
1991
+ assertOptionalString("couponId", args.couponId, LIMITS.ID_MAX_LENGTH);
1992
+ assertOptionalString("redeemedByUserId", args.redeemedByUserId, LIMITS.USER_ID_MAX_LENGTH);
1860
1993
  const where = { tenantId };
1861
1994
  if (args.couponId) where.couponId = args.couponId;
1862
1995
  if (args.redeemedByUserId) where.redeemedByUserId = args.redeemedByUserId;
@@ -1872,6 +2005,8 @@ function createCouponClient(config) {
1872
2005
  async listLogs(args) {
1873
2006
  const tenantId = tid(args.tenantId);
1874
2007
  const limit = assertPaginationLimit(args.limit);
2008
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
2009
+ assertOptionalString("resourceId", args.resourceId, LIMITS.ID_MAX_LENGTH);
1875
2010
  const where = { tenantId };
1876
2011
  if (args.action) where.action = args.action;
1877
2012
  if (args.resourceId) where.resourceId = args.resourceId;
package/dist/index.d.cts CHANGED
@@ -68,6 +68,22 @@ declare const LIMITS: {
68
68
  readonly PAGINATION_MAX_LIMIT: 200;
69
69
  readonly NAME_MAX_LENGTH: 200;
70
70
  readonly CURRENCY_LENGTH: 3;
71
+ /**
72
+ * [L] Caller-supplied identifier caps. Ids (coupon / campaign / cursor) are
73
+ * bounded by the audit log's `resourceId VarChar(64)`; `orderRef` by the
74
+ * redemption table's `VarChar(128)`. User / actor ids share the same bound
75
+ * so an unbounded string can never reach a Json or text column unchecked.
76
+ */
77
+ readonly ID_MAX_LENGTH: 64;
78
+ readonly USER_ID_MAX_LENGTH: 128;
79
+ readonly ORDER_REF_MAX_LENGTH: 128;
80
+ /** [L] Upper bound on serialized `metadata` (bytes of JSON text). */
81
+ readonly METADATA_MAX_BYTES: 16384;
82
+ /** [L] Per-list entry cap and per-entry length cap for eligibility rules. */
83
+ readonly ELIGIBILITY_MAX_ENTRIES: 1000;
84
+ readonly ELIGIBILITY_ENTRY_MAX_LENGTH: 128;
85
+ /** [L] Length cap for `context.plan / productId / categoryId`. */
86
+ readonly CONTEXT_VALUE_MAX_LENGTH: 128;
71
87
  };
72
88
  /**
73
89
  * Audit log action strings (stable identifiers).
package/dist/index.d.ts CHANGED
@@ -68,6 +68,22 @@ declare const LIMITS: {
68
68
  readonly PAGINATION_MAX_LIMIT: 200;
69
69
  readonly NAME_MAX_LENGTH: 200;
70
70
  readonly CURRENCY_LENGTH: 3;
71
+ /**
72
+ * [L] Caller-supplied identifier caps. Ids (coupon / campaign / cursor) are
73
+ * bounded by the audit log's `resourceId VarChar(64)`; `orderRef` by the
74
+ * redemption table's `VarChar(128)`. User / actor ids share the same bound
75
+ * so an unbounded string can never reach a Json or text column unchecked.
76
+ */
77
+ readonly ID_MAX_LENGTH: 64;
78
+ readonly USER_ID_MAX_LENGTH: 128;
79
+ readonly ORDER_REF_MAX_LENGTH: 128;
80
+ /** [L] Upper bound on serialized `metadata` (bytes of JSON text). */
81
+ readonly METADATA_MAX_BYTES: 16384;
82
+ /** [L] Per-list entry cap and per-entry length cap for eligibility rules. */
83
+ readonly ELIGIBILITY_MAX_ENTRIES: 1000;
84
+ readonly ELIGIBILITY_ENTRY_MAX_LENGTH: 128;
85
+ /** [L] Length cap for `context.plan / productId / categoryId`. */
86
+ readonly CONTEXT_VALUE_MAX_LENGTH: 128;
71
87
  };
72
88
  /**
73
89
  * Audit log action strings (stable identifiers).
package/dist/index.mjs CHANGED
@@ -53,7 +53,23 @@ var LIMITS = {
53
53
  PAGINATION_DEFAULT_LIMIT: 50,
54
54
  PAGINATION_MAX_LIMIT: 200,
55
55
  NAME_MAX_LENGTH: 200,
56
- CURRENCY_LENGTH: 3
56
+ CURRENCY_LENGTH: 3,
57
+ /**
58
+ * [L] Caller-supplied identifier caps. Ids (coupon / campaign / cursor) are
59
+ * bounded by the audit log's `resourceId VarChar(64)`; `orderRef` by the
60
+ * redemption table's `VarChar(128)`. User / actor ids share the same bound
61
+ * so an unbounded string can never reach a Json or text column unchecked.
62
+ */
63
+ ID_MAX_LENGTH: 64,
64
+ USER_ID_MAX_LENGTH: 128,
65
+ ORDER_REF_MAX_LENGTH: 128,
66
+ /** [L] Upper bound on serialized `metadata` (bytes of JSON text). */
67
+ METADATA_MAX_BYTES: 16384,
68
+ /** [L] Per-list entry cap and per-entry length cap for eligibility rules. */
69
+ ELIGIBILITY_MAX_ENTRIES: 1e3,
70
+ ELIGIBILITY_ENTRY_MAX_LENGTH: 128,
71
+ /** [L] Length cap for `context.plan / productId / categoryId`. */
72
+ CONTEXT_VALUE_MAX_LENGTH: 128
57
73
  };
58
74
  var AUDIT_ACTION = {
59
75
  COUPON_CREATED: "coupon.created",
@@ -615,6 +631,40 @@ function lockCouponRow(tx, table, couponId, tenantId) {
615
631
  });
616
632
  return tx.$queryRaw(strings, couponId, tenantId);
617
633
  }
634
+ function assertOptionalString(name, value, max) {
635
+ if (value === void 0 || value === null) return;
636
+ if (typeof value !== "string" || value.length === 0) {
637
+ throw new ValidationError(`${name} must be a non-empty string`);
638
+ }
639
+ if (value.length > max) {
640
+ throw new ValidationError(`${name} length exceeds max (${max})`);
641
+ }
642
+ }
643
+ function assertRequiredString(name, value, max) {
644
+ if (value === void 0 || value === null) {
645
+ throw new ValidationError(`${name} is required`);
646
+ }
647
+ assertOptionalString(name, value, max);
648
+ }
649
+ function assertContext(context) {
650
+ if (context === void 0 || context === null) return;
651
+ if (typeof context !== "object" || Array.isArray(context)) {
652
+ throw new ValidationError("context must be a plain object");
653
+ }
654
+ const c = context;
655
+ assertOptionalString("context.plan", c.plan, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
656
+ assertOptionalString("context.productId", c.productId, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
657
+ assertOptionalString("context.categoryId", c.categoryId, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
658
+ }
659
+ function assertCouponStatus(status) {
660
+ if (status === void 0) return;
661
+ const allowed = Object.values(COUPON_STATUS);
662
+ if (typeof status !== "string" || !allowed.includes(status)) {
663
+ throw new ValidationError(
664
+ `status must be one of ${allowed.join(", ")}`
665
+ );
666
+ }
667
+ }
618
668
  function assertTenantId(value) {
619
669
  if (typeof value !== "string" || value.length === 0) {
620
670
  throw new ValidationError("tenantId is required");
@@ -676,6 +726,17 @@ function assertMetadata(meta) {
676
726
  );
677
727
  }
678
728
  }
729
+ let serialized;
730
+ try {
731
+ serialized = JSON.stringify(rec);
732
+ } catch {
733
+ throw new ValidationError("metadata must be JSON-serializable");
734
+ }
735
+ if (Buffer.byteLength(serialized, "utf8") > LIMITS.METADATA_MAX_BYTES) {
736
+ throw new ValidationError(
737
+ `metadata exceeds max size (${LIMITS.METADATA_MAX_BYTES} bytes)`
738
+ );
739
+ }
679
740
  return rec;
680
741
  }
681
742
  function assertPaginationLimit(limit) {
@@ -727,6 +788,16 @@ function assertEligibility(rules) {
727
788
  `eligibility.${key} must be an array of non-empty strings`
728
789
  );
729
790
  }
791
+ if (v.length > LIMITS.ELIGIBILITY_MAX_ENTRIES) {
792
+ throw new ValidationError(
793
+ `eligibility.${key} exceeds max entries (${LIMITS.ELIGIBILITY_MAX_ENTRIES})`
794
+ );
795
+ }
796
+ if (v.some((x) => x.length > LIMITS.ELIGIBILITY_ENTRY_MAX_LENGTH)) {
797
+ throw new ValidationError(
798
+ `eligibility.${key} entry exceeds max length (${LIMITS.ELIGIBILITY_ENTRY_MAX_LENGTH})`
799
+ );
800
+ }
730
801
  out[key] = v;
731
802
  }
732
803
  return out;
@@ -743,6 +814,9 @@ function validateCreateCouponInput(input) {
743
814
  validateDiscountPolicy(input.discount);
744
815
  assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
745
816
  assertMetadata(input.metadata);
817
+ assertCouponStatus(input.status);
818
+ assertOptionalString("campaignId", input.campaignId, LIMITS.ID_MAX_LENGTH);
819
+ assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
746
820
  if (input.maxRedemptions !== void 0 && input.maxRedemptions !== null) {
747
821
  if (typeof input.maxRedemptions !== "number" || !Number.isInteger(input.maxRedemptions) || input.maxRedemptions <= 0) {
748
822
  throw new ValidationError("maxRedemptions must be a positive integer or null");
@@ -778,14 +852,16 @@ function validateIssueBulkInput(input) {
778
852
  validateDiscountPolicy(input.discount);
779
853
  assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
780
854
  assertMetadata(input.metadata);
855
+ assertOptionalString("campaignId", input.campaignId, LIMITS.ID_MAX_LENGTH);
856
+ assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
781
857
  }
782
858
  function validateIssueCouponInput(input) {
783
859
  if (!input || typeof input !== "object") {
784
860
  throw new ValidationError("input must be an object");
785
861
  }
786
- if (typeof input.couponId !== "string" || input.couponId.length === 0) {
787
- throw new ValidationError("couponId is required");
788
- }
862
+ assertRequiredString("couponId", input.couponId, LIMITS.ID_MAX_LENGTH);
863
+ assertOptionalString("issuedToUserId", input.issuedToUserId, LIMITS.USER_ID_MAX_LENGTH);
864
+ assertOptionalString("issuedBy", input.issuedBy, LIMITS.USER_ID_MAX_LENGTH);
789
865
  assertMetadata(input.metadata);
790
866
  }
791
867
  function validateValidateInput(input) {
@@ -795,6 +871,8 @@ function validateValidateInput(input) {
795
871
  if (typeof input.code !== "string" || input.code.length === 0) {
796
872
  throw new ValidationError("code is required");
797
873
  }
874
+ assertOptionalString("userId", input.userId, LIMITS.USER_ID_MAX_LENGTH);
875
+ assertContext(input.context);
798
876
  if (input.subtotal !== void 0) {
799
877
  if (typeof input.subtotal !== "number" || !Number.isInteger(input.subtotal) || input.subtotal < 0) {
800
878
  throw new ValidationError("subtotal must be a non-negative integer");
@@ -815,6 +893,9 @@ function validateRedeemInput(input) {
815
893
  throw new ValidationError("subtotal must be a non-negative integer");
816
894
  }
817
895
  assertCurrency(input.currency);
896
+ assertOptionalString("userId", input.userId, LIMITS.USER_ID_MAX_LENGTH);
897
+ assertOptionalString("orderRef", input.orderRef, LIMITS.ORDER_REF_MAX_LENGTH);
898
+ assertContext(input.context);
818
899
  assertMetadata(input.metadata);
819
900
  }
820
901
  function validateCreateCampaignInput(input) {
@@ -832,6 +913,7 @@ function validateCreateCampaignInput(input) {
832
913
  validateDiscountPolicy(input.discount);
833
914
  assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
834
915
  assertMetadata(input.metadata);
916
+ assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
835
917
  if (input.maxCoupons !== void 0 && input.maxCoupons !== null) {
836
918
  if (typeof input.maxCoupons !== "number" || !Number.isInteger(input.maxCoupons) || input.maxCoupons <= 0) {
837
919
  throw new ValidationError(
@@ -865,6 +947,9 @@ z.discriminatedUnion("kind", [
865
947
  function w(v) {
866
948
  return v;
867
949
  }
950
+ function isUniqueViolation(err) {
951
+ return typeof err === "object" && err !== null && err.code === "P2002";
952
+ }
868
953
  var NOOP_LOGGER = {
869
954
  debug() {
870
955
  },
@@ -910,6 +995,13 @@ async function safeEmit(handler, logger, event) {
910
995
  });
911
996
  }
912
997
  }
998
+ function assertUserIdForPerUserLimit(userId) {
999
+ if (typeof userId !== "string" || userId.length === 0) {
1000
+ throw new ValidationError(
1001
+ "userId is required: this coupon enforces maxRedemptionsPerUser"
1002
+ );
1003
+ }
1004
+ }
913
1005
  async function resolveLazyStatus(delegates, tx, coupon, now) {
914
1006
  if (coupon.status === COUPON_STATUS.ARCHIVED) {
915
1007
  return { kind: "reject", reason: "ARCHIVED", coupon };
@@ -1001,20 +1093,21 @@ function createCouponClient(config) {
1001
1093
  const created = await delegates.coupon(tx).create({ data: row });
1002
1094
  return rowToCoupon(created);
1003
1095
  } catch (err) {
1096
+ if (!isUniqueViolation(err)) throw err;
1004
1097
  lastError = err;
1005
1098
  if (baseCode) {
1006
1099
  throw new ValidationError(
1007
1100
  `coupon code already exists in tenant`,
1008
- { prismaError: err instanceof Error ? err.message : String(err) },
1009
- { tenantId, couponCode: baseCode }
1101
+ void 0,
1102
+ { tenantId, couponCode: baseCode, cause: err }
1010
1103
  );
1011
1104
  }
1012
1105
  }
1013
1106
  }
1014
1107
  throw new ValidationError(
1015
1108
  `failed to create coupon after ${maxAttempts} attempts (unique code collision)`,
1016
- { lastError: lastError instanceof Error ? lastError.message : String(lastError) },
1017
- { tenantId }
1109
+ void 0,
1110
+ { tenantId, cause: lastError }
1018
1111
  );
1019
1112
  }
1020
1113
  const coupons = {
@@ -1119,15 +1212,25 @@ function createCouponClient(config) {
1119
1212
  let skipped = 0;
1120
1213
  let totalAttempts = 0;
1121
1214
  await prisma.$transaction(async (tx) => {
1215
+ const seen = /* @__PURE__ */ new Set();
1122
1216
  for (let i = 0; i < input.count; i++) {
1123
1217
  let placed = null;
1124
1218
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1125
1219
  totalAttempts++;
1126
- const code = cfg.codeGenerator({ length: codeLength });
1220
+ const canonical = canonicalizeCode(
1221
+ cfg.codeGenerator({ length: codeLength })
1222
+ );
1223
+ const collided = seen.has(canonical) || await delegates.coupon(tx).findFirst({
1224
+ where: { tenantId, code: canonical },
1225
+ select: { id: true }
1226
+ }) !== null;
1227
+ if (collided) {
1228
+ if (attempt === maxAttempts) skipped++;
1229
+ continue;
1230
+ }
1231
+ let row;
1127
1232
  try {
1128
- sanitizeCode(code);
1129
- const canonical = canonicalizeCode(code);
1130
- const row = await delegates.coupon(tx).create({
1233
+ row = await delegates.coupon(tx).create({
1131
1234
  data: {
1132
1235
  id: cfg.idGenerator(),
1133
1236
  tenantId,
@@ -1143,13 +1246,17 @@ function createCouponClient(config) {
1143
1246
  metadata: w(metadata ?? null)
1144
1247
  }
1145
1248
  });
1146
- placed = rowToCoupon(row);
1147
- break;
1148
1249
  } catch (err) {
1149
- if (attempt === maxAttempts) {
1150
- skipped++;
1151
- }
1250
+ if (!isUniqueViolation(err)) throw err;
1251
+ throw new ValidationError(
1252
+ "bulk issuance aborted: coupon code collided with a concurrent insert",
1253
+ void 0,
1254
+ { tenantId, couponCode: canonical, cause: err }
1255
+ );
1152
1256
  }
1257
+ seen.add(canonical);
1258
+ placed = rowToCoupon(row);
1259
+ break;
1153
1260
  }
1154
1261
  if (placed) {
1155
1262
  created.push(placed);
@@ -1242,9 +1349,7 @@ function createCouponClient(config) {
1242
1349
  },
1243
1350
  async get(args) {
1244
1351
  const tenantId = tid(args.tenantId);
1245
- if (!args.id || typeof args.id !== "string") {
1246
- throw new ValidationError("id is required");
1247
- }
1352
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1248
1353
  const row = await delegates.coupon(prisma).findFirst({
1249
1354
  where: { id: args.id, tenantId }
1250
1355
  });
@@ -1253,6 +1358,8 @@ function createCouponClient(config) {
1253
1358
  async list(args) {
1254
1359
  const tenantId = tid(args.tenantId);
1255
1360
  const limit = assertPaginationLimit(args.limit);
1361
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
1362
+ assertOptionalString("campaignId", args.campaignId, LIMITS.ID_MAX_LENGTH);
1256
1363
  const where = { tenantId };
1257
1364
  if (args.campaignId) where.campaignId = args.campaignId;
1258
1365
  if (args.status) where.status = args.status;
@@ -1267,6 +1374,8 @@ function createCouponClient(config) {
1267
1374
  },
1268
1375
  async pause(args) {
1269
1376
  const tenantId = tid(args.tenantId);
1377
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1378
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1270
1379
  const now = cfg.now();
1271
1380
  const coupon = await prisma.$transaction(async (tx) => {
1272
1381
  const row = await delegates.coupon(tx).findFirst({
@@ -1300,6 +1409,8 @@ function createCouponClient(config) {
1300
1409
  },
1301
1410
  async resume(args) {
1302
1411
  const tenantId = tid(args.tenantId);
1412
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1413
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1303
1414
  const now = cfg.now();
1304
1415
  const coupon = await prisma.$transaction(async (tx) => {
1305
1416
  const row = await delegates.coupon(tx).findFirst({
@@ -1333,6 +1444,8 @@ function createCouponClient(config) {
1333
1444
  },
1334
1445
  async archive(args) {
1335
1446
  const tenantId = tid(args.tenantId);
1447
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1448
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1336
1449
  const now = cfg.now();
1337
1450
  const coupon = await prisma.$transaction(async (tx) => {
1338
1451
  const row = await delegates.coupon(tx).findFirst({
@@ -1390,7 +1503,8 @@ function createCouponClient(config) {
1390
1503
  if (!elig.ok) {
1391
1504
  return { ok: false, reason: elig.reason };
1392
1505
  }
1393
- if (coupon.maxRedemptionsPerUser !== null && input.userId) {
1506
+ if (coupon.maxRedemptionsPerUser !== null) {
1507
+ assertUserIdForPerUserLimit(input.userId);
1394
1508
  const count = await delegates.couponRedemption(tx).count({
1395
1509
  where: {
1396
1510
  tenantId,
@@ -1474,7 +1588,8 @@ function createCouponClient(config) {
1474
1588
  if (!elig.ok) {
1475
1589
  throw eligibilityReasonToError(elig.reason);
1476
1590
  }
1477
- if (coupon.maxRedemptionsPerUser !== null && input.userId) {
1591
+ if (coupon.maxRedemptionsPerUser !== null) {
1592
+ assertUserIdForPerUserLimit(input.userId);
1478
1593
  const userCount = await delegates.couponRedemption(tx).count({
1479
1594
  where: {
1480
1595
  tenantId,
@@ -1548,6 +1663,7 @@ function createCouponClient(config) {
1548
1663
  });
1549
1664
  redemption = rowToRedemption(inserted);
1550
1665
  } catch (err) {
1666
+ if (!isUniqueViolation(err)) throw err;
1551
1667
  throw new CouponAlreadyRedeemedError(void 0, {
1552
1668
  tenantId,
1553
1669
  couponId: coupon.id,
@@ -1555,7 +1671,7 @@ function createCouponClient(config) {
1555
1671
  cause: err
1556
1672
  });
1557
1673
  }
1558
- if (coupon.maxRedemptionsPerUser !== null && input.userId) {
1674
+ if (coupon.maxRedemptionsPerUser !== null) {
1559
1675
  const userCountFinal = await delegates.couponRedemption(tx).count({
1560
1676
  where: {
1561
1677
  tenantId,
@@ -1655,20 +1771,30 @@ function createCouponClient(config) {
1655
1771
  const metadata = assertMetadata(input.metadata);
1656
1772
  const now = cfg.now();
1657
1773
  const campaign = await prisma.$transaction(async (tx) => {
1658
- const row = await delegates.campaign(tx).create({
1659
- data: {
1660
- id: cfg.idGenerator(),
1661
- tenantId,
1662
- name: input.name,
1663
- status: CAMPAIGN_STATUS.ACTIVE,
1664
- discount: w(input.discount),
1665
- eligibility: w(eligibility),
1666
- startsAt: input.startsAt ?? null,
1667
- endsAt: input.endsAt ?? null,
1668
- maxCoupons: input.maxCoupons ?? null,
1669
- metadata: w(metadata ?? null)
1670
- }
1671
- });
1774
+ let row;
1775
+ try {
1776
+ row = await delegates.campaign(tx).create({
1777
+ data: {
1778
+ id: cfg.idGenerator(),
1779
+ tenantId,
1780
+ name: input.name,
1781
+ status: CAMPAIGN_STATUS.ACTIVE,
1782
+ discount: w(input.discount),
1783
+ eligibility: w(eligibility),
1784
+ startsAt: input.startsAt ?? null,
1785
+ endsAt: input.endsAt ?? null,
1786
+ maxCoupons: input.maxCoupons ?? null,
1787
+ metadata: w(metadata ?? null)
1788
+ }
1789
+ });
1790
+ } catch (err) {
1791
+ if (!isUniqueViolation(err)) throw err;
1792
+ throw new ValidationError(
1793
+ "campaign name already exists in tenant",
1794
+ void 0,
1795
+ { tenantId, cause: err }
1796
+ );
1797
+ }
1672
1798
  const campaign2 = rowToCampaign(row);
1673
1799
  await writeAudit(delegates.couponAuditLog(tx), {
1674
1800
  tenantId,
@@ -1690,7 +1816,7 @@ function createCouponClient(config) {
1690
1816
  },
1691
1817
  async get(args) {
1692
1818
  const tenantId = tid(args.tenantId);
1693
- if (!args.id) throw new ValidationError("id is required");
1819
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1694
1820
  const row = await delegates.campaign(prisma).findFirst({
1695
1821
  where: { id: args.id, tenantId }
1696
1822
  });
@@ -1699,6 +1825,7 @@ function createCouponClient(config) {
1699
1825
  async list(args) {
1700
1826
  const tenantId = tid(args.tenantId);
1701
1827
  const limit = assertPaginationLimit(args.limit);
1828
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
1702
1829
  const where = { tenantId };
1703
1830
  if (args.status) where.status = args.status;
1704
1831
  return paginate(
@@ -1712,9 +1839,8 @@ function createCouponClient(config) {
1712
1839
  },
1713
1840
  async issue(args) {
1714
1841
  if (!args || typeof args !== "object") throw new ValidationError("args required");
1715
- if (typeof args.campaignId !== "string" || !args.campaignId) {
1716
- throw new ValidationError("campaignId is required");
1717
- }
1842
+ assertRequiredString("campaignId", args.campaignId, LIMITS.ID_MAX_LENGTH);
1843
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1718
1844
  if (typeof args.count !== "number" || !Number.isInteger(args.count) || args.count <= 0 || args.count > LIMITS.BULK_MAX_COUNT) {
1719
1845
  throw new ValidationError(
1720
1846
  `count must be a positive integer <= ${LIMITS.BULK_MAX_COUNT}`
@@ -1749,7 +1875,7 @@ function createCouponClient(config) {
1749
1875
  },
1750
1876
  async summary(args) {
1751
1877
  const tenantId = tid(args.tenantId);
1752
- if (!args.id) throw new ValidationError("id is required");
1878
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1753
1879
  const campRow = await delegates.campaign(prisma).findFirst({
1754
1880
  where: { id: args.id, tenantId }
1755
1881
  });
@@ -1802,7 +1928,8 @@ function createCouponClient(config) {
1802
1928
  },
1803
1929
  async close(args) {
1804
1930
  const tenantId = tid(args.tenantId);
1805
- if (!args.id) throw new ValidationError("id is required");
1931
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1932
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1806
1933
  const now = cfg.now();
1807
1934
  const campaign = await prisma.$transaction(async (tx) => {
1808
1935
  const row = await delegates.campaign(tx).findFirst({
@@ -1840,6 +1967,9 @@ function createCouponClient(config) {
1840
1967
  async listIssuances(args) {
1841
1968
  const tenantId = tid(args.tenantId);
1842
1969
  const limit = assertPaginationLimit(args.limit);
1970
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
1971
+ assertOptionalString("couponId", args.couponId, LIMITS.ID_MAX_LENGTH);
1972
+ assertOptionalString("issuedToUserId", args.issuedToUserId, LIMITS.USER_ID_MAX_LENGTH);
1843
1973
  const where = { tenantId };
1844
1974
  if (args.couponId) where.couponId = args.couponId;
1845
1975
  if (args.issuedToUserId) where.issuedToUserId = args.issuedToUserId;
@@ -1855,6 +1985,9 @@ function createCouponClient(config) {
1855
1985
  async listRedemptions(args) {
1856
1986
  const tenantId = tid(args.tenantId);
1857
1987
  const limit = assertPaginationLimit(args.limit);
1988
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
1989
+ assertOptionalString("couponId", args.couponId, LIMITS.ID_MAX_LENGTH);
1990
+ assertOptionalString("redeemedByUserId", args.redeemedByUserId, LIMITS.USER_ID_MAX_LENGTH);
1858
1991
  const where = { tenantId };
1859
1992
  if (args.couponId) where.couponId = args.couponId;
1860
1993
  if (args.redeemedByUserId) where.redeemedByUserId = args.redeemedByUserId;
@@ -1870,6 +2003,8 @@ function createCouponClient(config) {
1870
2003
  async listLogs(args) {
1871
2004
  const tenantId = tid(args.tenantId);
1872
2005
  const limit = assertPaginationLimit(args.limit);
2006
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
2007
+ assertOptionalString("resourceId", args.resourceId, LIMITS.ID_MAX_LENGTH);
1873
2008
  const where = { tenantId };
1874
2009
  if (args.action) where.action = args.action;
1875
2010
  if (args.resourceId) where.resourceId = args.resourceId;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "packageManager": "pnpm@11.10.0",
3
3
  "name": "@withwiz/coupon",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Multi-tenant, concurrency-safe coupon domain core for Prisma + Node.js applications",
6
6
  "license": "MIT",
7
7
  "author": "withwiz",
@@ -76,11 +76,12 @@
76
76
  },
77
77
  "devDependencies": {
78
78
  "@prisma/client": "^6.0.0",
79
- "@types/node": "^20.12.0",
80
- "@vitest/coverage-v8": "^1.6.0",
79
+ "@types/node": "^22.0.0",
80
+ "@vitest/coverage-v8": "^5.0.0",
81
81
  "prisma": "^6.0.0",
82
82
  "tsup": "^8.0.0",
83
83
  "typescript": "^5.4.0",
84
- "vitest": "^1.6.0"
84
+ "vite": "^7.0.0",
85
+ "vitest": "^5.0.0"
85
86
  }
86
87
  }