@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 +22 -0
- package/dist/index.cjs +178 -43
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.mjs +178 -43
- package/package.json +5 -4
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
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1152
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1150
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1716
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
80
|
-
"@vitest/coverage-v8": "^
|
|
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
|
-
"
|
|
84
|
+
"vite": "^7.0.0",
|
|
85
|
+
"vitest": "^5.0.0"
|
|
85
86
|
}
|
|
86
87
|
}
|