@stamprally/core 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 -143
- package/dist/index.cjs +687 -1274
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +280 -670
- package/dist/index.d.ts +280 -670
- package/dist/index.js +677 -1263
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,251 +1,63 @@
|
|
|
1
1
|
// src/engine/evaluate.ts
|
|
2
2
|
var EARTH_RADIUS_METERS = 6371e3;
|
|
3
|
-
function
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
const latitudeARadians = toRadians(latitudeA);
|
|
10
|
-
const latitudeBRadians = toRadians(latitudeB);
|
|
11
|
-
const haversine = Math.sin(latitudeDelta / 2) ** 2 + Math.cos(latitudeARadians) * Math.cos(latitudeBRadians) * Math.sin(longitudeDelta / 2) ** 2;
|
|
12
|
-
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(Math.min(1, haversine)));
|
|
13
|
-
}
|
|
14
|
-
function isValidCoordinate(latitude, longitude) {
|
|
15
|
-
return Number.isFinite(latitude) && latitude >= -90 && latitude <= 90 && Number.isFinite(longitude) && longitude >= -180 && longitude <= 180;
|
|
16
|
-
}
|
|
17
|
-
function contextTypeMismatch(conditionType, expectedContextType, actualContextType) {
|
|
18
|
-
return {
|
|
19
|
-
ok: false,
|
|
20
|
-
error: {
|
|
21
|
-
code: "CONDITION_MISMATCH",
|
|
22
|
-
conditionType,
|
|
23
|
-
reason: "CONTEXT_TYPE_MISMATCH",
|
|
24
|
-
expectedContextType,
|
|
25
|
-
actualContextType
|
|
26
|
-
}
|
|
27
|
-
};
|
|
3
|
+
function calculateDistanceMeters(aLat, aLon, bLat, bLon) {
|
|
4
|
+
const radians = (value2) => value2 * Math.PI / 180;
|
|
5
|
+
const dLat = radians(bLat - aLat);
|
|
6
|
+
const dLon = radians(bLon - aLon);
|
|
7
|
+
const value = Math.sin(dLat / 2) ** 2 + Math.cos(radians(aLat)) * Math.cos(radians(bLat)) * Math.sin(dLon / 2) ** 2;
|
|
8
|
+
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(Math.min(1, value)));
|
|
28
9
|
}
|
|
29
|
-
function
|
|
30
|
-
|
|
10
|
+
function mismatch(conditionType, reason, extra = {}) {
|
|
11
|
+
return { ok: false, error: { code: "CONDITION_MISMATCH", conditionType, reason, ...extra } };
|
|
31
12
|
}
|
|
32
|
-
function evaluateConditionDetailed(condition2, context
|
|
13
|
+
function evaluateConditionDetailed(condition2, context) {
|
|
33
14
|
switch (condition2.type) {
|
|
34
|
-
case "
|
|
35
|
-
return context.type === "
|
|
36
|
-
case "
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
case "geo": {
|
|
49
|
-
if (context.type !== "geo") {
|
|
50
|
-
return contextTypeMismatch("geo", "geo", context.type);
|
|
51
|
-
}
|
|
52
|
-
if (!isValidCoordinate(condition2.latitude, condition2.longitude) || !isValidCoordinate(context.currentLatitude, context.currentLongitude) || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters < 0) {
|
|
53
|
-
return {
|
|
54
|
-
ok: false,
|
|
55
|
-
error: {
|
|
56
|
-
code: "CONDITION_MISMATCH",
|
|
57
|
-
conditionType: "geo",
|
|
58
|
-
reason: "INVALID_GEO_INPUT"
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
const distanceMeters2 = calculateDistanceMeters(
|
|
15
|
+
case "qr":
|
|
16
|
+
return context.type === "qr" && context.token === condition2.secretToken ? { ok: true, value: { conditionType: "qr" } } : mismatch("qr", "INVALID_PROOF");
|
|
17
|
+
case "passcode":
|
|
18
|
+
return context.type === "passcode" && (condition2.caseSensitive === false ? context.code.toLocaleLowerCase() === condition2.code.toLocaleLowerCase() : context.code === condition2.code) ? { ok: true, value: { conditionType: "passcode" } } : mismatch("passcode", "INVALID_PROOF");
|
|
19
|
+
case "nfc":
|
|
20
|
+
return context.type === "nfc" && context.tagId === condition2.tagId ? { ok: true, value: { conditionType: "nfc" } } : mismatch("nfc", "INVALID_PROOF");
|
|
21
|
+
case "custom":
|
|
22
|
+
return mismatch("custom", "VALIDATOR_FAILED");
|
|
23
|
+
case "gps": {
|
|
24
|
+
if (!Number.isFinite(condition2.latitude) || !Number.isFinite(condition2.longitude) || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters < 0 || context.type !== "gps" || !Number.isFinite(context.latitude) || !Number.isFinite(context.longitude))
|
|
25
|
+
return mismatch("gps", "INVALID_GEO_INPUT");
|
|
26
|
+
const distanceMeters = calculateDistanceMeters(
|
|
63
27
|
condition2.latitude,
|
|
64
28
|
condition2.longitude,
|
|
65
|
-
context.
|
|
66
|
-
context.
|
|
29
|
+
context.latitude,
|
|
30
|
+
context.longitude
|
|
67
31
|
);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
ok: false,
|
|
73
|
-
error: {
|
|
74
|
-
code: "CONDITION_MISMATCH",
|
|
75
|
-
conditionType: "geo",
|
|
76
|
-
reason: "OUTSIDE_RADIUS",
|
|
77
|
-
distanceMeters: distanceMeters2,
|
|
78
|
-
radiusMeters: condition2.radiusMeters,
|
|
79
|
-
differenceMeters: distanceMeters2 - condition2.radiusMeters
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
case "composite": {
|
|
84
|
-
if (context.type !== "composite") {
|
|
85
|
-
return contextTypeMismatch("composite", "composite", context.type);
|
|
86
|
-
}
|
|
87
|
-
if (condition2.conditions.length !== context.contexts.length) {
|
|
88
|
-
return {
|
|
89
|
-
ok: false,
|
|
90
|
-
error: {
|
|
91
|
-
code: "CONDITION_MISMATCH",
|
|
92
|
-
conditionType: "composite",
|
|
93
|
-
reason: "CONTEXT_LENGTH_MISMATCH",
|
|
94
|
-
expectedCount: condition2.conditions.length,
|
|
95
|
-
actualCount: context.contexts.length
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
const failures = [];
|
|
100
|
-
let matchedCount = 0;
|
|
101
|
-
for (const [index, childCondition] of condition2.conditions.entries()) {
|
|
102
|
-
const childContext = context.contexts[index];
|
|
103
|
-
if (childContext === void 0) continue;
|
|
104
|
-
const result = evaluateConditionDetailed(childCondition, childContext, now2);
|
|
105
|
-
if (result.ok) matchedCount += 1;
|
|
106
|
-
else failures.push({ index, error: result.error });
|
|
107
|
-
}
|
|
108
|
-
if (condition2.operator === "AND" && failures.length === 0) {
|
|
109
|
-
return { ok: true, value: { conditionType: "composite" } };
|
|
110
|
-
}
|
|
111
|
-
if (condition2.operator === "OR" && matchedCount > 0) {
|
|
112
|
-
return { ok: true, value: { conditionType: "composite" } };
|
|
113
|
-
}
|
|
114
|
-
return {
|
|
115
|
-
ok: false,
|
|
116
|
-
error: {
|
|
117
|
-
code: "CONDITION_MISMATCH",
|
|
118
|
-
conditionType: "composite",
|
|
119
|
-
reason: condition2.operator === "AND" ? "AND_CHILD_FAILED" : "OR_ALL_FAILED",
|
|
120
|
-
failures
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
case "time_window": {
|
|
125
|
-
const startsAt = Date.parse(condition2.startsAt);
|
|
126
|
-
const endsAt = Date.parse(condition2.endsAt);
|
|
127
|
-
const currentTime = Date.parse(now2);
|
|
128
|
-
if (!Number.isFinite(currentTime)) {
|
|
129
|
-
return {
|
|
130
|
-
ok: false,
|
|
131
|
-
error: {
|
|
132
|
-
code: "CONDITION_MISMATCH",
|
|
133
|
-
conditionType: "time_window",
|
|
134
|
-
reason: "INVALID_NOW",
|
|
135
|
-
now: now2,
|
|
136
|
-
startsAt: condition2.startsAt,
|
|
137
|
-
endsAt: condition2.endsAt
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
if (!Number.isFinite(startsAt) || !Number.isFinite(endsAt) || startsAt > endsAt) {
|
|
142
|
-
return {
|
|
143
|
-
ok: false,
|
|
144
|
-
error: {
|
|
145
|
-
code: "CONDITION_MISMATCH",
|
|
146
|
-
conditionType: "time_window",
|
|
147
|
-
reason: "INVALID_TIME_WINDOW",
|
|
148
|
-
now: now2,
|
|
149
|
-
startsAt: condition2.startsAt,
|
|
150
|
-
endsAt: condition2.endsAt
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
if (currentTime < startsAt || currentTime > endsAt) {
|
|
155
|
-
return {
|
|
156
|
-
ok: false,
|
|
157
|
-
error: {
|
|
158
|
-
code: "CONDITION_MISMATCH",
|
|
159
|
-
conditionType: "time_window",
|
|
160
|
-
reason: currentTime < startsAt ? "BEFORE_START" : "AFTER_END",
|
|
161
|
-
now: now2,
|
|
162
|
-
startsAt: condition2.startsAt,
|
|
163
|
-
endsAt: condition2.endsAt
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
const childResult = evaluateConditionDetailed(condition2.condition, context, now2);
|
|
168
|
-
return childResult.ok ? { ok: true, value: { conditionType: "time_window" } } : childResult;
|
|
32
|
+
return distanceMeters <= condition2.radiusMeters ? { ok: true, value: { conditionType: "gps", distanceMeters } } : mismatch("gps", "OUTSIDE_RADIUS", {
|
|
33
|
+
distanceMeters,
|
|
34
|
+
radiusMeters: condition2.radiusMeters
|
|
35
|
+
});
|
|
169
36
|
}
|
|
170
|
-
default:
|
|
171
|
-
return assertNever(condition2);
|
|
172
37
|
}
|
|
173
38
|
}
|
|
174
|
-
function evaluateCondition(condition2, context
|
|
175
|
-
return evaluateConditionDetailed(condition2, context
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// src/engine/checkIn.ts
|
|
179
|
-
function unwrapContext(input) {
|
|
180
|
-
if ("verificationContext" in input) {
|
|
181
|
-
return {
|
|
182
|
-
context: input.verificationContext,
|
|
183
|
-
now: input.now ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
184
|
-
...input.expectedStampId === void 0 ? {} : { expectedStampId: input.expectedStampId },
|
|
185
|
-
alreadyClaimed: input.alreadyClaimed ?? false
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
return {
|
|
189
|
-
context: input,
|
|
190
|
-
now: "now" in input && typeof input.now === "string" ? input.now : (/* @__PURE__ */ new Date()).toISOString(),
|
|
191
|
-
..."expectedStampId" in input && typeof input.expectedStampId === "string" ? { expectedStampId: input.expectedStampId } : {},
|
|
192
|
-
alreadyClaimed: "alreadyClaimed" in input && input.alreadyClaimed === true
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
function evaluateCheckIn(spot, input) {
|
|
196
|
-
const context = unwrapContext(input);
|
|
197
|
-
if (context.alreadyClaimed) {
|
|
198
|
-
return {
|
|
199
|
-
ok: false,
|
|
200
|
-
success: false,
|
|
201
|
-
code: "ALREADY_CLAIMED",
|
|
202
|
-
stampId: spot.id,
|
|
203
|
-
message: "This spot has already been claimed."
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
if (context.expectedStampId !== void 0 && context.expectedStampId !== spot.id) {
|
|
207
|
-
return {
|
|
208
|
-
ok: false,
|
|
209
|
-
success: false,
|
|
210
|
-
code: "ORDER_VIOLATION",
|
|
211
|
-
stampId: spot.id,
|
|
212
|
-
message: `The next required spot is '${context.expectedStampId}'.`
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
const evaluated = evaluateConditionDetailed(spot.condition, context.context, context.now);
|
|
216
|
-
if (evaluated.ok) {
|
|
217
|
-
return { ok: true, success: true, stampId: spot.id, checkedAt: context.now };
|
|
218
|
-
}
|
|
219
|
-
const code = evaluated.error.reason === "OUTSIDE_RADIUS" ? "OUT_OF_RANGE" : evaluated.error.reason === "BEFORE_START" || evaluated.error.reason === "AFTER_END" ? "EXPIRED" : evaluated.error.reason === "TOKEN_MISMATCH" ? "INVALID_PROOF" : "INVALID_CONTEXT";
|
|
220
|
-
return { ok: false, success: false, code, stampId: spot.id, message: evaluated.error.reason };
|
|
39
|
+
function evaluateCondition(condition2, context) {
|
|
40
|
+
return evaluateConditionDetailed(condition2, context).ok;
|
|
221
41
|
}
|
|
222
42
|
|
|
223
43
|
// src/engine/order.ts
|
|
224
|
-
function
|
|
225
|
-
return
|
|
226
|
-
const orderDifference = (left.stamp.orderIndex ?? left.stamp.order ?? Number.POSITIVE_INFINITY) - (right.stamp.orderIndex ?? right.stamp.order ?? Number.POSITIVE_INFINITY);
|
|
227
|
-
return orderDifference === 0 ? left.index - right.index : orderDifference;
|
|
228
|
-
}).map(({ stamp }) => stamp);
|
|
44
|
+
function getOrderedSpots(spots) {
|
|
45
|
+
return [...spots].sort((left, right) => left.orderIndex - right.orderIndex);
|
|
229
46
|
}
|
|
230
47
|
|
|
231
48
|
// src/engine/progress.ts
|
|
232
49
|
function calculateProgress(state, config) {
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
state.records.map((record) => record.stampId).filter((
|
|
50
|
+
const ids = new Set(config.spots.map((spot2) => spot2.id));
|
|
51
|
+
const acquired = new Set(
|
|
52
|
+
state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
|
|
236
53
|
);
|
|
237
|
-
const
|
|
238
|
-
const acquired = acquiredStampIds.size;
|
|
239
|
-
const isCompleted = total > 0 && acquired === total;
|
|
240
|
-
const remainingStamps = config.stamps.filter((stamp) => !acquiredStampIds.has(stamp.id));
|
|
241
|
-
const nextAvailableStamps = config.isSequential === true ? getOrderedStamps(config).filter((stamp) => !acquiredStampIds.has(stamp.id)).slice(0, 1) : remainingStamps;
|
|
54
|
+
const remaining = config.spots.filter((spot2) => !acquired.has(spot2.id));
|
|
242
55
|
return {
|
|
243
|
-
acquired,
|
|
244
|
-
total,
|
|
245
|
-
percentage:
|
|
246
|
-
isCompleted,
|
|
247
|
-
|
|
248
|
-
nextAvailableStamps
|
|
56
|
+
acquired: acquired.size,
|
|
57
|
+
total: config.spots.length,
|
|
58
|
+
percentage: config.spots.length === 0 ? 0 : acquired.size / config.spots.length * 100,
|
|
59
|
+
isCompleted: config.spots.length > 0 && acquired.size === config.spots.length,
|
|
60
|
+
nextAvailableSpots: [...remaining].sort((left, right) => left.orderIndex - right.orderIndex)
|
|
249
61
|
};
|
|
250
62
|
}
|
|
251
63
|
|
|
@@ -279,9 +91,9 @@ function createUniqueClaimTicketNumber(rewardId, issuedAt) {
|
|
|
279
91
|
const timestampPart = Number.isNaN(timestamp) ? Date.now() : timestamp;
|
|
280
92
|
return `CLAIM-${rewardId}-${timestampPart}-${createRandomHash()}`;
|
|
281
93
|
}
|
|
282
|
-
function issueClaimTicketNumber(
|
|
94
|
+
function issueClaimTicketNumber(reward2, currentState, options = {}) {
|
|
283
95
|
if (currentState.claimTicketNumber !== void 0) return currentState;
|
|
284
|
-
const claimTicketNumber =
|
|
96
|
+
const claimTicketNumber = createClaimTicketNumber(reward2.id, options);
|
|
285
97
|
return { ...currentState, claimTicketNumber };
|
|
286
98
|
}
|
|
287
99
|
|
|
@@ -350,9 +162,9 @@ function getCurrentGeoContext(options = {}) {
|
|
|
350
162
|
try {
|
|
351
163
|
navigator.geolocation.getCurrentPosition(
|
|
352
164
|
(position) => {
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
if (!Number.isFinite(
|
|
165
|
+
const latitude = position.coords.latitude;
|
|
166
|
+
const longitude = position.coords.longitude;
|
|
167
|
+
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90 || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
|
356
168
|
resolve({
|
|
357
169
|
ok: false,
|
|
358
170
|
error: createDetectorError(
|
|
@@ -365,7 +177,7 @@ function getCurrentGeoContext(options = {}) {
|
|
|
365
177
|
}
|
|
366
178
|
resolve({
|
|
367
179
|
ok: true,
|
|
368
|
-
value: { type: "
|
|
180
|
+
value: { type: "gps", latitude, longitude }
|
|
369
181
|
});
|
|
370
182
|
},
|
|
371
183
|
(error) => {
|
|
@@ -473,7 +285,7 @@ function readNfcContext(options = {}) {
|
|
|
473
285
|
fail(createDetectorError("nfc", "NO_TOKEN", "The NFC tag has no text token."));
|
|
474
286
|
return;
|
|
475
287
|
}
|
|
476
|
-
finish({ ok: true, value: { type: "
|
|
288
|
+
finish({ ok: true, value: { type: "nfc", tagId: token } });
|
|
477
289
|
};
|
|
478
290
|
reader.onreadingerror = (event) => fail(createDetectorError("nfc", "READ_FAILED", "The NFC tag could not be read.", event));
|
|
479
291
|
try {
|
|
@@ -490,13 +302,7 @@ function normalizePasscode(input, caseSensitive = false) {
|
|
|
490
302
|
return caseSensitive ? normalized : normalized.toUpperCase();
|
|
491
303
|
}
|
|
492
304
|
function verifyPasscode(inputCode, condition2) {
|
|
493
|
-
|
|
494
|
-
const expected = normalizePasscode(condition2.passcode, condition2.caseSensitive);
|
|
495
|
-
return input === expected ? { success: true } : {
|
|
496
|
-
success: false,
|
|
497
|
-
reason: "INVALID_PASSCODE",
|
|
498
|
-
message: "The passcode is invalid."
|
|
499
|
-
};
|
|
305
|
+
return normalizePasscode(inputCode, condition2.caseSensitive) === normalizePasscode(condition2.code, condition2.caseSensitive) ? { success: true } : { success: false, message: "The passcode is invalid." };
|
|
500
306
|
}
|
|
501
307
|
|
|
502
308
|
// src/detectors/qr.ts
|
|
@@ -603,7 +409,7 @@ async function readQrContext(videoElement, options = {}) {
|
|
|
603
409
|
if (!detection.ok) return detection;
|
|
604
410
|
const token = detection.value.find((barcode) => barcode.rawValue.length > 0)?.rawValue;
|
|
605
411
|
if (token !== void 0) {
|
|
606
|
-
return { ok: true, value: { type: "
|
|
412
|
+
return { ok: true, value: { type: "qr", token } };
|
|
607
413
|
}
|
|
608
414
|
const interval = await raceWithTermination(waitForNextScan(), termination.promise);
|
|
609
415
|
if (!interval.ok) return interval;
|
|
@@ -621,153 +427,89 @@ async function readQrContext(videoElement, options = {}) {
|
|
|
621
427
|
}
|
|
622
428
|
|
|
623
429
|
// src/engine/transition.ts
|
|
624
|
-
function reconcileRewardStates(rewards, currentStates, acquiredStampCount,
|
|
625
|
-
const
|
|
626
|
-
return rewards.map((
|
|
627
|
-
const current =
|
|
628
|
-
if (current?.status === "CONSUMED" || current?.status === "EXPIRED")
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
if (
|
|
632
|
-
return { rewardId:
|
|
633
|
-
|
|
634
|
-
const stockLimit = reward.stockLimit ?? reward.maxStock;
|
|
635
|
-
if (stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= stockLimit) {
|
|
636
|
-
return {
|
|
637
|
-
rewardId: reward.id,
|
|
638
|
-
status: "EXPIRED",
|
|
639
|
-
...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
|
|
640
|
-
};
|
|
641
|
-
}
|
|
642
|
-
if (acquiredStampCount >= reward.requiredStampCount) {
|
|
643
|
-
if (current?.status === "AVAILABLE") return current;
|
|
430
|
+
function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now) {
|
|
431
|
+
const states = new Map(currentStates.map((state) => [state.rewardId, state]));
|
|
432
|
+
return rewards.map((reward2) => {
|
|
433
|
+
const current = states.get(reward2.id);
|
|
434
|
+
if (current?.status === "CONSUMED" || current?.status === "EXPIRED") return current;
|
|
435
|
+
if (reward2.validUntil !== void 0 && Date.parse(reward2.validUntil) <= Date.parse(now))
|
|
436
|
+
return { rewardId: reward2.id, status: "EXPIRED" };
|
|
437
|
+
if (reward2.stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= reward2.stockLimit)
|
|
438
|
+
return { rewardId: reward2.id, status: "EXPIRED" };
|
|
439
|
+
if (acquiredStampCount >= reward2.requiredStampCount)
|
|
644
440
|
return {
|
|
645
|
-
rewardId:
|
|
441
|
+
rewardId: reward2.id,
|
|
646
442
|
status: "AVAILABLE",
|
|
647
|
-
unlockedAt: current
|
|
648
|
-
...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
|
|
443
|
+
...current?.unlockedAt === void 0 ? { unlockedAt: now } : { unlockedAt: current.unlockedAt }
|
|
649
444
|
};
|
|
650
|
-
}
|
|
651
|
-
if (current?.status === "LOCKED" && current.unlockedAt === void 0) return current;
|
|
652
|
-
return { rewardId: reward.id, status: "LOCKED" };
|
|
445
|
+
return { rewardId: reward2.id, status: "LOCKED" };
|
|
653
446
|
});
|
|
654
447
|
}
|
|
655
448
|
function consumeReward(params) {
|
|
656
|
-
const { reward, currentState } = params;
|
|
657
|
-
if (currentState.status === "CONSUMED")
|
|
658
|
-
return {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (
|
|
664
|
-
return {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(params.now)) {
|
|
670
|
-
return { ok: false, error: { code: "EXPIRED", reason: "EXPIRED", rewardId: reward.id } };
|
|
671
|
-
}
|
|
672
|
-
const stockLimit = reward.stockLimit ?? reward.maxStock;
|
|
673
|
-
const userClaimLimit = reward.userClaimLimit ?? reward.limitPerUser;
|
|
674
|
-
if (stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= stockLimit) {
|
|
675
|
-
return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward.id } };
|
|
676
|
-
}
|
|
677
|
-
if (userClaimLimit !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= userClaimLimit) {
|
|
678
|
-
return {
|
|
679
|
-
ok: false,
|
|
680
|
-
error: { code: "USER_LIMIT_REACHED", reason: "LIMIT_EXCEEDED", rewardId: reward.id }
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
if (reward.redemptionMethod === "staff_passcode") {
|
|
684
|
-
const passcodeResult = reward.staffPasscode === void 0 ? null : verifyPasscode(params.inputPasscode ?? "", { passcode: reward.staffPasscode });
|
|
685
|
-
if (passcodeResult === null || !passcodeResult.success) {
|
|
449
|
+
const { reward: reward2, currentState } = params;
|
|
450
|
+
if (currentState.status === "CONSUMED")
|
|
451
|
+
return { ok: false, error: { code: "ALREADY_CONSUMED", rewardId: reward2.id } };
|
|
452
|
+
if (currentState.status !== "AVAILABLE")
|
|
453
|
+
return { ok: false, error: { code: "NOT_AVAILABLE", rewardId: reward2.id } };
|
|
454
|
+
if (reward2.validUntil !== void 0 && Date.parse(reward2.validUntil) <= Date.parse(params.now))
|
|
455
|
+
return { ok: false, error: { code: "EXPIRED", rewardId: reward2.id } };
|
|
456
|
+
if (reward2.stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= reward2.stockLimit)
|
|
457
|
+
return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward2.id } };
|
|
458
|
+
if (reward2.userClaimLimit !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= reward2.userClaimLimit)
|
|
459
|
+
return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId: reward2.id } };
|
|
460
|
+
if (reward2.redemptionMethod === "staff_passcode") {
|
|
461
|
+
if (reward2.staffPasscode === void 0 || !verifyPasscode(params.inputPasscode ?? "", { code: reward2.staffPasscode }).success)
|
|
686
462
|
return {
|
|
687
463
|
ok: false,
|
|
688
464
|
error: {
|
|
689
465
|
code: "INVALID_PASSCODE",
|
|
690
|
-
rewardId:
|
|
691
|
-
message:
|
|
466
|
+
rewardId: reward2.id,
|
|
467
|
+
message: "The passcode is invalid."
|
|
692
468
|
}
|
|
693
469
|
};
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
if (reward.redemptionMethod === "view_only") {
|
|
697
|
-
return { ok: true, value: currentState };
|
|
698
470
|
}
|
|
471
|
+
if (reward2.redemptionMethod === "view_only") return { ok: true, value: currentState };
|
|
699
472
|
return {
|
|
700
473
|
ok: true,
|
|
701
474
|
value: {
|
|
702
475
|
...currentState,
|
|
703
476
|
status: "CONSUMED",
|
|
704
477
|
consumedAt: params.now,
|
|
705
|
-
claimTicketNumber: createUniqueClaimTicketNumber(
|
|
706
|
-
|
|
707
|
-
...params.userId === void 0 ? {} : { userRedemptionCount: (currentState.userRedemptionCount ?? 0) + 1 },
|
|
478
|
+
claimTicketNumber: createUniqueClaimTicketNumber(reward2.id, params.now),
|
|
479
|
+
redeemedCount: (currentState.redeemedCount ?? 0) + 1,
|
|
708
480
|
...params.staffId === void 0 ? {} : { consumedByStaffId: params.staffId }
|
|
709
481
|
}
|
|
710
482
|
};
|
|
711
483
|
}
|
|
712
|
-
function processStamp(state, config,
|
|
713
|
-
const
|
|
714
|
-
if (
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
if (
|
|
719
|
-
return {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
};
|
|
723
|
-
}
|
|
724
|
-
if (config.isSequential === true) {
|
|
725
|
-
const expectedStamp = getOrderedStamps(config).find((stamp) => !acquiredStampIds.has(stamp.id));
|
|
726
|
-
if (expectedStamp !== void 0 && expectedStamp.id !== targetStampId) {
|
|
484
|
+
function processStamp(state, config, spotId, context, now) {
|
|
485
|
+
const spot2 = config.spots.find((item) => item.id === spotId);
|
|
486
|
+
if (spot2 === void 0) return { ok: false, error: { code: "SPOT_NOT_FOUND", spotId } };
|
|
487
|
+
if (state.records.some((record2) => record2.stampId === spotId))
|
|
488
|
+
return { ok: false, error: { code: "STAMP_ALREADY_ACQUIRED", spotId } };
|
|
489
|
+
const acquired = new Set(state.records.map((record2) => record2.stampId));
|
|
490
|
+
if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
491
|
+
return { ok: false, error: { code: "PREREQUISITES_NOT_MET", spotId } };
|
|
492
|
+
for (const condition2 of spot2.conditions) {
|
|
493
|
+
if (condition2.type === "custom" || !evaluateConditionDetailed(condition2, context).ok)
|
|
727
494
|
return {
|
|
728
495
|
ok: false,
|
|
729
|
-
error: {
|
|
730
|
-
code: "
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
}
|
|
496
|
+
error: condition2.type === "custom" ? {
|
|
497
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
498
|
+
spotId,
|
|
499
|
+
message: "Custom validation requires an async validator."
|
|
500
|
+
} : { code: "INVALID_PROOF", spotId }
|
|
734
501
|
};
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
const conditionResult = evaluateConditionDetailed(targetStamp.condition, context, now2);
|
|
738
|
-
if (!conditionResult.ok) {
|
|
739
|
-
return {
|
|
740
|
-
ok: false,
|
|
741
|
-
error: {
|
|
742
|
-
code: "CONDITION_MISMATCH",
|
|
743
|
-
stampId: targetStampId,
|
|
744
|
-
mismatch: conditionResult.error
|
|
745
|
-
}
|
|
746
|
-
};
|
|
747
502
|
}
|
|
748
|
-
const record = { stampId:
|
|
749
|
-
const
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
};
|
|
757
|
-
const events = [{ type: "stampAcquired", record }];
|
|
758
|
-
if (nextRewards !== void 0) {
|
|
759
|
-
for (const rewardState of nextRewards) {
|
|
760
|
-
const previous = state.rewards?.find((item) => item.rewardId === rewardState.rewardId);
|
|
761
|
-
if (rewardState.status === "AVAILABLE" && previous?.status !== "AVAILABLE") {
|
|
762
|
-
events.push({ type: "rewardUnlocked", rewardId: rewardState.rewardId, unlockedAt: now2 });
|
|
763
|
-
}
|
|
503
|
+
const record = { stampId: spotId, acquiredAt: now };
|
|
504
|
+
const records = [...state.records, record];
|
|
505
|
+
const rewards = reconcileRewardStates(config.rewards, state.rewards, records.length, now);
|
|
506
|
+
return {
|
|
507
|
+
ok: true,
|
|
508
|
+
value: {
|
|
509
|
+
nextState: { ...state, records, rewards, updatedAt: now },
|
|
510
|
+
events: [{ type: "stampAcquired", record }]
|
|
764
511
|
}
|
|
765
|
-
}
|
|
766
|
-
const completed = config.stamps.length > 0 && config.stamps.every((stamp) => nextState.records.some((item) => item.stampId === stamp.id));
|
|
767
|
-
if (completed) {
|
|
768
|
-
events.push({ type: "rallyCompleted", rallyId: config.id, completedAt: now2 });
|
|
769
|
-
}
|
|
770
|
-
return { ok: true, value: { nextState, events } };
|
|
512
|
+
};
|
|
771
513
|
}
|
|
772
514
|
|
|
773
515
|
// src/client/storage.ts
|
|
@@ -813,7 +555,7 @@ function isRewardState(value) {
|
|
|
813
555
|
function isStampRallyState(value) {
|
|
814
556
|
if (typeof value !== "object" || value === null) return false;
|
|
815
557
|
const state = value;
|
|
816
|
-
return typeof state.rallyId === "string" && typeof state.updatedAt === "string" && Array.isArray(state.records) && state.records.every(isRecord) && (state.rewards === void 0 || Array.isArray(state.rewards) && state.rewards.every(isRewardState));
|
|
558
|
+
return typeof state.rallyId === "string" && (typeof state.userId === "string" || state.userId === null) && typeof state.updatedAt === "string" && Array.isArray(state.records) && state.records.every(isRecord) && (state.rewards === void 0 || Array.isArray(state.rewards) && state.rewards.every(isRewardState));
|
|
817
559
|
}
|
|
818
560
|
function isValidDate(value) {
|
|
819
561
|
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
@@ -824,7 +566,7 @@ function isSnapshotRecord(value) {
|
|
|
824
566
|
function isRallySnapshot(value) {
|
|
825
567
|
if (typeof value !== "object" || value === null) return false;
|
|
826
568
|
const snapshot = value;
|
|
827
|
-
return snapshot.version === 1 && typeof snapshot.rallyId === "string" && Array.isArray(snapshot.
|
|
569
|
+
return snapshot.version === 1 && typeof snapshot.rallyId === "string" && (typeof snapshot.userId === "string" || snapshot.userId === null) && Array.isArray(snapshot.records) && snapshot.records.every(isSnapshotRecord) && Array.isArray(snapshot.rewards) && snapshot.rewards.every(isRewardState) && isValidDate(snapshot.exportedAt);
|
|
828
570
|
}
|
|
829
571
|
function exportProgressToken(snapshot) {
|
|
830
572
|
return globalThis.btoa(encodeURIComponent(JSON.stringify(snapshot)));
|
|
@@ -835,7 +577,7 @@ function importProgressToken(token, currentRallyId) {
|
|
|
835
577
|
if (!isRallySnapshot(parsed) || parsed.rallyId !== currentRallyId) return null;
|
|
836
578
|
return {
|
|
837
579
|
...parsed,
|
|
838
|
-
|
|
580
|
+
records: parsed.records.map(cloneRecord),
|
|
839
581
|
rewards: parsed.rewards.map(cloneRewardState)
|
|
840
582
|
};
|
|
841
583
|
} catch {
|
|
@@ -844,17 +586,20 @@ function importProgressToken(token, currentRallyId) {
|
|
|
844
586
|
}
|
|
845
587
|
var InMemoryStorage = class {
|
|
846
588
|
#states = /* @__PURE__ */ new Map();
|
|
847
|
-
async load(rallyId) {
|
|
848
|
-
const state = this.#states.get(rallyId);
|
|
589
|
+
async load(rallyId, userId) {
|
|
590
|
+
const state = this.#states.get(storageKey(rallyId, userId));
|
|
849
591
|
return state === void 0 ? null : cloneState(state);
|
|
850
592
|
}
|
|
851
593
|
async save(state) {
|
|
852
|
-
this.#states.set(state.rallyId, cloneState(state));
|
|
594
|
+
this.#states.set(storageKey(state.rallyId, state.userId), cloneState(state));
|
|
853
595
|
}
|
|
854
|
-
async remove(rallyId) {
|
|
855
|
-
this.#states.delete(rallyId);
|
|
596
|
+
async remove(rallyId, userId) {
|
|
597
|
+
this.#states.delete(storageKey(rallyId, userId));
|
|
856
598
|
}
|
|
857
599
|
};
|
|
600
|
+
function storageKey(rallyId, userId) {
|
|
601
|
+
return `stamprally:${rallyId}:${userId ?? "anonymous"}`;
|
|
602
|
+
}
|
|
858
603
|
var defaultStorageWarningHandler = (error) => {
|
|
859
604
|
console.warn(`[@stamprally/core] ${error.message}`, error);
|
|
860
605
|
};
|
|
@@ -871,10 +616,10 @@ var LocalStorageAdapter = class {
|
|
|
871
616
|
this.#failureMode = options.failureMode ?? "fallback";
|
|
872
617
|
this.#onWarning = options.onWarning ?? defaultStorageWarningHandler;
|
|
873
618
|
}
|
|
874
|
-
async load(rallyId) {
|
|
875
|
-
if (this.#isFallbackActive) return this.#fallbackStorage.load(rallyId);
|
|
619
|
+
async load(rallyId, userId) {
|
|
620
|
+
if (this.#isFallbackActive) return this.#fallbackStorage.load(rallyId, userId);
|
|
876
621
|
try {
|
|
877
|
-
const serialized = this.#getStorage("load", rallyId).getItem(this.#key(rallyId));
|
|
622
|
+
const serialized = this.#getStorage("load", rallyId).getItem(this.#key(rallyId, userId));
|
|
878
623
|
if (serialized === null) return null;
|
|
879
624
|
let parsed;
|
|
880
625
|
try {
|
|
@@ -905,7 +650,7 @@ var LocalStorageAdapter = class {
|
|
|
905
650
|
`Failed to read rally '${rallyId}' from localStorage.`,
|
|
906
651
|
rallyId
|
|
907
652
|
),
|
|
908
|
-
() => this.#fallbackStorage.load(rallyId)
|
|
653
|
+
() => this.#fallbackStorage.load(rallyId, userId)
|
|
909
654
|
);
|
|
910
655
|
}
|
|
911
656
|
}
|
|
@@ -913,7 +658,7 @@ var LocalStorageAdapter = class {
|
|
|
913
658
|
if (this.#isFallbackActive) return this.#fallbackStorage.save(state);
|
|
914
659
|
try {
|
|
915
660
|
this.#getStorage("save", state.rallyId).setItem(
|
|
916
|
-
this.#key(state.rallyId),
|
|
661
|
+
this.#key(state.rallyId, state.userId),
|
|
917
662
|
JSON.stringify(state)
|
|
918
663
|
);
|
|
919
664
|
} catch (cause) {
|
|
@@ -929,10 +674,10 @@ var LocalStorageAdapter = class {
|
|
|
929
674
|
);
|
|
930
675
|
}
|
|
931
676
|
}
|
|
932
|
-
async remove(rallyId) {
|
|
933
|
-
if (this.#isFallbackActive) return this.#fallbackStorage.remove(rallyId);
|
|
677
|
+
async remove(rallyId, userId) {
|
|
678
|
+
if (this.#isFallbackActive) return this.#fallbackStorage.remove(rallyId, userId);
|
|
934
679
|
try {
|
|
935
|
-
this.#getStorage("remove", rallyId).removeItem(this.#key(rallyId));
|
|
680
|
+
this.#getStorage("remove", rallyId).removeItem(this.#key(rallyId, userId));
|
|
936
681
|
} catch (cause) {
|
|
937
682
|
return this.#handleFailure(
|
|
938
683
|
this.#normalizeError(
|
|
@@ -942,12 +687,12 @@ var LocalStorageAdapter = class {
|
|
|
942
687
|
`Failed to remove rally '${rallyId}' from localStorage.`,
|
|
943
688
|
rallyId
|
|
944
689
|
),
|
|
945
|
-
() => this.#fallbackStorage.remove(rallyId)
|
|
690
|
+
() => this.#fallbackStorage.remove(rallyId, userId)
|
|
946
691
|
);
|
|
947
692
|
}
|
|
948
693
|
}
|
|
949
|
-
#key(rallyId) {
|
|
950
|
-
return `${this.#keyPrefix}${rallyId}`;
|
|
694
|
+
#key(rallyId, userId) {
|
|
695
|
+
return `${this.#keyPrefix}${rallyId}:${userId ?? "anonymous"}`;
|
|
951
696
|
}
|
|
952
697
|
#getStorage(operation, rallyId) {
|
|
953
698
|
if (this.#providedStorage === null) {
|
|
@@ -1007,12 +752,12 @@ var IndexedDBAdapter = class {
|
|
|
1007
752
|
this.#providedFactory = options.indexedDB;
|
|
1008
753
|
this.#databaseName = options.databaseName ?? "stamprally";
|
|
1009
754
|
}
|
|
1010
|
-
async load(rallyId) {
|
|
755
|
+
async load(rallyId, userId) {
|
|
1011
756
|
const database = await this.#openDatabase(rallyId);
|
|
1012
757
|
return new Promise((resolve, reject) => {
|
|
1013
758
|
try {
|
|
1014
759
|
const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readonly");
|
|
1015
|
-
const request = transaction.objectStore(INDEXED_DB_STORE_NAME).get(rallyId);
|
|
760
|
+
const request = transaction.objectStore(INDEXED_DB_STORE_NAME).get(storageKey(rallyId, userId));
|
|
1016
761
|
request.onsuccess = () => {
|
|
1017
762
|
const value = request.result;
|
|
1018
763
|
if (value === void 0) {
|
|
@@ -1059,7 +804,7 @@ var IndexedDBAdapter = class {
|
|
|
1059
804
|
return new Promise((resolve, reject) => {
|
|
1060
805
|
try {
|
|
1061
806
|
const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readwrite");
|
|
1062
|
-
transaction.objectStore(INDEXED_DB_STORE_NAME).put(cloneState(state), state.rallyId);
|
|
807
|
+
transaction.objectStore(INDEXED_DB_STORE_NAME).put(cloneState(state), storageKey(state.rallyId, state.userId));
|
|
1063
808
|
transaction.oncomplete = () => resolve();
|
|
1064
809
|
transaction.onerror = () => {
|
|
1065
810
|
reject(
|
|
@@ -1084,12 +829,12 @@ var IndexedDBAdapter = class {
|
|
|
1084
829
|
}
|
|
1085
830
|
});
|
|
1086
831
|
}
|
|
1087
|
-
async remove(rallyId) {
|
|
832
|
+
async remove(rallyId, userId) {
|
|
1088
833
|
const database = await this.#openDatabase(rallyId);
|
|
1089
834
|
return new Promise((resolve, reject) => {
|
|
1090
835
|
try {
|
|
1091
836
|
const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readwrite");
|
|
1092
|
-
transaction.objectStore(INDEXED_DB_STORE_NAME).delete(rallyId);
|
|
837
|
+
transaction.objectStore(INDEXED_DB_STORE_NAME).delete(storageKey(rallyId, userId));
|
|
1093
838
|
transaction.oncomplete = () => resolve();
|
|
1094
839
|
transaction.onerror = () => {
|
|
1095
840
|
reject(
|
|
@@ -1197,255 +942,60 @@ var IndexedDBAdapter = class {
|
|
|
1197
942
|
};
|
|
1198
943
|
|
|
1199
944
|
// src/client/client.ts
|
|
1200
|
-
var systemClock = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1201
|
-
var StampRallyClient = class {
|
|
1202
|
-
#listeners = /* @__PURE__ */ new Set();
|
|
1203
|
-
#eventListeners = /* @__PURE__ */ new Set();
|
|
1204
|
-
#config;
|
|
1205
|
-
#storage;
|
|
1206
|
-
#clock;
|
|
1207
|
-
#currentState = null;
|
|
1208
|
-
#initialization = null;
|
|
1209
|
-
#operationQueue = Promise.resolve();
|
|
1210
|
-
constructor(config, storage, clock = systemClock) {
|
|
1211
|
-
this.#config = config;
|
|
1212
|
-
this.#storage = storage;
|
|
1213
|
-
this.#clock = clock;
|
|
1214
|
-
}
|
|
1215
|
-
getState() {
|
|
1216
|
-
return this.#currentState;
|
|
1217
|
-
}
|
|
1218
|
-
getConfig() {
|
|
1219
|
-
return this.#config;
|
|
1220
|
-
}
|
|
1221
|
-
subscribe(listener, options = {}) {
|
|
1222
|
-
if (options.events === true) {
|
|
1223
|
-
this.#eventListeners.add(listener);
|
|
1224
|
-
return () => this.#eventListeners.delete(listener);
|
|
1225
|
-
}
|
|
1226
|
-
this.#listeners.add(listener);
|
|
1227
|
-
return () => {
|
|
1228
|
-
this.#listeners.delete(listener);
|
|
1229
|
-
};
|
|
1230
|
-
}
|
|
1231
|
-
subscribeEvents(listener) {
|
|
1232
|
-
this.#eventListeners.add(listener);
|
|
1233
|
-
return () => this.#eventListeners.delete(listener);
|
|
1234
|
-
}
|
|
1235
|
-
async updateConfig(newConfig) {
|
|
1236
|
-
return this.#enqueue(async () => {
|
|
1237
|
-
const current = await this.initialize();
|
|
1238
|
-
this.#config = newConfig;
|
|
1239
|
-
const next = this.#reconcileState(cloneState(current), this.#clock());
|
|
1240
|
-
await this.#storage.save(next);
|
|
1241
|
-
this.#currentState = next;
|
|
1242
|
-
this.#initialization = Promise.resolve(next);
|
|
1243
|
-
this.#emit(next);
|
|
1244
|
-
return next;
|
|
1245
|
-
});
|
|
1246
|
-
}
|
|
1247
|
-
notifyRewardClaimed(rewardId, state = this.#currentState) {
|
|
1248
|
-
if (state !== null) this.#emitEvent({ type: "rewardClaimed", rewardId, state });
|
|
1249
|
-
}
|
|
1250
|
-
notifySyncCompleted(state = this.#currentState) {
|
|
1251
|
-
if (state !== null) this.#emitEvent({ type: "syncCompleted", state });
|
|
1252
|
-
}
|
|
1253
|
-
init() {
|
|
1254
|
-
return this.initialize();
|
|
1255
|
-
}
|
|
1256
|
-
initialize() {
|
|
1257
|
-
if (this.#currentState !== null) {
|
|
1258
|
-
return Promise.resolve(this.#currentState);
|
|
1259
|
-
}
|
|
1260
|
-
if (this.#initialization === null) {
|
|
1261
|
-
this.#initialization = this.#storage.load(this.#config.id).then((storedState) => {
|
|
1262
|
-
const state = storedState === null ? this.#createEmptyState(this.#clock()) : this.#reconcileState(cloneState(storedState), storedState.updatedAt);
|
|
1263
|
-
this.#currentState = state;
|
|
1264
|
-
this.#emit(state);
|
|
1265
|
-
return state;
|
|
1266
|
-
}).catch((error) => {
|
|
1267
|
-
this.#initialization = null;
|
|
1268
|
-
throw error;
|
|
1269
|
-
});
|
|
1270
|
-
}
|
|
1271
|
-
return this.#initialization;
|
|
1272
|
-
}
|
|
1273
|
-
acquire(stampId, context, now2 = this.#clock()) {
|
|
1274
|
-
return this.#enqueue(async () => {
|
|
1275
|
-
const currentState = await this.initialize();
|
|
1276
|
-
const result = processStamp(currentState, this.#config, stampId, context, now2);
|
|
1277
|
-
if (!result.ok) {
|
|
1278
|
-
this.#emitEvent({ type: "error", error: result.error });
|
|
1279
|
-
return result;
|
|
1280
|
-
}
|
|
1281
|
-
await this.#storage.save(result.value.nextState);
|
|
1282
|
-
this.#currentState = result.value.nextState;
|
|
1283
|
-
this.#emit(result.value.nextState);
|
|
1284
|
-
this.#emitEvent({ type: "checkIn", stampId, state: result.value.nextState });
|
|
1285
|
-
return result;
|
|
1286
|
-
});
|
|
1287
|
-
}
|
|
1288
|
-
reset(now2 = this.#clock()) {
|
|
1289
|
-
return this.#enqueue(async () => {
|
|
1290
|
-
const initialization = this.#initialization;
|
|
1291
|
-
if (initialization !== null) {
|
|
1292
|
-
await initialization.catch(() => void 0);
|
|
1293
|
-
}
|
|
1294
|
-
await this.#storage.remove(this.#config.id);
|
|
1295
|
-
const nextState = this.#createEmptyState(now2);
|
|
1296
|
-
this.#currentState = nextState;
|
|
1297
|
-
this.#initialization = Promise.resolve(nextState);
|
|
1298
|
-
this.#emit(nextState);
|
|
1299
|
-
return nextState;
|
|
1300
|
-
});
|
|
1301
|
-
}
|
|
1302
|
-
restore(state) {
|
|
1303
|
-
return this.#enqueue(async () => {
|
|
1304
|
-
const initialization = this.#initialization;
|
|
1305
|
-
if (initialization !== null) {
|
|
1306
|
-
await initialization.catch(() => void 0);
|
|
1307
|
-
}
|
|
1308
|
-
if (state.rallyId !== this.#config.id) {
|
|
1309
|
-
throw new Error(
|
|
1310
|
-
`Cannot restore rally '${state.rallyId}' into client '${this.#config.id}'.`
|
|
1311
|
-
);
|
|
1312
|
-
}
|
|
1313
|
-
const nextState = this.#reconcileState(cloneState(state), state.updatedAt);
|
|
1314
|
-
await this.#storage.save(nextState);
|
|
1315
|
-
this.#currentState = nextState;
|
|
1316
|
-
this.#initialization = Promise.resolve(nextState);
|
|
1317
|
-
this.#emit(nextState);
|
|
1318
|
-
return nextState;
|
|
1319
|
-
});
|
|
1320
|
-
}
|
|
1321
|
-
#enqueue(operation) {
|
|
1322
|
-
const next = this.#operationQueue.then(operation, operation);
|
|
1323
|
-
this.#operationQueue = next.then(
|
|
1324
|
-
() => void 0,
|
|
1325
|
-
() => void 0
|
|
1326
|
-
);
|
|
1327
|
-
return next;
|
|
1328
|
-
}
|
|
1329
|
-
#emit(state) {
|
|
1330
|
-
for (const listener of this.#listeners) {
|
|
1331
|
-
listener(state);
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
#emitEvent(event) {
|
|
1335
|
-
for (const listener of this.#eventListeners) listener(event);
|
|
1336
|
-
}
|
|
1337
|
-
#createEmptyState(now2) {
|
|
1338
|
-
const state = {
|
|
1339
|
-
rallyId: this.#config.id,
|
|
1340
|
-
records: [],
|
|
1341
|
-
...this.#config.rewards === void 0 ? {} : {
|
|
1342
|
-
rewards: reconcileRewardStates(this.#config.rewards, [], 0, now2)
|
|
1343
|
-
},
|
|
1344
|
-
updatedAt: now2
|
|
1345
|
-
};
|
|
1346
|
-
return state;
|
|
1347
|
-
}
|
|
1348
|
-
#reconcileState(state, now2) {
|
|
1349
|
-
const configuredStampIds = new Set(this.#config.stamps.map((stamp) => stamp.id));
|
|
1350
|
-
const seenStampIds = /* @__PURE__ */ new Set();
|
|
1351
|
-
const records = state.records.filter((record) => {
|
|
1352
|
-
if (!configuredStampIds.has(record.stampId) || seenStampIds.has(record.stampId)) return false;
|
|
1353
|
-
seenStampIds.add(record.stampId);
|
|
1354
|
-
return true;
|
|
1355
|
-
});
|
|
1356
|
-
if (this.#config.rewards === void 0 && state.rewards === void 0) {
|
|
1357
|
-
return records.length === state.records.length ? state : { ...state, records };
|
|
1358
|
-
}
|
|
1359
|
-
return {
|
|
1360
|
-
...state,
|
|
1361
|
-
records,
|
|
1362
|
-
rewards: reconcileRewardStates(
|
|
1363
|
-
this.#config.rewards ?? [],
|
|
1364
|
-
state.rewards ?? [],
|
|
1365
|
-
records.length,
|
|
1366
|
-
now2
|
|
1367
|
-
)
|
|
1368
|
-
};
|
|
1369
|
-
}
|
|
1370
|
-
};
|
|
1371
|
-
|
|
1372
|
-
// src/client/universalClient.ts
|
|
1373
945
|
function isStorage(value) {
|
|
1374
946
|
return "load" in value && "save" in value && "remove" in value;
|
|
1375
947
|
}
|
|
1376
|
-
function
|
|
948
|
+
function id(prefix) {
|
|
1377
949
|
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
|
1378
950
|
}
|
|
1379
|
-
function
|
|
1380
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1381
|
-
}
|
|
1382
|
-
function text(value) {
|
|
1383
|
-
if (typeof value === "string") return value;
|
|
1384
|
-
if (typeof value === "object" && value !== null) {
|
|
1385
|
-
const first = Object.values(value).find((item) => typeof item === "string");
|
|
1386
|
-
if (typeof first === "string") return first;
|
|
1387
|
-
}
|
|
1388
|
-
return "";
|
|
1389
|
-
}
|
|
1390
|
-
function proofString(value) {
|
|
951
|
+
function proof(value) {
|
|
1391
952
|
if (typeof value === "string") return value;
|
|
1392
953
|
if (typeof value === "object" && value !== null) {
|
|
1393
|
-
const
|
|
1394
|
-
for (const key of ["token", "code", "passcode", "value"])
|
|
1395
|
-
if (typeof
|
|
1396
|
-
}
|
|
954
|
+
const item = value;
|
|
955
|
+
for (const key of ["token", "code", "passcode", "value", "tagId"])
|
|
956
|
+
if (typeof item[key] === "string") return item[key];
|
|
1397
957
|
}
|
|
1398
958
|
return "";
|
|
1399
959
|
}
|
|
1400
|
-
function
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
const latitude = typeof value.latitude === "number" ? value.latitude : Number.NaN;
|
|
1417
|
-
const longitude = typeof value.longitude === "number" ? value.longitude : Number.NaN;
|
|
1418
|
-
return Number.isFinite(latitude) && Number.isFinite(longitude) && distanceMeters(condition2.latitude, condition2.longitude, latitude, longitude) <= condition2.radiusMeters;
|
|
1419
|
-
}
|
|
1420
|
-
case "custom":
|
|
1421
|
-
return true;
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
function asReward(reward) {
|
|
1425
|
-
return { ...reward, description: text(reward.description) };
|
|
1426
|
-
}
|
|
1427
|
-
function initialState(config, timestamp) {
|
|
1428
|
-
const rewards = config.rewards.map(asReward);
|
|
960
|
+
function matches(condition2, value) {
|
|
961
|
+
if (condition2.type === "gps") {
|
|
962
|
+
if (typeof value !== "object" || value === null) return false;
|
|
963
|
+
const item = value;
|
|
964
|
+
const latitude = item.latitude;
|
|
965
|
+
const longitude = item.longitude;
|
|
966
|
+
if (typeof latitude !== "number" || typeof longitude !== "number") return false;
|
|
967
|
+
const radians = (v) => v * Math.PI / 180;
|
|
968
|
+
const dLat = radians(latitude - condition2.latitude);
|
|
969
|
+
const dLon = radians(longitude - condition2.longitude);
|
|
970
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition2.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
|
|
971
|
+
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition2.radiusMeters;
|
|
972
|
+
}
|
|
973
|
+
return proof(value).trim() !== "";
|
|
974
|
+
}
|
|
975
|
+
function emptyState(config, userId, now) {
|
|
1429
976
|
return {
|
|
1430
977
|
rallyId: config.id,
|
|
978
|
+
userId,
|
|
1431
979
|
records: [],
|
|
1432
|
-
rewards: reconcileRewardStates(rewards, [], 0,
|
|
1433
|
-
updatedAt:
|
|
980
|
+
rewards: reconcileRewardStates(config.rewards, [], 0, now),
|
|
981
|
+
updatedAt: now
|
|
1434
982
|
};
|
|
1435
983
|
}
|
|
1436
|
-
var
|
|
984
|
+
var StampRallyClient = class {
|
|
1437
985
|
#listeners = /* @__PURE__ */ new Set();
|
|
1438
986
|
#eventListeners = /* @__PURE__ */ new Set();
|
|
1439
|
-
#config;
|
|
1440
987
|
#storage;
|
|
1441
988
|
#options;
|
|
989
|
+
#config;
|
|
990
|
+
#userId;
|
|
1442
991
|
#state = null;
|
|
1443
992
|
#initialization = null;
|
|
1444
993
|
#queue = Promise.resolve();
|
|
1445
|
-
constructor(config, storageOrOptions = {}
|
|
994
|
+
constructor(config, storageOrOptions = {}) {
|
|
1446
995
|
this.#config = config;
|
|
1447
|
-
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions
|
|
996
|
+
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
|
|
1448
997
|
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
998
|
+
this.#userId = this.#options.userId ?? null;
|
|
1449
999
|
}
|
|
1450
1000
|
getConfig() {
|
|
1451
1001
|
return this.#config;
|
|
@@ -1453,6 +1003,9 @@ var UniversalStampRallyClient = class {
|
|
|
1453
1003
|
getState() {
|
|
1454
1004
|
return this.#state;
|
|
1455
1005
|
}
|
|
1006
|
+
getUserId() {
|
|
1007
|
+
return this.#userId;
|
|
1008
|
+
}
|
|
1456
1009
|
subscribe(listener) {
|
|
1457
1010
|
this.#listeners.add(listener);
|
|
1458
1011
|
return () => this.#listeners.delete(listener);
|
|
@@ -1467,11 +1020,11 @@ var UniversalStampRallyClient = class {
|
|
|
1467
1020
|
initialize() {
|
|
1468
1021
|
if (this.#state !== null) return Promise.resolve(this.#state);
|
|
1469
1022
|
if (this.#initialization === null) {
|
|
1470
|
-
this.#initialization = this.#storage.load(this.#config.id).then((
|
|
1471
|
-
const
|
|
1472
|
-
this.#state =
|
|
1473
|
-
this.#emit(
|
|
1474
|
-
return
|
|
1023
|
+
this.#initialization = this.#storage.load(this.#config.id, this.#userId).then((state) => {
|
|
1024
|
+
const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
|
|
1025
|
+
this.#state = next;
|
|
1026
|
+
this.#emit(next);
|
|
1027
|
+
return next;
|
|
1475
1028
|
}).catch((error) => {
|
|
1476
1029
|
this.#initialization = null;
|
|
1477
1030
|
throw error;
|
|
@@ -1479,11 +1032,31 @@ var UniversalStampRallyClient = class {
|
|
|
1479
1032
|
}
|
|
1480
1033
|
return this.#initialization;
|
|
1481
1034
|
}
|
|
1035
|
+
switchUser(newUserId) {
|
|
1036
|
+
return this.#enqueue(async () => {
|
|
1037
|
+
if (this.#userId === newUserId && this.#state !== null) return this.#state;
|
|
1038
|
+
this.#userId = newUserId;
|
|
1039
|
+
this.#state = null;
|
|
1040
|
+
this.#initialization = null;
|
|
1041
|
+
return this.initialize();
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
async getUserState(rallyId, userId) {
|
|
1045
|
+
return this.#storage.load(rallyId, userId);
|
|
1046
|
+
}
|
|
1047
|
+
clearUserState(userId = this.#userId) {
|
|
1048
|
+
return this.#enqueue(async () => {
|
|
1049
|
+
await this.#storage.remove(this.#config.id, userId);
|
|
1050
|
+
if (userId === this.#userId) {
|
|
1051
|
+
await this.#initializeFresh();
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1482
1055
|
checkIn(spotId, proofData, options = {}) {
|
|
1483
1056
|
return this.#enqueue(async () => {
|
|
1484
1057
|
const current = await this.initialize();
|
|
1485
|
-
const
|
|
1486
|
-
if (
|
|
1058
|
+
const spot2 = this.#config.spots.find((item) => item.id === spotId);
|
|
1059
|
+
if (spot2 === void 0)
|
|
1487
1060
|
return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
|
|
1488
1061
|
if (current.records.some((record2) => record2.stampId === spotId))
|
|
1489
1062
|
return this.#fail({
|
|
@@ -1492,70 +1065,61 @@ var UniversalStampRallyClient = class {
|
|
|
1492
1065
|
message: "Spot was already claimed."
|
|
1493
1066
|
});
|
|
1494
1067
|
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
1495
|
-
if (
|
|
1068
|
+
if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
1496
1069
|
return this.#fail({
|
|
1497
1070
|
code: "PREREQUISITES_NOT_MET",
|
|
1498
1071
|
spotId,
|
|
1499
1072
|
message: "Prerequisite spots are not complete."
|
|
1500
1073
|
});
|
|
1501
|
-
for (const condition2 of
|
|
1074
|
+
for (const condition2 of spot2.conditions) {
|
|
1502
1075
|
if (condition2.type === "custom") {
|
|
1503
1076
|
const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
|
|
1504
|
-
if (validator
|
|
1505
|
-
try {
|
|
1506
|
-
const validation = await validator({
|
|
1507
|
-
spotId,
|
|
1508
|
-
proofData,
|
|
1509
|
-
config: this.#config,
|
|
1510
|
-
userState: current
|
|
1511
|
-
});
|
|
1512
|
-
if (!validation.success)
|
|
1513
|
-
return this.#fail({
|
|
1514
|
-
code: "CUSTOM_VALIDATION_FAILED",
|
|
1515
|
-
spotId,
|
|
1516
|
-
message: validation.error ?? "Custom validation failed."
|
|
1517
|
-
});
|
|
1518
|
-
} catch (error) {
|
|
1519
|
-
return this.#fail({
|
|
1520
|
-
code: "CUSTOM_VALIDATION_FAILED",
|
|
1521
|
-
spotId,
|
|
1522
|
-
message: error instanceof Error ? error.message : "Custom validation failed."
|
|
1523
|
-
});
|
|
1524
|
-
}
|
|
1525
|
-
} else {
|
|
1077
|
+
if (validator === void 0)
|
|
1526
1078
|
return this.#fail({
|
|
1527
1079
|
code: "CUSTOM_VALIDATION_FAILED",
|
|
1528
1080
|
spotId,
|
|
1529
1081
|
message: "No custom validator is registered."
|
|
1530
1082
|
});
|
|
1531
|
-
|
|
1532
|
-
|
|
1083
|
+
const context = {
|
|
1084
|
+
rallyId: this.#config.id,
|
|
1085
|
+
spotId,
|
|
1086
|
+
proofData,
|
|
1087
|
+
condition: { type: "custom", validatorName: condition2.validatorName },
|
|
1088
|
+
userState: current
|
|
1089
|
+
};
|
|
1090
|
+
const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
|
|
1091
|
+
if (result === false || typeof result === "object" && !result.valid)
|
|
1092
|
+
return this.#fail({
|
|
1093
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1094
|
+
spotId,
|
|
1095
|
+
message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
|
|
1096
|
+
});
|
|
1097
|
+
} else if (!matches(condition2, proofData))
|
|
1533
1098
|
return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
|
|
1534
|
-
}
|
|
1535
1099
|
}
|
|
1536
|
-
const
|
|
1100
|
+
const now = options.now ?? this.#now();
|
|
1537
1101
|
const request = {
|
|
1538
1102
|
rallyId: this.#config.id,
|
|
1103
|
+
userId: this.#userId,
|
|
1539
1104
|
spotId,
|
|
1540
1105
|
proofData,
|
|
1541
|
-
idempotencyKey: options.idempotencyKey ??
|
|
1542
|
-
now
|
|
1106
|
+
idempotencyKey: options.idempotencyKey ?? id("check-in"),
|
|
1107
|
+
now,
|
|
1543
1108
|
state: current
|
|
1544
1109
|
};
|
|
1545
1110
|
const remote = this.#options.syncAdapter?.checkIn;
|
|
1546
1111
|
if (options.sync !== false && remote !== void 0) {
|
|
1547
1112
|
const result = await remote(request);
|
|
1548
|
-
|
|
1549
|
-
return this.#commitCheckIn(result.value.state, result);
|
|
1113
|
+
return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
|
|
1550
1114
|
}
|
|
1551
|
-
const record = { stampId: spotId, acquiredAt:
|
|
1115
|
+
const record = { stampId: spotId, acquiredAt: now };
|
|
1552
1116
|
const next = this.#reconcile({
|
|
1553
1117
|
...current,
|
|
1554
1118
|
records: [...current.records, record],
|
|
1555
|
-
updatedAt:
|
|
1119
|
+
updatedAt: now
|
|
1556
1120
|
});
|
|
1557
1121
|
await this.#storage.save(next);
|
|
1558
|
-
return this.#commitCheckIn(
|
|
1122
|
+
return this.#commitCheckIn({ ok: true, value: { state: next, record } });
|
|
1559
1123
|
});
|
|
1560
1124
|
}
|
|
1561
1125
|
claimReward(rewardId, options = {}) {
|
|
@@ -1564,107 +1128,128 @@ var UniversalStampRallyClient = class {
|
|
|
1564
1128
|
const configured = this.#config.rewards.find((item) => item.id === rewardId);
|
|
1565
1129
|
if (configured === void 0)
|
|
1566
1130
|
return this.#fail({ code: "REWARD_NOT_FOUND", rewardId, message: "Reward was not found." });
|
|
1567
|
-
const
|
|
1568
|
-
const state = current.rewards
|
|
1131
|
+
const now = options.now ?? this.#now();
|
|
1132
|
+
const state = current.rewards.find((item) => item.rewardId === rewardId) ?? {
|
|
1569
1133
|
rewardId,
|
|
1570
1134
|
status: "LOCKED"
|
|
1571
1135
|
};
|
|
1572
1136
|
const local = consumeReward({
|
|
1573
|
-
reward:
|
|
1137
|
+
reward: configured,
|
|
1574
1138
|
currentState: state,
|
|
1575
|
-
now
|
|
1139
|
+
now,
|
|
1576
1140
|
...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
|
|
1577
1141
|
...options.staffId === void 0 ? {} : { staffId: options.staffId }
|
|
1578
1142
|
});
|
|
1579
1143
|
if (!local.ok) return this.#fail(local.error);
|
|
1580
1144
|
const request = {
|
|
1581
1145
|
rallyId: this.#config.id,
|
|
1146
|
+
userId: this.#userId,
|
|
1582
1147
|
rewardId,
|
|
1583
|
-
idempotencyKey: options.idempotencyKey ??
|
|
1584
|
-
now
|
|
1148
|
+
idempotencyKey: options.idempotencyKey ?? id("claim"),
|
|
1149
|
+
now,
|
|
1585
1150
|
options,
|
|
1586
1151
|
state: current
|
|
1587
1152
|
};
|
|
1588
1153
|
const remote = this.#options.syncAdapter?.claimReward;
|
|
1589
1154
|
if (options.sync !== false && remote !== void 0) {
|
|
1590
1155
|
const result = await remote(request);
|
|
1591
|
-
|
|
1592
|
-
return this.#commitClaim(result.value.state, result);
|
|
1156
|
+
return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
|
|
1593
1157
|
}
|
|
1594
|
-
const
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1158
|
+
const next = {
|
|
1159
|
+
...current,
|
|
1160
|
+
rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
|
|
1161
|
+
updatedAt: now
|
|
1162
|
+
};
|
|
1598
1163
|
await this.#storage.save(next);
|
|
1599
|
-
return this.#commitClaim(
|
|
1164
|
+
return this.#commitClaim({ ok: true, value: { state: next, reward: local.value } });
|
|
1600
1165
|
});
|
|
1601
1166
|
}
|
|
1602
|
-
sync(adapter = this.#options.syncAdapter
|
|
1167
|
+
sync(adapter = this.#options.syncAdapter) {
|
|
1603
1168
|
return this.#enqueue(async () => {
|
|
1604
1169
|
const current = await this.initialize();
|
|
1605
|
-
if (adapter
|
|
1170
|
+
if (adapter?.sync === void 0) {
|
|
1606
1171
|
this.#emitEvent({ type: "sync", state: current });
|
|
1607
1172
|
return;
|
|
1608
1173
|
}
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
this.#emitEvent({ type: "sync", state: next });
|
|
1617
|
-
} catch (error) {
|
|
1618
|
-
const failure = {
|
|
1619
|
-
code: "SYNC_FAILED",
|
|
1620
|
-
message: error instanceof Error ? error.message : String(error)
|
|
1621
|
-
};
|
|
1622
|
-
this.#emitEvent({ type: "error", error: failure });
|
|
1623
|
-
throw error;
|
|
1624
|
-
}
|
|
1174
|
+
const next = this.#reconcile(
|
|
1175
|
+
await adapter.sync({ rallyId: this.#config.id, userId: this.#userId, state: current })
|
|
1176
|
+
);
|
|
1177
|
+
await this.#storage.save(next);
|
|
1178
|
+
this.#state = next;
|
|
1179
|
+
this.#emit(next);
|
|
1180
|
+
this.#emitEvent({ type: "sync", state: next });
|
|
1625
1181
|
});
|
|
1626
1182
|
}
|
|
1627
|
-
|
|
1628
|
-
return this.#
|
|
1183
|
+
reset() {
|
|
1184
|
+
return this.#enqueue(async () => {
|
|
1185
|
+
await this.#storage.remove(this.#config.id, this.#userId);
|
|
1186
|
+
return this.#initializeFresh();
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
restore(state) {
|
|
1190
|
+
return this.#enqueue(async () => {
|
|
1191
|
+
if (state.rallyId !== this.#config.id || state.userId !== this.#userId)
|
|
1192
|
+
throw new Error("State belongs to another rally or user.");
|
|
1193
|
+
const next = this.#reconcile(state);
|
|
1194
|
+
await this.#storage.save(next);
|
|
1195
|
+
this.#state = next;
|
|
1196
|
+
this.#initialization = Promise.resolve(next);
|
|
1197
|
+
this.#emit(next);
|
|
1198
|
+
return next;
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
#enqueue(operation) {
|
|
1202
|
+
const next = this.#queue.then(operation, operation);
|
|
1203
|
+
this.#queue = next.then(
|
|
1204
|
+
() => void 0,
|
|
1205
|
+
() => void 0
|
|
1206
|
+
);
|
|
1207
|
+
return next;
|
|
1208
|
+
}
|
|
1209
|
+
#initializeFresh() {
|
|
1210
|
+
const next = emptyState(this.#config, this.#userId, this.#now());
|
|
1211
|
+
this.#state = next;
|
|
1212
|
+
this.#initialization = Promise.resolve(next);
|
|
1213
|
+
this.#emit(next);
|
|
1214
|
+
return Promise.resolve(next);
|
|
1629
1215
|
}
|
|
1630
1216
|
#reconcile(state) {
|
|
1631
|
-
const ids = new Set(this.#config.spots.map((
|
|
1632
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1217
|
+
const ids = new Set(this.#config.spots.map((spot2) => spot2.id));
|
|
1633
1218
|
const records = state.records.filter(
|
|
1634
|
-
(record) => ids.has(record.stampId) &&
|
|
1219
|
+
(record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
|
|
1635
1220
|
);
|
|
1636
1221
|
return {
|
|
1637
1222
|
...cloneState(state),
|
|
1223
|
+
userId: this.#userId,
|
|
1638
1224
|
records,
|
|
1639
1225
|
rewards: reconcileRewardStates(
|
|
1640
|
-
this.#config.rewards
|
|
1641
|
-
state.rewards
|
|
1226
|
+
this.#config.rewards,
|
|
1227
|
+
state.rewards,
|
|
1642
1228
|
records.length,
|
|
1643
1229
|
state.updatedAt
|
|
1644
1230
|
)
|
|
1645
1231
|
};
|
|
1646
1232
|
}
|
|
1647
|
-
#
|
|
1648
|
-
|
|
1649
|
-
this.#queue = next.then(
|
|
1650
|
-
() => void 0,
|
|
1651
|
-
() => void 0
|
|
1652
|
-
);
|
|
1653
|
-
return next;
|
|
1233
|
+
#now() {
|
|
1234
|
+
return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1654
1235
|
}
|
|
1655
1236
|
#fail(error) {
|
|
1656
1237
|
this.#emitEvent({ type: "error", error });
|
|
1657
1238
|
return { ok: false, error };
|
|
1658
1239
|
}
|
|
1659
|
-
#commitCheckIn(
|
|
1660
|
-
|
|
1661
|
-
|
|
1240
|
+
#commitCheckIn(result) {
|
|
1241
|
+
if (result.ok) {
|
|
1242
|
+
this.#state = result.value.state;
|
|
1243
|
+
this.#emit(this.#state);
|
|
1244
|
+
}
|
|
1662
1245
|
this.#emitEvent({ type: "checkIn", result });
|
|
1663
1246
|
return result;
|
|
1664
1247
|
}
|
|
1665
|
-
#commitClaim(
|
|
1666
|
-
|
|
1667
|
-
|
|
1248
|
+
#commitClaim(result) {
|
|
1249
|
+
if (result.ok) {
|
|
1250
|
+
this.#state = result.value.state;
|
|
1251
|
+
this.#emit(this.#state);
|
|
1252
|
+
}
|
|
1668
1253
|
this.#emitEvent({ type: "rewardClaimed", result });
|
|
1669
1254
|
return result;
|
|
1670
1255
|
}
|
|
@@ -1737,7 +1322,7 @@ async function encryptPayload(plaintext, secret, api) {
|
|
|
1737
1322
|
);
|
|
1738
1323
|
return encode(new Uint8Array([...iv, ...encrypted]));
|
|
1739
1324
|
}
|
|
1740
|
-
async function verifySecureToken(token, secretKey,
|
|
1325
|
+
async function verifySecureToken(token, secretKey, now = Date.now()) {
|
|
1741
1326
|
try {
|
|
1742
1327
|
const parts = token.split(".");
|
|
1743
1328
|
if (parts.length !== 4 || parts[0] !== "sr3" || parts[1] !== "e" && parts[1] !== "p") {
|
|
@@ -1780,7 +1365,7 @@ async function verifySecureToken(token, secretKey, now2 = Date.now()) {
|
|
|
1780
1365
|
};
|
|
1781
1366
|
}
|
|
1782
1367
|
const payload = parsed;
|
|
1783
|
-
if (typeof payload.exp === "number" &&
|
|
1368
|
+
if (typeof payload.exp === "number" && now >= payload.exp * 1e3) {
|
|
1784
1369
|
return {
|
|
1785
1370
|
ok: false,
|
|
1786
1371
|
valid: false,
|
|
@@ -1809,367 +1394,23 @@ async function decryptPayload(body, secret) {
|
|
|
1809
1394
|
}
|
|
1810
1395
|
|
|
1811
1396
|
// src/domain/i18n.ts
|
|
1812
|
-
function
|
|
1813
|
-
if (
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
if (
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
};
|
|
1825
|
-
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
function isObject(value) {
|
|
1830
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1831
|
-
}
|
|
1832
|
-
function add(errors, path, code, message) {
|
|
1833
|
-
errors.push({ path, code, message });
|
|
1834
|
-
}
|
|
1835
|
-
function hasText(value) {
|
|
1836
|
-
if (typeof value === "string") return value.trim() !== "";
|
|
1837
|
-
if (!isObject(value)) return false;
|
|
1838
|
-
return Object.values(value).some((item) => typeof item === "string" && item.trim() !== "");
|
|
1839
|
-
}
|
|
1840
|
-
function validateCondition(value, path, errors) {
|
|
1841
|
-
if (!isObject(value) || typeof value.type !== "string") {
|
|
1842
|
-
add(errors, path, "INVALID_TYPE", "Condition must be an object with a type.");
|
|
1843
|
-
return;
|
|
1844
|
-
}
|
|
1845
|
-
switch (value.type) {
|
|
1846
|
-
case "instant":
|
|
1847
|
-
return;
|
|
1848
|
-
case "token":
|
|
1849
|
-
if (typeof value.token !== "string" || value.token.trim() === "") {
|
|
1850
|
-
add(errors, `${path}.token`, "REQUIRED", "Token must not be empty.");
|
|
1851
|
-
}
|
|
1852
|
-
return;
|
|
1853
|
-
case "geo": {
|
|
1854
|
-
const latitude = value.latitude;
|
|
1855
|
-
const longitude = value.longitude;
|
|
1856
|
-
if (typeof latitude !== "number" || !Number.isFinite(latitude) || latitude < -90 || latitude > 90 || typeof longitude !== "number" || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
|
1857
|
-
add(
|
|
1858
|
-
errors,
|
|
1859
|
-
path,
|
|
1860
|
-
"INVALID_COORDINATES",
|
|
1861
|
-
"Latitude must be between -90 and 90 and longitude between -180 and 180."
|
|
1862
|
-
);
|
|
1863
|
-
}
|
|
1864
|
-
if (typeof value.radiusMeters !== "number" || !Number.isFinite(value.radiusMeters) || value.radiusMeters <= 0) {
|
|
1865
|
-
add(errors, `${path}.radiusMeters`, "INVALID_RADIUS", "Radius must be greater than zero.");
|
|
1866
|
-
}
|
|
1867
|
-
return;
|
|
1868
|
-
}
|
|
1869
|
-
case "composite":
|
|
1870
|
-
if (value.operator !== "AND" && value.operator !== "OR") {
|
|
1871
|
-
add(errors, `${path}.operator`, "INVALID_TYPE", "Operator must be AND or OR.");
|
|
1872
|
-
}
|
|
1873
|
-
if (!Array.isArray(value.conditions)) {
|
|
1874
|
-
add(errors, `${path}.conditions`, "INVALID_TYPE", "Composite conditions must be an array.");
|
|
1875
|
-
return;
|
|
1876
|
-
}
|
|
1877
|
-
value.conditions.forEach((child, index) => {
|
|
1878
|
-
validateCondition(child, `${path}.conditions[${index}]`, errors);
|
|
1879
|
-
});
|
|
1880
|
-
return;
|
|
1881
|
-
case "time_window":
|
|
1882
|
-
if (typeof value.startsAt !== "string" || Number.isNaN(Date.parse(value.startsAt))) {
|
|
1883
|
-
add(errors, `${path}.startsAt`, "INVALID_DATE", "Start must be a valid ISO date.");
|
|
1884
|
-
}
|
|
1885
|
-
if (typeof value.endsAt !== "string" || Number.isNaN(Date.parse(value.endsAt))) {
|
|
1886
|
-
add(errors, `${path}.endsAt`, "INVALID_DATE", "End must be a valid ISO date.");
|
|
1887
|
-
}
|
|
1888
|
-
if (typeof value.startsAt === "string" && typeof value.endsAt === "string" && !Number.isNaN(Date.parse(value.startsAt)) && !Number.isNaN(Date.parse(value.endsAt)) && Date.parse(value.startsAt) >= Date.parse(value.endsAt)) {
|
|
1889
|
-
add(errors, path, "INVALID_DATE", "Time window start must be before its end.");
|
|
1890
|
-
}
|
|
1891
|
-
validateCondition(value.condition, `${path}.condition`, errors);
|
|
1892
|
-
return;
|
|
1893
|
-
default:
|
|
1894
|
-
add(errors, `${path}.type`, "INVALID_TYPE", "Unsupported condition type.");
|
|
1895
|
-
}
|
|
1896
|
-
}
|
|
1897
|
-
function validateSpot(value, index, errors) {
|
|
1898
|
-
const path = `stamps[${index}]`;
|
|
1899
|
-
if (!isObject(value)) {
|
|
1900
|
-
add(errors, path, "INVALID_TYPE", "Spot must be an object.");
|
|
1901
|
-
return;
|
|
1902
|
-
}
|
|
1903
|
-
if (typeof value.id !== "string") add(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
|
|
1904
|
-
else if (value.id.trim() === "")
|
|
1905
|
-
add(errors, `${path}.id`, "EMPTY_STRING", "Spot ID must not be empty.");
|
|
1906
|
-
if (value.name === void 0) add(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
|
|
1907
|
-
else if (!hasText(value.name))
|
|
1908
|
-
add(errors, `${path}.name`, "EMPTY_STRING", "Spot name must not be empty.");
|
|
1909
|
-
if (value.order !== void 0 && (typeof value.order !== "number" || !Number.isFinite(value.order))) {
|
|
1910
|
-
add(errors, `${path}.order`, "INVALID_TYPE", "Spot order must be a finite number.");
|
|
1911
|
-
}
|
|
1912
|
-
validateCondition(value.condition, `${path}.condition`, errors);
|
|
1913
|
-
for (const field of ["dependsOn", "requiresStampIds"]) {
|
|
1914
|
-
if (value[field] !== void 0 && (!Array.isArray(value[field]) || value[field].some((item) => typeof item !== "string"))) {
|
|
1915
|
-
add(errors, `${path}.${field}`, "INVALID_TYPE", `${field} must be an array of IDs.`);
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
for (const field of ["order", "orderIndex"]) {
|
|
1919
|
-
if (value[field] !== void 0 && (typeof value[field] !== "number" || !Number.isFinite(value[field]))) {
|
|
1920
|
-
add(errors, `${path}.${field}`, "INVALID_TYPE", `${field} must be a finite number.`);
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
}
|
|
1924
|
-
function validateReward(value, index, stampCount, errors) {
|
|
1925
|
-
const path = `rewards[${index}]`;
|
|
1926
|
-
if (!isObject(value)) {
|
|
1927
|
-
add(errors, path, "INVALID_TYPE", "Reward must be an object.");
|
|
1928
|
-
return;
|
|
1929
|
-
}
|
|
1930
|
-
if (typeof value.id !== "string" || value.id.trim() === "") {
|
|
1931
|
-
add(errors, `${path}.id`, "REQUIRED", "Reward ID must not be empty.");
|
|
1932
|
-
}
|
|
1933
|
-
if (!hasText(value.title)) {
|
|
1934
|
-
add(errors, `${path}.title`, "EMPTY_STRING", "Reward title must not be empty.");
|
|
1935
|
-
}
|
|
1936
|
-
if (!hasText(value.description)) {
|
|
1937
|
-
add(errors, `${path}.description`, "EMPTY_STRING", "Reward description must not be empty.");
|
|
1938
|
-
}
|
|
1939
|
-
const required = value.requiredStampCount;
|
|
1940
|
-
if (typeof required !== "number" || !Number.isInteger(required) || required < 0 || required > stampCount) {
|
|
1941
|
-
add(
|
|
1942
|
-
errors,
|
|
1943
|
-
`${path}.requiredStampCount`,
|
|
1944
|
-
"INVALID_REWARD",
|
|
1945
|
-
"Required stamps must be an integer within the rally."
|
|
1946
|
-
);
|
|
1947
|
-
}
|
|
1948
|
-
if (value.validUntil !== void 0 && (typeof value.validUntil !== "string" || Number.isNaN(Date.parse(value.validUntil)))) {
|
|
1949
|
-
add(errors, `${path}.validUntil`, "INVALID_DATE", "Reward expiry must be a valid ISO date.");
|
|
1950
|
-
}
|
|
1951
|
-
for (const field of ["maxStock", "limitPerUser", "stockLimit", "userClaimLimit"]) {
|
|
1952
|
-
const count = value[field];
|
|
1953
|
-
if (count !== void 0 && (typeof count !== "number" || !Number.isInteger(count) || count <= 0)) {
|
|
1954
|
-
add(errors, `${path}.${field}`, "INVALID_REWARD", `${field} must be a positive integer.`);
|
|
1955
|
-
}
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
function collectDependencies(spot) {
|
|
1959
|
-
const dependencies = /* @__PURE__ */ new Set();
|
|
1960
|
-
for (const field of ["dependsOn", "requiresStampIds"]) {
|
|
1961
|
-
const values = spot[field];
|
|
1962
|
-
if (Array.isArray(values)) {
|
|
1963
|
-
for (const value of values) if (typeof value === "string") dependencies.add(value);
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1966
|
-
return [...dependencies];
|
|
1967
|
-
}
|
|
1968
|
-
function validateDag(stamps, errors) {
|
|
1969
|
-
const graph = /* @__PURE__ */ new Map();
|
|
1970
|
-
for (const value of stamps) {
|
|
1971
|
-
if (!isObject(value) || typeof value.id !== "string") continue;
|
|
1972
|
-
graph.set(value.id, [...graph.get(value.id) ?? [], ...collectDependencies(value)]);
|
|
1973
|
-
}
|
|
1974
|
-
const visiting = /* @__PURE__ */ new Set();
|
|
1975
|
-
const visited = /* @__PURE__ */ new Set();
|
|
1976
|
-
const visit = (id, path) => {
|
|
1977
|
-
if (visiting.has(id)) {
|
|
1978
|
-
add(errors, path, "CYCLE_DETECTED", `Dependency cycle detected at '${id}'.`);
|
|
1979
|
-
return;
|
|
1980
|
-
}
|
|
1981
|
-
if (visited.has(id)) return;
|
|
1982
|
-
visiting.add(id);
|
|
1983
|
-
for (const dependency of graph.get(id) ?? [])
|
|
1984
|
-
if (graph.has(dependency)) visit(dependency, path);
|
|
1985
|
-
visiting.delete(id);
|
|
1986
|
-
visited.add(id);
|
|
1987
|
-
};
|
|
1988
|
-
for (const id of graph.keys()) visit(id, `stamps[${id}]`);
|
|
1989
|
-
}
|
|
1990
|
-
function validateRallyConfig(config) {
|
|
1991
|
-
const errors = [];
|
|
1992
|
-
if (!isObject(config))
|
|
1993
|
-
return {
|
|
1994
|
-
valid: false,
|
|
1995
|
-
errors: [{ path: "", code: "INVALID_TYPE", message: "Rally config must be an object." }]
|
|
1996
|
-
};
|
|
1997
|
-
if (typeof config.id !== "string") add(errors, "id", "REQUIRED", "Rally ID is required.");
|
|
1998
|
-
else if (config.id.trim() === "")
|
|
1999
|
-
add(errors, "id", "EMPTY_STRING", "Rally ID must not be empty.");
|
|
2000
|
-
if (config.title !== void 0 && !hasText(config.title))
|
|
2001
|
-
add(errors, "title", "EMPTY_STRING", "Rally title must not be empty.");
|
|
2002
|
-
if (config.version !== void 0 && config.version !== CURRENT_RALLY_CONFIG_VERSION) {
|
|
2003
|
-
add(
|
|
2004
|
-
errors,
|
|
2005
|
-
"version",
|
|
2006
|
-
"INVALID_VERSION",
|
|
2007
|
-
`Config version must be ${CURRENT_RALLY_CONFIG_VERSION}.`
|
|
2008
|
-
);
|
|
2009
|
-
}
|
|
2010
|
-
for (const field of ["startDate", "endDate"]) {
|
|
2011
|
-
if (config[field] !== void 0 && (typeof config[field] !== "string" || Number.isNaN(Date.parse(config[field])))) {
|
|
2012
|
-
add(errors, field, "INVALID_DATE", `${field} must be a valid ISO date.`);
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
if (typeof config.startDate === "string" && typeof config.endDate === "string" && !Number.isNaN(Date.parse(config.startDate)) && !Number.isNaN(Date.parse(config.endDate)) && Date.parse(config.startDate) >= Date.parse(config.endDate)) {
|
|
2016
|
-
add(errors, "startDate", "INVALID_DATE", "startDate must be before endDate.");
|
|
2017
|
-
}
|
|
2018
|
-
if (!Array.isArray(config.stamps)) {
|
|
2019
|
-
add(errors, "stamps", "INVALID_TYPE", "stamps must be an array.");
|
|
2020
|
-
} else {
|
|
2021
|
-
config.stamps.forEach((spot, index) => {
|
|
2022
|
-
validateSpot(spot, index, errors);
|
|
2023
|
-
});
|
|
2024
|
-
const ids = config.stamps.map(
|
|
2025
|
-
(spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : ""
|
|
2026
|
-
);
|
|
2027
|
-
ids.forEach((id, index) => {
|
|
2028
|
-
if (id !== "" && ids.indexOf(id) !== index)
|
|
2029
|
-
add(errors, `stamps[${index}].id`, "DUPLICATE_ID", `Duplicate spot ID '${id}'.`);
|
|
2030
|
-
});
|
|
2031
|
-
validateDag(config.stamps, errors);
|
|
2032
|
-
}
|
|
2033
|
-
if (config.rewards !== void 0 && !Array.isArray(config.rewards)) {
|
|
2034
|
-
add(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
|
|
2035
|
-
} else if (Array.isArray(config.rewards)) {
|
|
2036
|
-
const stampCount = Array.isArray(config.stamps) ? config.stamps.length : 0;
|
|
2037
|
-
config.rewards.forEach((reward, index) => {
|
|
2038
|
-
validateReward(reward, index, stampCount, errors);
|
|
2039
|
-
});
|
|
2040
|
-
const ids = config.rewards.map(
|
|
2041
|
-
(reward) => isObject(reward) && typeof reward.id === "string" ? reward.id : ""
|
|
2042
|
-
);
|
|
2043
|
-
ids.forEach((id, index) => {
|
|
2044
|
-
if (id !== "" && ids.indexOf(id) !== index)
|
|
2045
|
-
add(errors, `rewards[${index}].id`, "DUPLICATE_ID", `Duplicate reward ID '${id}'.`);
|
|
2046
|
-
});
|
|
2047
|
-
if (Array.isArray(config.stamps)) {
|
|
2048
|
-
const stampIds = new Set(
|
|
2049
|
-
config.stamps.map((spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : "")
|
|
2050
|
-
);
|
|
2051
|
-
ids.forEach((id, index) => {
|
|
2052
|
-
if (id !== "" && stampIds.has(id))
|
|
2053
|
-
add(
|
|
2054
|
-
errors,
|
|
2055
|
-
`rewards[${index}].id`,
|
|
2056
|
-
"DUPLICATE_ID",
|
|
2057
|
-
`Reward ID '${id}' is already used by a spot.`
|
|
2058
|
-
);
|
|
2059
|
-
});
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
2062
|
-
return { valid: errors.length === 0, errors };
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
|
-
// src/domain/migration.ts
|
|
2066
|
-
function isObject2(value) {
|
|
2067
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2068
|
-
}
|
|
2069
|
-
function text2(value) {
|
|
2070
|
-
if (typeof value === "string") return value;
|
|
2071
|
-
if (!isObject2(value)) return void 0;
|
|
2072
|
-
const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
|
|
2073
|
-
return entries.length === 0 ? void 0 : Object.fromEntries(entries);
|
|
2074
|
-
}
|
|
2075
|
-
function condition(value) {
|
|
2076
|
-
if (!isObject2(value) || typeof value.type !== "string") return { type: "instant" };
|
|
2077
|
-
switch (value.type) {
|
|
2078
|
-
case "instant":
|
|
2079
|
-
return { type: "instant" };
|
|
2080
|
-
case "token":
|
|
2081
|
-
case "passcode":
|
|
2082
|
-
return {
|
|
2083
|
-
type: "token",
|
|
2084
|
-
token: typeof value.token === "string" ? value.token : typeof value.passcode === "string" ? value.passcode : ""
|
|
2085
|
-
};
|
|
2086
|
-
case "geo":
|
|
2087
|
-
return {
|
|
2088
|
-
type: "geo",
|
|
2089
|
-
latitude: typeof value.latitude === "number" ? value.latitude : 0,
|
|
2090
|
-
longitude: typeof value.longitude === "number" ? value.longitude : 0,
|
|
2091
|
-
radiusMeters: typeof value.radiusMeters === "number" ? value.radiusMeters : typeof value.radius === "number" ? value.radius : 1
|
|
2092
|
-
};
|
|
2093
|
-
case "composite":
|
|
2094
|
-
return {
|
|
2095
|
-
type: "composite",
|
|
2096
|
-
operator: value.operator === "OR" ? "OR" : "AND",
|
|
2097
|
-
conditions: Array.isArray(value.conditions) ? value.conditions.map(condition) : []
|
|
2098
|
-
};
|
|
2099
|
-
case "time_window":
|
|
2100
|
-
return {
|
|
2101
|
-
type: "time_window",
|
|
2102
|
-
startsAt: typeof value.startsAt === "string" ? value.startsAt : "1970-01-01T00:00:00.000Z",
|
|
2103
|
-
endsAt: typeof value.endsAt === "string" ? value.endsAt : "9999-12-31T23:59:59.999Z",
|
|
2104
|
-
condition: condition(value.condition)
|
|
2105
|
-
};
|
|
2106
|
-
default:
|
|
2107
|
-
return { type: "instant" };
|
|
2108
|
-
}
|
|
2109
|
-
}
|
|
2110
|
-
function migrateSpot(value, index) {
|
|
2111
|
-
const source2 = isObject2(value) ? value : {};
|
|
2112
|
-
const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `spot-${index + 1}`;
|
|
2113
|
-
const description = text2(source2.description);
|
|
2114
|
-
const hint = text2(source2.hint);
|
|
2115
|
-
return {
|
|
2116
|
-
id,
|
|
2117
|
-
name: text2(source2.name) ?? `Spot ${index + 1}`,
|
|
2118
|
-
...description === void 0 ? {} : { description },
|
|
2119
|
-
...hint === void 0 ? {} : { hint },
|
|
2120
|
-
condition: condition(
|
|
2121
|
-
source2.condition ?? (typeof source2.token === "string" ? { type: "token", token: source2.token } : void 0)
|
|
2122
|
-
),
|
|
2123
|
-
...typeof source2.orderIndex === "number" ? { orderIndex: source2.orderIndex } : typeof source2.order === "number" ? { order: source2.order } : {},
|
|
2124
|
-
...typeof source2.deckId === "string" ? { deckId: source2.deckId } : {},
|
|
2125
|
-
...typeof source2.groupId === "string" ? { groupId: source2.groupId } : {},
|
|
2126
|
-
...typeof source2.guideId === "string" ? { guideId: source2.guideId } : {},
|
|
2127
|
-
...typeof source2.iconUrl === "string" ? { iconUrl: source2.iconUrl } : {},
|
|
2128
|
-
...typeof source2.imageUrl === "string" ? { imageUrl: source2.imageUrl } : {},
|
|
2129
|
-
...typeof source2.externalUrl === "string" ? { externalUrl: source2.externalUrl } : {},
|
|
2130
|
-
...typeof source2.redirectUrlAfterClaim === "string" ? { redirectUrlAfterClaim: source2.redirectUrlAfterClaim } : {},
|
|
2131
|
-
...isObject2(source2.metadata) ? { metadata: source2.metadata } : {},
|
|
2132
|
-
...Array.isArray(source2.dependsOn) ? { dependsOn: source2.dependsOn.filter((item) => typeof item === "string") } : {}
|
|
2133
|
-
};
|
|
2134
|
-
}
|
|
2135
|
-
function migrateReward(value, index) {
|
|
2136
|
-
const source2 = isObject2(value) ? value : {};
|
|
2137
|
-
return {
|
|
2138
|
-
id: typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `reward-${index + 1}`,
|
|
2139
|
-
title: text2(source2.title) ?? `Reward ${index + 1}`,
|
|
2140
|
-
description: text2(source2.description) ?? "",
|
|
2141
|
-
type: source2.type === "digital" ? "digital" : "in_person",
|
|
2142
|
-
redemptionMethod: source2.redemptionMethod === "staff_passcode" || source2.redemptionMethod === "view_only" || source2.redemptionMethod === "server_claim" ? source2.redemptionMethod : "manual_slide",
|
|
2143
|
-
requiredStampCount: typeof source2.requiredStampCount === "number" && Number.isFinite(source2.requiredStampCount) ? Math.max(0, Math.trunc(source2.requiredStampCount)) : 0,
|
|
2144
|
-
...typeof source2.digitalContentUrl === "string" ? { digitalContentUrl: source2.digitalContentUrl } : {},
|
|
2145
|
-
...typeof source2.staffPasscode === "string" ? { staffPasscode: source2.staffPasscode } : {},
|
|
2146
|
-
...typeof source2.validUntil === "string" ? { validUntil: source2.validUntil } : {},
|
|
2147
|
-
...typeof source2.maxStock === "number" ? { maxStock: source2.maxStock } : {},
|
|
2148
|
-
...typeof source2.limitPerUser === "number" ? { limitPerUser: source2.limitPerUser } : {},
|
|
2149
|
-
...typeof source2.stockLimit === "number" ? { stockLimit: source2.stockLimit } : {},
|
|
2150
|
-
...typeof source2.userClaimLimit === "number" ? { userClaimLimit: source2.userClaimLimit } : {},
|
|
2151
|
-
...typeof source2.claimTicketNumber === "string" ? { claimTicketNumber: source2.claimTicketNumber } : {}
|
|
2152
|
-
};
|
|
2153
|
-
}
|
|
2154
|
-
function migrateRallyConfig(raw) {
|
|
2155
|
-
const source2 = isObject2(raw) ? raw : {};
|
|
2156
|
-
const rawStamps = Array.isArray(source2.stamps) ? source2.stamps : Array.isArray(source2.spots) ? source2.spots : [];
|
|
2157
|
-
const rawRewards = Array.isArray(source2.rewards) ? source2.rewards : [];
|
|
2158
|
-
const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : "migrated-rally";
|
|
2159
|
-
const title = text2(source2.title);
|
|
2160
|
-
const description = text2(source2.description);
|
|
2161
|
-
const theme = isObject2(source2.theme) ? source2.theme : void 0;
|
|
2162
|
-
return {
|
|
2163
|
-
id,
|
|
2164
|
-
...title === void 0 ? {} : { title },
|
|
2165
|
-
...description === void 0 ? {} : { description },
|
|
2166
|
-
stamps: rawStamps.map(migrateSpot),
|
|
2167
|
-
...rawRewards.length === 0 ? {} : { rewards: rawRewards.map(migrateReward) },
|
|
2168
|
-
...typeof source2.isSequential === "boolean" ? { isSequential: source2.isSequential } : {},
|
|
2169
|
-
...theme === void 0 ? {} : { theme },
|
|
2170
|
-
version: CURRENT_RALLY_CONFIG_VERSION,
|
|
2171
|
-
...typeof source2.startDate === "string" ? { startDate: source2.startDate } : typeof source2.startsAt === "string" ? { startDate: source2.startsAt } : {},
|
|
2172
|
-
...typeof source2.endDate === "string" ? { endDate: source2.endDate } : typeof source2.endsAt === "string" ? { endDate: source2.endsAt } : {}
|
|
1397
|
+
function updateLocalizedField(current, locale, newValue) {
|
|
1398
|
+
if (typeof current === "string" || current === void 0)
|
|
1399
|
+
return { [locale]: newValue };
|
|
1400
|
+
return { ...current, [locale]: newValue };
|
|
1401
|
+
}
|
|
1402
|
+
function resolveLocalizedText(text, locale, fallbackLocale) {
|
|
1403
|
+
if (text === void 0 || text === "") return "";
|
|
1404
|
+
if (typeof text === "string") return text;
|
|
1405
|
+
const fallback = fallbackLocale === void 0 ? Object.values(text).find((value) => typeof value === "string") : text[fallbackLocale];
|
|
1406
|
+
return text[locale] || fallback || "";
|
|
1407
|
+
}
|
|
1408
|
+
function toLocalizedString(text) {
|
|
1409
|
+
if (text === void 0) return { ja: "", en: "" };
|
|
1410
|
+
return typeof text === "string" ? { ja: text, en: "" } : {
|
|
1411
|
+
ja: text["ja"] ?? "",
|
|
1412
|
+
en: text["en"] ?? "",
|
|
1413
|
+
...text
|
|
2173
1414
|
};
|
|
2174
1415
|
}
|
|
2175
1416
|
|
|
@@ -2184,12 +1425,10 @@ var DEFAULT_SHEET_THEME = {
|
|
|
2184
1425
|
unclaimedOpacity: 1,
|
|
2185
1426
|
fontFamily: "serif"
|
|
2186
1427
|
};
|
|
2187
|
-
|
|
2188
|
-
// src/domain/universalModel.ts
|
|
2189
|
-
function toPublicCondition(condition2) {
|
|
1428
|
+
function publicCondition(condition2) {
|
|
2190
1429
|
switch (condition2.type) {
|
|
2191
1430
|
case "qr":
|
|
2192
|
-
return { type: "qr", qrEntryUrl: condition2.qrEntryUrl
|
|
1431
|
+
return condition2.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition2.qrEntryUrl };
|
|
2193
1432
|
case "passcode":
|
|
2194
1433
|
return { type: "passcode" };
|
|
2195
1434
|
case "gps":
|
|
@@ -2199,54 +1438,85 @@ function toPublicCondition(condition2) {
|
|
|
2199
1438
|
longitude: condition2.longitude,
|
|
2200
1439
|
radiusMeters: condition2.radiusMeters
|
|
2201
1440
|
};
|
|
1441
|
+
case "nfc":
|
|
1442
|
+
return { type: "nfc" };
|
|
2202
1443
|
case "custom":
|
|
2203
1444
|
return { type: "custom", validatorName: condition2.validatorName };
|
|
2204
1445
|
}
|
|
2205
1446
|
}
|
|
2206
|
-
function
|
|
1447
|
+
function toPublicConfig(config) {
|
|
2207
1448
|
return {
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
1449
|
+
id: config.id,
|
|
1450
|
+
version: config.version,
|
|
1451
|
+
title: config.title,
|
|
1452
|
+
...config.description === void 0 ? {} : { description: config.description },
|
|
1453
|
+
...config.theme === void 0 ? {} : { theme: config.theme },
|
|
1454
|
+
spots: config.spots.map((spot2) => ({
|
|
1455
|
+
...spot2,
|
|
1456
|
+
conditions: spot2.conditions.map(publicCondition)
|
|
2212
1457
|
})),
|
|
2213
1458
|
rewards: config.rewards.map(
|
|
2214
|
-
({
|
|
2215
|
-
)
|
|
1459
|
+
({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward2 }) => reward2
|
|
1460
|
+
),
|
|
1461
|
+
...config.metadata === void 0 ? {} : { metadata: config.metadata },
|
|
1462
|
+
...config.serverEndpoint === void 0 ? {} : { serverEndpoint: config.serverEndpoint }
|
|
2216
1463
|
};
|
|
2217
1464
|
}
|
|
2218
|
-
function
|
|
1465
|
+
function assertPublicConfig(config) {
|
|
1466
|
+
if (!isPublicConfig(config)) throw new Error("Configuration contains private rally fields.");
|
|
1467
|
+
}
|
|
1468
|
+
function isPublicConfig(value) {
|
|
2219
1469
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
2220
1470
|
const candidate = value;
|
|
2221
|
-
|
|
1471
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1472
|
+
const containsPrivateField = (item) => {
|
|
1473
|
+
if (typeof item !== "object" || item === null) return false;
|
|
1474
|
+
if (seen.has(item)) return false;
|
|
1475
|
+
seen.add(item);
|
|
1476
|
+
if (Array.isArray(item)) return item.some(containsPrivateField);
|
|
1477
|
+
const record = item;
|
|
1478
|
+
if (["staffPasscode", "secretToken", "serverMetadata", "secretParams", "digitalContentUrl"].some(
|
|
1479
|
+
(key) => key in record
|
|
1480
|
+
))
|
|
1481
|
+
return true;
|
|
1482
|
+
return Object.values(record).some(containsPrivateField);
|
|
1483
|
+
};
|
|
1484
|
+
if (containsPrivateField(candidate)) return false;
|
|
1485
|
+
for (const key of [
|
|
1486
|
+
"staffPasscode",
|
|
1487
|
+
"secretToken",
|
|
1488
|
+
"serverMetadata",
|
|
1489
|
+
"secretParams",
|
|
1490
|
+
"code",
|
|
1491
|
+
"tagId"
|
|
1492
|
+
]) {
|
|
1493
|
+
if (key in candidate) return false;
|
|
1494
|
+
}
|
|
1495
|
+
if (typeof candidate.id !== "string" || typeof candidate.version !== "string" || typeof candidate.title !== "string" && (typeof candidate.title !== "object" || candidate.title === null || Array.isArray(candidate.title)) || !Array.isArray(candidate.spots) || !Array.isArray(candidate.rewards))
|
|
2222
1496
|
return false;
|
|
2223
|
-
return
|
|
2224
|
-
if (typeof
|
|
2225
|
-
const
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
1497
|
+
return candidate.spots.every((spot2) => {
|
|
1498
|
+
if (typeof spot2 !== "object" || spot2 === null || Array.isArray(spot2)) return false;
|
|
1499
|
+
const item = spot2;
|
|
1500
|
+
if (typeof item.id !== "string" || typeof item.orderIndex !== "number" || item.name === void 0 || "secretToken" in item || "code" in item || "tagId" in item || "secretParams" in item)
|
|
1501
|
+
return false;
|
|
1502
|
+
return Array.isArray(item.conditions) && item.conditions.every((condition2) => {
|
|
1503
|
+
if (typeof condition2 !== "object" || condition2 === null || Array.isArray(condition2))
|
|
1504
|
+
return false;
|
|
1505
|
+
const value2 = condition2;
|
|
1506
|
+
if ("secretToken" in value2 || "code" in value2 || "tagId" in value2 || "secretParams" in value2)
|
|
1507
|
+
return false;
|
|
1508
|
+
const type = value2.type;
|
|
1509
|
+
if (type === "qr" || type === "passcode" || type === "nfc") return true;
|
|
1510
|
+
if (type === "custom") return typeof value2.validatorName === "string";
|
|
1511
|
+
return type === "gps" && typeof value2.latitude === "number" && typeof value2.longitude === "number" && typeof value2.radiusMeters === "number";
|
|
2230
1512
|
});
|
|
2231
|
-
}) && candidate.rewards.every((
|
|
2232
|
-
if (typeof
|
|
2233
|
-
const item =
|
|
2234
|
-
return item.
|
|
1513
|
+
}) && candidate.rewards.every((reward2) => {
|
|
1514
|
+
if (typeof reward2 !== "object" || reward2 === null || Array.isArray(reward2)) return false;
|
|
1515
|
+
const item = reward2;
|
|
1516
|
+
return typeof item.id === "string" && typeof item.requiredStampCount === "number" && (item.type === "digital" || item.type === "in_person") && (item.redemptionMethod === "manual_slide" || item.redemptionMethod === "staff_passcode" || item.redemptionMethod === "view_only" || item.redemptionMethod === "server_claim") && !("staffPasscode" in item || "digitalContentUrl" in item);
|
|
2235
1517
|
});
|
|
2236
1518
|
}
|
|
2237
1519
|
|
|
2238
|
-
// src/domain/publicConfig.ts
|
|
2239
|
-
function stripSensitiveConfig(config) {
|
|
2240
|
-
if ("spots" in config && !("stamps" in config)) {
|
|
2241
|
-
return toPublicRallyConfig(config);
|
|
2242
|
-
}
|
|
2243
|
-
const rewards = config.rewards?.map(({ staffPasscode: _staffPasscode, ...reward }) => reward);
|
|
2244
|
-
return {
|
|
2245
|
-
...config,
|
|
2246
|
-
...rewards === void 0 ? {} : { rewards }
|
|
2247
|
-
};
|
|
2248
|
-
}
|
|
2249
|
-
|
|
2250
1520
|
// src/domain/themePresets.ts
|
|
2251
1521
|
var THEME_PRESETS = [
|
|
2252
1522
|
{
|
|
@@ -2346,178 +1616,322 @@ var THEME_PRESETS = [
|
|
|
2346
1616
|
}
|
|
2347
1617
|
];
|
|
2348
1618
|
|
|
2349
|
-
// src/domain/
|
|
2350
|
-
|
|
1619
|
+
// src/domain/validation.ts
|
|
1620
|
+
var ConfigValidationError = class extends Error {
|
|
1621
|
+
constructor(errors) {
|
|
1622
|
+
super(errors.map((error) => `${error.path}: ${error.message}`).join("; "));
|
|
1623
|
+
this.errors = errors;
|
|
1624
|
+
}
|
|
1625
|
+
errors;
|
|
1626
|
+
name = "ConfigValidationError";
|
|
1627
|
+
};
|
|
1628
|
+
function isRecord2(value) {
|
|
2351
1629
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2352
1630
|
}
|
|
2353
|
-
function
|
|
2354
|
-
|
|
1631
|
+
function hasOwn(value, key) {
|
|
1632
|
+
return Object.hasOwn(value, key);
|
|
1633
|
+
}
|
|
1634
|
+
function add(errors, path, message, code) {
|
|
1635
|
+
errors.push({ path, message, code });
|
|
1636
|
+
}
|
|
1637
|
+
function requiredString(value, key, path, errors) {
|
|
1638
|
+
if (typeof value[key] !== "string" || value[key].length === 0) {
|
|
1639
|
+
add(errors, `${path}.${key}`, "A non-empty string is required.", "required_string");
|
|
1640
|
+
return false;
|
|
1641
|
+
}
|
|
1642
|
+
return true;
|
|
1643
|
+
}
|
|
1644
|
+
function optionalString(value, key, path, errors) {
|
|
1645
|
+
if (hasOwn(value, key) && value[key] !== void 0 && typeof value[key] !== "string")
|
|
1646
|
+
add(errors, `${path}.${key}`, "Expected a string.", "invalid_type");
|
|
2355
1647
|
}
|
|
2356
|
-
function
|
|
2357
|
-
if (
|
|
2358
|
-
|
|
1648
|
+
function optionalBoolean(value, key, path, errors) {
|
|
1649
|
+
if (hasOwn(value, key) && value[key] !== void 0 && typeof value[key] !== "boolean")
|
|
1650
|
+
add(errors, `${path}.${key}`, "Expected a boolean.", "invalid_type");
|
|
2359
1651
|
}
|
|
2360
|
-
function
|
|
2361
|
-
|
|
2362
|
-
|
|
1652
|
+
function finiteNumber(value, key, path, errors, minimum) {
|
|
1653
|
+
const item = value[key];
|
|
1654
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
1655
|
+
add(errors, `${path}.${key}`, "Expected a finite number.", "invalid_number");
|
|
1656
|
+
} else if (minimum !== void 0 && item < minimum) {
|
|
1657
|
+
add(
|
|
1658
|
+
errors,
|
|
1659
|
+
`${path}.${key}`,
|
|
1660
|
+
`Expected a number greater than or equal to ${minimum}.`,
|
|
1661
|
+
"out_of_range"
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
function localizedText(value, path, errors) {
|
|
1666
|
+
if (typeof value === "string") return;
|
|
1667
|
+
if (!isRecord2(value)) {
|
|
1668
|
+
add(errors, path, "Expected a string or a locale map.", "invalid_localized_text");
|
|
2363
1669
|
return;
|
|
2364
1670
|
}
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
1671
|
+
for (const [locale, text] of Object.entries(value)) {
|
|
1672
|
+
if (typeof text !== "string")
|
|
1673
|
+
add(errors, `${path}.${locale}`, "Expected a string.", "invalid_type");
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
function theme(value, path, errors) {
|
|
1677
|
+
if (!isRecord2(value)) {
|
|
1678
|
+
add(errors, path, "Expected a theme object.", "invalid_type");
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
for (const key of ["primaryColor", "cardBackgroundColor", "textColor"])
|
|
1682
|
+
requiredString(value, key, path, errors);
|
|
1683
|
+
optionalString(value, "backgroundColor", path, errors);
|
|
1684
|
+
optionalString(value, "backgroundImageUrl", path, errors);
|
|
1685
|
+
optionalString(value, "completedStampColor", path, errors);
|
|
1686
|
+
optionalString(value, "fontFamily", path, errors);
|
|
1687
|
+
const slotShape = value.slotShape;
|
|
1688
|
+
if (slotShape !== "circle" && slotShape !== "square" && slotShape !== "rounded")
|
|
1689
|
+
add(errors, `${path}.slotShape`, "Expected circle, square, or rounded.", "invalid_enum");
|
|
1690
|
+
finiteNumber(value, "gridColumns", path, errors, 1);
|
|
1691
|
+
if (typeof value.gridColumns === "number" && !Number.isInteger(value.gridColumns))
|
|
1692
|
+
add(errors, `${path}.gridColumns`, "Expected an integer.", "invalid_integer");
|
|
1693
|
+
if (hasOwn(value, "unclaimedOpacity")) finiteNumber(value, "unclaimedOpacity", path, errors, 0);
|
|
1694
|
+
}
|
|
1695
|
+
function externalReferences(value, path, errors) {
|
|
1696
|
+
if (!Array.isArray(value)) {
|
|
1697
|
+
add(errors, path, "Expected an array.", "invalid_type");
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
value.forEach((item, index) => {
|
|
1701
|
+
const itemPath = `${path}[${index}]`;
|
|
1702
|
+
if (!isRecord2(item)) {
|
|
1703
|
+
add(errors, itemPath, "Expected an object.", "invalid_type");
|
|
2385
1704
|
return;
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
}
|
|
2397
|
-
const
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
1705
|
+
}
|
|
1706
|
+
requiredString(item, "type", itemPath, errors);
|
|
1707
|
+
requiredString(item, "id", itemPath, errors);
|
|
1708
|
+
optionalString(item, "url", itemPath, errors);
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
function condition(value, path, errors, isPublic) {
|
|
1712
|
+
if (!isRecord2(value)) {
|
|
1713
|
+
add(errors, path, "Expected a condition object.", "invalid_type");
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const type = value.type;
|
|
1717
|
+
if (type === "qr") {
|
|
1718
|
+
if (isPublic) {
|
|
1719
|
+
if (hasOwn(value, "secretToken"))
|
|
1720
|
+
add(errors, `${path}.secretToken`, "Private field is not allowed.", "private_field");
|
|
1721
|
+
} else requiredString(value, "secretToken", path, errors);
|
|
1722
|
+
optionalString(value, "qrEntryUrl", path, errors);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
if (type === "passcode") {
|
|
1726
|
+
if (isPublic) {
|
|
1727
|
+
for (const key of ["code", "caseSensitive"])
|
|
1728
|
+
if (hasOwn(value, key))
|
|
1729
|
+
add(errors, `${path}.${key}`, "Private field is not allowed.", "private_field");
|
|
1730
|
+
} else {
|
|
1731
|
+
requiredString(value, "code", path, errors);
|
|
1732
|
+
optionalBoolean(value, "caseSensitive", path, errors);
|
|
1733
|
+
}
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
if (type === "gps") {
|
|
1737
|
+
finiteNumber(value, "latitude", path, errors);
|
|
1738
|
+
finiteNumber(value, "longitude", path, errors);
|
|
1739
|
+
finiteNumber(value, "radiusMeters", path, errors, 0);
|
|
1740
|
+
if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
|
|
1741
|
+
add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
|
|
1742
|
+
if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
|
|
1743
|
+
add(
|
|
2402
1744
|
errors,
|
|
2403
|
-
|
|
2404
|
-
"
|
|
2405
|
-
|
|
1745
|
+
`${path}.longitude`,
|
|
1746
|
+
"Expected a longitude between -180 and 180.",
|
|
1747
|
+
"out_of_range"
|
|
2406
1748
|
);
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
}
|
|
2416
|
-
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
if (type === "nfc") {
|
|
1752
|
+
if (isPublic) {
|
|
1753
|
+
if (hasOwn(value, "tagId"))
|
|
1754
|
+
add(errors, `${path}.tagId`, "Private field is not allowed.", "private_field");
|
|
1755
|
+
} else requiredString(value, "tagId", path, errors);
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
if (type === "custom") {
|
|
1759
|
+
requiredString(value, "validatorName", path, errors);
|
|
1760
|
+
if (isPublic && hasOwn(value, "secretParams"))
|
|
1761
|
+
add(errors, `${path}.secretParams`, "Private field is not allowed.", "private_field");
|
|
1762
|
+
if (!isPublic && hasOwn(value, "secretParams") && !isRecord2(value.secretParams))
|
|
1763
|
+
add(errors, `${path}.secretParams`, "Expected an object.", "invalid_type");
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
add(errors, `${path}.type`, "Unknown condition type.", "invalid_enum");
|
|
2417
1767
|
}
|
|
2418
|
-
function
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
return
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
add2(errors, "id", "REQUIRED", "Rally ID is required.");
|
|
2427
|
-
if (typeof value.version !== "string" || value.version.trim() === "")
|
|
2428
|
-
add2(errors, "version", "INVALID_VERSION", "Version is required.");
|
|
2429
|
-
if (!Array.isArray(value.spots)) {
|
|
2430
|
-
add2(errors, "spots", "INVALID_TYPE", "spots must be an array.");
|
|
2431
|
-
} else {
|
|
2432
|
-
const ids = [];
|
|
2433
|
-
value.spots.forEach((spot, index) => {
|
|
2434
|
-
const path = `spots[${index}]`;
|
|
2435
|
-
if (!isObject3(spot)) {
|
|
2436
|
-
add2(errors, path, "INVALID_TYPE", "Spot must be an object.");
|
|
2437
|
-
return;
|
|
2438
|
-
}
|
|
2439
|
-
if (typeof spot.id !== "string" || spot.id.trim() === "")
|
|
2440
|
-
add2(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
|
|
2441
|
-
else if (ids.includes(spot.id))
|
|
2442
|
-
add2(errors, `${path}.id`, "DUPLICATE_ID", `Duplicate spot ID '${spot.id}'.`);
|
|
2443
|
-
else ids.push(spot.id);
|
|
2444
|
-
if (!hasText2(spot.name)) add2(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
|
|
2445
|
-
if (typeof spot.orderIndex !== "number" || !Number.isInteger(spot.orderIndex))
|
|
2446
|
-
add2(errors, `${path}.orderIndex`, "INVALID_TYPE", "orderIndex must be an integer.");
|
|
2447
|
-
if (!Array.isArray(spot.conditions) || spot.conditions.length === 0)
|
|
2448
|
-
add2(errors, `${path}.conditions`, "REQUIRED", "At least one condition is required.");
|
|
2449
|
-
else {
|
|
2450
|
-
spot.conditions.forEach((condition2, conditionIndex) => {
|
|
2451
|
-
validateCondition2(condition2, `${path}.conditions[${conditionIndex}]`, errors);
|
|
2452
|
-
});
|
|
2453
|
-
}
|
|
2454
|
-
});
|
|
2455
|
-
validateDag2(value.spots, errors);
|
|
2456
|
-
}
|
|
2457
|
-
if (!Array.isArray(value.rewards))
|
|
2458
|
-
add2(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
|
|
2459
|
-
else {
|
|
2460
|
-
const ids = [];
|
|
2461
|
-
value.rewards.forEach((reward, index) => {
|
|
2462
|
-
const path = `rewards[${index}]`;
|
|
2463
|
-
if (!isObject3(reward)) {
|
|
2464
|
-
add2(errors, path, "INVALID_TYPE", "Reward must be an object.");
|
|
2465
|
-
return;
|
|
2466
|
-
}
|
|
2467
|
-
if (typeof reward.id !== "string" || reward.id.trim() === "")
|
|
2468
|
-
add2(errors, `${path}.id`, "REQUIRED", "Reward ID is required.");
|
|
2469
|
-
else if (ids.includes(reward.id))
|
|
2470
|
-
add2(errors, `${path}.id`, "DUPLICATE_ID", `Duplicate reward ID '${reward.id}'.`);
|
|
2471
|
-
else ids.push(reward.id);
|
|
2472
|
-
if (!hasText2(reward.title))
|
|
2473
|
-
add2(errors, `${path}.title`, "REQUIRED", "Reward title is required.");
|
|
2474
|
-
if (typeof reward.requiredStampCount !== "number" || !Number.isInteger(reward.requiredStampCount) || reward.requiredStampCount < 0)
|
|
2475
|
-
add2(
|
|
2476
|
-
errors,
|
|
2477
|
-
`${path}.requiredStampCount`,
|
|
2478
|
-
"INVALID_REWARD",
|
|
2479
|
-
"requiredStampCount must be a non-negative integer."
|
|
2480
|
-
);
|
|
2481
|
-
});
|
|
1768
|
+
function unlockCondition(value, path, errors) {
|
|
1769
|
+
if (!isRecord2(value)) {
|
|
1770
|
+
add(errors, path, "Expected an unlock condition object.", "invalid_type");
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
if (value.type === "stamp_count") {
|
|
1774
|
+
finiteNumber(value, "count", path, errors, 0);
|
|
1775
|
+
return;
|
|
2482
1776
|
}
|
|
2483
|
-
|
|
1777
|
+
if (value.type === "stamps") {
|
|
1778
|
+
if (!Array.isArray(value.stampIds))
|
|
1779
|
+
add(errors, `${path}.stampIds`, "Expected an array.", "invalid_type");
|
|
1780
|
+
else
|
|
1781
|
+
value.stampIds.forEach((item, index) => {
|
|
1782
|
+
if (typeof item !== "string" || item.length === 0)
|
|
1783
|
+
add(
|
|
1784
|
+
errors,
|
|
1785
|
+
`${path}.stampIds[${index}]`,
|
|
1786
|
+
"Expected a non-empty string.",
|
|
1787
|
+
"required_string"
|
|
1788
|
+
);
|
|
1789
|
+
});
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
if (value.type === "all" || value.type === "any") {
|
|
1793
|
+
if (!Array.isArray(value.conditions))
|
|
1794
|
+
add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
|
|
1795
|
+
else
|
|
1796
|
+
value.conditions.forEach((item, index) => {
|
|
1797
|
+
unlockCondition(item, `${path}.conditions[${index}]`, errors);
|
|
1798
|
+
});
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
add(errors, `${path}.type`, "Unknown unlock condition type.", "invalid_enum");
|
|
2484
1802
|
}
|
|
2485
|
-
function
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
return
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
if (
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
);
|
|
2499
|
-
if (
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
1803
|
+
function spot(value, path, errors, isPublic) {
|
|
1804
|
+
if (!isRecord2(value)) {
|
|
1805
|
+
add(errors, path, "Expected a spot object.", "invalid_type");
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
requiredString(value, "id", path, errors);
|
|
1809
|
+
finiteNumber(value, "orderIndex", path, errors, 0);
|
|
1810
|
+
if (typeof value.orderIndex === "number" && !Number.isInteger(value.orderIndex))
|
|
1811
|
+
add(errors, `${path}.orderIndex`, "Expected an integer.", "invalid_integer");
|
|
1812
|
+
localizedText(value.name, `${path}.name`, errors);
|
|
1813
|
+
for (const key of ["description", "hint"])
|
|
1814
|
+
if (hasOwn(value, key)) localizedText(value[key], `${path}.${key}`, errors);
|
|
1815
|
+
for (const key of ["imageUrl", "iconUrl", "redirectUrlAfterClaim"])
|
|
1816
|
+
optionalString(value, key, path, errors);
|
|
1817
|
+
if (hasOwn(value, "externalReferences") && value.externalReferences !== void 0)
|
|
1818
|
+
externalReferences(value.externalReferences, `${path}.externalReferences`, errors);
|
|
1819
|
+
if (hasOwn(value, "prerequisites") && value.prerequisites !== void 0) {
|
|
1820
|
+
if (!Array.isArray(value.prerequisites))
|
|
1821
|
+
add(errors, `${path}.prerequisites`, "Expected an array.", "invalid_type");
|
|
1822
|
+
else
|
|
1823
|
+
value.prerequisites.forEach((item, index) => {
|
|
1824
|
+
if (typeof item !== "string" || item.length === 0)
|
|
1825
|
+
add(
|
|
2507
1826
|
errors,
|
|
2508
|
-
|
|
2509
|
-
"
|
|
2510
|
-
"
|
|
1827
|
+
`${path}.prerequisites[${index}]`,
|
|
1828
|
+
"Expected a non-empty string.",
|
|
1829
|
+
"required_string"
|
|
2511
1830
|
);
|
|
2512
1831
|
});
|
|
1832
|
+
}
|
|
1833
|
+
if (!Array.isArray(value.conditions))
|
|
1834
|
+
add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
|
|
1835
|
+
else
|
|
1836
|
+
value.conditions.forEach((item, index) => {
|
|
1837
|
+
condition(item, `${path}.conditions[${index}]`, errors, isPublic);
|
|
2513
1838
|
});
|
|
2514
|
-
return { valid: errors.length === 0, errors };
|
|
2515
1839
|
}
|
|
2516
|
-
function
|
|
2517
|
-
|
|
1840
|
+
function reward(value, path, errors, isPublic) {
|
|
1841
|
+
if (!isRecord2(value)) {
|
|
1842
|
+
add(errors, path, "Expected a reward object.", "invalid_type");
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
requiredString(value, "id", path, errors);
|
|
1846
|
+
localizedText(value.title, `${path}.title`, errors);
|
|
1847
|
+
if (hasOwn(value, "description")) localizedText(value.description, `${path}.description`, errors);
|
|
1848
|
+
if (value.type !== "digital" && value.type !== "in_person")
|
|
1849
|
+
add(errors, `${path}.type`, "Unknown reward type.", "invalid_enum");
|
|
1850
|
+
if (!["manual_slide", "staff_passcode", "view_only", "server_claim"].includes(
|
|
1851
|
+
String(value.redemptionMethod)
|
|
1852
|
+
))
|
|
1853
|
+
add(errors, `${path}.redemptionMethod`, "Unknown redemption method.", "invalid_enum");
|
|
1854
|
+
finiteNumber(value, "requiredStampCount", path, errors, 0);
|
|
1855
|
+
for (const key of ["stockLimit", "userClaimLimit"]) {
|
|
1856
|
+
if (hasOwn(value, key) && value[key] !== void 0) {
|
|
1857
|
+
finiteNumber(value, key, path, errors, 0);
|
|
1858
|
+
if (typeof value[key] === "number" && !Number.isInteger(value[key]))
|
|
1859
|
+
add(errors, `${path}.${key}`, "Expected an integer.", "invalid_integer");
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
optionalString(value, "validUntil", path, errors);
|
|
1863
|
+
if (typeof value.validUntil === "string" && Number.isNaN(Date.parse(value.validUntil)))
|
|
1864
|
+
add(errors, `${path}.validUntil`, "Expected a valid date string.", "invalid_date");
|
|
1865
|
+
if (isPublic) {
|
|
1866
|
+
for (const key of ["staffPasscode", "digitalContentUrl"])
|
|
1867
|
+
if (hasOwn(value, key))
|
|
1868
|
+
add(errors, `${path}.${key}`, "Private field is not allowed.", "private_field");
|
|
1869
|
+
} else {
|
|
1870
|
+
optionalString(value, "staffPasscode", path, errors);
|
|
1871
|
+
optionalString(value, "digitalContentUrl", path, errors);
|
|
1872
|
+
}
|
|
1873
|
+
if (hasOwn(value, "conditions") && value.conditions !== void 0) {
|
|
1874
|
+
if (!Array.isArray(value.conditions))
|
|
1875
|
+
add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
|
|
1876
|
+
else
|
|
1877
|
+
value.conditions.forEach((item, index) => {
|
|
1878
|
+
unlockCondition(item, `${path}.conditions[${index}]`, errors);
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
function validate(value, isPublic) {
|
|
1883
|
+
const errors = [];
|
|
1884
|
+
if (!isRecord2(value)) {
|
|
1885
|
+
add(errors, "$", "Expected a configuration object.", "invalid_type");
|
|
1886
|
+
return errors;
|
|
1887
|
+
}
|
|
1888
|
+
requiredString(value, "id", "$", errors);
|
|
1889
|
+
requiredString(value, "version", "$", errors);
|
|
1890
|
+
localizedText(value.title, "$.title", errors);
|
|
1891
|
+
if (hasOwn(value, "description")) localizedText(value.description, "$.description", errors);
|
|
1892
|
+
if (hasOwn(value, "theme") && value.theme !== void 0) theme(value.theme, "$.theme", errors);
|
|
1893
|
+
if (!Array.isArray(value.spots)) add(errors, "spots", "Expected an array.", "invalid_type");
|
|
1894
|
+
else
|
|
1895
|
+
value.spots.forEach((item, index) => {
|
|
1896
|
+
spot(item, `spots[${index}]`, errors, isPublic);
|
|
1897
|
+
});
|
|
1898
|
+
if (!Array.isArray(value.rewards)) add(errors, "rewards", "Expected an array.", "invalid_type");
|
|
1899
|
+
else
|
|
1900
|
+
value.rewards.forEach((item, index) => {
|
|
1901
|
+
reward(item, `rewards[${index}]`, errors, isPublic);
|
|
1902
|
+
});
|
|
1903
|
+
if (!isPublic) {
|
|
1904
|
+
optionalString(value, "staffPasscode", "$", errors);
|
|
1905
|
+
if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord2(value.inventory))
|
|
1906
|
+
add(errors, "$.inventory", "Expected an object.", "invalid_type");
|
|
1907
|
+
if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord2(value.serverMetadata))
|
|
1908
|
+
add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
|
|
1909
|
+
optionalString(value, "serverEndpoint", "$", errors);
|
|
1910
|
+
} else {
|
|
1911
|
+
for (const key of ["staffPasscode", "serverMetadata", "inventory"])
|
|
1912
|
+
if (hasOwn(value, key))
|
|
1913
|
+
add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
|
|
1914
|
+
optionalString(value, "serverEndpoint", "$", errors);
|
|
1915
|
+
}
|
|
1916
|
+
return errors;
|
|
1917
|
+
}
|
|
1918
|
+
function safeParseAdminConfig(input) {
|
|
1919
|
+
const errors = validate(input, false);
|
|
1920
|
+
return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
|
|
1921
|
+
}
|
|
1922
|
+
function parseAdminConfig(input) {
|
|
1923
|
+
const result = safeParseAdminConfig(input);
|
|
1924
|
+
if (!result.success) throw new ConfigValidationError(result.errors);
|
|
1925
|
+
return result.data;
|
|
1926
|
+
}
|
|
1927
|
+
function safeParsePublicConfig(input) {
|
|
1928
|
+
const errors = validate(input, true);
|
|
1929
|
+
return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
|
|
2518
1930
|
}
|
|
2519
|
-
function
|
|
2520
|
-
|
|
1931
|
+
function parsePublicConfig(input) {
|
|
1932
|
+
const result = safeParsePublicConfig(input);
|
|
1933
|
+
if (!result.success) throw new ConfigValidationError(result.errors);
|
|
1934
|
+
return result.data;
|
|
2521
1935
|
}
|
|
2522
1936
|
|
|
2523
1937
|
// src/security/snapshotToken.ts
|
|
@@ -2558,9 +1972,9 @@ async function importAesKey(secret) {
|
|
|
2558
1972
|
const digest = await cryptoApi2().subtle.digest("SHA-256", webCryptoBytes(secret));
|
|
2559
1973
|
return cryptoApi2().subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
2560
1974
|
}
|
|
2561
|
-
function isExpired(payload,
|
|
2562
|
-
if (typeof payload.exp === "number" &&
|
|
2563
|
-
return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <=
|
|
1975
|
+
function isExpired(payload, now) {
|
|
1976
|
+
if (typeof payload.exp === "number" && now >= payload.exp * 1e3) return true;
|
|
1977
|
+
return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <= now;
|
|
2564
1978
|
}
|
|
2565
1979
|
async function createSignedSnapshotToken(payload, secretKey) {
|
|
2566
1980
|
const api = cryptoApi2();
|
|
@@ -2580,7 +1994,7 @@ async function createSignedSnapshotToken(payload, secretKey) {
|
|
|
2580
1994
|
);
|
|
2581
1995
|
return `sr2.${body}.${base64Url(signature)}`;
|
|
2582
1996
|
}
|
|
2583
|
-
async function verifySnapshotToken(token, secretKey,
|
|
1997
|
+
async function verifySnapshotToken(token, secretKey, now = Date.now()) {
|
|
2584
1998
|
try {
|
|
2585
1999
|
const parts = token.split(".");
|
|
2586
2000
|
if (parts.length !== 3 || parts[0] !== "sr2") {
|
|
@@ -2622,7 +2036,7 @@ async function verifySnapshotToken(token, secretKey, now2 = Date.now()) {
|
|
|
2622
2036
|
const payload = JSON.parse(new TextDecoder().decode(payloadText));
|
|
2623
2037
|
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
2624
2038
|
throw new Error("Invalid payload.");
|
|
2625
|
-
if (isExpired(payload,
|
|
2039
|
+
if (isExpired(payload, now)) {
|
|
2626
2040
|
return {
|
|
2627
2041
|
ok: false,
|
|
2628
2042
|
valid: false,
|
|
@@ -2639,6 +2053,6 @@ async function verifySnapshotToken(token, secretKey, now2 = Date.now()) {
|
|
|
2639
2053
|
}
|
|
2640
2054
|
}
|
|
2641
2055
|
|
|
2642
|
-
export {
|
|
2056
|
+
export { ConfigValidationError, DEFAULT_SHEET_THEME, InMemoryStorage, IndexedDBAdapter, LocalStorageAdapter, StampRallyClient, StorageAdapterError, THEME_PRESETS, assertPublicConfig, calculateDistanceMeters, calculateProgress, consumeReward, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCondition, evaluateConditionDetailed, exportProgressToken, getCurrentGeoContext, getOrderedSpots, importProgressToken, isGeolocationSupported, isNfcSupported, isPublicConfig, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, normalizePasscode, parseAdminConfig, parsePublicConfig, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, safeParseAdminConfig, safeParsePublicConfig, storageKey, toLocalizedString, toPublicConfig, updateLocalizedField, verifyPasscode, verifySecureToken, verifySnapshotToken };
|
|
2643
2057
|
//# sourceMappingURL=index.js.map
|
|
2644
2058
|
//# sourceMappingURL=index.js.map
|