@stamprally/server 0.8.0 → 0.9.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/dist/index.cjs +288 -625
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +58 -131
- package/dist/index.d.ts +58 -131
- package/dist/index.js +290 -625
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
// src/index.ts
|
|
1
|
+
import { reconcileRewardStates, consumeReward, evaluateConditionDetailed } from '@stamprally/core';
|
|
4
2
|
|
|
5
3
|
// src/persistence.ts
|
|
6
4
|
var InMemoryServerPersistenceAdapter = class {
|
|
@@ -8,46 +6,52 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
8
6
|
#idempotent = /* @__PURE__ */ new Map();
|
|
9
7
|
#states = /* @__PURE__ */ new Map();
|
|
10
8
|
#stocks;
|
|
9
|
+
#claims = /* @__PURE__ */ new Map();
|
|
11
10
|
#auditLogs = [];
|
|
12
11
|
constructor(options = {}) {
|
|
13
12
|
this.#stocks = new Map(Object.entries(options.stocks ?? {}));
|
|
14
13
|
}
|
|
15
|
-
async acquireLock(
|
|
14
|
+
async acquireLock(key, ttlMs) {
|
|
16
15
|
const now2 = Date.now();
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
19
|
-
this.#locks.set(
|
|
16
|
+
const until = this.#locks.get(key);
|
|
17
|
+
if (until !== void 0 && until > now2) return false;
|
|
18
|
+
this.#locks.set(key, now2 + Math.max(1, ttlMs));
|
|
20
19
|
return true;
|
|
21
20
|
}
|
|
22
|
-
async releaseLock(
|
|
23
|
-
this.#locks.delete(
|
|
21
|
+
async releaseLock(key) {
|
|
22
|
+
this.#locks.delete(key);
|
|
24
23
|
}
|
|
25
24
|
async decrementRewardStock(rewardId) {
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
25
|
+
const current = this.#stocks.get(rewardId);
|
|
26
|
+
if (current === void 0) return { success: true, remainingStock: Number.POSITIVE_INFINITY };
|
|
27
|
+
if (current <= 0) return { success: false, remainingStock: 0 };
|
|
28
|
+
this.#stocks.set(rewardId, current - 1);
|
|
29
|
+
return { success: true, remainingStock: current - 1 };
|
|
30
|
+
}
|
|
31
|
+
async incrementRewardStock(rewardId) {
|
|
32
|
+
const current = this.#stocks.get(rewardId);
|
|
33
|
+
if (current !== void 0) this.#stocks.set(rewardId, current + 1);
|
|
34
|
+
}
|
|
35
|
+
async getIdempotentResult(key) {
|
|
36
|
+
const value = this.#idempotent.get(key);
|
|
37
|
+
if (value === void 0 || value.expiresAt <= Date.now()) {
|
|
38
|
+
this.#idempotent.delete(key);
|
|
38
39
|
return null;
|
|
39
40
|
}
|
|
40
|
-
return structuredClone(
|
|
41
|
+
return structuredClone(value.value);
|
|
41
42
|
}
|
|
42
|
-
async saveIdempotentResult(
|
|
43
|
-
this.#idempotent.set(
|
|
43
|
+
async saveIdempotentResult(key, result, ttlMs) {
|
|
44
|
+
this.#idempotent.set(key, {
|
|
44
45
|
value: structuredClone(result),
|
|
45
46
|
expiresAt: Date.now() + Math.max(1, ttlMs)
|
|
46
47
|
});
|
|
47
48
|
}
|
|
49
|
+
async getUserClaimCount(rallyId, userId, rewardId) {
|
|
50
|
+
return this.#claims.get(`${rallyId}:${userId}:${rewardId}`) ?? 0;
|
|
51
|
+
}
|
|
48
52
|
async getUserState(rallyId, userId) {
|
|
49
|
-
const
|
|
50
|
-
return
|
|
53
|
+
const value = this.#states.get(`${rallyId}:${userId}`);
|
|
54
|
+
return value === void 0 ? null : structuredClone(value);
|
|
51
55
|
}
|
|
52
56
|
async saveUserState(rallyId, userId, state) {
|
|
53
57
|
this.#states.set(`${rallyId}:${userId}`, structuredClone(state));
|
|
@@ -58,78 +62,65 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
58
62
|
getAuditLogs() {
|
|
59
63
|
return structuredClone(this.#auditLogs);
|
|
60
64
|
}
|
|
65
|
+
recordClaim(rallyId, userId, rewardId) {
|
|
66
|
+
const key = `${rallyId}:${userId}:${rewardId}`;
|
|
67
|
+
this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
|
|
68
|
+
}
|
|
61
69
|
};
|
|
62
|
-
function distanceMeters(aLat, aLon, bLat, bLon) {
|
|
63
|
-
const radians = (degrees) => degrees * Math.PI / 180;
|
|
64
|
-
const dLat = radians(bLat - aLat);
|
|
65
|
-
const dLon = radians(bLon - aLon);
|
|
66
|
-
const latA = radians(aLat);
|
|
67
|
-
const latB = radians(bLat);
|
|
68
|
-
const value = Math.sin(dLat / 2) ** 2 + Math.cos(latA) * Math.cos(latB) * Math.sin(dLon / 2) ** 2;
|
|
69
|
-
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, value)));
|
|
70
|
-
}
|
|
71
|
-
function initialState(config, now2) {
|
|
72
|
-
return {
|
|
73
|
-
rallyId: config.id,
|
|
74
|
-
records: [],
|
|
75
|
-
rewards: reconcileRewardStates(
|
|
76
|
-
config.rewards.map((reward) => ({ ...reward, description: reward.description ?? "" })),
|
|
77
|
-
[],
|
|
78
|
-
0,
|
|
79
|
-
now2
|
|
80
|
-
),
|
|
81
|
-
updatedAt: now2
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
70
|
function json(body, status = 200) {
|
|
85
71
|
return new Response(JSON.stringify(body), {
|
|
86
72
|
status,
|
|
87
73
|
headers: { "content-type": "application/json; charset=utf-8" }
|
|
88
74
|
});
|
|
89
75
|
}
|
|
90
|
-
function
|
|
91
|
-
return
|
|
92
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
93
|
-
return value;
|
|
94
|
-
}).catch(() => null);
|
|
76
|
+
function isObject(value) {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
95
78
|
}
|
|
96
|
-
function
|
|
97
|
-
return {
|
|
79
|
+
function requestId(prefix) {
|
|
80
|
+
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
|
98
81
|
}
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
context.longitude
|
|
111
|
-
) <= condition.radiusMeters;
|
|
112
|
-
case "custom":
|
|
113
|
-
return customValidators?.[condition.validatorName]?.(
|
|
114
|
-
context.type === "custom" ? context.value : void 0,
|
|
115
|
-
condition
|
|
116
|
-
) ?? false;
|
|
117
|
-
}
|
|
82
|
+
function now(options, requested) {
|
|
83
|
+
return requested ?? options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
84
|
+
}
|
|
85
|
+
function initialState(config, userId, timestamp) {
|
|
86
|
+
return {
|
|
87
|
+
rallyId: config.id,
|
|
88
|
+
userId,
|
|
89
|
+
records: [],
|
|
90
|
+
rewards: reconcileRewardStates(config.rewards, [], 0, timestamp),
|
|
91
|
+
updatedAt: timestamp
|
|
92
|
+
};
|
|
118
93
|
}
|
|
119
|
-
function
|
|
94
|
+
function getProof(context) {
|
|
95
|
+
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;
|
|
96
|
+
}
|
|
97
|
+
async function evaluate(condition, context, validator, base) {
|
|
98
|
+
if (condition.type !== "custom") return evaluateConditionDetailed(condition, context).ok;
|
|
99
|
+
if (validator === void 0) return false;
|
|
100
|
+
const validationContext = {
|
|
101
|
+
rallyId: base.rallyId,
|
|
102
|
+
spotId: base.spotId,
|
|
103
|
+
proofData: getProof(context),
|
|
104
|
+
condition,
|
|
105
|
+
userState: base.state
|
|
106
|
+
};
|
|
107
|
+
const result = typeof validator === "function" ? await validator(validationContext) : await validator.validate(validationContext);
|
|
108
|
+
return result === true || typeof result === "object" && result.valid;
|
|
109
|
+
}
|
|
110
|
+
function audit(rallyId, userId, action, resourceId, key, status, timestamp, code) {
|
|
120
111
|
return {
|
|
121
|
-
id:
|
|
122
|
-
timestamp
|
|
123
|
-
rallyId
|
|
124
|
-
userId
|
|
125
|
-
action
|
|
126
|
-
resourceId
|
|
112
|
+
id: requestId("audit"),
|
|
113
|
+
timestamp,
|
|
114
|
+
rallyId,
|
|
115
|
+
userId,
|
|
116
|
+
action,
|
|
117
|
+
resourceId,
|
|
127
118
|
status,
|
|
128
|
-
idempotencyKey:
|
|
129
|
-
...
|
|
119
|
+
idempotencyKey: key,
|
|
120
|
+
...code === void 0 ? {} : { metadata: { errorCode: code } }
|
|
130
121
|
};
|
|
131
122
|
}
|
|
132
|
-
var
|
|
123
|
+
var StampRallyServer = class {
|
|
133
124
|
#config;
|
|
134
125
|
#persistence;
|
|
135
126
|
#options;
|
|
@@ -138,185 +129,87 @@ var UniversalRallyServer = class {
|
|
|
138
129
|
this.#persistence = persistence;
|
|
139
130
|
this.#options = options;
|
|
140
131
|
}
|
|
141
|
-
|
|
132
|
+
async handle(request) {
|
|
133
|
+
if (request.method !== "POST")
|
|
134
|
+
return json({ ok: false, code: "METHOD_NOT_ALLOWED", message: "POST is required." }, 405);
|
|
135
|
+
const path = new URL(request.url).pathname;
|
|
136
|
+
if (path.endsWith("/check-in")) return this.handleCheckIn(request);
|
|
137
|
+
if (path.endsWith("/claim-reward")) return this.handleClaimReward(request);
|
|
138
|
+
if (path.endsWith("/sync")) return this.handleSync(request);
|
|
139
|
+
return json({ ok: false, code: "NOT_FOUND", message: "Route not found." }, 404);
|
|
140
|
+
}
|
|
142
141
|
async handleCheckIn(request) {
|
|
143
|
-
const body = await
|
|
144
|
-
const userId = await this.#
|
|
145
|
-
if (this.#
|
|
146
|
-
return json(
|
|
147
|
-
{ ok: false, error: { code: "UNAUTHORIZED", message: "Authentication is required." } },
|
|
148
|
-
401
|
|
149
|
-
);
|
|
150
|
-
if (body === null || userId === null || body.rallyId !== this.#config.id || body.spotId === "" || body.idempotencyKey === "")
|
|
142
|
+
const body = await this.#body(request);
|
|
143
|
+
const userId = await this.#user(request, body?.userId);
|
|
144
|
+
if (body === null || userId === null || body.rallyId !== this.#config.id || body.spotId === "" || body.idempotencyKey === "" || body.context === void 0)
|
|
151
145
|
return json(
|
|
152
146
|
{
|
|
153
147
|
ok: false,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
message: "rallyId, spotId, and idempotencyKey are required."
|
|
157
|
-
}
|
|
148
|
+
code: "INVALID_REQUEST",
|
|
149
|
+
message: "rallyId, spotId, context, and idempotencyKey are required."
|
|
158
150
|
},
|
|
159
151
|
400
|
|
160
152
|
);
|
|
161
|
-
if (body.context === void 0)
|
|
162
|
-
return json(
|
|
163
|
-
{ ok: false, error: { code: "INVALID_REQUEST", message: "context is required." } },
|
|
164
|
-
400
|
|
165
|
-
);
|
|
166
153
|
const result = await this.checkIn({ ...body, userId });
|
|
167
|
-
return result.ok ?
|
|
154
|
+
return json(result, result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422);
|
|
168
155
|
}
|
|
169
156
|
async handleClaimReward(request) {
|
|
170
|
-
const body = await
|
|
171
|
-
const userId = await this.#
|
|
172
|
-
if (this.#options.authenticate !== void 0 && userId === null)
|
|
173
|
-
return json(
|
|
174
|
-
{ ok: false, error: { code: "UNAUTHORIZED", message: "Authentication is required." } },
|
|
175
|
-
401
|
|
176
|
-
);
|
|
157
|
+
const body = await this.#body(request);
|
|
158
|
+
const userId = await this.#user(request, body?.userId);
|
|
177
159
|
if (body === null || userId === null || body.rallyId !== this.#config.id || body.rewardId === "" || body.idempotencyKey === "")
|
|
178
160
|
return json(
|
|
179
161
|
{
|
|
180
162
|
ok: false,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
message: "rallyId, rewardId, and idempotencyKey are required."
|
|
184
|
-
}
|
|
163
|
+
code: "INVALID_REQUEST",
|
|
164
|
+
message: "rallyId, rewardId, and idempotencyKey are required."
|
|
185
165
|
},
|
|
186
166
|
400
|
|
187
167
|
);
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
if (previous !== null) return json(previous, previous.ok ? 200 : 422);
|
|
191
|
-
const reward = this.#config.rewards.find((item) => item.id === body.rewardId);
|
|
192
|
-
if (reward === void 0) {
|
|
193
|
-
const failure = {
|
|
194
|
-
ok: false,
|
|
195
|
-
code: "REWARD_NOT_FOUND",
|
|
196
|
-
message: "Reward was not found."
|
|
197
|
-
};
|
|
198
|
-
return json(await this.#rememberClaim(key, failure, body, userId), 404);
|
|
199
|
-
}
|
|
200
|
-
const timestamp = body.now ?? this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
201
|
-
const current = await this.sync(body.rallyId, userId);
|
|
202
|
-
const currentReward = current.rewards?.find((item) => item.rewardId === reward.id) ?? {
|
|
203
|
-
rewardId: reward.id,
|
|
204
|
-
status: "LOCKED"
|
|
205
|
-
};
|
|
206
|
-
const userClaims = await this.#getUserClaimCount(userId, reward.id);
|
|
207
|
-
const local = consumeReward({
|
|
208
|
-
reward: adminReward(reward),
|
|
209
|
-
currentState: currentReward,
|
|
210
|
-
now: timestamp,
|
|
211
|
-
userId,
|
|
212
|
-
userRedemptionCount: userClaims,
|
|
213
|
-
...body.staffPasscode === void 0 ? {} : { inputPasscode: body.staffPasscode },
|
|
214
|
-
...body.staffId === void 0 ? {} : { staffId: body.staffId }
|
|
215
|
-
});
|
|
216
|
-
if (!local.ok)
|
|
217
|
-
return json(
|
|
218
|
-
await this.#rememberClaim(
|
|
219
|
-
key,
|
|
220
|
-
{
|
|
221
|
-
ok: false,
|
|
222
|
-
code: local.error.code,
|
|
223
|
-
message: "Reward cannot be claimed.",
|
|
224
|
-
error: local.error
|
|
225
|
-
},
|
|
226
|
-
body,
|
|
227
|
-
userId
|
|
228
|
-
),
|
|
229
|
-
422
|
|
230
|
-
);
|
|
231
|
-
const stock = await this.#persistence.decrementRewardStock(reward.id);
|
|
232
|
-
if (!stock.success)
|
|
233
|
-
return json(
|
|
234
|
-
await this.#rememberClaim(
|
|
235
|
-
key,
|
|
236
|
-
{ ok: false, code: "OUT_OF_STOCK", message: "Reward is out of stock." },
|
|
237
|
-
body,
|
|
238
|
-
userId
|
|
239
|
-
),
|
|
240
|
-
422
|
|
241
|
-
);
|
|
242
|
-
const next = {
|
|
243
|
-
...current,
|
|
244
|
-
rewards: (current.rewards ?? []).map(
|
|
245
|
-
(item) => item.rewardId === reward.id ? local.value : item
|
|
246
|
-
),
|
|
247
|
-
updatedAt: timestamp
|
|
248
|
-
};
|
|
249
|
-
await this.#persistence.saveUserState(body.rallyId, userId, next);
|
|
250
|
-
const success = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
|
|
251
|
-
await this.#persistence.saveIdempotentResult(
|
|
252
|
-
key,
|
|
253
|
-
success,
|
|
254
|
-
this.#options.idempotencyTtlMs ?? 864e5
|
|
255
|
-
);
|
|
256
|
-
await this.#persistence.recordAuditLog({
|
|
257
|
-
id: `audit-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`,
|
|
258
|
-
timestamp,
|
|
259
|
-
rallyId: body.rallyId,
|
|
260
|
-
userId,
|
|
261
|
-
action: "CLAIM_REWARD",
|
|
262
|
-
resourceId: reward.id,
|
|
263
|
-
status: "SUCCESS",
|
|
264
|
-
idempotencyKey: body.idempotencyKey
|
|
265
|
-
});
|
|
266
|
-
return json(success);
|
|
168
|
+
const result = await this.claimReward({ ...body, userId });
|
|
169
|
+
return json(result, result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422);
|
|
267
170
|
}
|
|
268
171
|
async handleSync(request) {
|
|
269
|
-
const body = await
|
|
270
|
-
const userId = await this.#
|
|
271
|
-
if (this.#options.authenticate !== void 0 && userId === null)
|
|
272
|
-
return json(
|
|
273
|
-
{ ok: false, error: { code: "UNAUTHORIZED", message: "Authentication is required." } },
|
|
274
|
-
401
|
|
275
|
-
);
|
|
172
|
+
const body = await this.#body(request);
|
|
173
|
+
const userId = await this.#user(request, body?.userId);
|
|
276
174
|
if (body === null || userId === null || body.rallyId !== this.#config.id)
|
|
277
|
-
return json(
|
|
278
|
-
{ ok: false, error: { code: "INVALID_REQUEST", message: "rallyId is required." } },
|
|
279
|
-
400
|
|
280
|
-
);
|
|
175
|
+
return json({ ok: false, code: "INVALID_REQUEST", message: "rallyId is required." }, 400);
|
|
281
176
|
return json({ ok: true, state: await this.sync(body.rallyId, userId) });
|
|
282
177
|
}
|
|
283
|
-
async handle(request) {
|
|
284
|
-
if (request.method !== "POST")
|
|
285
|
-
return json({ ok: false, error: { code: "METHOD_NOT_ALLOWED" } }, 405);
|
|
286
|
-
const path = new URL(request.url).pathname;
|
|
287
|
-
if (path.endsWith("/check-in")) return this.handleCheckIn(request);
|
|
288
|
-
if (path.endsWith("/claim-reward")) return this.handleClaimReward(request);
|
|
289
|
-
if (path.endsWith("/sync")) return this.handleSync(request);
|
|
290
|
-
return json({ ok: false, error: { code: "NOT_FOUND" } }, 404);
|
|
291
|
-
}
|
|
292
178
|
async checkIn(request) {
|
|
293
179
|
const key = `check-in:${request.rallyId}:${request.userId}:${request.idempotencyKey}`;
|
|
294
180
|
const previous = await this.#persistence.getIdempotentResult(key);
|
|
295
181
|
if (previous !== null) return previous;
|
|
296
182
|
const lockKey = `state:${request.rallyId}:${request.userId}`;
|
|
297
|
-
|
|
298
|
-
if (!locked)
|
|
183
|
+
if (!await this.#persistence.acquireLock(lockKey, this.#options.lockTtlMs ?? 5e3))
|
|
299
184
|
return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
|
|
300
|
-
const
|
|
185
|
+
const timestamp = now(this.#options, request.now);
|
|
301
186
|
try {
|
|
302
187
|
const spot = this.#config.spots.find((item) => item.id === request.spotId);
|
|
303
188
|
if (spot === void 0)
|
|
304
189
|
return this.#remember(
|
|
305
190
|
key,
|
|
306
191
|
{ ok: false, code: "SPOT_NOT_FOUND", message: "Spot was not found." },
|
|
307
|
-
request,
|
|
308
|
-
|
|
192
|
+
request.rallyId,
|
|
193
|
+
request.userId,
|
|
194
|
+
"CHECK_IN",
|
|
195
|
+
request.spotId,
|
|
196
|
+
request.idempotencyKey,
|
|
197
|
+
timestamp
|
|
309
198
|
);
|
|
310
|
-
const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config,
|
|
199
|
+
const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
|
|
311
200
|
if (current.records.some((record) => record.stampId === request.spotId))
|
|
312
201
|
return this.#remember(
|
|
313
202
|
key,
|
|
314
|
-
{ ok: false, code: "
|
|
315
|
-
request,
|
|
316
|
-
|
|
203
|
+
{ ok: false, code: "STAMP_ALREADY_ACQUIRED", message: "Spot was already claimed." },
|
|
204
|
+
request.rallyId,
|
|
205
|
+
request.userId,
|
|
206
|
+
"CHECK_IN",
|
|
207
|
+
request.spotId,
|
|
208
|
+
request.idempotencyKey,
|
|
209
|
+
timestamp
|
|
317
210
|
);
|
|
318
211
|
const acquired = new Set(current.records.map((record) => record.stampId));
|
|
319
|
-
if (spot.prerequisites?.some((
|
|
212
|
+
if (spot.prerequisites?.some((id) => !acquired.has(id)))
|
|
320
213
|
return this.#remember(
|
|
321
214
|
key,
|
|
322
215
|
{
|
|
@@ -324,63 +217,202 @@ var UniversalRallyServer = class {
|
|
|
324
217
|
code: "PREREQUISITES_NOT_MET",
|
|
325
218
|
message: "Prerequisite spots are not complete."
|
|
326
219
|
},
|
|
220
|
+
request.rallyId,
|
|
221
|
+
request.userId,
|
|
222
|
+
"CHECK_IN",
|
|
223
|
+
request.spotId,
|
|
224
|
+
request.idempotencyKey,
|
|
225
|
+
timestamp
|
|
226
|
+
);
|
|
227
|
+
for (const condition of spot.conditions)
|
|
228
|
+
if (!await evaluate(
|
|
229
|
+
condition,
|
|
230
|
+
request.context,
|
|
231
|
+
condition.type === "custom" ? this.#options.customValidators?.[condition.validatorName] : void 0,
|
|
232
|
+
{ rallyId: request.rallyId, spotId: request.spotId, state: current }
|
|
233
|
+
))
|
|
234
|
+
return this.#remember(
|
|
235
|
+
key,
|
|
236
|
+
{ ok: false, code: "INVALID_PROOF", message: "Verification failed." },
|
|
237
|
+
request.rallyId,
|
|
238
|
+
request.userId,
|
|
239
|
+
"CHECK_IN",
|
|
240
|
+
request.spotId,
|
|
241
|
+
request.idempotencyKey,
|
|
242
|
+
timestamp
|
|
243
|
+
);
|
|
244
|
+
const next = {
|
|
245
|
+
...current,
|
|
246
|
+
records: [...current.records, { stampId: request.spotId, acquiredAt: timestamp }],
|
|
247
|
+
rewards: reconcileRewardStates(
|
|
248
|
+
this.#config.rewards,
|
|
249
|
+
current.rewards,
|
|
250
|
+
current.records.length + 1,
|
|
251
|
+
timestamp
|
|
252
|
+
),
|
|
253
|
+
updatedAt: timestamp
|
|
254
|
+
};
|
|
255
|
+
await this.#persistence.saveUserState(request.rallyId, request.userId, next);
|
|
256
|
+
return this.#remember(
|
|
257
|
+
key,
|
|
258
|
+
{ ok: true, state: next },
|
|
259
|
+
request.rallyId,
|
|
260
|
+
request.userId,
|
|
261
|
+
"CHECK_IN",
|
|
262
|
+
request.spotId,
|
|
263
|
+
request.idempotencyKey,
|
|
264
|
+
timestamp
|
|
265
|
+
);
|
|
266
|
+
} finally {
|
|
267
|
+
await this.#persistence.releaseLock(lockKey);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async claimReward(request) {
|
|
271
|
+
const key = `claim:${request.rallyId}:${request.userId}:${request.rewardId}:${request.idempotencyKey}`;
|
|
272
|
+
const previous = await this.#persistence.getIdempotentResult(key);
|
|
273
|
+
if (previous !== null) return previous;
|
|
274
|
+
const reward = this.#config.rewards.find((item) => item.id === request.rewardId);
|
|
275
|
+
if (reward === void 0)
|
|
276
|
+
return this.#rememberClaim(
|
|
277
|
+
key,
|
|
278
|
+
{ ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
|
|
279
|
+
request,
|
|
280
|
+
now(this.#options, request.now)
|
|
281
|
+
);
|
|
282
|
+
const lockKey = `reward:${request.rallyId}:${reward.id}`;
|
|
283
|
+
if (!await this.#persistence.acquireLock(lockKey, this.#options.lockTtlMs ?? 5e3))
|
|
284
|
+
return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
|
|
285
|
+
const timestamp = now(this.#options, request.now);
|
|
286
|
+
let decremented = false;
|
|
287
|
+
try {
|
|
288
|
+
const checked = await this.#persistence.getIdempotentResult(key);
|
|
289
|
+
if (checked !== null) return checked;
|
|
290
|
+
const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
|
|
291
|
+
const claimCount = await this.#persistence.getUserClaimCount(
|
|
292
|
+
request.rallyId,
|
|
293
|
+
request.userId,
|
|
294
|
+
reward.id
|
|
295
|
+
);
|
|
296
|
+
const storedReward = current.rewards.find((item) => item.rewardId === reward.id) ?? {
|
|
297
|
+
rewardId: reward.id,
|
|
298
|
+
status: "LOCKED"
|
|
299
|
+
};
|
|
300
|
+
const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
|
|
301
|
+
const local = consumeReward({
|
|
302
|
+
reward,
|
|
303
|
+
currentState: currentReward,
|
|
304
|
+
now: timestamp,
|
|
305
|
+
userRedemptionCount: claimCount,
|
|
306
|
+
...request.staffPasscode === void 0 ? {} : { inputPasscode: request.staffPasscode },
|
|
307
|
+
...request.staffId === void 0 ? {} : { staffId: request.staffId }
|
|
308
|
+
});
|
|
309
|
+
if (!local.ok)
|
|
310
|
+
return this.#rememberClaim(
|
|
311
|
+
key,
|
|
312
|
+
{ ok: false, code: local.error.code, message: "Reward cannot be claimed." },
|
|
327
313
|
request,
|
|
328
|
-
|
|
314
|
+
timestamp
|
|
329
315
|
);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
return this.#remember(
|
|
316
|
+
const stock = await this.#persistence.decrementRewardStock(reward.id);
|
|
317
|
+
if (!stock.success)
|
|
318
|
+
return this.#rememberClaim(
|
|
334
319
|
key,
|
|
335
|
-
{ ok: false, code: "
|
|
320
|
+
{ ok: false, code: "OUT_OF_STOCK", message: "Reward is out of stock." },
|
|
336
321
|
request,
|
|
337
|
-
|
|
322
|
+
timestamp
|
|
338
323
|
);
|
|
339
|
-
|
|
324
|
+
decremented = true;
|
|
325
|
+
const next = {
|
|
340
326
|
...current,
|
|
341
|
-
|
|
342
|
-
updatedAt:
|
|
327
|
+
rewards: current.rewards.map((item) => item.rewardId === reward.id ? local.value : item),
|
|
328
|
+
updatedAt: timestamp
|
|
329
|
+
};
|
|
330
|
+
try {
|
|
331
|
+
await this.#persistence.saveUserState(request.rallyId, request.userId, next);
|
|
332
|
+
const response = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
|
|
333
|
+
await this.#persistence.recordAuditLog(
|
|
334
|
+
audit(
|
|
335
|
+
request.rallyId,
|
|
336
|
+
request.userId,
|
|
337
|
+
"CLAIM_REWARD",
|
|
338
|
+
reward.id,
|
|
339
|
+
request.idempotencyKey,
|
|
340
|
+
"SUCCESS",
|
|
341
|
+
timestamp
|
|
342
|
+
)
|
|
343
|
+
);
|
|
344
|
+
await this.#persistence.saveIdempotentResult(
|
|
345
|
+
key,
|
|
346
|
+
response,
|
|
347
|
+
this.#options.idempotencyTtlMs ?? 864e5
|
|
348
|
+
);
|
|
349
|
+
if (this.#persistence instanceof Object && "recordClaim" in this.#persistence && typeof this.#persistence.recordClaim === "function")
|
|
350
|
+
this.#persistence.recordClaim(request.rallyId, request.userId, reward.id);
|
|
351
|
+
return response;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
await this.#persistence.incrementRewardStock(reward.id);
|
|
354
|
+
decremented = false;
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (decremented) await this.#persistence.incrementRewardStock(reward.id);
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
code: "PERSISTENCE_FAILED",
|
|
362
|
+
message: error instanceof Error ? error.message : "Reward claim failed."
|
|
343
363
|
};
|
|
344
|
-
await this.#persistence.saveUserState(request.rallyId, request.userId, state);
|
|
345
|
-
return this.#remember(key, { ok: true, state }, request, now2);
|
|
346
364
|
} finally {
|
|
347
365
|
await this.#persistence.releaseLock(lockKey);
|
|
348
366
|
}
|
|
349
367
|
}
|
|
350
368
|
async sync(rallyId, userId) {
|
|
351
|
-
|
|
352
|
-
return await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, now2);
|
|
369
|
+
return await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
|
|
353
370
|
}
|
|
354
|
-
async #
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
371
|
+
async #body(request) {
|
|
372
|
+
try {
|
|
373
|
+
const value = await request.json();
|
|
374
|
+
return isObject(value) ? value : null;
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
358
378
|
}
|
|
359
|
-
async #
|
|
360
|
-
|
|
361
|
-
|
|
379
|
+
async #user(request, requested) {
|
|
380
|
+
if (this.#options.authenticate !== void 0)
|
|
381
|
+
return await this.#options.authenticate(request) ?? null;
|
|
382
|
+
return requested ?? null;
|
|
362
383
|
}
|
|
363
|
-
async #
|
|
384
|
+
async #remember(key, result, rallyId, userId, action, resourceId, idempotencyKey, timestamp) {
|
|
385
|
+
await this.#persistence.recordAuditLog(
|
|
386
|
+
audit(
|
|
387
|
+
rallyId,
|
|
388
|
+
userId,
|
|
389
|
+
action,
|
|
390
|
+
resourceId,
|
|
391
|
+
idempotencyKey,
|
|
392
|
+
result.ok ? "SUCCESS" : "REJECTED",
|
|
393
|
+
timestamp,
|
|
394
|
+
result.ok ? void 0 : result.code
|
|
395
|
+
)
|
|
396
|
+
);
|
|
364
397
|
await this.#persistence.saveIdempotentResult(
|
|
365
398
|
key,
|
|
366
399
|
result,
|
|
367
400
|
this.#options.idempotencyTtlMs ?? 864e5
|
|
368
401
|
);
|
|
369
|
-
await this.#persistence.recordAuditLog({
|
|
370
|
-
id: `audit-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`,
|
|
371
|
-
timestamp: request.now ?? this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
372
|
-
rallyId: request.rallyId,
|
|
373
|
-
userId,
|
|
374
|
-
action: "CLAIM_REWARD",
|
|
375
|
-
resourceId: request.rewardId,
|
|
376
|
-
status: "REJECTED",
|
|
377
|
-
idempotencyKey: request.idempotencyKey
|
|
378
|
-
});
|
|
379
402
|
return result;
|
|
380
403
|
}
|
|
381
|
-
async #
|
|
404
|
+
async #rememberClaim(key, result, request, timestamp) {
|
|
382
405
|
await this.#persistence.recordAuditLog(
|
|
383
|
-
audit(
|
|
406
|
+
audit(
|
|
407
|
+
request.rallyId,
|
|
408
|
+
request.userId,
|
|
409
|
+
"CLAIM_REWARD",
|
|
410
|
+
request.rewardId,
|
|
411
|
+
request.idempotencyKey,
|
|
412
|
+
"REJECTED",
|
|
413
|
+
timestamp,
|
|
414
|
+
result.ok ? void 0 : result.code
|
|
415
|
+
)
|
|
384
416
|
);
|
|
385
417
|
await this.#persistence.saveIdempotentResult(
|
|
386
418
|
key,
|
|
@@ -391,373 +423,6 @@ var UniversalRallyServer = class {
|
|
|
391
423
|
}
|
|
392
424
|
};
|
|
393
425
|
|
|
394
|
-
|
|
395
|
-
var InMemoryServerStorage = class {
|
|
396
|
-
#states = /* @__PURE__ */ new Map();
|
|
397
|
-
#stocks;
|
|
398
|
-
#claims = /* @__PURE__ */ new Map();
|
|
399
|
-
#auditLogs = [];
|
|
400
|
-
constructor(options = {}) {
|
|
401
|
-
this.#stocks = new Map(Object.entries(options.stocks ?? {}));
|
|
402
|
-
}
|
|
403
|
-
async getRewardStock(rewardId) {
|
|
404
|
-
return this.#stocks.get(rewardId) ?? null;
|
|
405
|
-
}
|
|
406
|
-
async decrementRewardStock(rewardId) {
|
|
407
|
-
const stock = this.#stocks.get(rewardId);
|
|
408
|
-
if (stock === void 0) return true;
|
|
409
|
-
if (stock <= 0) return false;
|
|
410
|
-
this.#stocks.set(rewardId, stock - 1);
|
|
411
|
-
return true;
|
|
412
|
-
}
|
|
413
|
-
async getUserClaims(userId, rewardId) {
|
|
414
|
-
return this.#claims.get(`${userId}:${rewardId}`) ?? 0;
|
|
415
|
-
}
|
|
416
|
-
async recordAuditLog(log) {
|
|
417
|
-
this.#auditLogs.push({ ...log });
|
|
418
|
-
}
|
|
419
|
-
async saveUserState(userId, state) {
|
|
420
|
-
this.#states.set(userId, cloneUserState(state));
|
|
421
|
-
}
|
|
422
|
-
async getUserState(userId) {
|
|
423
|
-
const state = this.#states.get(userId);
|
|
424
|
-
return state === void 0 ? null : cloneUserState(state);
|
|
425
|
-
}
|
|
426
|
-
getAuditLogs() {
|
|
427
|
-
return this.#auditLogs.map((log) => ({ ...log }));
|
|
428
|
-
}
|
|
429
|
-
recordClaim(userId, rewardId) {
|
|
430
|
-
const key = `${userId}:${rewardId}`;
|
|
431
|
-
this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
|
|
432
|
-
}
|
|
433
|
-
};
|
|
434
|
-
function cloneUserState(state) {
|
|
435
|
-
return {
|
|
436
|
-
...state,
|
|
437
|
-
records: state.records.map((record) => ({
|
|
438
|
-
...record,
|
|
439
|
-
...record.metadata === void 0 ? {} : { metadata: { ...record.metadata } }
|
|
440
|
-
})),
|
|
441
|
-
...state.rewards === void 0 ? {} : { rewards: state.rewards.map((reward) => ({ ...reward })) }
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
function jsonResponse(body, status = 200) {
|
|
445
|
-
return new Response(JSON.stringify(body), {
|
|
446
|
-
status,
|
|
447
|
-
headers: { "content-type": "application/json; charset=utf-8" }
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
function errorResponse(code, message, status) {
|
|
451
|
-
return jsonResponse({ ok: false, error: { code, message } }, status);
|
|
452
|
-
}
|
|
453
|
-
function isObject(value) {
|
|
454
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
455
|
-
}
|
|
456
|
-
function contextForClaim(method, proofData) {
|
|
457
|
-
if (method === "token" || method === "qr" || method === "passcode") {
|
|
458
|
-
return {
|
|
459
|
-
type: "token",
|
|
460
|
-
token: isObject(proofData) && typeof proofData.token === "string" ? proofData.token : String(proofData ?? "")
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
if (method === "geo" || method === "geolocation") {
|
|
464
|
-
const value = isObject(proofData) ? proofData : {};
|
|
465
|
-
return {
|
|
466
|
-
type: "geo",
|
|
467
|
-
currentLatitude: typeof value.latitude === "number" ? value.latitude : typeof value.currentLatitude === "number" ? value.currentLatitude : Number.NaN,
|
|
468
|
-
currentLongitude: typeof value.longitude === "number" ? value.longitude : typeof value.currentLongitude === "number" ? value.currentLongitude : Number.NaN
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
return { type: "instant" };
|
|
472
|
-
}
|
|
473
|
-
function now() {
|
|
474
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
475
|
-
}
|
|
476
|
-
function hasText(value) {
|
|
477
|
-
return typeof value === "string" && value.trim() !== "";
|
|
478
|
-
}
|
|
479
|
-
function safeProofData(value) {
|
|
480
|
-
return isObject(value) && typeof value.token === "string" ? { type: "token" } : value;
|
|
481
|
-
}
|
|
482
|
-
function proofFromContext(context) {
|
|
483
|
-
if (context?.type === "token") return { token: context.token };
|
|
484
|
-
if (context?.type === "geo") {
|
|
485
|
-
return { latitude: context.currentLatitude, longitude: context.currentLongitude };
|
|
486
|
-
}
|
|
487
|
-
return void 0;
|
|
488
|
-
}
|
|
489
|
-
function id(prefix) {
|
|
490
|
-
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
|
491
|
-
}
|
|
492
|
-
var StampRallyServer = class {
|
|
493
|
-
#config;
|
|
494
|
-
#storage;
|
|
495
|
-
#idempotent = /* @__PURE__ */ new Map();
|
|
496
|
-
#claims = /* @__PURE__ */ new Map();
|
|
497
|
-
#queue = Promise.resolve();
|
|
498
|
-
constructor(config, storage) {
|
|
499
|
-
this.#config = config;
|
|
500
|
-
this.#storage = storage;
|
|
501
|
-
}
|
|
502
|
-
handle(request) {
|
|
503
|
-
const path = new URL(request.url).pathname;
|
|
504
|
-
if (request.method !== "POST")
|
|
505
|
-
return Promise.resolve(errorResponse("METHOD_NOT_ALLOWED", "POST is required.", 405));
|
|
506
|
-
if (path.endsWith("/check-in")) return this.verifyCheckIn(request);
|
|
507
|
-
if (path.endsWith("/claim-reward")) return this.claimReward(request);
|
|
508
|
-
if (path.endsWith("/sync")) return this.syncProgress(request);
|
|
509
|
-
return Promise.resolve(errorResponse("NOT_FOUND", "Route not found.", 404));
|
|
510
|
-
}
|
|
511
|
-
verifyCheckIn(request) {
|
|
512
|
-
return this.#enqueue(() => this.#verifyCheckIn(request));
|
|
513
|
-
}
|
|
514
|
-
claimReward(request) {
|
|
515
|
-
return this.#enqueue(() => this.#claimReward(request));
|
|
516
|
-
}
|
|
517
|
-
syncProgress(request) {
|
|
518
|
-
return this.#enqueue(() => this.#syncProgress(request));
|
|
519
|
-
}
|
|
520
|
-
#enqueue(operation) {
|
|
521
|
-
const next = this.#queue.then(operation, operation);
|
|
522
|
-
this.#queue = next.then(
|
|
523
|
-
() => void 0,
|
|
524
|
-
() => void 0
|
|
525
|
-
);
|
|
526
|
-
return next;
|
|
527
|
-
}
|
|
528
|
-
async #authenticate(request) {
|
|
529
|
-
return this.#config.authenticate === void 0 ? null : this.#config.authenticate(request);
|
|
530
|
-
}
|
|
531
|
-
async #parse(request) {
|
|
532
|
-
try {
|
|
533
|
-
const value = await request.json();
|
|
534
|
-
return isObject(value) ? value : null;
|
|
535
|
-
} catch {
|
|
536
|
-
return null;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
async #verifyCheckIn(request) {
|
|
540
|
-
const authenticatedUser = await this.#authenticate(request);
|
|
541
|
-
const body = await this.#parse(request);
|
|
542
|
-
const userId = body?.userId ?? authenticatedUser;
|
|
543
|
-
if (userId === null || userId === void 0 || body === null || !hasText(userId) || !hasText(body.spotId) || !hasText(body.claimMethod) || !hasText(body.idempotencyKey)) {
|
|
544
|
-
return errorResponse(
|
|
545
|
-
"INVALID_REQUEST",
|
|
546
|
-
"userId, spotId, and idempotencyKey are required.",
|
|
547
|
-
400
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
if (authenticatedUser !== null && authenticatedUser !== userId)
|
|
551
|
-
return errorResponse("UNAUTHORIZED", "User identity does not match authentication.", 401);
|
|
552
|
-
const key = `check-in:${userId}:${body.idempotencyKey}`;
|
|
553
|
-
const previous = this.#idempotent.get(key);
|
|
554
|
-
if (previous !== void 0) return previous.clone();
|
|
555
|
-
const timestamp = now();
|
|
556
|
-
const current = await this.#storage.getUserState(userId) ?? emptyState(this.#config, timestamp);
|
|
557
|
-
const result = processStamp(
|
|
558
|
-
current,
|
|
559
|
-
this.#config,
|
|
560
|
-
body.spotId,
|
|
561
|
-
contextForClaim(body.claimMethod, body.proofData),
|
|
562
|
-
timestamp
|
|
563
|
-
);
|
|
564
|
-
if (!result.ok) {
|
|
565
|
-
await this.#audit(
|
|
566
|
-
userId,
|
|
567
|
-
"CHECK_IN",
|
|
568
|
-
body.spotId,
|
|
569
|
-
body.idempotencyKey,
|
|
570
|
-
"REJECTED",
|
|
571
|
-
safeProofData(body.proofData),
|
|
572
|
-
result.error
|
|
573
|
-
);
|
|
574
|
-
return this.#remember(key, jsonResponse({ ok: false, error: result.error }, 422));
|
|
575
|
-
}
|
|
576
|
-
const ttl = this.#config.proofTtlSeconds ?? 3600;
|
|
577
|
-
const token = await createSecureToken(
|
|
578
|
-
{
|
|
579
|
-
type: "stamp_claim",
|
|
580
|
-
rallyId: this.#config.id,
|
|
581
|
-
userId,
|
|
582
|
-
spotId: body.spotId,
|
|
583
|
-
acquiredAt: timestamp,
|
|
584
|
-
exp: Math.floor(Date.now() / 1e3) + ttl
|
|
585
|
-
},
|
|
586
|
-
this.#config.secretKey,
|
|
587
|
-
{ encrypt: true }
|
|
588
|
-
);
|
|
589
|
-
await this.#storage.saveUserState(userId, result.value.nextState);
|
|
590
|
-
await this.#audit(
|
|
591
|
-
userId,
|
|
592
|
-
"CHECK_IN",
|
|
593
|
-
body.spotId,
|
|
594
|
-
body.idempotencyKey,
|
|
595
|
-
"SUCCESS",
|
|
596
|
-
safeProofData(body.proofData)
|
|
597
|
-
);
|
|
598
|
-
return this.#remember(
|
|
599
|
-
key,
|
|
600
|
-
jsonResponse({
|
|
601
|
-
ok: true,
|
|
602
|
-
state: result.value.nextState,
|
|
603
|
-
proof: {
|
|
604
|
-
token,
|
|
605
|
-
rallyId: this.#config.id,
|
|
606
|
-
userId,
|
|
607
|
-
spotId: body.spotId,
|
|
608
|
-
acquiredAt: timestamp
|
|
609
|
-
}
|
|
610
|
-
})
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
async #claimReward(request) {
|
|
614
|
-
const authenticatedUser = await this.#authenticate(request);
|
|
615
|
-
const body = await this.#parse(request);
|
|
616
|
-
const userId = body?.userId ?? authenticatedUser;
|
|
617
|
-
if (userId === null || userId === void 0 || body === null || !hasText(userId) || !hasText(body.rewardId) || !hasText(body.idempotencyKey))
|
|
618
|
-
return errorResponse(
|
|
619
|
-
"INVALID_REQUEST",
|
|
620
|
-
"userId, rewardId, and idempotencyKey are required.",
|
|
621
|
-
400
|
|
622
|
-
);
|
|
623
|
-
if (authenticatedUser !== null && authenticatedUser !== userId)
|
|
624
|
-
return errorResponse("UNAUTHORIZED", "User identity does not match authentication.", 401);
|
|
625
|
-
const key = `claim-reward:${userId}:${body.rewardId}:${body.idempotencyKey}`;
|
|
626
|
-
const previous = this.#idempotent.get(key);
|
|
627
|
-
if (previous !== void 0) return previous.clone();
|
|
628
|
-
const reward = this.#config.rewards?.find((item) => item.id === body.rewardId);
|
|
629
|
-
if (reward === void 0) {
|
|
630
|
-
await this.#audit(userId, "CLAIM_REWARD", body.rewardId, body.idempotencyKey, "REJECTED");
|
|
631
|
-
return this.#remember(key, errorResponse("REWARD_NOT_FOUND", "Reward was not found.", 404));
|
|
632
|
-
}
|
|
633
|
-
const timestamp = now();
|
|
634
|
-
const current = await this.#storage.getUserState(userId) ?? emptyState(this.#config, timestamp);
|
|
635
|
-
const rewardState = current.rewards?.find((item) => item.rewardId === reward.id);
|
|
636
|
-
if (rewardState === void 0) {
|
|
637
|
-
await this.#audit(userId, "CLAIM_REWARD", reward.id, body.idempotencyKey, "REJECTED");
|
|
638
|
-
return this.#remember(key, errorResponse("NOT_AVAILABLE", "Reward is not available.", 422));
|
|
639
|
-
}
|
|
640
|
-
const userClaims = Math.max(
|
|
641
|
-
await this.#storage.getUserClaims(userId, reward.id),
|
|
642
|
-
this.#claims.get(`${userId}:${reward.id}`) ?? 0
|
|
643
|
-
);
|
|
644
|
-
const stock = await this.#storage.getRewardStock(reward.id);
|
|
645
|
-
const userLimit = reward.userClaimLimit ?? reward.limitPerUser;
|
|
646
|
-
const canReclaimServerReward = reward.redemptionMethod === "server_claim" && (userLimit === void 0 || userClaims < userLimit) && (stock === null || stock > 0);
|
|
647
|
-
const claimableState = canReclaimServerReward && rewardState.status === "CONSUMED" ? { ...rewardState, status: "AVAILABLE" } : rewardState;
|
|
648
|
-
const local = consumeReward({
|
|
649
|
-
reward,
|
|
650
|
-
currentState: claimableState,
|
|
651
|
-
now: timestamp,
|
|
652
|
-
...body.staffPasscode === void 0 ? {} : { inputPasscode: body.staffPasscode },
|
|
653
|
-
...body.staffId === void 0 ? {} : { staffId: body.staffId },
|
|
654
|
-
userId,
|
|
655
|
-
userRedemptionCount: userClaims
|
|
656
|
-
});
|
|
657
|
-
if (!local.ok)
|
|
658
|
-
return this.#remember(
|
|
659
|
-
key,
|
|
660
|
-
await this.#rewardError(userId, reward.id, body.idempotencyKey, local.error)
|
|
661
|
-
);
|
|
662
|
-
if (stock !== null && !await this.#storage.decrementRewardStock(reward.id))
|
|
663
|
-
return this.#remember(
|
|
664
|
-
key,
|
|
665
|
-
await this.#rewardError(userId, reward.id, body.idempotencyKey, {
|
|
666
|
-
code: "OUT_OF_STOCK",
|
|
667
|
-
rewardId: reward.id
|
|
668
|
-
})
|
|
669
|
-
);
|
|
670
|
-
const nextState = {
|
|
671
|
-
...current,
|
|
672
|
-
rewards: (current.rewards ?? []).map(
|
|
673
|
-
(item) => item.rewardId === reward.id ? local.value : item
|
|
674
|
-
),
|
|
675
|
-
updatedAt: timestamp
|
|
676
|
-
};
|
|
677
|
-
await this.#storage.saveUserState(userId, nextState);
|
|
678
|
-
const claimKey = `${userId}:${reward.id}`;
|
|
679
|
-
this.#claims.set(claimKey, userClaims + 1);
|
|
680
|
-
if (this.#storage instanceof InMemoryServerStorage)
|
|
681
|
-
this.#storage.recordClaim(userId, reward.id);
|
|
682
|
-
await this.#audit(userId, "CLAIM_REWARD", reward.id, body.idempotencyKey, "SUCCESS", {
|
|
683
|
-
staffId: body.staffId
|
|
684
|
-
});
|
|
685
|
-
return this.#remember(
|
|
686
|
-
key,
|
|
687
|
-
jsonResponse({
|
|
688
|
-
ok: true,
|
|
689
|
-
state: nextState,
|
|
690
|
-
claimTicketNumber: local.value.claimTicketNumber
|
|
691
|
-
})
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
async #rewardError(userId, rewardId, key, error) {
|
|
695
|
-
await this.#audit(userId, "CLAIM_REWARD", rewardId, key, "REJECTED", void 0, error);
|
|
696
|
-
return jsonResponse({ ok: false, error }, 422);
|
|
697
|
-
}
|
|
698
|
-
async #syncProgress(request) {
|
|
699
|
-
const authenticatedUser = await this.#authenticate(request);
|
|
700
|
-
const body = await this.#parse(request);
|
|
701
|
-
if (body === null || body.userId === void 0)
|
|
702
|
-
return errorResponse("INVALID_REQUEST", "userId is required.", 400);
|
|
703
|
-
if (authenticatedUser !== null && authenticatedUser !== body.userId)
|
|
704
|
-
return errorResponse("UNAUTHORIZED", "User identity does not match authentication.", 401);
|
|
705
|
-
const queue = body.queue ?? body.operations ?? [];
|
|
706
|
-
for (const operation of queue) {
|
|
707
|
-
const userId = operation.userId ?? body.userId;
|
|
708
|
-
const spotId = operation.spotId ?? operation.stampId;
|
|
709
|
-
const claimMethod = operation.claimMethod ?? operation.context?.type;
|
|
710
|
-
if (!hasText(userId) || !hasText(spotId) || !hasText(claimMethod)) continue;
|
|
711
|
-
const synthetic = new Request(new URL("/api/check-in", request.url), {
|
|
712
|
-
method: "POST",
|
|
713
|
-
body: JSON.stringify({
|
|
714
|
-
userId,
|
|
715
|
-
spotId,
|
|
716
|
-
claimMethod,
|
|
717
|
-
proofData: operation.proofData ?? proofFromContext(operation.context),
|
|
718
|
-
idempotencyKey: operation.idempotencyKey
|
|
719
|
-
}),
|
|
720
|
-
headers: { "content-type": "application/json" }
|
|
721
|
-
});
|
|
722
|
-
await this.#verifyCheckIn(synthetic);
|
|
723
|
-
}
|
|
724
|
-
const timestamp = now();
|
|
725
|
-
const state = await this.#storage.getUserState(body.userId) ?? emptyState(this.#config, timestamp);
|
|
726
|
-
await this.#storage.saveUserState(body.userId, state);
|
|
727
|
-
return jsonResponse({ ok: true, state, accepted: queue.length });
|
|
728
|
-
}
|
|
729
|
-
async #audit(userId, action, resourceId, idempotencyKey, status, proofData, error) {
|
|
730
|
-
await this.#storage.recordAuditLog({
|
|
731
|
-
id: id("audit"),
|
|
732
|
-
timestamp: now(),
|
|
733
|
-
rallyId: this.#config.id,
|
|
734
|
-
userId,
|
|
735
|
-
action,
|
|
736
|
-
resourceId,
|
|
737
|
-
status,
|
|
738
|
-
idempotencyKey,
|
|
739
|
-
...proofData === void 0 ? {} : { proofData },
|
|
740
|
-
...error === void 0 ? {} : {
|
|
741
|
-
metadata: {
|
|
742
|
-
errorCode: isObject(error) && typeof error.code === "string" ? error.code : "UNKNOWN"
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
#remember(key, response) {
|
|
748
|
-
this.#idempotent.set(key, response.clone());
|
|
749
|
-
return response;
|
|
750
|
-
}
|
|
751
|
-
};
|
|
752
|
-
function emptyState(config, timestamp) {
|
|
753
|
-
return {
|
|
754
|
-
rallyId: config.id,
|
|
755
|
-
records: [],
|
|
756
|
-
...config.rewards === void 0 ? {} : { rewards: reconcileRewardStates(config.rewards, [], 0, timestamp) },
|
|
757
|
-
updatedAt: timestamp
|
|
758
|
-
};
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
export { InMemoryServerPersistenceAdapter, InMemoryServerStorage, StampRallyServer, UniversalRallyServer };
|
|
426
|
+
export { InMemoryServerPersistenceAdapter, StampRallyServer };
|
|
762
427
|
//# sourceMappingURL=index.js.map
|
|
763
428
|
//# sourceMappingURL=index.js.map
|