@stamprally/server 0.14.0 → 0.15.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 +10 -3
- package/dist/index.cjs +326 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -4
- package/dist/index.d.ts +41 -4
- package/dist/index.js +325 -64
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -13,6 +13,10 @@ async function executeClaimRewardTransaction(database, store, params, mutation)
|
|
|
13
13
|
return { success: false, error: next.error };
|
|
14
14
|
}
|
|
15
15
|
if (next.nextStock !== null) await store.writeStock(transaction, params, next.nextStock);
|
|
16
|
+
if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock !== void 0) {
|
|
17
|
+
if (next.nextSecondaryStock !== null)
|
|
18
|
+
await store.writeSecondaryStock(transaction, params, next.nextSecondaryStock);
|
|
19
|
+
}
|
|
16
20
|
await store.writeUserState(transaction, params, next.nextUserState);
|
|
17
21
|
await store.writeClaimRecord(transaction, params, next.nextUserState);
|
|
18
22
|
await store.writeAudit(transaction, next.auditLog);
|
|
@@ -20,8 +24,41 @@ async function executeClaimRewardTransaction(database, store, params, mutation)
|
|
|
20
24
|
await store.writeIdempotency(transaction, params, next.result);
|
|
21
25
|
return { success: true };
|
|
22
26
|
});
|
|
23
|
-
} catch (
|
|
24
|
-
return { success: false, error:
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function executeCheckInTransaction(database, store, params, mutation, current) {
|
|
32
|
+
try {
|
|
33
|
+
return await database.transaction(async (transaction) => {
|
|
34
|
+
const userState = current ?? (store.readUserState === void 0 ? (() => {
|
|
35
|
+
throw new Error("A current user state or readUserState implementation is required.");
|
|
36
|
+
})() : await store.readUserState(transaction, params));
|
|
37
|
+
const next = mutation({ userState });
|
|
38
|
+
if (next.error !== void 0) {
|
|
39
|
+
await store.writeAudit(transaction, next.auditLog);
|
|
40
|
+
if (params.idempotencyKey !== void 0 && next.result !== void 0)
|
|
41
|
+
await store.writeIdempotency(transaction, params, next.result);
|
|
42
|
+
return { success: false, error: next.error };
|
|
43
|
+
}
|
|
44
|
+
await store.writeUserState(transaction, params, next.nextUserState);
|
|
45
|
+
await store.writeAudit(transaction, next.auditLog);
|
|
46
|
+
if (params.idempotencyKey !== void 0 && next.result !== void 0)
|
|
47
|
+
await store.writeIdempotency(transaction, params, next.result);
|
|
48
|
+
return { success: true };
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function executeRedisTransaction(redis, queue) {
|
|
55
|
+
try {
|
|
56
|
+
const multi = redis.multi();
|
|
57
|
+
queue(multi);
|
|
58
|
+
const result = await redis.exec(multi);
|
|
59
|
+
return result === null ? { success: false, error: "Redis transaction was aborted." } : { success: true };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
25
62
|
}
|
|
26
63
|
}
|
|
27
64
|
|
|
@@ -89,8 +126,14 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
89
126
|
const userState = await this.getUserState(params.rallyId, params.userId) ?? params.initialUserState;
|
|
90
127
|
if (userState === void 0)
|
|
91
128
|
return { success: false, error: "A user state is required for this transaction." };
|
|
129
|
+
const storedStock = await this.getRewardStock(
|
|
130
|
+
params.rallyId,
|
|
131
|
+
params.stockKey ?? params.rewardId
|
|
132
|
+
);
|
|
133
|
+
const storedSecondaryStock = params.secondaryStockKey === void 0 ? null : await this.getRewardStock(params.rallyId, params.secondaryStockKey);
|
|
92
134
|
const mutationResult = mutation({
|
|
93
|
-
stock:
|
|
135
|
+
stock: storedStock ?? params.initialStock ?? null,
|
|
136
|
+
secondaryStock: storedSecondaryStock ?? params.initialSecondaryStock ?? null,
|
|
94
137
|
claimCount: await this.getUserClaimCount(params.rallyId, params.userId, params.rewardId),
|
|
95
138
|
userState
|
|
96
139
|
});
|
|
@@ -106,16 +149,45 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
106
149
|
);
|
|
107
150
|
return { success: false, error: mutationResult.error };
|
|
108
151
|
}
|
|
109
|
-
const currentStock = await this.getRewardStock(
|
|
110
|
-
|
|
152
|
+
const currentStock = await this.getRewardStock(
|
|
153
|
+
params.rallyId,
|
|
154
|
+
params.stockKey ?? params.rewardId
|
|
155
|
+
);
|
|
156
|
+
const effectiveStock = currentStock ?? params.initialStock ?? null;
|
|
157
|
+
if (currentStock === null && params.initialStock !== void 0 && params.initialStock !== null)
|
|
158
|
+
this.#stocks.set(
|
|
159
|
+
this.#stockKey(params.rallyId, params.stockKey ?? params.rewardId),
|
|
160
|
+
params.initialStock
|
|
161
|
+
);
|
|
162
|
+
const currentSecondaryStock = params.secondaryStockKey === void 0 ? null : await this.getRewardStock(params.rallyId, params.secondaryStockKey);
|
|
163
|
+
const effectiveSecondaryStock = currentSecondaryStock ?? params.initialSecondaryStock ?? null;
|
|
164
|
+
if (currentSecondaryStock === null && params.secondaryStockKey !== void 0 && params.initialSecondaryStock !== void 0 && params.initialSecondaryStock !== null)
|
|
165
|
+
this.#stocks.set(
|
|
166
|
+
this.#stockKey(params.rallyId, params.secondaryStockKey),
|
|
167
|
+
params.initialSecondaryStock
|
|
168
|
+
);
|
|
169
|
+
if (effectiveStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
|
|
111
170
|
throw new Error("The transaction produced an invalid stock value.");
|
|
112
|
-
if (
|
|
171
|
+
if (effectiveStock === null && mutationResult.nextStock !== null)
|
|
113
172
|
throw new Error("The transaction changed an unlimited stock to a limited stock.");
|
|
114
173
|
if (mutationResult.nextStock !== null)
|
|
115
174
|
this.#stocks.set(
|
|
116
|
-
this.#stockKey(params.rallyId, params.rewardId),
|
|
175
|
+
this.#stockKey(params.rallyId, params.stockKey ?? params.rewardId),
|
|
117
176
|
mutationResult.nextStock
|
|
118
177
|
);
|
|
178
|
+
if (params.secondaryStockKey !== void 0 && mutationResult.nextSecondaryStock !== void 0) {
|
|
179
|
+
if (effectiveSecondaryStock !== null && (mutationResult.nextSecondaryStock === null || mutationResult.nextSecondaryStock < 0))
|
|
180
|
+
throw new Error("The transaction produced an invalid secondary stock value.");
|
|
181
|
+
if (effectiveSecondaryStock === null && mutationResult.nextSecondaryStock !== null)
|
|
182
|
+
throw new Error(
|
|
183
|
+
"The transaction changed an unlimited secondary stock to a limited stock."
|
|
184
|
+
);
|
|
185
|
+
if (mutationResult.nextSecondaryStock !== null)
|
|
186
|
+
this.#stocks.set(
|
|
187
|
+
this.#stockKey(params.rallyId, params.secondaryStockKey),
|
|
188
|
+
mutationResult.nextSecondaryStock
|
|
189
|
+
);
|
|
190
|
+
}
|
|
119
191
|
await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
|
|
120
192
|
const reward = mutationResult.nextUserState.rewards.find(
|
|
121
193
|
(item) => item.rewardId === params.rewardId
|
|
@@ -138,8 +210,8 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
138
210
|
);
|
|
139
211
|
return { success: true };
|
|
140
212
|
});
|
|
141
|
-
} catch (
|
|
142
|
-
return { success: false, error:
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
143
215
|
}
|
|
144
216
|
}
|
|
145
217
|
async executeCheckInTransaction(params, mutation) {
|
|
@@ -171,8 +243,8 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
171
243
|
);
|
|
172
244
|
return { success: true };
|
|
173
245
|
});
|
|
174
|
-
} catch (
|
|
175
|
-
return { success: false, error:
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
176
248
|
}
|
|
177
249
|
}
|
|
178
250
|
async rollbackUserState(rallyId, userId, previousState) {
|
|
@@ -258,7 +330,7 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
258
330
|
};
|
|
259
331
|
try {
|
|
260
332
|
return await operation(this);
|
|
261
|
-
} catch (
|
|
333
|
+
} catch (error) {
|
|
262
334
|
this.#stocks.clear();
|
|
263
335
|
for (const [key, value] of snapshot.stocks) this.#stocks.set(key, value);
|
|
264
336
|
this.#idempotent.clear();
|
|
@@ -270,14 +342,14 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
270
342
|
for (const [key, value] of snapshot.claims) this.#claims.set(key, value);
|
|
271
343
|
this.#claimRecords.splice(0, this.#claimRecords.length, ...snapshot.claimRecords);
|
|
272
344
|
this.#auditLogs.splice(0, this.#auditLogs.length, ...snapshot.auditLogs);
|
|
273
|
-
throw
|
|
345
|
+
throw error;
|
|
274
346
|
}
|
|
275
347
|
}
|
|
276
348
|
};
|
|
277
349
|
|
|
278
350
|
// src/security.ts
|
|
279
|
-
function
|
|
280
|
-
return { success: false, errors:
|
|
351
|
+
function errors(...items) {
|
|
352
|
+
return { success: false, errors: items };
|
|
281
353
|
}
|
|
282
354
|
function record(value) {
|
|
283
355
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -285,46 +357,125 @@ function record(value) {
|
|
|
285
357
|
function nonEmpty(value) {
|
|
286
358
|
return typeof value === "string" && value.trim().length > 0;
|
|
287
359
|
}
|
|
288
|
-
function
|
|
289
|
-
if (!record(value) || typeof value.type !== "string")
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
360
|
+
function contextErrors(value) {
|
|
361
|
+
if (!record(value) || typeof value.type !== "string")
|
|
362
|
+
return [
|
|
363
|
+
{
|
|
364
|
+
path: "proof",
|
|
365
|
+
message: "proof is not a valid verification context.",
|
|
366
|
+
code: "INVALID_TYPE"
|
|
367
|
+
}
|
|
368
|
+
];
|
|
369
|
+
if (value.type === "qr" && !nonEmpty(value.token))
|
|
370
|
+
return [
|
|
371
|
+
{ path: "proof.token", message: "token must be a non-empty string.", code: "INVALID_TYPE" }
|
|
372
|
+
];
|
|
373
|
+
if (value.type === "passcode" && !nonEmpty(value.code))
|
|
374
|
+
return [
|
|
375
|
+
{ path: "proof.code", message: "code must be a non-empty string.", code: "INVALID_TYPE" }
|
|
376
|
+
];
|
|
377
|
+
if (value.type === "nfc" && !nonEmpty(value.tagId))
|
|
378
|
+
return [
|
|
379
|
+
{ path: "proof.tagId", message: "tagId must be a non-empty string.", code: "INVALID_TYPE" }
|
|
380
|
+
];
|
|
381
|
+
if (value.type === "custom")
|
|
382
|
+
return "value" in value ? [] : [{ path: "proof.value", message: "value is required.", code: "REQUIRED" }];
|
|
383
|
+
if (value.type === "qr" || value.type === "passcode" || value.type === "nfc") return [];
|
|
384
|
+
if (value.type !== "gps")
|
|
385
|
+
return [{ path: "proof.type", message: "Unknown verification type.", code: "INVALID_ENUM" }];
|
|
386
|
+
const result = [];
|
|
387
|
+
if (typeof value.latitude !== "number" || !Number.isFinite(value.latitude))
|
|
388
|
+
result.push({
|
|
389
|
+
path: "proof.latitude",
|
|
390
|
+
message: "Latitude must be a finite number.",
|
|
391
|
+
code: "INVALID_TYPE"
|
|
392
|
+
});
|
|
393
|
+
else if (value.latitude < -90 || value.latitude > 90)
|
|
394
|
+
result.push({
|
|
395
|
+
path: "proof.latitude",
|
|
396
|
+
message: "Latitude must be between -90 and 90.",
|
|
397
|
+
code: "INVALID_RANGE"
|
|
398
|
+
});
|
|
399
|
+
if (typeof value.longitude !== "number" || !Number.isFinite(value.longitude))
|
|
400
|
+
result.push({
|
|
401
|
+
path: "proof.longitude",
|
|
402
|
+
message: "Longitude must be a finite number.",
|
|
403
|
+
code: "INVALID_TYPE"
|
|
404
|
+
});
|
|
405
|
+
else if (value.longitude < -180 || value.longitude > 180)
|
|
406
|
+
result.push({
|
|
407
|
+
path: "proof.longitude",
|
|
408
|
+
message: "Longitude must be between -180 and 180.",
|
|
409
|
+
code: "INVALID_RANGE"
|
|
410
|
+
});
|
|
411
|
+
if ("radiusMeters" in value && (typeof value.radiusMeters !== "number" || !Number.isFinite(value.radiusMeters) || value.radiusMeters <= 0))
|
|
412
|
+
result.push({
|
|
413
|
+
path: "proof.radiusMeters",
|
|
414
|
+
message: "Radius must be greater than zero.",
|
|
415
|
+
code: "INVALID_RANGE"
|
|
416
|
+
});
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
function dateInput(value) {
|
|
420
|
+
if (typeof value === "number") return Number.isInteger(value) && value > 0;
|
|
421
|
+
if (typeof value !== "string" || value.trim() === "") return false;
|
|
422
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})$/.test(value) && !Number.isNaN(Date.parse(value));
|
|
295
423
|
}
|
|
296
|
-
function
|
|
297
|
-
if (
|
|
298
|
-
return fields.
|
|
424
|
+
function requiredErrors(value, fields) {
|
|
425
|
+
if (record(value) && fields.every((field) => nonEmpty(value[field]))) return [];
|
|
426
|
+
return fields.filter((field) => !record(value) || !nonEmpty(value[field])).map((field) => ({
|
|
427
|
+
path: field,
|
|
428
|
+
message: `${field} must be a non-empty string.`,
|
|
429
|
+
code: "REQUIRED"
|
|
430
|
+
}));
|
|
299
431
|
}
|
|
300
432
|
function validateCheckInRequest(value) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (!
|
|
304
|
-
return
|
|
433
|
+
const required = requiredErrors(value, ["rallyId", "spotId", "idempotencyKey"]);
|
|
434
|
+
if (required.length > 0) return errors(...required);
|
|
435
|
+
if (!record(value))
|
|
436
|
+
return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
|
|
437
|
+
const proof = contextErrors(value.context);
|
|
438
|
+
if (proof.length > 0) return errors(...proof);
|
|
305
439
|
if (value.userId !== void 0 && !nonEmpty(value.userId))
|
|
306
|
-
return
|
|
307
|
-
if (value.now !== void 0 && !
|
|
308
|
-
return
|
|
440
|
+
return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
|
|
441
|
+
if (value.now !== void 0 && !dateInput(value.now))
|
|
442
|
+
return errors({
|
|
443
|
+
path: "now",
|
|
444
|
+
message: "now must be an ISO 8601 date or positive timestamp.",
|
|
445
|
+
code: "INVALID_DATE"
|
|
446
|
+
});
|
|
309
447
|
return { success: true, data: value };
|
|
310
448
|
}
|
|
311
449
|
function validateClaimRewardRequest(value) {
|
|
312
|
-
|
|
313
|
-
|
|
450
|
+
const required = requiredErrors(value, ["rallyId", "rewardId", "idempotencyKey"]);
|
|
451
|
+
if (required.length > 0) return errors(...required);
|
|
452
|
+
if (!record(value))
|
|
453
|
+
return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
|
|
314
454
|
if (value.userId !== void 0 && !nonEmpty(value.userId))
|
|
315
|
-
return
|
|
455
|
+
return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
|
|
316
456
|
if (value.staffPasscode !== void 0 && !nonEmpty(value.staffPasscode))
|
|
317
|
-
return
|
|
457
|
+
return errors({
|
|
458
|
+
path: "staffPasscode",
|
|
459
|
+
message: "staffPasscode must be non-empty.",
|
|
460
|
+
code: "INVALID_TYPE"
|
|
461
|
+
});
|
|
318
462
|
if (value.staffId !== void 0 && !nonEmpty(value.staffId))
|
|
319
|
-
return
|
|
463
|
+
return errors({ path: "staffId", message: "staffId must be non-empty.", code: "INVALID_TYPE" });
|
|
320
464
|
if (value.now !== void 0 && !nonEmpty(value.now))
|
|
321
|
-
return
|
|
465
|
+
return errors({
|
|
466
|
+
path: "now",
|
|
467
|
+
message: "now must be an ISO 8601 date or positive timestamp.",
|
|
468
|
+
code: "INVALID_DATE"
|
|
469
|
+
});
|
|
322
470
|
return { success: true, data: value };
|
|
323
471
|
}
|
|
324
472
|
function validateSyncRequest(value) {
|
|
325
|
-
|
|
473
|
+
const required = requiredErrors(value, ["rallyId"]);
|
|
474
|
+
if (required.length > 0) return errors(...required);
|
|
475
|
+
if (!record(value))
|
|
476
|
+
return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
|
|
326
477
|
if (value.userId !== void 0 && !nonEmpty(value.userId))
|
|
327
|
-
return
|
|
478
|
+
return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
|
|
328
479
|
return { success: true, data: value };
|
|
329
480
|
}
|
|
330
481
|
function json(body, status = 200) {
|
|
@@ -336,6 +487,9 @@ function json(body, status = 200) {
|
|
|
336
487
|
function isObject(value) {
|
|
337
488
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
338
489
|
}
|
|
490
|
+
function isUuidV4(value) {
|
|
491
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
492
|
+
}
|
|
339
493
|
function requestId(prefix) {
|
|
340
494
|
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
|
341
495
|
}
|
|
@@ -346,6 +500,15 @@ function timestampMillis(timestamp) {
|
|
|
346
500
|
const value = Date.parse(timestamp);
|
|
347
501
|
return Number.isFinite(value) ? value : Date.now();
|
|
348
502
|
}
|
|
503
|
+
function validationResponse(errors2) {
|
|
504
|
+
return json(
|
|
505
|
+
{
|
|
506
|
+
error: "VALIDATION_FAILED",
|
|
507
|
+
details: errors2.map(({ path, message, code }) => ({ path, message, code }))
|
|
508
|
+
},
|
|
509
|
+
400
|
|
510
|
+
);
|
|
511
|
+
}
|
|
349
512
|
function initialState(config, userId, timestamp) {
|
|
350
513
|
return {
|
|
351
514
|
rallyId: config.id,
|
|
@@ -355,16 +518,37 @@ function initialState(config, userId, timestamp) {
|
|
|
355
518
|
updatedAt: timestamp
|
|
356
519
|
};
|
|
357
520
|
}
|
|
358
|
-
function
|
|
359
|
-
|
|
521
|
+
function rewardStock(config, rewardId, stockLimit) {
|
|
522
|
+
const configured = config.inventory?.[rewardId];
|
|
523
|
+
if (stockLimit === void 0) return configured ?? null;
|
|
524
|
+
if (configured === void 0) return stockLimit;
|
|
525
|
+
return Math.min(stockLimit, configured);
|
|
526
|
+
}
|
|
527
|
+
function sharedStock(config) {
|
|
528
|
+
return config.inventory?.sharedStock ?? config.inventory?.global ?? null;
|
|
529
|
+
}
|
|
530
|
+
function inventoryPlan(config, rewardId, stockLimit) {
|
|
531
|
+
const individual = rewardStock(config, rewardId, stockLimit);
|
|
532
|
+
const shared = sharedStock(config);
|
|
533
|
+
if (config.inventoryMode === "shared" && shared !== null) {
|
|
534
|
+
return {
|
|
535
|
+
primaryKey: "__shared__",
|
|
536
|
+
primaryInitial: shared,
|
|
537
|
+
...individual === null ? {} : { secondaryKey: rewardId, secondaryInitial: individual }
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
return { primaryKey: rewardId, primaryInitial: individual };
|
|
541
|
+
}
|
|
542
|
+
function getProof(context) {
|
|
543
|
+
return context.type === "qr" ? context.token : context.type === "passcode" ? context.code : context.type === "gps" ? { latitude: context.latitude, longitude: context.longitude } : context.type === "nfc" ? context.tagId : context.value;
|
|
360
544
|
}
|
|
361
|
-
async function evaluate(condition,
|
|
362
|
-
if (condition.type !== "custom") return evaluateConditionDetailed(condition,
|
|
545
|
+
async function evaluate(condition, context, validator, base) {
|
|
546
|
+
if (condition.type !== "custom") return evaluateConditionDetailed(condition, context).ok;
|
|
363
547
|
if (validator === void 0) return false;
|
|
364
548
|
const validationContext = {
|
|
365
549
|
rallyId: base.rallyId,
|
|
366
550
|
spotId: base.spotId,
|
|
367
|
-
proofData: getProof(
|
|
551
|
+
proofData: getProof(context),
|
|
368
552
|
condition,
|
|
369
553
|
userState: base.state
|
|
370
554
|
};
|
|
@@ -408,11 +592,15 @@ var StampRallyServer = class {
|
|
|
408
592
|
}
|
|
409
593
|
async handleCheckIn(request) {
|
|
410
594
|
const body = validateCheckInRequest(await this.#body(request));
|
|
411
|
-
if (!body.success
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
595
|
+
if (!body.success) return validationResponse(body.errors);
|
|
596
|
+
if (body.data.rallyId !== this.#config.id)
|
|
597
|
+
return validationResponse([
|
|
598
|
+
{
|
|
599
|
+
path: "rallyId",
|
|
600
|
+
message: "The rally does not match this server.",
|
|
601
|
+
code: "INVALID_VALUE"
|
|
602
|
+
}
|
|
603
|
+
]);
|
|
416
604
|
const userId = await this.#user(request);
|
|
417
605
|
if (userId === null)
|
|
418
606
|
return json(
|
|
@@ -427,8 +615,15 @@ var StampRallyServer = class {
|
|
|
427
615
|
}
|
|
428
616
|
async handleClaimReward(request) {
|
|
429
617
|
const body = validateClaimRewardRequest(await this.#body(request));
|
|
430
|
-
if (!body.success
|
|
431
|
-
|
|
618
|
+
if (!body.success) return validationResponse(body.errors);
|
|
619
|
+
if (body.data.rallyId !== this.#config.id)
|
|
620
|
+
return validationResponse([
|
|
621
|
+
{
|
|
622
|
+
path: "rallyId",
|
|
623
|
+
message: "The rally does not match this server.",
|
|
624
|
+
code: "INVALID_VALUE"
|
|
625
|
+
}
|
|
626
|
+
]);
|
|
432
627
|
const userId = await this.#user(request);
|
|
433
628
|
if (userId === null)
|
|
434
629
|
return json(
|
|
@@ -443,8 +638,15 @@ var StampRallyServer = class {
|
|
|
443
638
|
}
|
|
444
639
|
async handleSync(request) {
|
|
445
640
|
const body = validateSyncRequest(await this.#body(request));
|
|
446
|
-
if (!body.success
|
|
447
|
-
|
|
641
|
+
if (!body.success) return validationResponse(body.errors);
|
|
642
|
+
if (body.data.rallyId !== this.#config.id)
|
|
643
|
+
return validationResponse([
|
|
644
|
+
{
|
|
645
|
+
path: "rallyId",
|
|
646
|
+
message: "The rally does not match this server.",
|
|
647
|
+
code: "INVALID_VALUE"
|
|
648
|
+
}
|
|
649
|
+
]);
|
|
448
650
|
const userId = await this.#user(request);
|
|
449
651
|
if (userId === null)
|
|
450
652
|
return json(
|
|
@@ -599,7 +801,7 @@ var StampRallyServer = class {
|
|
|
599
801
|
request,
|
|
600
802
|
now(this.#options)
|
|
601
803
|
);
|
|
602
|
-
const lockKey = `reward:${request.rallyId}:${reward.id}`;
|
|
804
|
+
const lockKey = this.#config.inventoryMode === "shared" ? "inventory:shared" : `reward:${request.rallyId}:${reward.id}`;
|
|
603
805
|
if (!await this.#persistence.acquireLock(
|
|
604
806
|
request.rallyId,
|
|
605
807
|
lockKey,
|
|
@@ -607,6 +809,7 @@ var StampRallyServer = class {
|
|
|
607
809
|
))
|
|
608
810
|
return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
|
|
609
811
|
const timestamp = now(this.#options);
|
|
812
|
+
const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
|
|
610
813
|
try {
|
|
611
814
|
const checked = await this.#persistence.getIdempotentResult(
|
|
612
815
|
request.rallyId,
|
|
@@ -619,6 +822,10 @@ var StampRallyServer = class {
|
|
|
619
822
|
rallyId: request.rallyId,
|
|
620
823
|
userId: request.userId,
|
|
621
824
|
rewardId: reward.id,
|
|
825
|
+
stockKey: plan.primaryKey,
|
|
826
|
+
...plan.secondaryKey === void 0 ? {} : { secondaryStockKey: plan.secondaryKey },
|
|
827
|
+
initialStock: plan.primaryInitial,
|
|
828
|
+
...plan.secondaryInitial === void 0 ? {} : { initialSecondaryStock: plan.secondaryInitial },
|
|
622
829
|
ticketNumber: request.idempotencyKey,
|
|
623
830
|
timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp),
|
|
624
831
|
idempotencyKey: key,
|
|
@@ -626,7 +833,7 @@ var StampRallyServer = class {
|
|
|
626
833
|
...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
|
|
627
834
|
initialUserState: initialState(this.#config, request.userId, timestamp)
|
|
628
835
|
},
|
|
629
|
-
({ stock, claimCount, userState }) => {
|
|
836
|
+
({ stock, secondaryStock, claimCount, userState }) => {
|
|
630
837
|
const storedReward = userState.rewards.find((item) => item.rewardId === reward.id) ?? {
|
|
631
838
|
rewardId: reward.id,
|
|
632
839
|
status: "LOCKED"
|
|
@@ -642,7 +849,7 @@ var StampRallyServer = class {
|
|
|
642
849
|
timestamp,
|
|
643
850
|
code
|
|
644
851
|
);
|
|
645
|
-
if (stock !== null && stock <= 0) {
|
|
852
|
+
if (stock !== null && stock <= 0 || secondaryStock !== null && secondaryStock <= 0) {
|
|
646
853
|
responseHolder.value = {
|
|
647
854
|
ok: false,
|
|
648
855
|
code: "OUT_OF_STOCK",
|
|
@@ -650,6 +857,7 @@ var StampRallyServer = class {
|
|
|
650
857
|
};
|
|
651
858
|
return {
|
|
652
859
|
nextStock: stock,
|
|
860
|
+
...plan.secondaryKey === void 0 ? {} : { nextSecondaryStock: secondaryStock },
|
|
653
861
|
nextUserState: userState,
|
|
654
862
|
auditLog: makeAudit("REJECTED", "OUT_OF_STOCK"),
|
|
655
863
|
result: responseHolder.value,
|
|
@@ -680,14 +888,35 @@ var StampRallyServer = class {
|
|
|
680
888
|
};
|
|
681
889
|
}
|
|
682
890
|
const nextRewards = userState.rewards.some((item) => item.rewardId === reward.id) ? userState.rewards.map((item) => item.rewardId === reward.id ? local.value : item) : [...userState.rewards, local.value];
|
|
891
|
+
const consumed = local.value.claimTicketNumber !== void 0;
|
|
892
|
+
const nextStock = consumed && stock !== null ? Math.max(0, stock - 1) : stock;
|
|
893
|
+
const nextSecondaryStock = consumed && secondaryStock !== null ? Math.max(0, secondaryStock - 1) : secondaryStock;
|
|
683
894
|
const next = {
|
|
684
895
|
...userState,
|
|
685
896
|
rewards: nextRewards,
|
|
686
|
-
updatedAt: timestamp
|
|
897
|
+
updatedAt: timestamp,
|
|
898
|
+
inventory: {
|
|
899
|
+
...plan.primaryKey === "__shared__" && nextStock !== null ? { sharedRemaining: nextStock } : {},
|
|
900
|
+
...plan.secondaryKey !== void 0 && nextSecondaryStock !== null ? { rewardRemaining: { [reward.id]: nextSecondaryStock } } : plan.primaryKey === reward.id && nextStock !== null ? { rewardRemaining: { [reward.id]: nextStock } } : {}
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
const inventory = {
|
|
904
|
+
...plan.primaryKey === "__shared__" && nextStock !== null ? { sharedRemaining: nextStock } : {},
|
|
905
|
+
...plan.secondaryKey !== void 0 && nextSecondaryStock !== null ? { rewardRemaining: nextSecondaryStock } : plan.primaryKey === reward.id && nextStock !== null ? { rewardRemaining: nextStock } : {}
|
|
906
|
+
};
|
|
907
|
+
responseHolder.value = local.value.claimTicketNumber === void 0 ? {
|
|
908
|
+
ok: true,
|
|
909
|
+
state: next,
|
|
910
|
+
...Object.keys(inventory).length === 0 ? {} : { inventory }
|
|
911
|
+
} : {
|
|
912
|
+
ok: true,
|
|
913
|
+
state: next,
|
|
914
|
+
claimTicketNumber: local.value.claimTicketNumber,
|
|
915
|
+
...Object.keys(inventory).length === 0 ? {} : { inventory }
|
|
687
916
|
};
|
|
688
|
-
responseHolder.value = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
|
|
689
917
|
return {
|
|
690
|
-
nextStock
|
|
918
|
+
nextStock,
|
|
919
|
+
...plan.secondaryKey === void 0 ? {} : { nextSecondaryStock },
|
|
691
920
|
nextUserState: next,
|
|
692
921
|
auditLog: makeAudit("SUCCESS"),
|
|
693
922
|
result: responseHolder.value
|
|
@@ -709,18 +938,46 @@ var StampRallyServer = class {
|
|
|
709
938
|
code: "PERSISTENCE_FAILED",
|
|
710
939
|
message: result.error ?? "Reward claim failed."
|
|
711
940
|
};
|
|
712
|
-
} catch (
|
|
941
|
+
} catch (error) {
|
|
713
942
|
return {
|
|
714
943
|
ok: false,
|
|
715
944
|
code: "PERSISTENCE_FAILED",
|
|
716
|
-
message:
|
|
945
|
+
message: error instanceof Error ? error.message : "Reward claim failed."
|
|
717
946
|
};
|
|
718
947
|
} finally {
|
|
719
948
|
await this.#persistence.releaseLock(request.rallyId, lockKey);
|
|
720
949
|
}
|
|
721
950
|
}
|
|
722
951
|
async sync(rallyId, userId) {
|
|
723
|
-
|
|
952
|
+
const state = await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
|
|
953
|
+
return this.#attachInventory(state);
|
|
954
|
+
}
|
|
955
|
+
async #attachInventory(state) {
|
|
956
|
+
const rewardRemaining = {};
|
|
957
|
+
for (const reward of this.#config.rewards) {
|
|
958
|
+
const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
|
|
959
|
+
if (plan.secondaryKey !== void 0) {
|
|
960
|
+
const stock = await this.#persistence.getRewardStock(state.rallyId, plan.secondaryKey);
|
|
961
|
+
const remaining = stock ?? plan.secondaryInitial ?? null;
|
|
962
|
+
if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
|
|
963
|
+
} else if (plan.primaryKey !== "__shared__") {
|
|
964
|
+
const stock = await this.#persistence.getRewardStock(state.rallyId, plan.primaryKey);
|
|
965
|
+
const remaining = stock ?? plan.primaryInitial;
|
|
966
|
+
if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const shared = sharedStock(this.#config);
|
|
970
|
+
const storedShared = await this.#persistence.getRewardStock(state.rallyId, "__shared__");
|
|
971
|
+
const sharedRemaining = this.#config.inventoryMode === "shared" && shared !== null ? Math.max(0, storedShared ?? shared) : void 0;
|
|
972
|
+
return {
|
|
973
|
+
...state,
|
|
974
|
+
...Object.keys(rewardRemaining).length === 0 && sharedRemaining === void 0 ? {} : {
|
|
975
|
+
inventory: {
|
|
976
|
+
...sharedRemaining === void 0 ? {} : { sharedRemaining },
|
|
977
|
+
...Object.keys(rewardRemaining).length === 0 ? {} : { rewardRemaining }
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
};
|
|
724
981
|
}
|
|
725
982
|
async #body(request) {
|
|
726
983
|
try {
|
|
@@ -738,6 +995,10 @@ var StampRallyServer = class {
|
|
|
738
995
|
const authenticatedUserId = identity.authenticatedUserId;
|
|
739
996
|
return authenticatedUserId.length > 0 ? authenticatedUserId : null;
|
|
740
997
|
}
|
|
998
|
+
if (this.#options.anonymousPolicy === "reject") return null;
|
|
999
|
+
const sessionId = request.headers.get("X-Anonymous-Session-Id");
|
|
1000
|
+
if (sessionId !== null && isUuidV4(sessionId)) return sessionId;
|
|
1001
|
+
if (this.#options.anonymousPolicy === "session_scoped") return null;
|
|
741
1002
|
return "anonymous";
|
|
742
1003
|
}
|
|
743
1004
|
async #rememberCheckInTransaction(request, timestamp, key, current, mutation, responseHolder) {
|
|
@@ -784,6 +1045,6 @@ var StampRallyServer = class {
|
|
|
784
1045
|
}
|
|
785
1046
|
};
|
|
786
1047
|
|
|
787
|
-
export { InMemoryServerPersistenceAdapter, StampRallyServer, executeClaimRewardTransaction, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
|
|
1048
|
+
export { InMemoryServerPersistenceAdapter, StampRallyServer, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
|
|
788
1049
|
//# sourceMappingURL=index.js.map
|
|
789
1050
|
//# sourceMappingURL=index.js.map
|