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