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