@withwiz/coupon 0.1.0 → 0.3.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 +67 -2
- package/dist/index.cjs +238 -52
- package/dist/index.d.cts +52 -1
- package/dist/index.d.ts +52 -1
- package/dist/index.mjs +238 -53
- package/package.json +7 -6
package/dist/index.cjs
CHANGED
|
@@ -55,7 +55,25 @@ 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,
|
|
75
|
+
/** Upper bound for `duration.months` on a REPEATING discount. */
|
|
76
|
+
DURATION_MAX_MONTHS: 36
|
|
59
77
|
};
|
|
60
78
|
var AUDIT_ACTION = {
|
|
61
79
|
COUPON_CREATED: "coupon.created",
|
|
@@ -264,6 +282,7 @@ function validateDiscountPolicy(policy) {
|
|
|
264
282
|
if (policy.currency !== void 0) {
|
|
265
283
|
ensureCurrency(policy.currency);
|
|
266
284
|
}
|
|
285
|
+
validateDuration(policy.duration);
|
|
267
286
|
return;
|
|
268
287
|
}
|
|
269
288
|
if (policy.kind === "FIXED") {
|
|
@@ -281,6 +300,7 @@ function validateDiscountPolicy(policy) {
|
|
|
281
300
|
);
|
|
282
301
|
}
|
|
283
302
|
}
|
|
303
|
+
validateDuration(policy.duration);
|
|
284
304
|
return;
|
|
285
305
|
}
|
|
286
306
|
throw new InvalidDiscountPolicyError(
|
|
@@ -353,6 +373,27 @@ function extractPolicyCurrency(policy) {
|
|
|
353
373
|
if (policy.kind === "PERCENT") return policy.currency;
|
|
354
374
|
return void 0;
|
|
355
375
|
}
|
|
376
|
+
function validateDuration(duration) {
|
|
377
|
+
if (duration === void 0) return;
|
|
378
|
+
if (!duration || typeof duration !== "object") {
|
|
379
|
+
throw new InvalidDiscountPolicyError("duration must be an object");
|
|
380
|
+
}
|
|
381
|
+
const kind = duration.kind;
|
|
382
|
+
if (kind === "ONCE" || kind === "FOREVER") return;
|
|
383
|
+
if (kind === "REPEATING") {
|
|
384
|
+
const months = duration.months;
|
|
385
|
+
if (typeof months !== "number" || !Number.isInteger(months) || months < 1 || months > LIMITS.DURATION_MAX_MONTHS) {
|
|
386
|
+
throw new InvalidDiscountPolicyError(
|
|
387
|
+
`duration.months must be an integer in [1, ${LIMITS.DURATION_MAX_MONTHS}] (got ${String(months)})`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
throw new InvalidDiscountPolicyError(`unknown duration kind: ${String(kind)}`);
|
|
393
|
+
}
|
|
394
|
+
function resolveDuration(policy) {
|
|
395
|
+
return policy.duration ?? { kind: "ONCE" };
|
|
396
|
+
}
|
|
356
397
|
|
|
357
398
|
// src/eligibility.ts
|
|
358
399
|
function checkEligibility(input) {
|
|
@@ -570,6 +611,15 @@ function assertIdentifier(kind, key, value) {
|
|
|
570
611
|
}
|
|
571
612
|
return value;
|
|
572
613
|
}
|
|
614
|
+
function resolveSchemaName(schema) {
|
|
615
|
+
if (schema === void 0) return void 0;
|
|
616
|
+
if (typeof schema !== "string" || !IDENTIFIER_RE.test(schema)) {
|
|
617
|
+
throw new InvalidModelNameError(
|
|
618
|
+
`Invalid schema name: ${JSON.stringify(schema)}. Expected a plain identifier matching ${IDENTIFIER_RE.source}.`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
return schema;
|
|
622
|
+
}
|
|
573
623
|
function resolveModelNames(overrides) {
|
|
574
624
|
const out = { ...DEFAULT_MODEL_NAMES };
|
|
575
625
|
for (const key of MODEL_KEYS) {
|
|
@@ -596,19 +646,21 @@ function pick(db, key, name) {
|
|
|
596
646
|
}
|
|
597
647
|
return delegate;
|
|
598
648
|
}
|
|
599
|
-
function createDelegateResolver(models, tables) {
|
|
649
|
+
function createDelegateResolver(models, tables, schema) {
|
|
600
650
|
const resolver = {
|
|
601
651
|
models,
|
|
602
|
-
tables
|
|
652
|
+
tables,
|
|
653
|
+
schema
|
|
603
654
|
};
|
|
604
655
|
for (const key of MODEL_KEYS) {
|
|
605
656
|
resolver[key] = (db) => pick(db, key, models[key]);
|
|
606
657
|
}
|
|
607
658
|
return resolver;
|
|
608
659
|
}
|
|
609
|
-
function lockCouponRow(tx, table, couponId, tenantId) {
|
|
660
|
+
function lockCouponRow(tx, table, couponId, tenantId, schema) {
|
|
661
|
+
const target = schema ? `"${schema}"."${table}"` : `"${table}"`;
|
|
610
662
|
const parts = [
|
|
611
|
-
`SELECT * FROM
|
|
663
|
+
`SELECT * FROM ${target} WHERE "id" = `,
|
|
612
664
|
` AND "tenantId" = `,
|
|
613
665
|
` FOR UPDATE`
|
|
614
666
|
];
|
|
@@ -617,6 +669,40 @@ function lockCouponRow(tx, table, couponId, tenantId) {
|
|
|
617
669
|
});
|
|
618
670
|
return tx.$queryRaw(strings, couponId, tenantId);
|
|
619
671
|
}
|
|
672
|
+
function assertOptionalString(name, value, max) {
|
|
673
|
+
if (value === void 0 || value === null) return;
|
|
674
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
675
|
+
throw new ValidationError(`${name} must be a non-empty string`);
|
|
676
|
+
}
|
|
677
|
+
if (value.length > max) {
|
|
678
|
+
throw new ValidationError(`${name} length exceeds max (${max})`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function assertRequiredString(name, value, max) {
|
|
682
|
+
if (value === void 0 || value === null) {
|
|
683
|
+
throw new ValidationError(`${name} is required`);
|
|
684
|
+
}
|
|
685
|
+
assertOptionalString(name, value, max);
|
|
686
|
+
}
|
|
687
|
+
function assertContext(context) {
|
|
688
|
+
if (context === void 0 || context === null) return;
|
|
689
|
+
if (typeof context !== "object" || Array.isArray(context)) {
|
|
690
|
+
throw new ValidationError("context must be a plain object");
|
|
691
|
+
}
|
|
692
|
+
const c = context;
|
|
693
|
+
assertOptionalString("context.plan", c.plan, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
|
|
694
|
+
assertOptionalString("context.productId", c.productId, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
|
|
695
|
+
assertOptionalString("context.categoryId", c.categoryId, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
|
|
696
|
+
}
|
|
697
|
+
function assertCouponStatus(status) {
|
|
698
|
+
if (status === void 0) return;
|
|
699
|
+
const allowed = Object.values(COUPON_STATUS);
|
|
700
|
+
if (typeof status !== "string" || !allowed.includes(status)) {
|
|
701
|
+
throw new ValidationError(
|
|
702
|
+
`status must be one of ${allowed.join(", ")}`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
620
706
|
function assertTenantId(value) {
|
|
621
707
|
if (typeof value !== "string" || value.length === 0) {
|
|
622
708
|
throw new ValidationError("tenantId is required");
|
|
@@ -678,6 +764,17 @@ function assertMetadata(meta) {
|
|
|
678
764
|
);
|
|
679
765
|
}
|
|
680
766
|
}
|
|
767
|
+
let serialized;
|
|
768
|
+
try {
|
|
769
|
+
serialized = JSON.stringify(rec);
|
|
770
|
+
} catch {
|
|
771
|
+
throw new ValidationError("metadata must be JSON-serializable");
|
|
772
|
+
}
|
|
773
|
+
if (Buffer.byteLength(serialized, "utf8") > LIMITS.METADATA_MAX_BYTES) {
|
|
774
|
+
throw new ValidationError(
|
|
775
|
+
`metadata exceeds max size (${LIMITS.METADATA_MAX_BYTES} bytes)`
|
|
776
|
+
);
|
|
777
|
+
}
|
|
681
778
|
return rec;
|
|
682
779
|
}
|
|
683
780
|
function assertPaginationLimit(limit) {
|
|
@@ -729,6 +826,16 @@ function assertEligibility(rules) {
|
|
|
729
826
|
`eligibility.${key} must be an array of non-empty strings`
|
|
730
827
|
);
|
|
731
828
|
}
|
|
829
|
+
if (v.length > LIMITS.ELIGIBILITY_MAX_ENTRIES) {
|
|
830
|
+
throw new ValidationError(
|
|
831
|
+
`eligibility.${key} exceeds max entries (${LIMITS.ELIGIBILITY_MAX_ENTRIES})`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
if (v.some((x) => x.length > LIMITS.ELIGIBILITY_ENTRY_MAX_LENGTH)) {
|
|
835
|
+
throw new ValidationError(
|
|
836
|
+
`eligibility.${key} entry exceeds max length (${LIMITS.ELIGIBILITY_ENTRY_MAX_LENGTH})`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
732
839
|
out[key] = v;
|
|
733
840
|
}
|
|
734
841
|
return out;
|
|
@@ -745,6 +852,9 @@ function validateCreateCouponInput(input) {
|
|
|
745
852
|
validateDiscountPolicy(input.discount);
|
|
746
853
|
assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
|
|
747
854
|
assertMetadata(input.metadata);
|
|
855
|
+
assertCouponStatus(input.status);
|
|
856
|
+
assertOptionalString("campaignId", input.campaignId, LIMITS.ID_MAX_LENGTH);
|
|
857
|
+
assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
748
858
|
if (input.maxRedemptions !== void 0 && input.maxRedemptions !== null) {
|
|
749
859
|
if (typeof input.maxRedemptions !== "number" || !Number.isInteger(input.maxRedemptions) || input.maxRedemptions <= 0) {
|
|
750
860
|
throw new ValidationError("maxRedemptions must be a positive integer or null");
|
|
@@ -780,14 +890,16 @@ function validateIssueBulkInput(input) {
|
|
|
780
890
|
validateDiscountPolicy(input.discount);
|
|
781
891
|
assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
|
|
782
892
|
assertMetadata(input.metadata);
|
|
893
|
+
assertOptionalString("campaignId", input.campaignId, LIMITS.ID_MAX_LENGTH);
|
|
894
|
+
assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
783
895
|
}
|
|
784
896
|
function validateIssueCouponInput(input) {
|
|
785
897
|
if (!input || typeof input !== "object") {
|
|
786
898
|
throw new ValidationError("input must be an object");
|
|
787
899
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
900
|
+
assertRequiredString("couponId", input.couponId, LIMITS.ID_MAX_LENGTH);
|
|
901
|
+
assertOptionalString("issuedToUserId", input.issuedToUserId, LIMITS.USER_ID_MAX_LENGTH);
|
|
902
|
+
assertOptionalString("issuedBy", input.issuedBy, LIMITS.USER_ID_MAX_LENGTH);
|
|
791
903
|
assertMetadata(input.metadata);
|
|
792
904
|
}
|
|
793
905
|
function validateValidateInput(input) {
|
|
@@ -797,6 +909,8 @@ function validateValidateInput(input) {
|
|
|
797
909
|
if (typeof input.code !== "string" || input.code.length === 0) {
|
|
798
910
|
throw new ValidationError("code is required");
|
|
799
911
|
}
|
|
912
|
+
assertOptionalString("userId", input.userId, LIMITS.USER_ID_MAX_LENGTH);
|
|
913
|
+
assertContext(input.context);
|
|
800
914
|
if (input.subtotal !== void 0) {
|
|
801
915
|
if (typeof input.subtotal !== "number" || !Number.isInteger(input.subtotal) || input.subtotal < 0) {
|
|
802
916
|
throw new ValidationError("subtotal must be a non-negative integer");
|
|
@@ -817,6 +931,9 @@ function validateRedeemInput(input) {
|
|
|
817
931
|
throw new ValidationError("subtotal must be a non-negative integer");
|
|
818
932
|
}
|
|
819
933
|
assertCurrency(input.currency);
|
|
934
|
+
assertOptionalString("userId", input.userId, LIMITS.USER_ID_MAX_LENGTH);
|
|
935
|
+
assertOptionalString("orderRef", input.orderRef, LIMITS.ORDER_REF_MAX_LENGTH);
|
|
936
|
+
assertContext(input.context);
|
|
820
937
|
assertMetadata(input.metadata);
|
|
821
938
|
}
|
|
822
939
|
function validateCreateCampaignInput(input) {
|
|
@@ -834,6 +951,7 @@ function validateCreateCampaignInput(input) {
|
|
|
834
951
|
validateDiscountPolicy(input.discount);
|
|
835
952
|
assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
|
|
836
953
|
assertMetadata(input.metadata);
|
|
954
|
+
assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
837
955
|
if (input.maxCoupons !== void 0 && input.maxCoupons !== null) {
|
|
838
956
|
if (typeof input.maxCoupons !== "number" || !Number.isInteger(input.maxCoupons) || input.maxCoupons <= 0) {
|
|
839
957
|
throw new ValidationError(
|
|
@@ -847,19 +965,29 @@ function validateCreateCampaignInput(input) {
|
|
|
847
965
|
}
|
|
848
966
|
}
|
|
849
967
|
}
|
|
968
|
+
var discountDurationSchema = zod.z.discriminatedUnion("kind", [
|
|
969
|
+
zod.z.object({ kind: zod.z.literal("ONCE") }),
|
|
970
|
+
zod.z.object({
|
|
971
|
+
kind: zod.z.literal("REPEATING"),
|
|
972
|
+
months: zod.z.number().int().min(1).max(LIMITS.DURATION_MAX_MONTHS)
|
|
973
|
+
}),
|
|
974
|
+
zod.z.object({ kind: zod.z.literal("FOREVER") })
|
|
975
|
+
]);
|
|
850
976
|
zod.z.discriminatedUnion("kind", [
|
|
851
977
|
zod.z.object({
|
|
852
978
|
kind: zod.z.literal("PERCENT"),
|
|
853
979
|
percent: zod.z.number().gt(0).lte(100),
|
|
854
980
|
maxDiscount: zod.z.number().int().nonnegative().optional(),
|
|
855
981
|
minOrder: zod.z.number().int().nonnegative().optional(),
|
|
856
|
-
currency: zod.z.string().length(3).optional()
|
|
982
|
+
currency: zod.z.string().length(3).optional(),
|
|
983
|
+
duration: discountDurationSchema.optional()
|
|
857
984
|
}),
|
|
858
985
|
zod.z.object({
|
|
859
986
|
kind: zod.z.literal("FIXED"),
|
|
860
987
|
amount: zod.z.number().int().positive(),
|
|
861
988
|
currency: zod.z.string().length(3),
|
|
862
|
-
minOrder: zod.z.number().int().nonnegative().optional()
|
|
989
|
+
minOrder: zod.z.number().int().nonnegative().optional(),
|
|
990
|
+
duration: discountDurationSchema.optional()
|
|
863
991
|
})
|
|
864
992
|
]);
|
|
865
993
|
|
|
@@ -867,6 +995,9 @@ zod.z.discriminatedUnion("kind", [
|
|
|
867
995
|
function w(v) {
|
|
868
996
|
return v;
|
|
869
997
|
}
|
|
998
|
+
function isUniqueViolation(err) {
|
|
999
|
+
return typeof err === "object" && err !== null && err.code === "P2002";
|
|
1000
|
+
}
|
|
870
1001
|
var NOOP_LOGGER = {
|
|
871
1002
|
debug() {
|
|
872
1003
|
},
|
|
@@ -899,7 +1030,8 @@ function resolveConfig(config) {
|
|
|
899
1030
|
// allow-now: default fallback when config.now is omitted
|
|
900
1031
|
logger: config.logger ?? NOOP_LOGGER,
|
|
901
1032
|
models,
|
|
902
|
-
tables: resolveTableNames(models, config.tables)
|
|
1033
|
+
tables: resolveTableNames(models, config.tables),
|
|
1034
|
+
schema: resolveSchemaName(config.schema)
|
|
903
1035
|
};
|
|
904
1036
|
}
|
|
905
1037
|
async function safeEmit(handler, logger, event) {
|
|
@@ -912,6 +1044,13 @@ async function safeEmit(handler, logger, event) {
|
|
|
912
1044
|
});
|
|
913
1045
|
}
|
|
914
1046
|
}
|
|
1047
|
+
function assertUserIdForPerUserLimit(userId) {
|
|
1048
|
+
if (typeof userId !== "string" || userId.length === 0) {
|
|
1049
|
+
throw new ValidationError(
|
|
1050
|
+
"userId is required: this coupon enforces maxRedemptionsPerUser"
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
915
1054
|
async function resolveLazyStatus(delegates, tx, coupon, now) {
|
|
916
1055
|
if (coupon.status === COUPON_STATUS.ARCHIVED) {
|
|
917
1056
|
return { kind: "reject", reason: "ARCHIVED", coupon };
|
|
@@ -970,7 +1109,7 @@ function withIdTieBreak(orderBy) {
|
|
|
970
1109
|
function createCouponClient(config) {
|
|
971
1110
|
const cfg = resolveConfig(config);
|
|
972
1111
|
const prisma = cfg.prisma;
|
|
973
|
-
const delegates = createDelegateResolver(cfg.models, cfg.tables);
|
|
1112
|
+
const delegates = createDelegateResolver(cfg.models, cfg.tables, cfg.schema);
|
|
974
1113
|
const tid = (v) => resolveTenantId(v, cfg.defaultTenantId);
|
|
975
1114
|
async function paginate(delegate, where, limit, cursor, mapFn, orderBy = { createdAt: "desc" }) {
|
|
976
1115
|
const take = limit + 1;
|
|
@@ -1003,20 +1142,21 @@ function createCouponClient(config) {
|
|
|
1003
1142
|
const created = await delegates.coupon(tx).create({ data: row });
|
|
1004
1143
|
return rowToCoupon(created);
|
|
1005
1144
|
} catch (err) {
|
|
1145
|
+
if (!isUniqueViolation(err)) throw err;
|
|
1006
1146
|
lastError = err;
|
|
1007
1147
|
if (baseCode) {
|
|
1008
1148
|
throw new ValidationError(
|
|
1009
1149
|
`coupon code already exists in tenant`,
|
|
1010
|
-
|
|
1011
|
-
{ tenantId, couponCode: baseCode }
|
|
1150
|
+
void 0,
|
|
1151
|
+
{ tenantId, couponCode: baseCode, cause: err }
|
|
1012
1152
|
);
|
|
1013
1153
|
}
|
|
1014
1154
|
}
|
|
1015
1155
|
}
|
|
1016
1156
|
throw new ValidationError(
|
|
1017
1157
|
`failed to create coupon after ${maxAttempts} attempts (unique code collision)`,
|
|
1018
|
-
|
|
1019
|
-
{ tenantId }
|
|
1158
|
+
void 0,
|
|
1159
|
+
{ tenantId, cause: lastError }
|
|
1020
1160
|
);
|
|
1021
1161
|
}
|
|
1022
1162
|
const coupons = {
|
|
@@ -1121,15 +1261,25 @@ function createCouponClient(config) {
|
|
|
1121
1261
|
let skipped = 0;
|
|
1122
1262
|
let totalAttempts = 0;
|
|
1123
1263
|
await prisma.$transaction(async (tx) => {
|
|
1264
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1124
1265
|
for (let i = 0; i < input.count; i++) {
|
|
1125
1266
|
let placed = null;
|
|
1126
1267
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1127
1268
|
totalAttempts++;
|
|
1128
|
-
const
|
|
1269
|
+
const canonical = canonicalizeCode(
|
|
1270
|
+
cfg.codeGenerator({ length: codeLength })
|
|
1271
|
+
);
|
|
1272
|
+
const collided = seen.has(canonical) || await delegates.coupon(tx).findFirst({
|
|
1273
|
+
where: { tenantId, code: canonical },
|
|
1274
|
+
select: { id: true }
|
|
1275
|
+
}) !== null;
|
|
1276
|
+
if (collided) {
|
|
1277
|
+
if (attempt === maxAttempts) skipped++;
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
let row;
|
|
1129
1281
|
try {
|
|
1130
|
-
|
|
1131
|
-
const canonical = canonicalizeCode(code);
|
|
1132
|
-
const row = await delegates.coupon(tx).create({
|
|
1282
|
+
row = await delegates.coupon(tx).create({
|
|
1133
1283
|
data: {
|
|
1134
1284
|
id: cfg.idGenerator(),
|
|
1135
1285
|
tenantId,
|
|
@@ -1145,13 +1295,17 @@ function createCouponClient(config) {
|
|
|
1145
1295
|
metadata: w(metadata ?? null)
|
|
1146
1296
|
}
|
|
1147
1297
|
});
|
|
1148
|
-
placed = rowToCoupon(row);
|
|
1149
|
-
break;
|
|
1150
1298
|
} catch (err) {
|
|
1151
|
-
if (
|
|
1152
|
-
|
|
1153
|
-
|
|
1299
|
+
if (!isUniqueViolation(err)) throw err;
|
|
1300
|
+
throw new ValidationError(
|
|
1301
|
+
"bulk issuance aborted: coupon code collided with a concurrent insert",
|
|
1302
|
+
void 0,
|
|
1303
|
+
{ tenantId, couponCode: canonical, cause: err }
|
|
1304
|
+
);
|
|
1154
1305
|
}
|
|
1306
|
+
seen.add(canonical);
|
|
1307
|
+
placed = rowToCoupon(row);
|
|
1308
|
+
break;
|
|
1155
1309
|
}
|
|
1156
1310
|
if (placed) {
|
|
1157
1311
|
created.push(placed);
|
|
@@ -1244,9 +1398,7 @@ function createCouponClient(config) {
|
|
|
1244
1398
|
},
|
|
1245
1399
|
async get(args) {
|
|
1246
1400
|
const tenantId = tid(args.tenantId);
|
|
1247
|
-
|
|
1248
|
-
throw new ValidationError("id is required");
|
|
1249
|
-
}
|
|
1401
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1250
1402
|
const row = await delegates.coupon(prisma).findFirst({
|
|
1251
1403
|
where: { id: args.id, tenantId }
|
|
1252
1404
|
});
|
|
@@ -1255,6 +1407,8 @@ function createCouponClient(config) {
|
|
|
1255
1407
|
async list(args) {
|
|
1256
1408
|
const tenantId = tid(args.tenantId);
|
|
1257
1409
|
const limit = assertPaginationLimit(args.limit);
|
|
1410
|
+
assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
|
|
1411
|
+
assertOptionalString("campaignId", args.campaignId, LIMITS.ID_MAX_LENGTH);
|
|
1258
1412
|
const where = { tenantId };
|
|
1259
1413
|
if (args.campaignId) where.campaignId = args.campaignId;
|
|
1260
1414
|
if (args.status) where.status = args.status;
|
|
@@ -1269,6 +1423,8 @@ function createCouponClient(config) {
|
|
|
1269
1423
|
},
|
|
1270
1424
|
async pause(args) {
|
|
1271
1425
|
const tenantId = tid(args.tenantId);
|
|
1426
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1427
|
+
assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1272
1428
|
const now = cfg.now();
|
|
1273
1429
|
const coupon = await prisma.$transaction(async (tx) => {
|
|
1274
1430
|
const row = await delegates.coupon(tx).findFirst({
|
|
@@ -1302,6 +1458,8 @@ function createCouponClient(config) {
|
|
|
1302
1458
|
},
|
|
1303
1459
|
async resume(args) {
|
|
1304
1460
|
const tenantId = tid(args.tenantId);
|
|
1461
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1462
|
+
assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1305
1463
|
const now = cfg.now();
|
|
1306
1464
|
const coupon = await prisma.$transaction(async (tx) => {
|
|
1307
1465
|
const row = await delegates.coupon(tx).findFirst({
|
|
@@ -1335,6 +1493,8 @@ function createCouponClient(config) {
|
|
|
1335
1493
|
},
|
|
1336
1494
|
async archive(args) {
|
|
1337
1495
|
const tenantId = tid(args.tenantId);
|
|
1496
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1497
|
+
assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1338
1498
|
const now = cfg.now();
|
|
1339
1499
|
const coupon = await prisma.$transaction(async (tx) => {
|
|
1340
1500
|
const row = await delegates.coupon(tx).findFirst({
|
|
@@ -1392,7 +1552,8 @@ function createCouponClient(config) {
|
|
|
1392
1552
|
if (!elig.ok) {
|
|
1393
1553
|
return { ok: false, reason: elig.reason };
|
|
1394
1554
|
}
|
|
1395
|
-
if (coupon.maxRedemptionsPerUser !== null
|
|
1555
|
+
if (coupon.maxRedemptionsPerUser !== null) {
|
|
1556
|
+
assertUserIdForPerUserLimit(input.userId);
|
|
1396
1557
|
const count = await delegates.couponRedemption(tx).count({
|
|
1397
1558
|
where: {
|
|
1398
1559
|
tenantId,
|
|
@@ -1434,7 +1595,8 @@ function createCouponClient(config) {
|
|
|
1434
1595
|
tx,
|
|
1435
1596
|
delegates.tables.coupon,
|
|
1436
1597
|
couponId,
|
|
1437
|
-
tenantId
|
|
1598
|
+
tenantId,
|
|
1599
|
+
delegates.schema
|
|
1438
1600
|
);
|
|
1439
1601
|
if (!Array.isArray(locked) || locked.length === 0) {
|
|
1440
1602
|
throw new CouponNotFoundError("Coupon not found", { tenantId, couponId });
|
|
@@ -1476,7 +1638,8 @@ function createCouponClient(config) {
|
|
|
1476
1638
|
if (!elig.ok) {
|
|
1477
1639
|
throw eligibilityReasonToError(elig.reason);
|
|
1478
1640
|
}
|
|
1479
|
-
if (coupon.maxRedemptionsPerUser !== null
|
|
1641
|
+
if (coupon.maxRedemptionsPerUser !== null) {
|
|
1642
|
+
assertUserIdForPerUserLimit(input.userId);
|
|
1480
1643
|
const userCount = await delegates.couponRedemption(tx).count({
|
|
1481
1644
|
where: {
|
|
1482
1645
|
tenantId,
|
|
@@ -1550,6 +1713,7 @@ function createCouponClient(config) {
|
|
|
1550
1713
|
});
|
|
1551
1714
|
redemption = rowToRedemption(inserted);
|
|
1552
1715
|
} catch (err) {
|
|
1716
|
+
if (!isUniqueViolation(err)) throw err;
|
|
1553
1717
|
throw new CouponAlreadyRedeemedError(void 0, {
|
|
1554
1718
|
tenantId,
|
|
1555
1719
|
couponId: coupon.id,
|
|
@@ -1557,7 +1721,7 @@ function createCouponClient(config) {
|
|
|
1557
1721
|
cause: err
|
|
1558
1722
|
});
|
|
1559
1723
|
}
|
|
1560
|
-
if (coupon.maxRedemptionsPerUser !== null
|
|
1724
|
+
if (coupon.maxRedemptionsPerUser !== null) {
|
|
1561
1725
|
const userCountFinal = await delegates.couponRedemption(tx).count({
|
|
1562
1726
|
where: {
|
|
1563
1727
|
tenantId,
|
|
@@ -1657,20 +1821,30 @@ function createCouponClient(config) {
|
|
|
1657
1821
|
const metadata = assertMetadata(input.metadata);
|
|
1658
1822
|
const now = cfg.now();
|
|
1659
1823
|
const campaign = await prisma.$transaction(async (tx) => {
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1824
|
+
let row;
|
|
1825
|
+
try {
|
|
1826
|
+
row = await delegates.campaign(tx).create({
|
|
1827
|
+
data: {
|
|
1828
|
+
id: cfg.idGenerator(),
|
|
1829
|
+
tenantId,
|
|
1830
|
+
name: input.name,
|
|
1831
|
+
status: CAMPAIGN_STATUS.ACTIVE,
|
|
1832
|
+
discount: w(input.discount),
|
|
1833
|
+
eligibility: w(eligibility),
|
|
1834
|
+
startsAt: input.startsAt ?? null,
|
|
1835
|
+
endsAt: input.endsAt ?? null,
|
|
1836
|
+
maxCoupons: input.maxCoupons ?? null,
|
|
1837
|
+
metadata: w(metadata ?? null)
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
} catch (err) {
|
|
1841
|
+
if (!isUniqueViolation(err)) throw err;
|
|
1842
|
+
throw new ValidationError(
|
|
1843
|
+
"campaign name already exists in tenant",
|
|
1844
|
+
void 0,
|
|
1845
|
+
{ tenantId, cause: err }
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1674
1848
|
const campaign2 = rowToCampaign(row);
|
|
1675
1849
|
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1676
1850
|
tenantId,
|
|
@@ -1692,7 +1866,7 @@ function createCouponClient(config) {
|
|
|
1692
1866
|
},
|
|
1693
1867
|
async get(args) {
|
|
1694
1868
|
const tenantId = tid(args.tenantId);
|
|
1695
|
-
|
|
1869
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1696
1870
|
const row = await delegates.campaign(prisma).findFirst({
|
|
1697
1871
|
where: { id: args.id, tenantId }
|
|
1698
1872
|
});
|
|
@@ -1701,6 +1875,7 @@ function createCouponClient(config) {
|
|
|
1701
1875
|
async list(args) {
|
|
1702
1876
|
const tenantId = tid(args.tenantId);
|
|
1703
1877
|
const limit = assertPaginationLimit(args.limit);
|
|
1878
|
+
assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
|
|
1704
1879
|
const where = { tenantId };
|
|
1705
1880
|
if (args.status) where.status = args.status;
|
|
1706
1881
|
return paginate(
|
|
@@ -1714,9 +1889,8 @@ function createCouponClient(config) {
|
|
|
1714
1889
|
},
|
|
1715
1890
|
async issue(args) {
|
|
1716
1891
|
if (!args || typeof args !== "object") throw new ValidationError("args required");
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
}
|
|
1892
|
+
assertRequiredString("campaignId", args.campaignId, LIMITS.ID_MAX_LENGTH);
|
|
1893
|
+
assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1720
1894
|
if (typeof args.count !== "number" || !Number.isInteger(args.count) || args.count <= 0 || args.count > LIMITS.BULK_MAX_COUNT) {
|
|
1721
1895
|
throw new ValidationError(
|
|
1722
1896
|
`count must be a positive integer <= ${LIMITS.BULK_MAX_COUNT}`
|
|
@@ -1746,12 +1920,14 @@ function createCouponClient(config) {
|
|
|
1746
1920
|
endsAt: campaign.endsAt,
|
|
1747
1921
|
maxAttempts: args.maxAttempts,
|
|
1748
1922
|
eligibility: campaign.eligibility,
|
|
1923
|
+
maxRedemptions: args.maxRedemptions,
|
|
1924
|
+
maxRedemptionsPerUser: args.maxRedemptionsPerUser,
|
|
1749
1925
|
actorId: args.actorId
|
|
1750
1926
|
});
|
|
1751
1927
|
},
|
|
1752
1928
|
async summary(args) {
|
|
1753
1929
|
const tenantId = tid(args.tenantId);
|
|
1754
|
-
|
|
1930
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1755
1931
|
const campRow = await delegates.campaign(prisma).findFirst({
|
|
1756
1932
|
where: { id: args.id, tenantId }
|
|
1757
1933
|
});
|
|
@@ -1804,7 +1980,8 @@ function createCouponClient(config) {
|
|
|
1804
1980
|
},
|
|
1805
1981
|
async close(args) {
|
|
1806
1982
|
const tenantId = tid(args.tenantId);
|
|
1807
|
-
|
|
1983
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1984
|
+
assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1808
1985
|
const now = cfg.now();
|
|
1809
1986
|
const campaign = await prisma.$transaction(async (tx) => {
|
|
1810
1987
|
const row = await delegates.campaign(tx).findFirst({
|
|
@@ -1842,6 +2019,9 @@ function createCouponClient(config) {
|
|
|
1842
2019
|
async listIssuances(args) {
|
|
1843
2020
|
const tenantId = tid(args.tenantId);
|
|
1844
2021
|
const limit = assertPaginationLimit(args.limit);
|
|
2022
|
+
assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
|
|
2023
|
+
assertOptionalString("couponId", args.couponId, LIMITS.ID_MAX_LENGTH);
|
|
2024
|
+
assertOptionalString("issuedToUserId", args.issuedToUserId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1845
2025
|
const where = { tenantId };
|
|
1846
2026
|
if (args.couponId) where.couponId = args.couponId;
|
|
1847
2027
|
if (args.issuedToUserId) where.issuedToUserId = args.issuedToUserId;
|
|
@@ -1857,6 +2037,9 @@ function createCouponClient(config) {
|
|
|
1857
2037
|
async listRedemptions(args) {
|
|
1858
2038
|
const tenantId = tid(args.tenantId);
|
|
1859
2039
|
const limit = assertPaginationLimit(args.limit);
|
|
2040
|
+
assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
|
|
2041
|
+
assertOptionalString("couponId", args.couponId, LIMITS.ID_MAX_LENGTH);
|
|
2042
|
+
assertOptionalString("redeemedByUserId", args.redeemedByUserId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1860
2043
|
const where = { tenantId };
|
|
1861
2044
|
if (args.couponId) where.couponId = args.couponId;
|
|
1862
2045
|
if (args.redeemedByUserId) where.redeemedByUserId = args.redeemedByUserId;
|
|
@@ -1872,6 +2055,8 @@ function createCouponClient(config) {
|
|
|
1872
2055
|
async listLogs(args) {
|
|
1873
2056
|
const tenantId = tid(args.tenantId);
|
|
1874
2057
|
const limit = assertPaginationLimit(args.limit);
|
|
2058
|
+
assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
|
|
2059
|
+
assertOptionalString("resourceId", args.resourceId, LIMITS.ID_MAX_LENGTH);
|
|
1875
2060
|
const where = { tenantId };
|
|
1876
2061
|
if (args.action) where.action = args.action;
|
|
1877
2062
|
if (args.resourceId) where.resourceId = args.resourceId;
|
|
@@ -1925,4 +2110,5 @@ exports.TenantMismatchError = TenantMismatchError;
|
|
|
1925
2110
|
exports.ValidationError = ValidationError;
|
|
1926
2111
|
exports.calculateDiscount = calculate;
|
|
1927
2112
|
exports.createCouponClient = createCouponClient;
|
|
2113
|
+
exports.resolveDuration = resolveDuration;
|
|
1928
2114
|
exports.validateDiscountPolicy = validateDiscountPolicy;
|