@stamprally/core 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +469 -1077
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +302 -582
- package/dist/index.d.ts +302 -582
- package/dist/index.js +465 -1067
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -2,252 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
// src/engine/evaluate.ts
|
|
4
4
|
var EARTH_RADIUS_METERS = 6371e3;
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
function assertNever(value) {
|
|
32
|
-
throw new Error(`Unexpected condition: ${JSON.stringify(value)}`);
|
|
33
|
-
}
|
|
34
|
-
function evaluateConditionDetailed(condition2, context, now) {
|
|
35
|
-
switch (condition2.type) {
|
|
36
|
-
case "instant":
|
|
37
|
-
return context.type === "instant" ? { ok: true, value: { conditionType: "instant" } } : contextTypeMismatch("instant", "instant", context.type);
|
|
38
|
-
case "token":
|
|
39
|
-
if (context.type !== "token") {
|
|
40
|
-
return contextTypeMismatch("token", "token", context.type);
|
|
41
|
-
}
|
|
42
|
-
return context.token === condition2.token ? { ok: true, value: { conditionType: "token" } } : {
|
|
43
|
-
ok: false,
|
|
44
|
-
error: {
|
|
45
|
-
code: "CONDITION_MISMATCH",
|
|
46
|
-
conditionType: "token",
|
|
47
|
-
reason: "TOKEN_MISMATCH"
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
case "geo": {
|
|
51
|
-
if (context.type !== "geo") {
|
|
52
|
-
return contextTypeMismatch("geo", "geo", context.type);
|
|
53
|
-
}
|
|
54
|
-
if (!isValidCoordinate(condition2.latitude, condition2.longitude) || !isValidCoordinate(context.currentLatitude, context.currentLongitude) || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters < 0) {
|
|
55
|
-
return {
|
|
56
|
-
ok: false,
|
|
57
|
-
error: {
|
|
58
|
-
code: "CONDITION_MISMATCH",
|
|
59
|
-
conditionType: "geo",
|
|
60
|
-
reason: "INVALID_GEO_INPUT"
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
}
|
|
5
|
+
function calculateDistanceMeters(aLat, aLon, bLat, bLon) {
|
|
6
|
+
const radians = (value2) => value2 * Math.PI / 180;
|
|
7
|
+
const dLat = radians(bLat - aLat);
|
|
8
|
+
const dLon = radians(bLon - aLon);
|
|
9
|
+
const value = Math.sin(dLat / 2) ** 2 + Math.cos(radians(aLat)) * Math.cos(radians(bLat)) * Math.sin(dLon / 2) ** 2;
|
|
10
|
+
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(Math.min(1, value)));
|
|
11
|
+
}
|
|
12
|
+
function mismatch(conditionType, reason, extra = {}) {
|
|
13
|
+
return { ok: false, error: { code: "CONDITION_MISMATCH", conditionType, reason, ...extra } };
|
|
14
|
+
}
|
|
15
|
+
function evaluateConditionDetailed(condition, context) {
|
|
16
|
+
switch (condition.type) {
|
|
17
|
+
case "qr":
|
|
18
|
+
return context.type === "qr" && context.token === condition.secretToken ? { ok: true, value: { conditionType: "qr" } } : mismatch("qr", "INVALID_PROOF");
|
|
19
|
+
case "passcode":
|
|
20
|
+
return context.type === "passcode" && (condition.caseSensitive === false ? context.code.toLocaleLowerCase() === condition.code.toLocaleLowerCase() : context.code === condition.code) ? { ok: true, value: { conditionType: "passcode" } } : mismatch("passcode", "INVALID_PROOF");
|
|
21
|
+
case "nfc":
|
|
22
|
+
return context.type === "nfc" && context.tagId === condition.tagId ? { ok: true, value: { conditionType: "nfc" } } : mismatch("nfc", "INVALID_PROOF");
|
|
23
|
+
case "custom":
|
|
24
|
+
return mismatch("custom", "VALIDATOR_FAILED");
|
|
25
|
+
case "gps": {
|
|
26
|
+
if (!Number.isFinite(condition.latitude) || !Number.isFinite(condition.longitude) || !Number.isFinite(condition.radiusMeters) || condition.radiusMeters < 0 || context.type !== "gps" || !Number.isFinite(context.latitude) || !Number.isFinite(context.longitude))
|
|
27
|
+
return mismatch("gps", "INVALID_GEO_INPUT");
|
|
64
28
|
const distanceMeters = calculateDistanceMeters(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
context.
|
|
68
|
-
context.
|
|
29
|
+
condition.latitude,
|
|
30
|
+
condition.longitude,
|
|
31
|
+
context.latitude,
|
|
32
|
+
context.longitude
|
|
69
33
|
);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
ok: false,
|
|
75
|
-
error: {
|
|
76
|
-
code: "CONDITION_MISMATCH",
|
|
77
|
-
conditionType: "geo",
|
|
78
|
-
reason: "OUTSIDE_RADIUS",
|
|
79
|
-
distanceMeters,
|
|
80
|
-
radiusMeters: condition2.radiusMeters,
|
|
81
|
-
differenceMeters: distanceMeters - condition2.radiusMeters
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
case "composite": {
|
|
86
|
-
if (context.type !== "composite") {
|
|
87
|
-
return contextTypeMismatch("composite", "composite", context.type);
|
|
88
|
-
}
|
|
89
|
-
if (condition2.conditions.length !== context.contexts.length) {
|
|
90
|
-
return {
|
|
91
|
-
ok: false,
|
|
92
|
-
error: {
|
|
93
|
-
code: "CONDITION_MISMATCH",
|
|
94
|
-
conditionType: "composite",
|
|
95
|
-
reason: "CONTEXT_LENGTH_MISMATCH",
|
|
96
|
-
expectedCount: condition2.conditions.length,
|
|
97
|
-
actualCount: context.contexts.length
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
const failures = [];
|
|
102
|
-
let matchedCount = 0;
|
|
103
|
-
for (const [index, childCondition] of condition2.conditions.entries()) {
|
|
104
|
-
const childContext = context.contexts[index];
|
|
105
|
-
if (childContext === void 0) continue;
|
|
106
|
-
const result = evaluateConditionDetailed(childCondition, childContext, now);
|
|
107
|
-
if (result.ok) matchedCount += 1;
|
|
108
|
-
else failures.push({ index, error: result.error });
|
|
109
|
-
}
|
|
110
|
-
if (condition2.operator === "AND" && failures.length === 0) {
|
|
111
|
-
return { ok: true, value: { conditionType: "composite" } };
|
|
112
|
-
}
|
|
113
|
-
if (condition2.operator === "OR" && matchedCount > 0) {
|
|
114
|
-
return { ok: true, value: { conditionType: "composite" } };
|
|
115
|
-
}
|
|
116
|
-
return {
|
|
117
|
-
ok: false,
|
|
118
|
-
error: {
|
|
119
|
-
code: "CONDITION_MISMATCH",
|
|
120
|
-
conditionType: "composite",
|
|
121
|
-
reason: condition2.operator === "AND" ? "AND_CHILD_FAILED" : "OR_ALL_FAILED",
|
|
122
|
-
failures
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
case "time_window": {
|
|
127
|
-
const startsAt = Date.parse(condition2.startsAt);
|
|
128
|
-
const endsAt = Date.parse(condition2.endsAt);
|
|
129
|
-
const currentTime = Date.parse(now);
|
|
130
|
-
if (!Number.isFinite(currentTime)) {
|
|
131
|
-
return {
|
|
132
|
-
ok: false,
|
|
133
|
-
error: {
|
|
134
|
-
code: "CONDITION_MISMATCH",
|
|
135
|
-
conditionType: "time_window",
|
|
136
|
-
reason: "INVALID_NOW",
|
|
137
|
-
now,
|
|
138
|
-
startsAt: condition2.startsAt,
|
|
139
|
-
endsAt: condition2.endsAt
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
if (!Number.isFinite(startsAt) || !Number.isFinite(endsAt) || startsAt > endsAt) {
|
|
144
|
-
return {
|
|
145
|
-
ok: false,
|
|
146
|
-
error: {
|
|
147
|
-
code: "CONDITION_MISMATCH",
|
|
148
|
-
conditionType: "time_window",
|
|
149
|
-
reason: "INVALID_TIME_WINDOW",
|
|
150
|
-
now,
|
|
151
|
-
startsAt: condition2.startsAt,
|
|
152
|
-
endsAt: condition2.endsAt
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
if (currentTime < startsAt || currentTime > endsAt) {
|
|
157
|
-
return {
|
|
158
|
-
ok: false,
|
|
159
|
-
error: {
|
|
160
|
-
code: "CONDITION_MISMATCH",
|
|
161
|
-
conditionType: "time_window",
|
|
162
|
-
reason: currentTime < startsAt ? "BEFORE_START" : "AFTER_END",
|
|
163
|
-
now,
|
|
164
|
-
startsAt: condition2.startsAt,
|
|
165
|
-
endsAt: condition2.endsAt
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
const childResult = evaluateConditionDetailed(condition2.condition, context, now);
|
|
170
|
-
return childResult.ok ? { ok: true, value: { conditionType: "time_window" } } : childResult;
|
|
34
|
+
return distanceMeters <= condition.radiusMeters ? { ok: true, value: { conditionType: "gps", distanceMeters } } : mismatch("gps", "OUTSIDE_RADIUS", {
|
|
35
|
+
distanceMeters,
|
|
36
|
+
radiusMeters: condition.radiusMeters
|
|
37
|
+
});
|
|
171
38
|
}
|
|
172
|
-
default:
|
|
173
|
-
return assertNever(condition2);
|
|
174
39
|
}
|
|
175
40
|
}
|
|
176
|
-
function evaluateCondition(
|
|
177
|
-
return evaluateConditionDetailed(
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// src/engine/checkIn.ts
|
|
181
|
-
function unwrapContext(input) {
|
|
182
|
-
if ("verificationContext" in input) {
|
|
183
|
-
return {
|
|
184
|
-
context: input.verificationContext,
|
|
185
|
-
now: input.now ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
186
|
-
...input.expectedStampId === void 0 ? {} : { expectedStampId: input.expectedStampId },
|
|
187
|
-
alreadyClaimed: input.alreadyClaimed ?? false
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
return {
|
|
191
|
-
context: input,
|
|
192
|
-
now: "now" in input && typeof input.now === "string" ? input.now : (/* @__PURE__ */ new Date()).toISOString(),
|
|
193
|
-
..."expectedStampId" in input && typeof input.expectedStampId === "string" ? { expectedStampId: input.expectedStampId } : {},
|
|
194
|
-
alreadyClaimed: "alreadyClaimed" in input && input.alreadyClaimed === true
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
function evaluateCheckIn(spot, input) {
|
|
198
|
-
const context = unwrapContext(input);
|
|
199
|
-
if (context.alreadyClaimed) {
|
|
200
|
-
return {
|
|
201
|
-
ok: false,
|
|
202
|
-
success: false,
|
|
203
|
-
code: "ALREADY_CLAIMED",
|
|
204
|
-
stampId: spot.id,
|
|
205
|
-
message: "This spot has already been claimed."
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
if (context.expectedStampId !== void 0 && context.expectedStampId !== spot.id) {
|
|
209
|
-
return {
|
|
210
|
-
ok: false,
|
|
211
|
-
success: false,
|
|
212
|
-
code: "ORDER_VIOLATION",
|
|
213
|
-
stampId: spot.id,
|
|
214
|
-
message: `The next required spot is '${context.expectedStampId}'.`
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
const evaluated = evaluateConditionDetailed(spot.condition, context.context, context.now);
|
|
218
|
-
if (evaluated.ok) {
|
|
219
|
-
return { ok: true, success: true, stampId: spot.id, checkedAt: context.now };
|
|
220
|
-
}
|
|
221
|
-
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";
|
|
222
|
-
return { ok: false, success: false, code, stampId: spot.id, message: evaluated.error.reason };
|
|
41
|
+
function evaluateCondition(condition, context) {
|
|
42
|
+
return evaluateConditionDetailed(condition, context).ok;
|
|
223
43
|
}
|
|
224
44
|
|
|
225
45
|
// src/engine/order.ts
|
|
226
|
-
function
|
|
227
|
-
return
|
|
228
|
-
const orderDifference = (left.stamp.orderIndex ?? left.stamp.order ?? Number.POSITIVE_INFINITY) - (right.stamp.orderIndex ?? right.stamp.order ?? Number.POSITIVE_INFINITY);
|
|
229
|
-
return orderDifference === 0 ? left.index - right.index : orderDifference;
|
|
230
|
-
}).map(({ stamp }) => stamp);
|
|
46
|
+
function getOrderedSpots(spots) {
|
|
47
|
+
return [...spots].sort((left, right) => left.orderIndex - right.orderIndex);
|
|
231
48
|
}
|
|
232
49
|
|
|
233
50
|
// src/engine/progress.ts
|
|
234
51
|
function calculateProgress(state, config) {
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
state.records.map((record) => record.stampId).filter((
|
|
52
|
+
const ids = new Set(config.spots.map((spot) => spot.id));
|
|
53
|
+
const acquired = new Set(
|
|
54
|
+
state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
|
|
238
55
|
);
|
|
239
|
-
const
|
|
240
|
-
const acquired = acquiredStampIds.size;
|
|
241
|
-
const isCompleted = total > 0 && acquired === total;
|
|
242
|
-
const remainingStamps = config.stamps.filter((stamp) => !acquiredStampIds.has(stamp.id));
|
|
243
|
-
const nextAvailableStamps = config.isSequential === true ? getOrderedStamps(config).filter((stamp) => !acquiredStampIds.has(stamp.id)).slice(0, 1) : remainingStamps;
|
|
56
|
+
const remaining = config.spots.filter((spot) => !acquired.has(spot.id));
|
|
244
57
|
return {
|
|
245
|
-
acquired,
|
|
246
|
-
total,
|
|
247
|
-
percentage:
|
|
248
|
-
isCompleted,
|
|
249
|
-
|
|
250
|
-
nextAvailableStamps
|
|
58
|
+
acquired: acquired.size,
|
|
59
|
+
total: config.spots.length,
|
|
60
|
+
percentage: config.spots.length === 0 ? 0 : acquired.size / config.spots.length * 100,
|
|
61
|
+
isCompleted: config.spots.length > 0 && acquired.size === config.spots.length,
|
|
62
|
+
nextAvailableSpots: [...remaining].sort((left, right) => left.orderIndex - right.orderIndex)
|
|
251
63
|
};
|
|
252
64
|
}
|
|
253
65
|
|
|
@@ -283,7 +95,7 @@ function createUniqueClaimTicketNumber(rewardId, issuedAt) {
|
|
|
283
95
|
}
|
|
284
96
|
function issueClaimTicketNumber(reward, currentState, options = {}) {
|
|
285
97
|
if (currentState.claimTicketNumber !== void 0) return currentState;
|
|
286
|
-
const claimTicketNumber =
|
|
98
|
+
const claimTicketNumber = createClaimTicketNumber(reward.id, options);
|
|
287
99
|
return { ...currentState, claimTicketNumber };
|
|
288
100
|
}
|
|
289
101
|
|
|
@@ -352,9 +164,9 @@ function getCurrentGeoContext(options = {}) {
|
|
|
352
164
|
try {
|
|
353
165
|
navigator.geolocation.getCurrentPosition(
|
|
354
166
|
(position) => {
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
if (!Number.isFinite(
|
|
167
|
+
const latitude = position.coords.latitude;
|
|
168
|
+
const longitude = position.coords.longitude;
|
|
169
|
+
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90 || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
|
358
170
|
resolve({
|
|
359
171
|
ok: false,
|
|
360
172
|
error: createDetectorError(
|
|
@@ -367,7 +179,7 @@ function getCurrentGeoContext(options = {}) {
|
|
|
367
179
|
}
|
|
368
180
|
resolve({
|
|
369
181
|
ok: true,
|
|
370
|
-
value: { type: "
|
|
182
|
+
value: { type: "gps", latitude, longitude }
|
|
371
183
|
});
|
|
372
184
|
},
|
|
373
185
|
(error) => {
|
|
@@ -475,7 +287,7 @@ function readNfcContext(options = {}) {
|
|
|
475
287
|
fail(createDetectorError("nfc", "NO_TOKEN", "The NFC tag has no text token."));
|
|
476
288
|
return;
|
|
477
289
|
}
|
|
478
|
-
finish({ ok: true, value: { type: "
|
|
290
|
+
finish({ ok: true, value: { type: "nfc", tagId: token } });
|
|
479
291
|
};
|
|
480
292
|
reader.onreadingerror = (event) => fail(createDetectorError("nfc", "READ_FAILED", "The NFC tag could not be read.", event));
|
|
481
293
|
try {
|
|
@@ -491,14 +303,8 @@ function normalizePasscode(input, caseSensitive = false) {
|
|
|
491
303
|
const normalized = input.normalize("NFKC").trim();
|
|
492
304
|
return caseSensitive ? normalized : normalized.toUpperCase();
|
|
493
305
|
}
|
|
494
|
-
function verifyPasscode(inputCode,
|
|
495
|
-
|
|
496
|
-
const expected = normalizePasscode(condition2.passcode, condition2.caseSensitive);
|
|
497
|
-
return input === expected ? { success: true } : {
|
|
498
|
-
success: false,
|
|
499
|
-
reason: "INVALID_PASSCODE",
|
|
500
|
-
message: "The passcode is invalid."
|
|
501
|
-
};
|
|
306
|
+
function verifyPasscode(inputCode, condition) {
|
|
307
|
+
return normalizePasscode(inputCode, condition.caseSensitive) === normalizePasscode(condition.code, condition.caseSensitive) ? { success: true } : { success: false, message: "The passcode is invalid." };
|
|
502
308
|
}
|
|
503
309
|
|
|
504
310
|
// src/detectors/qr.ts
|
|
@@ -605,7 +411,7 @@ async function readQrContext(videoElement, options = {}) {
|
|
|
605
411
|
if (!detection.ok) return detection;
|
|
606
412
|
const token = detection.value.find((barcode) => barcode.rawValue.length > 0)?.rawValue;
|
|
607
413
|
if (token !== void 0) {
|
|
608
|
-
return { ok: true, value: { type: "
|
|
414
|
+
return { ok: true, value: { type: "qr", token } };
|
|
609
415
|
}
|
|
610
416
|
const interval = await raceWithTermination(waitForNextScan(), termination.promise);
|
|
611
417
|
if (!interval.ok) return interval;
|
|
@@ -624,80 +430,47 @@ async function readQrContext(videoElement, options = {}) {
|
|
|
624
430
|
|
|
625
431
|
// src/engine/transition.ts
|
|
626
432
|
function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now) {
|
|
627
|
-
const
|
|
433
|
+
const states = new Map(currentStates.map((state) => [state.rewardId, state]));
|
|
628
434
|
return rewards.map((reward) => {
|
|
629
|
-
const current =
|
|
630
|
-
if (current?.status === "CONSUMED" || current?.status === "EXPIRED")
|
|
631
|
-
|
|
632
|
-
}
|
|
633
|
-
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now)) {
|
|
435
|
+
const current = states.get(reward.id);
|
|
436
|
+
if (current?.status === "CONSUMED" || current?.status === "EXPIRED") return current;
|
|
437
|
+
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now))
|
|
634
438
|
return { rewardId: reward.id, status: "EXPIRED" };
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if (
|
|
638
|
-
return {
|
|
639
|
-
rewardId: reward.id,
|
|
640
|
-
status: "EXPIRED",
|
|
641
|
-
...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
if (acquiredStampCount >= reward.requiredStampCount) {
|
|
645
|
-
if (current?.status === "AVAILABLE") return current;
|
|
439
|
+
if (reward.stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= reward.stockLimit)
|
|
440
|
+
return { rewardId: reward.id, status: "EXPIRED" };
|
|
441
|
+
if (acquiredStampCount >= reward.requiredStampCount)
|
|
646
442
|
return {
|
|
647
443
|
rewardId: reward.id,
|
|
648
444
|
status: "AVAILABLE",
|
|
649
|
-
unlockedAt: current
|
|
650
|
-
...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
|
|
445
|
+
...current?.unlockedAt === void 0 ? { unlockedAt: now } : { unlockedAt: current.unlockedAt }
|
|
651
446
|
};
|
|
652
|
-
}
|
|
653
|
-
if (current?.status === "LOCKED" && current.unlockedAt === void 0) return current;
|
|
654
447
|
return { rewardId: reward.id, status: "LOCKED" };
|
|
655
448
|
});
|
|
656
449
|
}
|
|
657
450
|
function consumeReward(params) {
|
|
658
451
|
const { reward, currentState } = params;
|
|
659
|
-
if (currentState.status === "CONSUMED")
|
|
660
|
-
return {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
if (
|
|
666
|
-
return {
|
|
667
|
-
ok: false,
|
|
668
|
-
error: { code: "NOT_AVAILABLE", rewardId: reward.id }
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(params.now)) {
|
|
672
|
-
return { ok: false, error: { code: "EXPIRED", reason: "EXPIRED", rewardId: reward.id } };
|
|
673
|
-
}
|
|
674
|
-
const stockLimit = reward.stockLimit ?? reward.maxStock;
|
|
675
|
-
const userClaimLimit = reward.userClaimLimit ?? reward.limitPerUser;
|
|
676
|
-
if (stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= stockLimit) {
|
|
452
|
+
if (currentState.status === "CONSUMED")
|
|
453
|
+
return { ok: false, error: { code: "ALREADY_CONSUMED", rewardId: reward.id } };
|
|
454
|
+
if (currentState.status !== "AVAILABLE")
|
|
455
|
+
return { ok: false, error: { code: "NOT_AVAILABLE", rewardId: reward.id } };
|
|
456
|
+
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(params.now))
|
|
457
|
+
return { ok: false, error: { code: "EXPIRED", rewardId: reward.id } };
|
|
458
|
+
if (reward.stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= reward.stockLimit)
|
|
677
459
|
return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward.id } };
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
return {
|
|
681
|
-
ok: false,
|
|
682
|
-
error: { code: "USER_LIMIT_REACHED", reason: "LIMIT_EXCEEDED", rewardId: reward.id }
|
|
683
|
-
};
|
|
684
|
-
}
|
|
460
|
+
if (reward.userClaimLimit !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= reward.userClaimLimit)
|
|
461
|
+
return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId: reward.id } };
|
|
685
462
|
if (reward.redemptionMethod === "staff_passcode") {
|
|
686
|
-
|
|
687
|
-
if (passcodeResult === null || !passcodeResult.success) {
|
|
463
|
+
if (reward.staffPasscode === void 0 || !verifyPasscode(params.inputPasscode ?? "", { code: reward.staffPasscode }).success)
|
|
688
464
|
return {
|
|
689
465
|
ok: false,
|
|
690
466
|
error: {
|
|
691
467
|
code: "INVALID_PASSCODE",
|
|
692
468
|
rewardId: reward.id,
|
|
693
|
-
message:
|
|
469
|
+
message: "The passcode is invalid."
|
|
694
470
|
}
|
|
695
471
|
};
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
if (reward.redemptionMethod === "view_only") {
|
|
699
|
-
return { ok: true, value: currentState };
|
|
700
472
|
}
|
|
473
|
+
if (reward.redemptionMethod === "view_only") return { ok: true, value: currentState };
|
|
701
474
|
return {
|
|
702
475
|
ok: true,
|
|
703
476
|
value: {
|
|
@@ -705,71 +478,40 @@ function consumeReward(params) {
|
|
|
705
478
|
status: "CONSUMED",
|
|
706
479
|
consumedAt: params.now,
|
|
707
480
|
claimTicketNumber: createUniqueClaimTicketNumber(reward.id, params.now),
|
|
708
|
-
|
|
709
|
-
...params.userId === void 0 ? {} : { userRedemptionCount: (currentState.userRedemptionCount ?? 0) + 1 },
|
|
481
|
+
redeemedCount: (currentState.redeemedCount ?? 0) + 1,
|
|
710
482
|
...params.staffId === void 0 ? {} : { consumedByStaffId: params.staffId }
|
|
711
483
|
}
|
|
712
484
|
};
|
|
713
485
|
}
|
|
714
|
-
function processStamp(state, config,
|
|
715
|
-
const
|
|
716
|
-
if (
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const
|
|
720
|
-
if (
|
|
721
|
-
return {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
};
|
|
725
|
-
}
|
|
726
|
-
if (config.isSequential === true) {
|
|
727
|
-
const expectedStamp = getOrderedStamps(config).find((stamp) => !acquiredStampIds.has(stamp.id));
|
|
728
|
-
if (expectedStamp !== void 0 && expectedStamp.id !== targetStampId) {
|
|
486
|
+
function processStamp(state, config, spotId, context, now) {
|
|
487
|
+
const spot = config.spots.find((item) => item.id === spotId);
|
|
488
|
+
if (spot === void 0) return { ok: false, error: { code: "SPOT_NOT_FOUND", spotId } };
|
|
489
|
+
if (state.records.some((record2) => record2.stampId === spotId))
|
|
490
|
+
return { ok: false, error: { code: "STAMP_ALREADY_ACQUIRED", spotId } };
|
|
491
|
+
const acquired = new Set(state.records.map((record2) => record2.stampId));
|
|
492
|
+
if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
493
|
+
return { ok: false, error: { code: "PREREQUISITES_NOT_MET", spotId } };
|
|
494
|
+
for (const condition of spot.conditions) {
|
|
495
|
+
if (condition.type === "custom" || !evaluateConditionDetailed(condition, context).ok)
|
|
729
496
|
return {
|
|
730
497
|
ok: false,
|
|
731
|
-
error: {
|
|
732
|
-
code: "
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
498
|
+
error: condition.type === "custom" ? {
|
|
499
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
500
|
+
spotId,
|
|
501
|
+
message: "Custom validation requires an async validator."
|
|
502
|
+
} : { code: "INVALID_PROOF", spotId }
|
|
736
503
|
};
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
const conditionResult = evaluateConditionDetailed(targetStamp.condition, context, now);
|
|
740
|
-
if (!conditionResult.ok) {
|
|
741
|
-
return {
|
|
742
|
-
ok: false,
|
|
743
|
-
error: {
|
|
744
|
-
code: "CONDITION_MISMATCH",
|
|
745
|
-
stampId: targetStampId,
|
|
746
|
-
mismatch: conditionResult.error
|
|
747
|
-
}
|
|
748
|
-
};
|
|
749
504
|
}
|
|
750
|
-
const record = { stampId:
|
|
751
|
-
const
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
};
|
|
759
|
-
const events = [{ type: "stampAcquired", record }];
|
|
760
|
-
if (nextRewards !== void 0) {
|
|
761
|
-
for (const rewardState of nextRewards) {
|
|
762
|
-
const previous = state.rewards?.find((item) => item.rewardId === rewardState.rewardId);
|
|
763
|
-
if (rewardState.status === "AVAILABLE" && previous?.status !== "AVAILABLE") {
|
|
764
|
-
events.push({ type: "rewardUnlocked", rewardId: rewardState.rewardId, unlockedAt: now });
|
|
765
|
-
}
|
|
505
|
+
const record = { stampId: spotId, acquiredAt: now };
|
|
506
|
+
const records = [...state.records, record];
|
|
507
|
+
const rewards = reconcileRewardStates(config.rewards, state.rewards, records.length, now);
|
|
508
|
+
return {
|
|
509
|
+
ok: true,
|
|
510
|
+
value: {
|
|
511
|
+
nextState: { ...state, records, rewards, updatedAt: now },
|
|
512
|
+
events: [{ type: "stampAcquired", record }]
|
|
766
513
|
}
|
|
767
|
-
}
|
|
768
|
-
const completed = config.stamps.length > 0 && config.stamps.every((stamp) => nextState.records.some((item) => item.stampId === stamp.id));
|
|
769
|
-
if (completed) {
|
|
770
|
-
events.push({ type: "rallyCompleted", rallyId: config.id, completedAt: now });
|
|
771
|
-
}
|
|
772
|
-
return { ok: true, value: { nextState, events } };
|
|
514
|
+
};
|
|
773
515
|
}
|
|
774
516
|
|
|
775
517
|
// src/client/storage.ts
|
|
@@ -815,7 +557,7 @@ function isRewardState(value) {
|
|
|
815
557
|
function isStampRallyState(value) {
|
|
816
558
|
if (typeof value !== "object" || value === null) return false;
|
|
817
559
|
const state = value;
|
|
818
|
-
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));
|
|
560
|
+
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));
|
|
819
561
|
}
|
|
820
562
|
function isValidDate(value) {
|
|
821
563
|
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
@@ -826,7 +568,7 @@ function isSnapshotRecord(value) {
|
|
|
826
568
|
function isRallySnapshot(value) {
|
|
827
569
|
if (typeof value !== "object" || value === null) return false;
|
|
828
570
|
const snapshot = value;
|
|
829
|
-
return snapshot.version === 1 && typeof snapshot.rallyId === "string" && Array.isArray(snapshot.
|
|
571
|
+
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);
|
|
830
572
|
}
|
|
831
573
|
function exportProgressToken(snapshot) {
|
|
832
574
|
return globalThis.btoa(encodeURIComponent(JSON.stringify(snapshot)));
|
|
@@ -837,7 +579,7 @@ function importProgressToken(token, currentRallyId) {
|
|
|
837
579
|
if (!isRallySnapshot(parsed) || parsed.rallyId !== currentRallyId) return null;
|
|
838
580
|
return {
|
|
839
581
|
...parsed,
|
|
840
|
-
|
|
582
|
+
records: parsed.records.map(cloneRecord),
|
|
841
583
|
rewards: parsed.rewards.map(cloneRewardState)
|
|
842
584
|
};
|
|
843
585
|
} catch {
|
|
@@ -846,17 +588,20 @@ function importProgressToken(token, currentRallyId) {
|
|
|
846
588
|
}
|
|
847
589
|
var InMemoryStorage = class {
|
|
848
590
|
#states = /* @__PURE__ */ new Map();
|
|
849
|
-
async load(rallyId) {
|
|
850
|
-
const state = this.#states.get(rallyId);
|
|
591
|
+
async load(rallyId, userId) {
|
|
592
|
+
const state = this.#states.get(storageKey(rallyId, userId));
|
|
851
593
|
return state === void 0 ? null : cloneState(state);
|
|
852
594
|
}
|
|
853
595
|
async save(state) {
|
|
854
|
-
this.#states.set(state.rallyId, cloneState(state));
|
|
596
|
+
this.#states.set(storageKey(state.rallyId, state.userId), cloneState(state));
|
|
855
597
|
}
|
|
856
|
-
async remove(rallyId) {
|
|
857
|
-
this.#states.delete(rallyId);
|
|
598
|
+
async remove(rallyId, userId) {
|
|
599
|
+
this.#states.delete(storageKey(rallyId, userId));
|
|
858
600
|
}
|
|
859
601
|
};
|
|
602
|
+
function storageKey(rallyId, userId) {
|
|
603
|
+
return `stamprally:${rallyId}:${userId ?? "anonymous"}`;
|
|
604
|
+
}
|
|
860
605
|
var defaultStorageWarningHandler = (error) => {
|
|
861
606
|
console.warn(`[@stamprally/core] ${error.message}`, error);
|
|
862
607
|
};
|
|
@@ -873,10 +618,10 @@ var LocalStorageAdapter = class {
|
|
|
873
618
|
this.#failureMode = options.failureMode ?? "fallback";
|
|
874
619
|
this.#onWarning = options.onWarning ?? defaultStorageWarningHandler;
|
|
875
620
|
}
|
|
876
|
-
async load(rallyId) {
|
|
877
|
-
if (this.#isFallbackActive) return this.#fallbackStorage.load(rallyId);
|
|
621
|
+
async load(rallyId, userId) {
|
|
622
|
+
if (this.#isFallbackActive) return this.#fallbackStorage.load(rallyId, userId);
|
|
878
623
|
try {
|
|
879
|
-
const serialized = this.#getStorage("load", rallyId).getItem(this.#key(rallyId));
|
|
624
|
+
const serialized = this.#getStorage("load", rallyId).getItem(this.#key(rallyId, userId));
|
|
880
625
|
if (serialized === null) return null;
|
|
881
626
|
let parsed;
|
|
882
627
|
try {
|
|
@@ -907,7 +652,7 @@ var LocalStorageAdapter = class {
|
|
|
907
652
|
`Failed to read rally '${rallyId}' from localStorage.`,
|
|
908
653
|
rallyId
|
|
909
654
|
),
|
|
910
|
-
() => this.#fallbackStorage.load(rallyId)
|
|
655
|
+
() => this.#fallbackStorage.load(rallyId, userId)
|
|
911
656
|
);
|
|
912
657
|
}
|
|
913
658
|
}
|
|
@@ -915,7 +660,7 @@ var LocalStorageAdapter = class {
|
|
|
915
660
|
if (this.#isFallbackActive) return this.#fallbackStorage.save(state);
|
|
916
661
|
try {
|
|
917
662
|
this.#getStorage("save", state.rallyId).setItem(
|
|
918
|
-
this.#key(state.rallyId),
|
|
663
|
+
this.#key(state.rallyId, state.userId),
|
|
919
664
|
JSON.stringify(state)
|
|
920
665
|
);
|
|
921
666
|
} catch (cause) {
|
|
@@ -931,10 +676,10 @@ var LocalStorageAdapter = class {
|
|
|
931
676
|
);
|
|
932
677
|
}
|
|
933
678
|
}
|
|
934
|
-
async remove(rallyId) {
|
|
935
|
-
if (this.#isFallbackActive) return this.#fallbackStorage.remove(rallyId);
|
|
679
|
+
async remove(rallyId, userId) {
|
|
680
|
+
if (this.#isFallbackActive) return this.#fallbackStorage.remove(rallyId, userId);
|
|
936
681
|
try {
|
|
937
|
-
this.#getStorage("remove", rallyId).removeItem(this.#key(rallyId));
|
|
682
|
+
this.#getStorage("remove", rallyId).removeItem(this.#key(rallyId, userId));
|
|
938
683
|
} catch (cause) {
|
|
939
684
|
return this.#handleFailure(
|
|
940
685
|
this.#normalizeError(
|
|
@@ -944,12 +689,12 @@ var LocalStorageAdapter = class {
|
|
|
944
689
|
`Failed to remove rally '${rallyId}' from localStorage.`,
|
|
945
690
|
rallyId
|
|
946
691
|
),
|
|
947
|
-
() => this.#fallbackStorage.remove(rallyId)
|
|
692
|
+
() => this.#fallbackStorage.remove(rallyId, userId)
|
|
948
693
|
);
|
|
949
694
|
}
|
|
950
695
|
}
|
|
951
|
-
#key(rallyId) {
|
|
952
|
-
return `${this.#keyPrefix}${rallyId}`;
|
|
696
|
+
#key(rallyId, userId) {
|
|
697
|
+
return `${this.#keyPrefix}${rallyId}:${userId ?? "anonymous"}`;
|
|
953
698
|
}
|
|
954
699
|
#getStorage(operation, rallyId) {
|
|
955
700
|
if (this.#providedStorage === null) {
|
|
@@ -1009,12 +754,12 @@ var IndexedDBAdapter = class {
|
|
|
1009
754
|
this.#providedFactory = options.indexedDB;
|
|
1010
755
|
this.#databaseName = options.databaseName ?? "stamprally";
|
|
1011
756
|
}
|
|
1012
|
-
async load(rallyId) {
|
|
757
|
+
async load(rallyId, userId) {
|
|
1013
758
|
const database = await this.#openDatabase(rallyId);
|
|
1014
759
|
return new Promise((resolve, reject) => {
|
|
1015
760
|
try {
|
|
1016
761
|
const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readonly");
|
|
1017
|
-
const request = transaction.objectStore(INDEXED_DB_STORE_NAME).get(rallyId);
|
|
762
|
+
const request = transaction.objectStore(INDEXED_DB_STORE_NAME).get(storageKey(rallyId, userId));
|
|
1018
763
|
request.onsuccess = () => {
|
|
1019
764
|
const value = request.result;
|
|
1020
765
|
if (value === void 0) {
|
|
@@ -1061,7 +806,7 @@ var IndexedDBAdapter = class {
|
|
|
1061
806
|
return new Promise((resolve, reject) => {
|
|
1062
807
|
try {
|
|
1063
808
|
const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readwrite");
|
|
1064
|
-
transaction.objectStore(INDEXED_DB_STORE_NAME).put(cloneState(state), state.rallyId);
|
|
809
|
+
transaction.objectStore(INDEXED_DB_STORE_NAME).put(cloneState(state), storageKey(state.rallyId, state.userId));
|
|
1065
810
|
transaction.oncomplete = () => resolve();
|
|
1066
811
|
transaction.onerror = () => {
|
|
1067
812
|
reject(
|
|
@@ -1086,12 +831,12 @@ var IndexedDBAdapter = class {
|
|
|
1086
831
|
}
|
|
1087
832
|
});
|
|
1088
833
|
}
|
|
1089
|
-
async remove(rallyId) {
|
|
834
|
+
async remove(rallyId, userId) {
|
|
1090
835
|
const database = await this.#openDatabase(rallyId);
|
|
1091
836
|
return new Promise((resolve, reject) => {
|
|
1092
837
|
try {
|
|
1093
838
|
const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readwrite");
|
|
1094
|
-
transaction.objectStore(INDEXED_DB_STORE_NAME).delete(rallyId);
|
|
839
|
+
transaction.objectStore(INDEXED_DB_STORE_NAME).delete(storageKey(rallyId, userId));
|
|
1095
840
|
transaction.oncomplete = () => resolve();
|
|
1096
841
|
transaction.onerror = () => {
|
|
1097
842
|
reject(
|
|
@@ -1199,72 +944,89 @@ var IndexedDBAdapter = class {
|
|
|
1199
944
|
};
|
|
1200
945
|
|
|
1201
946
|
// src/client/client.ts
|
|
1202
|
-
|
|
947
|
+
function isStorage(value) {
|
|
948
|
+
return "load" in value && "save" in value && "remove" in value;
|
|
949
|
+
}
|
|
950
|
+
function id(prefix) {
|
|
951
|
+
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
|
952
|
+
}
|
|
953
|
+
function proof(value) {
|
|
954
|
+
if (typeof value === "string") return value;
|
|
955
|
+
if (typeof value === "object" && value !== null) {
|
|
956
|
+
const item = value;
|
|
957
|
+
for (const key of ["token", "code", "passcode", "value", "tagId"])
|
|
958
|
+
if (typeof item[key] === "string") return item[key];
|
|
959
|
+
}
|
|
960
|
+
return "";
|
|
961
|
+
}
|
|
962
|
+
function matches(condition, value) {
|
|
963
|
+
if (condition.type === "gps") {
|
|
964
|
+
if (typeof value !== "object" || value === null) return false;
|
|
965
|
+
const item = value;
|
|
966
|
+
const latitude = item.latitude;
|
|
967
|
+
const longitude = item.longitude;
|
|
968
|
+
if (typeof latitude !== "number" || typeof longitude !== "number") return false;
|
|
969
|
+
const radians = (v) => v * Math.PI / 180;
|
|
970
|
+
const dLat = radians(latitude - condition.latitude);
|
|
971
|
+
const dLon = radians(longitude - condition.longitude);
|
|
972
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
|
|
973
|
+
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition.radiusMeters;
|
|
974
|
+
}
|
|
975
|
+
return proof(value).trim() !== "";
|
|
976
|
+
}
|
|
977
|
+
function emptyState(config, userId, now) {
|
|
978
|
+
return {
|
|
979
|
+
rallyId: config.id,
|
|
980
|
+
userId,
|
|
981
|
+
records: [],
|
|
982
|
+
rewards: reconcileRewardStates(config.rewards, [], 0, now),
|
|
983
|
+
updatedAt: now
|
|
984
|
+
};
|
|
985
|
+
}
|
|
1203
986
|
var StampRallyClient = class {
|
|
1204
987
|
#listeners = /* @__PURE__ */ new Set();
|
|
1205
988
|
#eventListeners = /* @__PURE__ */ new Set();
|
|
1206
|
-
#config;
|
|
1207
989
|
#storage;
|
|
1208
|
-
#
|
|
1209
|
-
#
|
|
990
|
+
#options;
|
|
991
|
+
#config;
|
|
992
|
+
#userId;
|
|
993
|
+
#state = null;
|
|
1210
994
|
#initialization = null;
|
|
1211
|
-
#
|
|
1212
|
-
constructor(config,
|
|
995
|
+
#queue = Promise.resolve();
|
|
996
|
+
constructor(config, storageOrOptions = {}) {
|
|
1213
997
|
this.#config = config;
|
|
1214
|
-
this.#
|
|
1215
|
-
this.#
|
|
1216
|
-
|
|
1217
|
-
getState() {
|
|
1218
|
-
return this.#currentState;
|
|
998
|
+
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
|
|
999
|
+
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
1000
|
+
this.#userId = this.#options.userId ?? null;
|
|
1219
1001
|
}
|
|
1220
1002
|
getConfig() {
|
|
1221
1003
|
return this.#config;
|
|
1222
1004
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1005
|
+
getState() {
|
|
1006
|
+
return this.#state;
|
|
1007
|
+
}
|
|
1008
|
+
getUserId() {
|
|
1009
|
+
return this.#userId;
|
|
1010
|
+
}
|
|
1011
|
+
subscribe(listener) {
|
|
1228
1012
|
this.#listeners.add(listener);
|
|
1229
|
-
return () =>
|
|
1230
|
-
this.#listeners.delete(listener);
|
|
1231
|
-
};
|
|
1013
|
+
return () => this.#listeners.delete(listener);
|
|
1232
1014
|
}
|
|
1233
1015
|
subscribeEvents(listener) {
|
|
1234
1016
|
this.#eventListeners.add(listener);
|
|
1235
1017
|
return () => this.#eventListeners.delete(listener);
|
|
1236
1018
|
}
|
|
1237
|
-
async updateConfig(newConfig) {
|
|
1238
|
-
return this.#enqueue(async () => {
|
|
1239
|
-
const current = await this.initialize();
|
|
1240
|
-
this.#config = newConfig;
|
|
1241
|
-
const next = this.#reconcileState(cloneState(current), this.#clock());
|
|
1242
|
-
await this.#storage.save(next);
|
|
1243
|
-
this.#currentState = next;
|
|
1244
|
-
this.#initialization = Promise.resolve(next);
|
|
1245
|
-
this.#emit(next);
|
|
1246
|
-
return next;
|
|
1247
|
-
});
|
|
1248
|
-
}
|
|
1249
|
-
notifyRewardClaimed(rewardId, state = this.#currentState) {
|
|
1250
|
-
if (state !== null) this.#emitEvent({ type: "rewardClaimed", rewardId, state });
|
|
1251
|
-
}
|
|
1252
|
-
notifySyncCompleted(state = this.#currentState) {
|
|
1253
|
-
if (state !== null) this.#emitEvent({ type: "syncCompleted", state });
|
|
1254
|
-
}
|
|
1255
1019
|
init() {
|
|
1256
1020
|
return this.initialize();
|
|
1257
1021
|
}
|
|
1258
1022
|
initialize() {
|
|
1259
|
-
if (this.#
|
|
1260
|
-
return Promise.resolve(this.#currentState);
|
|
1261
|
-
}
|
|
1023
|
+
if (this.#state !== null) return Promise.resolve(this.#state);
|
|
1262
1024
|
if (this.#initialization === null) {
|
|
1263
|
-
this.#initialization = this.#storage.load(this.#config.id).then((
|
|
1264
|
-
const
|
|
1265
|
-
this.#
|
|
1266
|
-
this.#emit(
|
|
1267
|
-
return
|
|
1025
|
+
this.#initialization = this.#storage.load(this.#config.id, this.#userId).then((state) => {
|
|
1026
|
+
const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
|
|
1027
|
+
this.#state = next;
|
|
1028
|
+
this.#emit(next);
|
|
1029
|
+
return next;
|
|
1268
1030
|
}).catch((error) => {
|
|
1269
1031
|
this.#initialization = null;
|
|
1270
1032
|
throw error;
|
|
@@ -1272,103 +1034,233 @@ var StampRallyClient = class {
|
|
|
1272
1034
|
}
|
|
1273
1035
|
return this.#initialization;
|
|
1274
1036
|
}
|
|
1275
|
-
|
|
1037
|
+
switchUser(newUserId) {
|
|
1276
1038
|
return this.#enqueue(async () => {
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1039
|
+
if (this.#userId === newUserId && this.#state !== null) return this.#state;
|
|
1040
|
+
this.#userId = newUserId;
|
|
1041
|
+
this.#state = null;
|
|
1042
|
+
this.#initialization = null;
|
|
1043
|
+
return this.initialize();
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
async getUserState(rallyId, userId) {
|
|
1047
|
+
return this.#storage.load(rallyId, userId);
|
|
1048
|
+
}
|
|
1049
|
+
clearUserState(userId = this.#userId) {
|
|
1050
|
+
return this.#enqueue(async () => {
|
|
1051
|
+
await this.#storage.remove(this.#config.id, userId);
|
|
1052
|
+
if (userId === this.#userId) {
|
|
1053
|
+
await this.#initializeFresh();
|
|
1282
1054
|
}
|
|
1283
|
-
await this.#storage.save(result.value.nextState);
|
|
1284
|
-
this.#currentState = result.value.nextState;
|
|
1285
|
-
this.#emit(result.value.nextState);
|
|
1286
|
-
this.#emitEvent({ type: "checkIn", stampId, state: result.value.nextState });
|
|
1287
|
-
return result;
|
|
1288
1055
|
});
|
|
1289
1056
|
}
|
|
1290
|
-
|
|
1057
|
+
checkIn(spotId, proofData, options = {}) {
|
|
1291
1058
|
return this.#enqueue(async () => {
|
|
1292
|
-
const
|
|
1293
|
-
|
|
1294
|
-
|
|
1059
|
+
const current = await this.initialize();
|
|
1060
|
+
const spot = this.#config.spots.find((item) => item.id === spotId);
|
|
1061
|
+
if (spot === void 0)
|
|
1062
|
+
return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
|
|
1063
|
+
if (current.records.some((record2) => record2.stampId === spotId))
|
|
1064
|
+
return this.#fail({
|
|
1065
|
+
code: "STAMP_ALREADY_ACQUIRED",
|
|
1066
|
+
spotId,
|
|
1067
|
+
message: "Spot was already claimed."
|
|
1068
|
+
});
|
|
1069
|
+
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
1070
|
+
if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
1071
|
+
return this.#fail({
|
|
1072
|
+
code: "PREREQUISITES_NOT_MET",
|
|
1073
|
+
spotId,
|
|
1074
|
+
message: "Prerequisite spots are not complete."
|
|
1075
|
+
});
|
|
1076
|
+
for (const condition of spot.conditions) {
|
|
1077
|
+
if (condition.type === "custom") {
|
|
1078
|
+
const validator = this.#options.customValidators?.[condition.validatorName] ?? this.#options.customValidator;
|
|
1079
|
+
if (validator === void 0)
|
|
1080
|
+
return this.#fail({
|
|
1081
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1082
|
+
spotId,
|
|
1083
|
+
message: "No custom validator is registered."
|
|
1084
|
+
});
|
|
1085
|
+
const context = {
|
|
1086
|
+
rallyId: this.#config.id,
|
|
1087
|
+
spotId,
|
|
1088
|
+
proofData,
|
|
1089
|
+
condition: { type: "custom", validatorName: condition.validatorName },
|
|
1090
|
+
userState: current
|
|
1091
|
+
};
|
|
1092
|
+
const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
|
|
1093
|
+
if (result === false || typeof result === "object" && !result.valid)
|
|
1094
|
+
return this.#fail({
|
|
1095
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1096
|
+
spotId,
|
|
1097
|
+
message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
|
|
1098
|
+
});
|
|
1099
|
+
} else if (!matches(condition, proofData))
|
|
1100
|
+
return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
|
|
1101
|
+
}
|
|
1102
|
+
const now = options.now ?? this.#now();
|
|
1103
|
+
const request = {
|
|
1104
|
+
rallyId: this.#config.id,
|
|
1105
|
+
userId: this.#userId,
|
|
1106
|
+
spotId,
|
|
1107
|
+
proofData,
|
|
1108
|
+
idempotencyKey: options.idempotencyKey ?? id("check-in"),
|
|
1109
|
+
now,
|
|
1110
|
+
state: current
|
|
1111
|
+
};
|
|
1112
|
+
const remote = this.#options.syncAdapter?.checkIn;
|
|
1113
|
+
if (options.sync !== false && remote !== void 0) {
|
|
1114
|
+
const result = await remote(request);
|
|
1115
|
+
return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
|
|
1295
1116
|
}
|
|
1296
|
-
|
|
1297
|
-
const
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1117
|
+
const record = { stampId: spotId, acquiredAt: now };
|
|
1118
|
+
const next = this.#reconcile({
|
|
1119
|
+
...current,
|
|
1120
|
+
records: [...current.records, record],
|
|
1121
|
+
updatedAt: now
|
|
1122
|
+
});
|
|
1123
|
+
await this.#storage.save(next);
|
|
1124
|
+
return this.#commitCheckIn({ ok: true, value: { state: next, record } });
|
|
1302
1125
|
});
|
|
1303
1126
|
}
|
|
1304
|
-
|
|
1127
|
+
claimReward(rewardId, options = {}) {
|
|
1305
1128
|
return this.#enqueue(async () => {
|
|
1306
|
-
const
|
|
1307
|
-
|
|
1308
|
-
|
|
1129
|
+
const current = await this.initialize();
|
|
1130
|
+
const configured = this.#config.rewards.find((item) => item.id === rewardId);
|
|
1131
|
+
if (configured === void 0)
|
|
1132
|
+
return this.#fail({ code: "REWARD_NOT_FOUND", rewardId, message: "Reward was not found." });
|
|
1133
|
+
const now = options.now ?? this.#now();
|
|
1134
|
+
const state = current.rewards.find((item) => item.rewardId === rewardId) ?? {
|
|
1135
|
+
rewardId,
|
|
1136
|
+
status: "LOCKED"
|
|
1137
|
+
};
|
|
1138
|
+
const local = consumeReward({
|
|
1139
|
+
reward: configured,
|
|
1140
|
+
currentState: state,
|
|
1141
|
+
now,
|
|
1142
|
+
...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
|
|
1143
|
+
...options.staffId === void 0 ? {} : { staffId: options.staffId }
|
|
1144
|
+
});
|
|
1145
|
+
if (!local.ok) return this.#fail(local.error);
|
|
1146
|
+
const request = {
|
|
1147
|
+
rallyId: this.#config.id,
|
|
1148
|
+
userId: this.#userId,
|
|
1149
|
+
rewardId,
|
|
1150
|
+
idempotencyKey: options.idempotencyKey ?? id("claim"),
|
|
1151
|
+
now,
|
|
1152
|
+
options,
|
|
1153
|
+
state: current
|
|
1154
|
+
};
|
|
1155
|
+
const remote = this.#options.syncAdapter?.claimReward;
|
|
1156
|
+
if (options.sync !== false && remote !== void 0) {
|
|
1157
|
+
const result = await remote(request);
|
|
1158
|
+
return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
|
|
1309
1159
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1160
|
+
const next = {
|
|
1161
|
+
...current,
|
|
1162
|
+
rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
|
|
1163
|
+
updatedAt: now
|
|
1164
|
+
};
|
|
1165
|
+
await this.#storage.save(next);
|
|
1166
|
+
return this.#commitClaim({ ok: true, value: { state: next, reward: local.value } });
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
sync(adapter = this.#options.syncAdapter) {
|
|
1170
|
+
return this.#enqueue(async () => {
|
|
1171
|
+
const current = await this.initialize();
|
|
1172
|
+
if (adapter?.sync === void 0) {
|
|
1173
|
+
this.#emitEvent({ type: "sync", state: current });
|
|
1174
|
+
return;
|
|
1314
1175
|
}
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
this.#
|
|
1319
|
-
this.#
|
|
1320
|
-
|
|
1176
|
+
const next = this.#reconcile(
|
|
1177
|
+
await adapter.sync({ rallyId: this.#config.id, userId: this.#userId, state: current })
|
|
1178
|
+
);
|
|
1179
|
+
await this.#storage.save(next);
|
|
1180
|
+
this.#state = next;
|
|
1181
|
+
this.#emit(next);
|
|
1182
|
+
this.#emitEvent({ type: "sync", state: next });
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
reset() {
|
|
1186
|
+
return this.#enqueue(async () => {
|
|
1187
|
+
await this.#storage.remove(this.#config.id, this.#userId);
|
|
1188
|
+
return this.#initializeFresh();
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
restore(state) {
|
|
1192
|
+
return this.#enqueue(async () => {
|
|
1193
|
+
if (state.rallyId !== this.#config.id || state.userId !== this.#userId)
|
|
1194
|
+
throw new Error("State belongs to another rally or user.");
|
|
1195
|
+
const next = this.#reconcile(state);
|
|
1196
|
+
await this.#storage.save(next);
|
|
1197
|
+
this.#state = next;
|
|
1198
|
+
this.#initialization = Promise.resolve(next);
|
|
1199
|
+
this.#emit(next);
|
|
1200
|
+
return next;
|
|
1321
1201
|
});
|
|
1322
1202
|
}
|
|
1323
1203
|
#enqueue(operation) {
|
|
1324
|
-
const next = this.#
|
|
1325
|
-
this.#
|
|
1204
|
+
const next = this.#queue.then(operation, operation);
|
|
1205
|
+
this.#queue = next.then(
|
|
1326
1206
|
() => void 0,
|
|
1327
1207
|
() => void 0
|
|
1328
1208
|
);
|
|
1329
1209
|
return next;
|
|
1330
1210
|
}
|
|
1331
|
-
#
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
...this.#config.rewards === void 0 ? {} : {
|
|
1344
|
-
rewards: reconcileRewardStates(this.#config.rewards, [], 0, now)
|
|
1345
|
-
},
|
|
1346
|
-
updatedAt: now
|
|
1347
|
-
};
|
|
1348
|
-
return state;
|
|
1349
|
-
}
|
|
1350
|
-
#reconcileState(state, now) {
|
|
1351
|
-
const configuredStampIds = new Set(this.#config.stamps.map((stamp) => stamp.id));
|
|
1352
|
-
const seenStampIds = /* @__PURE__ */ new Set();
|
|
1353
|
-
const records = state.records.filter((record) => {
|
|
1354
|
-
if (!configuredStampIds.has(record.stampId) || seenStampIds.has(record.stampId)) return false;
|
|
1355
|
-
seenStampIds.add(record.stampId);
|
|
1356
|
-
return true;
|
|
1357
|
-
});
|
|
1358
|
-
if (this.#config.rewards === void 0 && state.rewards === void 0) {
|
|
1359
|
-
return records.length === state.records.length ? state : { ...state, records };
|
|
1360
|
-
}
|
|
1211
|
+
#initializeFresh() {
|
|
1212
|
+
const next = emptyState(this.#config, this.#userId, this.#now());
|
|
1213
|
+
this.#state = next;
|
|
1214
|
+
this.#initialization = Promise.resolve(next);
|
|
1215
|
+
this.#emit(next);
|
|
1216
|
+
return Promise.resolve(next);
|
|
1217
|
+
}
|
|
1218
|
+
#reconcile(state) {
|
|
1219
|
+
const ids = new Set(this.#config.spots.map((spot) => spot.id));
|
|
1220
|
+
const records = state.records.filter(
|
|
1221
|
+
(record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
|
|
1222
|
+
);
|
|
1361
1223
|
return {
|
|
1362
|
-
...state,
|
|
1224
|
+
...cloneState(state),
|
|
1225
|
+
userId: this.#userId,
|
|
1363
1226
|
records,
|
|
1364
1227
|
rewards: reconcileRewardStates(
|
|
1365
|
-
this.#config.rewards
|
|
1366
|
-
state.rewards
|
|
1228
|
+
this.#config.rewards,
|
|
1229
|
+
state.rewards,
|
|
1367
1230
|
records.length,
|
|
1368
|
-
|
|
1231
|
+
state.updatedAt
|
|
1369
1232
|
)
|
|
1370
1233
|
};
|
|
1371
1234
|
}
|
|
1235
|
+
#now() {
|
|
1236
|
+
return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1237
|
+
}
|
|
1238
|
+
#fail(error) {
|
|
1239
|
+
this.#emitEvent({ type: "error", error });
|
|
1240
|
+
return { ok: false, error };
|
|
1241
|
+
}
|
|
1242
|
+
#commitCheckIn(result) {
|
|
1243
|
+
if (result.ok) {
|
|
1244
|
+
this.#state = result.value.state;
|
|
1245
|
+
this.#emit(this.#state);
|
|
1246
|
+
}
|
|
1247
|
+
this.#emitEvent({ type: "checkIn", result });
|
|
1248
|
+
return result;
|
|
1249
|
+
}
|
|
1250
|
+
#commitClaim(result) {
|
|
1251
|
+
if (result.ok) {
|
|
1252
|
+
this.#state = result.value.state;
|
|
1253
|
+
this.#emit(this.#state);
|
|
1254
|
+
}
|
|
1255
|
+
this.#emitEvent({ type: "rewardClaimed", result });
|
|
1256
|
+
return result;
|
|
1257
|
+
}
|
|
1258
|
+
#emit(state) {
|
|
1259
|
+
for (const listener of this.#listeners) listener(state);
|
|
1260
|
+
}
|
|
1261
|
+
#emitEvent(event) {
|
|
1262
|
+
for (const listener of this.#eventListeners) listener(event);
|
|
1263
|
+
}
|
|
1372
1264
|
};
|
|
1373
1265
|
|
|
1374
1266
|
// src/crypto/token.ts
|
|
@@ -1504,367 +1396,18 @@ async function decryptPayload(body, secret) {
|
|
|
1504
1396
|
}
|
|
1505
1397
|
|
|
1506
1398
|
// src/domain/i18n.ts
|
|
1507
|
-
function resolveLocalizedText(
|
|
1508
|
-
if (
|
|
1509
|
-
if (typeof
|
|
1510
|
-
const fallback = fallbackLocale === void 0 ? Object.values(
|
|
1511
|
-
return
|
|
1512
|
-
}
|
|
1513
|
-
function toLocalizedString(
|
|
1514
|
-
if (
|
|
1515
|
-
return typeof
|
|
1516
|
-
ja:
|
|
1517
|
-
en:
|
|
1518
|
-
...
|
|
1519
|
-
};
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
// src/domain/validation.ts
|
|
1523
|
-
var CURRENT_RALLY_CONFIG_VERSION = 2;
|
|
1524
|
-
function isObject(value) {
|
|
1525
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1526
|
-
}
|
|
1527
|
-
function add(errors, path, code, message) {
|
|
1528
|
-
errors.push({ path, code, message });
|
|
1529
|
-
}
|
|
1530
|
-
function hasText(value) {
|
|
1531
|
-
if (typeof value === "string") return value.trim() !== "";
|
|
1532
|
-
if (!isObject(value)) return false;
|
|
1533
|
-
return Object.values(value).some((item) => typeof item === "string" && item.trim() !== "");
|
|
1534
|
-
}
|
|
1535
|
-
function validateCondition(value, path, errors) {
|
|
1536
|
-
if (!isObject(value) || typeof value.type !== "string") {
|
|
1537
|
-
add(errors, path, "INVALID_TYPE", "Condition must be an object with a type.");
|
|
1538
|
-
return;
|
|
1539
|
-
}
|
|
1540
|
-
switch (value.type) {
|
|
1541
|
-
case "instant":
|
|
1542
|
-
return;
|
|
1543
|
-
case "token":
|
|
1544
|
-
if (typeof value.token !== "string" || value.token.trim() === "") {
|
|
1545
|
-
add(errors, `${path}.token`, "REQUIRED", "Token must not be empty.");
|
|
1546
|
-
}
|
|
1547
|
-
return;
|
|
1548
|
-
case "geo": {
|
|
1549
|
-
const latitude = value.latitude;
|
|
1550
|
-
const longitude = value.longitude;
|
|
1551
|
-
if (typeof latitude !== "number" || !Number.isFinite(latitude) || latitude < -90 || latitude > 90 || typeof longitude !== "number" || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
|
1552
|
-
add(
|
|
1553
|
-
errors,
|
|
1554
|
-
path,
|
|
1555
|
-
"INVALID_COORDINATES",
|
|
1556
|
-
"Latitude must be between -90 and 90 and longitude between -180 and 180."
|
|
1557
|
-
);
|
|
1558
|
-
}
|
|
1559
|
-
if (typeof value.radiusMeters !== "number" || !Number.isFinite(value.radiusMeters) || value.radiusMeters <= 0) {
|
|
1560
|
-
add(errors, `${path}.radiusMeters`, "INVALID_RADIUS", "Radius must be greater than zero.");
|
|
1561
|
-
}
|
|
1562
|
-
return;
|
|
1563
|
-
}
|
|
1564
|
-
case "composite":
|
|
1565
|
-
if (value.operator !== "AND" && value.operator !== "OR") {
|
|
1566
|
-
add(errors, `${path}.operator`, "INVALID_TYPE", "Operator must be AND or OR.");
|
|
1567
|
-
}
|
|
1568
|
-
if (!Array.isArray(value.conditions)) {
|
|
1569
|
-
add(errors, `${path}.conditions`, "INVALID_TYPE", "Composite conditions must be an array.");
|
|
1570
|
-
return;
|
|
1571
|
-
}
|
|
1572
|
-
value.conditions.forEach((child, index) => {
|
|
1573
|
-
validateCondition(child, `${path}.conditions[${index}]`, errors);
|
|
1574
|
-
});
|
|
1575
|
-
return;
|
|
1576
|
-
case "time_window":
|
|
1577
|
-
if (typeof value.startsAt !== "string" || Number.isNaN(Date.parse(value.startsAt))) {
|
|
1578
|
-
add(errors, `${path}.startsAt`, "INVALID_DATE", "Start must be a valid ISO date.");
|
|
1579
|
-
}
|
|
1580
|
-
if (typeof value.endsAt !== "string" || Number.isNaN(Date.parse(value.endsAt))) {
|
|
1581
|
-
add(errors, `${path}.endsAt`, "INVALID_DATE", "End must be a valid ISO date.");
|
|
1582
|
-
}
|
|
1583
|
-
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)) {
|
|
1584
|
-
add(errors, path, "INVALID_DATE", "Time window start must be before its end.");
|
|
1585
|
-
}
|
|
1586
|
-
validateCondition(value.condition, `${path}.condition`, errors);
|
|
1587
|
-
return;
|
|
1588
|
-
default:
|
|
1589
|
-
add(errors, `${path}.type`, "INVALID_TYPE", "Unsupported condition type.");
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
function validateSpot(value, index, errors) {
|
|
1593
|
-
const path = `stamps[${index}]`;
|
|
1594
|
-
if (!isObject(value)) {
|
|
1595
|
-
add(errors, path, "INVALID_TYPE", "Spot must be an object.");
|
|
1596
|
-
return;
|
|
1597
|
-
}
|
|
1598
|
-
if (typeof value.id !== "string") add(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
|
|
1599
|
-
else if (value.id.trim() === "")
|
|
1600
|
-
add(errors, `${path}.id`, "EMPTY_STRING", "Spot ID must not be empty.");
|
|
1601
|
-
if (value.name === void 0) add(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
|
|
1602
|
-
else if (!hasText(value.name))
|
|
1603
|
-
add(errors, `${path}.name`, "EMPTY_STRING", "Spot name must not be empty.");
|
|
1604
|
-
if (value.order !== void 0 && (typeof value.order !== "number" || !Number.isFinite(value.order))) {
|
|
1605
|
-
add(errors, `${path}.order`, "INVALID_TYPE", "Spot order must be a finite number.");
|
|
1606
|
-
}
|
|
1607
|
-
validateCondition(value.condition, `${path}.condition`, errors);
|
|
1608
|
-
for (const field of ["dependsOn", "requiresStampIds"]) {
|
|
1609
|
-
if (value[field] !== void 0 && (!Array.isArray(value[field]) || value[field].some((item) => typeof item !== "string"))) {
|
|
1610
|
-
add(errors, `${path}.${field}`, "INVALID_TYPE", `${field} must be an array of IDs.`);
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
for (const field of ["order", "orderIndex"]) {
|
|
1614
|
-
if (value[field] !== void 0 && (typeof value[field] !== "number" || !Number.isFinite(value[field]))) {
|
|
1615
|
-
add(errors, `${path}.${field}`, "INVALID_TYPE", `${field} must be a finite number.`);
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
}
|
|
1619
|
-
function validateReward(value, index, stampCount, errors) {
|
|
1620
|
-
const path = `rewards[${index}]`;
|
|
1621
|
-
if (!isObject(value)) {
|
|
1622
|
-
add(errors, path, "INVALID_TYPE", "Reward must be an object.");
|
|
1623
|
-
return;
|
|
1624
|
-
}
|
|
1625
|
-
if (typeof value.id !== "string" || value.id.trim() === "") {
|
|
1626
|
-
add(errors, `${path}.id`, "REQUIRED", "Reward ID must not be empty.");
|
|
1627
|
-
}
|
|
1628
|
-
if (!hasText(value.title)) {
|
|
1629
|
-
add(errors, `${path}.title`, "EMPTY_STRING", "Reward title must not be empty.");
|
|
1630
|
-
}
|
|
1631
|
-
if (!hasText(value.description)) {
|
|
1632
|
-
add(errors, `${path}.description`, "EMPTY_STRING", "Reward description must not be empty.");
|
|
1633
|
-
}
|
|
1634
|
-
const required = value.requiredStampCount;
|
|
1635
|
-
if (typeof required !== "number" || !Number.isInteger(required) || required < 0 || required > stampCount) {
|
|
1636
|
-
add(
|
|
1637
|
-
errors,
|
|
1638
|
-
`${path}.requiredStampCount`,
|
|
1639
|
-
"INVALID_REWARD",
|
|
1640
|
-
"Required stamps must be an integer within the rally."
|
|
1641
|
-
);
|
|
1642
|
-
}
|
|
1643
|
-
if (value.validUntil !== void 0 && (typeof value.validUntil !== "string" || Number.isNaN(Date.parse(value.validUntil)))) {
|
|
1644
|
-
add(errors, `${path}.validUntil`, "INVALID_DATE", "Reward expiry must be a valid ISO date.");
|
|
1645
|
-
}
|
|
1646
|
-
for (const field of ["maxStock", "limitPerUser", "stockLimit", "userClaimLimit"]) {
|
|
1647
|
-
const count = value[field];
|
|
1648
|
-
if (count !== void 0 && (typeof count !== "number" || !Number.isInteger(count) || count <= 0)) {
|
|
1649
|
-
add(errors, `${path}.${field}`, "INVALID_REWARD", `${field} must be a positive integer.`);
|
|
1650
|
-
}
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
function collectDependencies(spot) {
|
|
1654
|
-
const dependencies = /* @__PURE__ */ new Set();
|
|
1655
|
-
for (const field of ["dependsOn", "requiresStampIds"]) {
|
|
1656
|
-
const values = spot[field];
|
|
1657
|
-
if (Array.isArray(values)) {
|
|
1658
|
-
for (const value of values) if (typeof value === "string") dependencies.add(value);
|
|
1659
|
-
}
|
|
1660
|
-
}
|
|
1661
|
-
return [...dependencies];
|
|
1662
|
-
}
|
|
1663
|
-
function validateDag(stamps, errors) {
|
|
1664
|
-
const graph = /* @__PURE__ */ new Map();
|
|
1665
|
-
for (const value of stamps) {
|
|
1666
|
-
if (!isObject(value) || typeof value.id !== "string") continue;
|
|
1667
|
-
graph.set(value.id, [...graph.get(value.id) ?? [], ...collectDependencies(value)]);
|
|
1668
|
-
}
|
|
1669
|
-
const visiting = /* @__PURE__ */ new Set();
|
|
1670
|
-
const visited = /* @__PURE__ */ new Set();
|
|
1671
|
-
const visit = (id, path) => {
|
|
1672
|
-
if (visiting.has(id)) {
|
|
1673
|
-
add(errors, path, "CYCLE_DETECTED", `Dependency cycle detected at '${id}'.`);
|
|
1674
|
-
return;
|
|
1675
|
-
}
|
|
1676
|
-
if (visited.has(id)) return;
|
|
1677
|
-
visiting.add(id);
|
|
1678
|
-
for (const dependency of graph.get(id) ?? [])
|
|
1679
|
-
if (graph.has(dependency)) visit(dependency, path);
|
|
1680
|
-
visiting.delete(id);
|
|
1681
|
-
visited.add(id);
|
|
1682
|
-
};
|
|
1683
|
-
for (const id of graph.keys()) visit(id, `stamps[${id}]`);
|
|
1684
|
-
}
|
|
1685
|
-
function validateRallyConfig(config) {
|
|
1686
|
-
const errors = [];
|
|
1687
|
-
if (!isObject(config))
|
|
1688
|
-
return {
|
|
1689
|
-
valid: false,
|
|
1690
|
-
errors: [{ path: "", code: "INVALID_TYPE", message: "Rally config must be an object." }]
|
|
1691
|
-
};
|
|
1692
|
-
if (typeof config.id !== "string") add(errors, "id", "REQUIRED", "Rally ID is required.");
|
|
1693
|
-
else if (config.id.trim() === "")
|
|
1694
|
-
add(errors, "id", "EMPTY_STRING", "Rally ID must not be empty.");
|
|
1695
|
-
if (config.title !== void 0 && !hasText(config.title))
|
|
1696
|
-
add(errors, "title", "EMPTY_STRING", "Rally title must not be empty.");
|
|
1697
|
-
if (config.version !== void 0 && config.version !== CURRENT_RALLY_CONFIG_VERSION) {
|
|
1698
|
-
add(
|
|
1699
|
-
errors,
|
|
1700
|
-
"version",
|
|
1701
|
-
"INVALID_VERSION",
|
|
1702
|
-
`Config version must be ${CURRENT_RALLY_CONFIG_VERSION}.`
|
|
1703
|
-
);
|
|
1704
|
-
}
|
|
1705
|
-
for (const field of ["startDate", "endDate"]) {
|
|
1706
|
-
if (config[field] !== void 0 && (typeof config[field] !== "string" || Number.isNaN(Date.parse(config[field])))) {
|
|
1707
|
-
add(errors, field, "INVALID_DATE", `${field} must be a valid ISO date.`);
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
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)) {
|
|
1711
|
-
add(errors, "startDate", "INVALID_DATE", "startDate must be before endDate.");
|
|
1712
|
-
}
|
|
1713
|
-
if (!Array.isArray(config.stamps)) {
|
|
1714
|
-
add(errors, "stamps", "INVALID_TYPE", "stamps must be an array.");
|
|
1715
|
-
} else {
|
|
1716
|
-
config.stamps.forEach((spot, index) => {
|
|
1717
|
-
validateSpot(spot, index, errors);
|
|
1718
|
-
});
|
|
1719
|
-
const ids = config.stamps.map(
|
|
1720
|
-
(spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : ""
|
|
1721
|
-
);
|
|
1722
|
-
ids.forEach((id, index) => {
|
|
1723
|
-
if (id !== "" && ids.indexOf(id) !== index)
|
|
1724
|
-
add(errors, `stamps[${index}].id`, "DUPLICATE_ID", `Duplicate spot ID '${id}'.`);
|
|
1725
|
-
});
|
|
1726
|
-
validateDag(config.stamps, errors);
|
|
1727
|
-
}
|
|
1728
|
-
if (config.rewards !== void 0 && !Array.isArray(config.rewards)) {
|
|
1729
|
-
add(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
|
|
1730
|
-
} else if (Array.isArray(config.rewards)) {
|
|
1731
|
-
const stampCount = Array.isArray(config.stamps) ? config.stamps.length : 0;
|
|
1732
|
-
config.rewards.forEach((reward, index) => {
|
|
1733
|
-
validateReward(reward, index, stampCount, errors);
|
|
1734
|
-
});
|
|
1735
|
-
const ids = config.rewards.map(
|
|
1736
|
-
(reward) => isObject(reward) && typeof reward.id === "string" ? reward.id : ""
|
|
1737
|
-
);
|
|
1738
|
-
ids.forEach((id, index) => {
|
|
1739
|
-
if (id !== "" && ids.indexOf(id) !== index)
|
|
1740
|
-
add(errors, `rewards[${index}].id`, "DUPLICATE_ID", `Duplicate reward ID '${id}'.`);
|
|
1741
|
-
});
|
|
1742
|
-
if (Array.isArray(config.stamps)) {
|
|
1743
|
-
const stampIds = new Set(
|
|
1744
|
-
config.stamps.map((spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : "")
|
|
1745
|
-
);
|
|
1746
|
-
ids.forEach((id, index) => {
|
|
1747
|
-
if (id !== "" && stampIds.has(id))
|
|
1748
|
-
add(
|
|
1749
|
-
errors,
|
|
1750
|
-
`rewards[${index}].id`,
|
|
1751
|
-
"DUPLICATE_ID",
|
|
1752
|
-
`Reward ID '${id}' is already used by a spot.`
|
|
1753
|
-
);
|
|
1754
|
-
});
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
return { valid: errors.length === 0, errors };
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
// src/domain/migration.ts
|
|
1761
|
-
function isObject2(value) {
|
|
1762
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1763
|
-
}
|
|
1764
|
-
function text(value) {
|
|
1765
|
-
if (typeof value === "string") return value;
|
|
1766
|
-
if (!isObject2(value)) return void 0;
|
|
1767
|
-
const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
|
|
1768
|
-
return entries.length === 0 ? void 0 : Object.fromEntries(entries);
|
|
1769
|
-
}
|
|
1770
|
-
function condition(value) {
|
|
1771
|
-
if (!isObject2(value) || typeof value.type !== "string") return { type: "instant" };
|
|
1772
|
-
switch (value.type) {
|
|
1773
|
-
case "instant":
|
|
1774
|
-
return { type: "instant" };
|
|
1775
|
-
case "token":
|
|
1776
|
-
case "passcode":
|
|
1777
|
-
return {
|
|
1778
|
-
type: "token",
|
|
1779
|
-
token: typeof value.token === "string" ? value.token : typeof value.passcode === "string" ? value.passcode : ""
|
|
1780
|
-
};
|
|
1781
|
-
case "geo":
|
|
1782
|
-
return {
|
|
1783
|
-
type: "geo",
|
|
1784
|
-
latitude: typeof value.latitude === "number" ? value.latitude : 0,
|
|
1785
|
-
longitude: typeof value.longitude === "number" ? value.longitude : 0,
|
|
1786
|
-
radiusMeters: typeof value.radiusMeters === "number" ? value.radiusMeters : typeof value.radius === "number" ? value.radius : 1
|
|
1787
|
-
};
|
|
1788
|
-
case "composite":
|
|
1789
|
-
return {
|
|
1790
|
-
type: "composite",
|
|
1791
|
-
operator: value.operator === "OR" ? "OR" : "AND",
|
|
1792
|
-
conditions: Array.isArray(value.conditions) ? value.conditions.map(condition) : []
|
|
1793
|
-
};
|
|
1794
|
-
case "time_window":
|
|
1795
|
-
return {
|
|
1796
|
-
type: "time_window",
|
|
1797
|
-
startsAt: typeof value.startsAt === "string" ? value.startsAt : "1970-01-01T00:00:00.000Z",
|
|
1798
|
-
endsAt: typeof value.endsAt === "string" ? value.endsAt : "9999-12-31T23:59:59.999Z",
|
|
1799
|
-
condition: condition(value.condition)
|
|
1800
|
-
};
|
|
1801
|
-
default:
|
|
1802
|
-
return { type: "instant" };
|
|
1803
|
-
}
|
|
1804
|
-
}
|
|
1805
|
-
function migrateSpot(value, index) {
|
|
1806
|
-
const source2 = isObject2(value) ? value : {};
|
|
1807
|
-
const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `spot-${index + 1}`;
|
|
1808
|
-
const description = text(source2.description);
|
|
1809
|
-
const hint = text(source2.hint);
|
|
1810
|
-
return {
|
|
1811
|
-
id,
|
|
1812
|
-
name: text(source2.name) ?? `Spot ${index + 1}`,
|
|
1813
|
-
...description === void 0 ? {} : { description },
|
|
1814
|
-
...hint === void 0 ? {} : { hint },
|
|
1815
|
-
condition: condition(
|
|
1816
|
-
source2.condition ?? (typeof source2.token === "string" ? { type: "token", token: source2.token } : void 0)
|
|
1817
|
-
),
|
|
1818
|
-
...typeof source2.orderIndex === "number" ? { orderIndex: source2.orderIndex } : typeof source2.order === "number" ? { order: source2.order } : {},
|
|
1819
|
-
...typeof source2.deckId === "string" ? { deckId: source2.deckId } : {},
|
|
1820
|
-
...typeof source2.groupId === "string" ? { groupId: source2.groupId } : {},
|
|
1821
|
-
...typeof source2.guideId === "string" ? { guideId: source2.guideId } : {},
|
|
1822
|
-
...typeof source2.iconUrl === "string" ? { iconUrl: source2.iconUrl } : {},
|
|
1823
|
-
...typeof source2.imageUrl === "string" ? { imageUrl: source2.imageUrl } : {},
|
|
1824
|
-
...typeof source2.externalUrl === "string" ? { externalUrl: source2.externalUrl } : {},
|
|
1825
|
-
...typeof source2.redirectUrlAfterClaim === "string" ? { redirectUrlAfterClaim: source2.redirectUrlAfterClaim } : {},
|
|
1826
|
-
...isObject2(source2.metadata) ? { metadata: source2.metadata } : {},
|
|
1827
|
-
...Array.isArray(source2.dependsOn) ? { dependsOn: source2.dependsOn.filter((item) => typeof item === "string") } : {}
|
|
1828
|
-
};
|
|
1829
|
-
}
|
|
1830
|
-
function migrateReward(value, index) {
|
|
1831
|
-
const source2 = isObject2(value) ? value : {};
|
|
1832
|
-
return {
|
|
1833
|
-
id: typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `reward-${index + 1}`,
|
|
1834
|
-
title: text(source2.title) ?? `Reward ${index + 1}`,
|
|
1835
|
-
description: text(source2.description) ?? "",
|
|
1836
|
-
type: source2.type === "digital" ? "digital" : "in_person",
|
|
1837
|
-
redemptionMethod: source2.redemptionMethod === "staff_passcode" || source2.redemptionMethod === "view_only" || source2.redemptionMethod === "server_claim" ? source2.redemptionMethod : "manual_slide",
|
|
1838
|
-
requiredStampCount: typeof source2.requiredStampCount === "number" && Number.isFinite(source2.requiredStampCount) ? Math.max(0, Math.trunc(source2.requiredStampCount)) : 0,
|
|
1839
|
-
...typeof source2.digitalContentUrl === "string" ? { digitalContentUrl: source2.digitalContentUrl } : {},
|
|
1840
|
-
...typeof source2.staffPasscode === "string" ? { staffPasscode: source2.staffPasscode } : {},
|
|
1841
|
-
...typeof source2.validUntil === "string" ? { validUntil: source2.validUntil } : {},
|
|
1842
|
-
...typeof source2.maxStock === "number" ? { maxStock: source2.maxStock } : {},
|
|
1843
|
-
...typeof source2.limitPerUser === "number" ? { limitPerUser: source2.limitPerUser } : {},
|
|
1844
|
-
...typeof source2.stockLimit === "number" ? { stockLimit: source2.stockLimit } : {},
|
|
1845
|
-
...typeof source2.userClaimLimit === "number" ? { userClaimLimit: source2.userClaimLimit } : {},
|
|
1846
|
-
...typeof source2.claimTicketNumber === "string" ? { claimTicketNumber: source2.claimTicketNumber } : {}
|
|
1847
|
-
};
|
|
1848
|
-
}
|
|
1849
|
-
function migrateRallyConfig(raw) {
|
|
1850
|
-
const source2 = isObject2(raw) ? raw : {};
|
|
1851
|
-
const rawStamps = Array.isArray(source2.stamps) ? source2.stamps : Array.isArray(source2.spots) ? source2.spots : [];
|
|
1852
|
-
const rawRewards = Array.isArray(source2.rewards) ? source2.rewards : [];
|
|
1853
|
-
const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : "migrated-rally";
|
|
1854
|
-
const title = text(source2.title);
|
|
1855
|
-
const description = text(source2.description);
|
|
1856
|
-
const theme = isObject2(source2.theme) ? source2.theme : void 0;
|
|
1857
|
-
return {
|
|
1858
|
-
id,
|
|
1859
|
-
...title === void 0 ? {} : { title },
|
|
1860
|
-
...description === void 0 ? {} : { description },
|
|
1861
|
-
stamps: rawStamps.map(migrateSpot),
|
|
1862
|
-
...rawRewards.length === 0 ? {} : { rewards: rawRewards.map(migrateReward) },
|
|
1863
|
-
...typeof source2.isSequential === "boolean" ? { isSequential: source2.isSequential } : {},
|
|
1864
|
-
...theme === void 0 ? {} : { theme },
|
|
1865
|
-
version: CURRENT_RALLY_CONFIG_VERSION,
|
|
1866
|
-
...typeof source2.startDate === "string" ? { startDate: source2.startDate } : typeof source2.startsAt === "string" ? { startDate: source2.startsAt } : {},
|
|
1867
|
-
...typeof source2.endDate === "string" ? { endDate: source2.endDate } : typeof source2.endsAt === "string" ? { endDate: source2.endsAt } : {}
|
|
1399
|
+
function resolveLocalizedText(text, locale, fallbackLocale) {
|
|
1400
|
+
if (text === void 0 || text === "") return "";
|
|
1401
|
+
if (typeof text === "string") return text;
|
|
1402
|
+
const fallback = fallbackLocale === void 0 ? Object.values(text).find((value) => typeof value === "string") : text[fallbackLocale];
|
|
1403
|
+
return text[locale] || fallback || "";
|
|
1404
|
+
}
|
|
1405
|
+
function toLocalizedString(text) {
|
|
1406
|
+
if (text === void 0) return { ja: "", en: "" };
|
|
1407
|
+
return typeof text === "string" ? { ja: text, en: "" } : {
|
|
1408
|
+
ja: text["ja"] ?? "",
|
|
1409
|
+
en: text["en"] ?? "",
|
|
1410
|
+
...text
|
|
1868
1411
|
};
|
|
1869
1412
|
}
|
|
1870
1413
|
|
|
@@ -1879,69 +1422,98 @@ var DEFAULT_SHEET_THEME = {
|
|
|
1879
1422
|
unclaimedOpacity: 1,
|
|
1880
1423
|
fontFamily: "serif"
|
|
1881
1424
|
};
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
function toPublicCondition(condition2) {
|
|
1885
|
-
switch (condition2.type) {
|
|
1425
|
+
function publicCondition(condition) {
|
|
1426
|
+
switch (condition.type) {
|
|
1886
1427
|
case "qr":
|
|
1887
|
-
return { type: "qr", qrEntryUrl:
|
|
1428
|
+
return condition.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition.qrEntryUrl };
|
|
1888
1429
|
case "passcode":
|
|
1889
1430
|
return { type: "passcode" };
|
|
1890
1431
|
case "gps":
|
|
1891
1432
|
return {
|
|
1892
1433
|
type: "gps",
|
|
1893
|
-
latitude:
|
|
1894
|
-
longitude:
|
|
1895
|
-
radiusMeters:
|
|
1434
|
+
latitude: condition.latitude,
|
|
1435
|
+
longitude: condition.longitude,
|
|
1436
|
+
radiusMeters: condition.radiusMeters
|
|
1896
1437
|
};
|
|
1438
|
+
case "nfc":
|
|
1439
|
+
return { type: "nfc" };
|
|
1897
1440
|
case "custom":
|
|
1898
|
-
return { type: "custom", validatorName:
|
|
1441
|
+
return { type: "custom", validatorName: condition.validatorName };
|
|
1899
1442
|
}
|
|
1900
1443
|
}
|
|
1901
|
-
function
|
|
1444
|
+
function toPublicConfig(config) {
|
|
1902
1445
|
return {
|
|
1903
|
-
|
|
1446
|
+
id: config.id,
|
|
1447
|
+
version: config.version,
|
|
1448
|
+
title: config.title,
|
|
1449
|
+
...config.description === void 0 ? {} : { description: config.description },
|
|
1450
|
+
...config.theme === void 0 ? {} : { theme: config.theme },
|
|
1904
1451
|
spots: config.spots.map((spot) => ({
|
|
1905
1452
|
...spot,
|
|
1906
|
-
conditions: spot.conditions.map(
|
|
1453
|
+
conditions: spot.conditions.map(publicCondition)
|
|
1907
1454
|
})),
|
|
1908
1455
|
rewards: config.rewards.map(
|
|
1909
|
-
({
|
|
1910
|
-
)
|
|
1456
|
+
({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward }) => reward
|
|
1457
|
+
),
|
|
1458
|
+
...config.metadata === void 0 ? {} : { metadata: config.metadata },
|
|
1459
|
+
...config.serverEndpoint === void 0 ? {} : { serverEndpoint: config.serverEndpoint }
|
|
1911
1460
|
};
|
|
1912
1461
|
}
|
|
1913
|
-
function
|
|
1462
|
+
function assertPublicConfig(config) {
|
|
1463
|
+
if (!isPublicConfig(config)) throw new Error("Configuration contains private rally fields.");
|
|
1464
|
+
}
|
|
1465
|
+
function isPublicConfig(value) {
|
|
1914
1466
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1915
1467
|
const candidate = value;
|
|
1916
|
-
|
|
1468
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1469
|
+
const containsPrivateField = (item) => {
|
|
1470
|
+
if (typeof item !== "object" || item === null) return false;
|
|
1471
|
+
if (seen.has(item)) return false;
|
|
1472
|
+
seen.add(item);
|
|
1473
|
+
if (Array.isArray(item)) return item.some(containsPrivateField);
|
|
1474
|
+
const record = item;
|
|
1475
|
+
if (["staffPasscode", "secretToken", "serverMetadata", "secretParams", "digitalContentUrl"].some(
|
|
1476
|
+
(key) => key in record
|
|
1477
|
+
))
|
|
1478
|
+
return true;
|
|
1479
|
+
return Object.values(record).some(containsPrivateField);
|
|
1480
|
+
};
|
|
1481
|
+
if (containsPrivateField(candidate)) return false;
|
|
1482
|
+
for (const key of [
|
|
1483
|
+
"staffPasscode",
|
|
1484
|
+
"secretToken",
|
|
1485
|
+
"serverMetadata",
|
|
1486
|
+
"secretParams",
|
|
1487
|
+
"code",
|
|
1488
|
+
"tagId"
|
|
1489
|
+
]) {
|
|
1490
|
+
if (key in candidate) return false;
|
|
1491
|
+
}
|
|
1492
|
+
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))
|
|
1917
1493
|
return false;
|
|
1918
|
-
return
|
|
1494
|
+
return candidate.spots.every((spot) => {
|
|
1919
1495
|
if (typeof spot !== "object" || spot === null || Array.isArray(spot)) return false;
|
|
1920
|
-
const
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1496
|
+
const item = spot;
|
|
1497
|
+
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)
|
|
1498
|
+
return false;
|
|
1499
|
+
return Array.isArray(item.conditions) && item.conditions.every((condition) => {
|
|
1500
|
+
if (typeof condition !== "object" || condition === null || Array.isArray(condition))
|
|
1501
|
+
return false;
|
|
1502
|
+
const value2 = condition;
|
|
1503
|
+
if ("secretToken" in value2 || "code" in value2 || "tagId" in value2 || "secretParams" in value2)
|
|
1504
|
+
return false;
|
|
1505
|
+
const type = value2.type;
|
|
1506
|
+
if (type === "qr" || type === "passcode" || type === "nfc") return true;
|
|
1507
|
+
if (type === "custom") return typeof value2.validatorName === "string";
|
|
1508
|
+
return type === "gps" && typeof value2.latitude === "number" && typeof value2.longitude === "number" && typeof value2.radiusMeters === "number";
|
|
1925
1509
|
});
|
|
1926
1510
|
}) && candidate.rewards.every((reward) => {
|
|
1927
1511
|
if (typeof reward !== "object" || reward === null || Array.isArray(reward)) return false;
|
|
1928
1512
|
const item = reward;
|
|
1929
|
-
return item.
|
|
1513
|
+
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);
|
|
1930
1514
|
});
|
|
1931
1515
|
}
|
|
1932
1516
|
|
|
1933
|
-
// src/domain/publicConfig.ts
|
|
1934
|
-
function stripSensitiveConfig(config) {
|
|
1935
|
-
if ("spots" in config && !("stamps" in config)) {
|
|
1936
|
-
return toPublicRallyConfig(config);
|
|
1937
|
-
}
|
|
1938
|
-
const rewards = config.rewards?.map(({ staffPasscode: _staffPasscode, ...reward }) => reward);
|
|
1939
|
-
return {
|
|
1940
|
-
...config,
|
|
1941
|
-
...rewards === void 0 ? {} : { rewards }
|
|
1942
|
-
};
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
1517
|
// src/domain/themePresets.ts
|
|
1946
1518
|
var THEME_PRESETS = [
|
|
1947
1519
|
{
|
|
@@ -2041,180 +1613,6 @@ var THEME_PRESETS = [
|
|
|
2041
1613
|
}
|
|
2042
1614
|
];
|
|
2043
1615
|
|
|
2044
|
-
// src/domain/universalValidation.ts
|
|
2045
|
-
function isObject3(value) {
|
|
2046
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2047
|
-
}
|
|
2048
|
-
function add2(errors, path, code, message) {
|
|
2049
|
-
errors.push({ path, code, message });
|
|
2050
|
-
}
|
|
2051
|
-
function hasText2(value) {
|
|
2052
|
-
if (typeof value === "string") return value.trim() !== "";
|
|
2053
|
-
return isObject3(value) && Object.values(value).some((item) => typeof item === "string" && item.trim() !== "");
|
|
2054
|
-
}
|
|
2055
|
-
function validateCondition2(condition2, path, errors) {
|
|
2056
|
-
if (!isObject3(condition2) || typeof condition2.type !== "string") {
|
|
2057
|
-
add2(errors, path, "INVALID_TYPE", "Condition must have a type.");
|
|
2058
|
-
return;
|
|
2059
|
-
}
|
|
2060
|
-
switch (condition2.type) {
|
|
2061
|
-
case "qr":
|
|
2062
|
-
if (typeof condition2.secretToken !== "string" || condition2.secretToken.trim() === "")
|
|
2063
|
-
add2(errors, `${path}.secretToken`, "REQUIRED", "QR secretToken is required.");
|
|
2064
|
-
if (condition2.qrEntryUrl !== void 0 && typeof condition2.qrEntryUrl !== "string")
|
|
2065
|
-
add2(errors, `${path}.qrEntryUrl`, "INVALID_TYPE", "QR entry URL must be a string.");
|
|
2066
|
-
return;
|
|
2067
|
-
case "passcode":
|
|
2068
|
-
if (typeof condition2.code !== "string" || condition2.code.trim() === "")
|
|
2069
|
-
add2(errors, `${path}.code`, "REQUIRED", "Passcode is required.");
|
|
2070
|
-
return;
|
|
2071
|
-
case "gps":
|
|
2072
|
-
if (typeof condition2.latitude !== "number" || !Number.isFinite(condition2.latitude) || condition2.latitude < -90 || condition2.latitude > 90 || typeof condition2.longitude !== "number" || !Number.isFinite(condition2.longitude) || condition2.longitude < -180 || condition2.longitude > 180)
|
|
2073
|
-
add2(errors, path, "INVALID_COORDINATES", "GPS coordinates are invalid.");
|
|
2074
|
-
if (typeof condition2.radiusMeters !== "number" || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters <= 0)
|
|
2075
|
-
add2(errors, `${path}.radiusMeters`, "INVALID_RADIUS", "GPS radius must be positive.");
|
|
2076
|
-
return;
|
|
2077
|
-
case "custom":
|
|
2078
|
-
if (typeof condition2.validatorName !== "string" || condition2.validatorName.trim() === "")
|
|
2079
|
-
add2(errors, `${path}.validatorName`, "REQUIRED", "Custom validatorName is required.");
|
|
2080
|
-
return;
|
|
2081
|
-
default:
|
|
2082
|
-
add2(errors, `${path}.type`, "INVALID_TYPE", "Unsupported verification condition.");
|
|
2083
|
-
}
|
|
2084
|
-
}
|
|
2085
|
-
function validateDag2(spots, errors) {
|
|
2086
|
-
const graph = /* @__PURE__ */ new Map();
|
|
2087
|
-
for (const spot of spots) {
|
|
2088
|
-
if (!isObject3(spot) || typeof spot.id !== "string") continue;
|
|
2089
|
-
const prerequisites = Array.isArray(spot.prerequisites) ? spot.prerequisites.filter((item) => typeof item === "string") : [];
|
|
2090
|
-
graph.set(spot.id, prerequisites);
|
|
2091
|
-
}
|
|
2092
|
-
const visiting = /* @__PURE__ */ new Set();
|
|
2093
|
-
const visited = /* @__PURE__ */ new Set();
|
|
2094
|
-
const visit = (id) => {
|
|
2095
|
-
if (visiting.has(id)) {
|
|
2096
|
-
add2(
|
|
2097
|
-
errors,
|
|
2098
|
-
`spots.${id}.prerequisites`,
|
|
2099
|
-
"CYCLE_DETECTED",
|
|
2100
|
-
`Dependency cycle detected at '${id}'.`
|
|
2101
|
-
);
|
|
2102
|
-
return;
|
|
2103
|
-
}
|
|
2104
|
-
if (visited.has(id)) return;
|
|
2105
|
-
visiting.add(id);
|
|
2106
|
-
for (const prerequisite of graph.get(id) ?? [])
|
|
2107
|
-
if (graph.has(prerequisite)) visit(prerequisite);
|
|
2108
|
-
visiting.delete(id);
|
|
2109
|
-
visited.add(id);
|
|
2110
|
-
};
|
|
2111
|
-
for (const id of graph.keys()) visit(id);
|
|
2112
|
-
}
|
|
2113
|
-
function validateAdminRallyConfig(value) {
|
|
2114
|
-
const errors = [];
|
|
2115
|
-
if (!isObject3(value))
|
|
2116
|
-
return {
|
|
2117
|
-
valid: false,
|
|
2118
|
-
errors: [{ path: "", code: "INVALID_TYPE", message: "Admin config must be an object." }]
|
|
2119
|
-
};
|
|
2120
|
-
if (typeof value.id !== "string" || value.id.trim() === "")
|
|
2121
|
-
add2(errors, "id", "REQUIRED", "Rally ID is required.");
|
|
2122
|
-
if (typeof value.version !== "string" || value.version.trim() === "")
|
|
2123
|
-
add2(errors, "version", "INVALID_VERSION", "Version is required.");
|
|
2124
|
-
if (!Array.isArray(value.spots)) {
|
|
2125
|
-
add2(errors, "spots", "INVALID_TYPE", "spots must be an array.");
|
|
2126
|
-
} else {
|
|
2127
|
-
const ids = [];
|
|
2128
|
-
value.spots.forEach((spot, index) => {
|
|
2129
|
-
const path = `spots[${index}]`;
|
|
2130
|
-
if (!isObject3(spot)) {
|
|
2131
|
-
add2(errors, path, "INVALID_TYPE", "Spot must be an object.");
|
|
2132
|
-
return;
|
|
2133
|
-
}
|
|
2134
|
-
if (typeof spot.id !== "string" || spot.id.trim() === "")
|
|
2135
|
-
add2(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
|
|
2136
|
-
else if (ids.includes(spot.id))
|
|
2137
|
-
add2(errors, `${path}.id`, "DUPLICATE_ID", `Duplicate spot ID '${spot.id}'.`);
|
|
2138
|
-
else ids.push(spot.id);
|
|
2139
|
-
if (!hasText2(spot.name)) add2(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
|
|
2140
|
-
if (typeof spot.orderIndex !== "number" || !Number.isInteger(spot.orderIndex))
|
|
2141
|
-
add2(errors, `${path}.orderIndex`, "INVALID_TYPE", "orderIndex must be an integer.");
|
|
2142
|
-
if (!Array.isArray(spot.conditions) || spot.conditions.length === 0)
|
|
2143
|
-
add2(errors, `${path}.conditions`, "REQUIRED", "At least one condition is required.");
|
|
2144
|
-
else {
|
|
2145
|
-
spot.conditions.forEach((condition2, conditionIndex) => {
|
|
2146
|
-
validateCondition2(condition2, `${path}.conditions[${conditionIndex}]`, errors);
|
|
2147
|
-
});
|
|
2148
|
-
}
|
|
2149
|
-
});
|
|
2150
|
-
validateDag2(value.spots, errors);
|
|
2151
|
-
}
|
|
2152
|
-
if (!Array.isArray(value.rewards))
|
|
2153
|
-
add2(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
|
|
2154
|
-
else {
|
|
2155
|
-
const ids = [];
|
|
2156
|
-
value.rewards.forEach((reward, index) => {
|
|
2157
|
-
const path = `rewards[${index}]`;
|
|
2158
|
-
if (!isObject3(reward)) {
|
|
2159
|
-
add2(errors, path, "INVALID_TYPE", "Reward must be an object.");
|
|
2160
|
-
return;
|
|
2161
|
-
}
|
|
2162
|
-
if (typeof reward.id !== "string" || reward.id.trim() === "")
|
|
2163
|
-
add2(errors, `${path}.id`, "REQUIRED", "Reward ID is required.");
|
|
2164
|
-
else if (ids.includes(reward.id))
|
|
2165
|
-
add2(errors, `${path}.id`, "DUPLICATE_ID", `Duplicate reward ID '${reward.id}'.`);
|
|
2166
|
-
else ids.push(reward.id);
|
|
2167
|
-
if (!hasText2(reward.title))
|
|
2168
|
-
add2(errors, `${path}.title`, "REQUIRED", "Reward title is required.");
|
|
2169
|
-
if (typeof reward.requiredStampCount !== "number" || !Number.isInteger(reward.requiredStampCount) || reward.requiredStampCount < 0)
|
|
2170
|
-
add2(
|
|
2171
|
-
errors,
|
|
2172
|
-
`${path}.requiredStampCount`,
|
|
2173
|
-
"INVALID_REWARD",
|
|
2174
|
-
"requiredStampCount must be a non-negative integer."
|
|
2175
|
-
);
|
|
2176
|
-
});
|
|
2177
|
-
}
|
|
2178
|
-
return { valid: errors.length === 0, errors };
|
|
2179
|
-
}
|
|
2180
|
-
function validatePublicRallyConfig(value) {
|
|
2181
|
-
const errors = [];
|
|
2182
|
-
if (!isObject3(value))
|
|
2183
|
-
return {
|
|
2184
|
-
valid: false,
|
|
2185
|
-
errors: [{ path: "", code: "INVALID_TYPE", message: "Public config must be an object." }]
|
|
2186
|
-
};
|
|
2187
|
-
if ("secretKey" in value || "verificationSecrets" in value)
|
|
2188
|
-
add2(
|
|
2189
|
-
errors,
|
|
2190
|
-
"",
|
|
2191
|
-
"SECRET_IN_PUBLIC_CONFIG",
|
|
2192
|
-
"Public config must not contain server verification secrets."
|
|
2193
|
-
);
|
|
2194
|
-
if (!Array.isArray(value.spots)) add2(errors, "spots", "INVALID_TYPE", "spots must be an array.");
|
|
2195
|
-
else
|
|
2196
|
-
value.spots.forEach((spot, index) => {
|
|
2197
|
-
if (!isObject3(spot) || !Array.isArray(spot.conditions)) return;
|
|
2198
|
-
spot.conditions.forEach((condition2, conditionIndex) => {
|
|
2199
|
-
if (!isObject3(condition2)) return;
|
|
2200
|
-
if ("secretToken" in condition2 || "code" in condition2 || "secretParams" in condition2)
|
|
2201
|
-
add2(
|
|
2202
|
-
errors,
|
|
2203
|
-
`spots[${index}].conditions[${conditionIndex}]`,
|
|
2204
|
-
"SECRET_IN_PUBLIC_CONFIG",
|
|
2205
|
-
"Public condition contains verification secret material."
|
|
2206
|
-
);
|
|
2207
|
-
});
|
|
2208
|
-
});
|
|
2209
|
-
return { valid: errors.length === 0, errors };
|
|
2210
|
-
}
|
|
2211
|
-
function isAdminRallyConfig(value) {
|
|
2212
|
-
return validateAdminRallyConfig(value).valid;
|
|
2213
|
-
}
|
|
2214
|
-
function isPublicRallyConfigShape(value) {
|
|
2215
|
-
return validatePublicRallyConfig(value).valid;
|
|
2216
|
-
}
|
|
2217
|
-
|
|
2218
1616
|
// src/security/snapshotToken.ts
|
|
2219
1617
|
var encoder2 = new TextEncoder();
|
|
2220
1618
|
function cryptoApi2() {
|
|
@@ -2334,7 +1732,6 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
|
|
|
2334
1732
|
}
|
|
2335
1733
|
}
|
|
2336
1734
|
|
|
2337
|
-
exports.CURRENT_RALLY_CONFIG_VERSION = CURRENT_RALLY_CONFIG_VERSION;
|
|
2338
1735
|
exports.DEFAULT_SHEET_THEME = DEFAULT_SHEET_THEME;
|
|
2339
1736
|
exports.InMemoryStorage = InMemoryStorage;
|
|
2340
1737
|
exports.IndexedDBAdapter = IndexedDBAdapter;
|
|
@@ -2342,6 +1739,7 @@ exports.LocalStorageAdapter = LocalStorageAdapter;
|
|
|
2342
1739
|
exports.StampRallyClient = StampRallyClient;
|
|
2343
1740
|
exports.StorageAdapterError = StorageAdapterError;
|
|
2344
1741
|
exports.THEME_PRESETS = THEME_PRESETS;
|
|
1742
|
+
exports.assertPublicConfig = assertPublicConfig;
|
|
2345
1743
|
exports.calculateDistanceMeters = calculateDistanceMeters;
|
|
2346
1744
|
exports.calculateProgress = calculateProgress;
|
|
2347
1745
|
exports.consumeReward = consumeReward;
|
|
@@ -2349,34 +1747,28 @@ exports.createClaimTicketNumber = createClaimTicketNumber;
|
|
|
2349
1747
|
exports.createSecureToken = createSecureToken;
|
|
2350
1748
|
exports.createSignedSnapshotToken = createSignedSnapshotToken;
|
|
2351
1749
|
exports.createUniqueClaimTicketNumber = createUniqueClaimTicketNumber;
|
|
2352
|
-
exports.evaluateCheckIn = evaluateCheckIn;
|
|
2353
1750
|
exports.evaluateCondition = evaluateCondition;
|
|
2354
1751
|
exports.evaluateConditionDetailed = evaluateConditionDetailed;
|
|
2355
1752
|
exports.exportProgressToken = exportProgressToken;
|
|
2356
1753
|
exports.getCurrentGeoContext = getCurrentGeoContext;
|
|
1754
|
+
exports.getOrderedSpots = getOrderedSpots;
|
|
2357
1755
|
exports.importProgressToken = importProgressToken;
|
|
2358
|
-
exports.isAdminRallyConfig = isAdminRallyConfig;
|
|
2359
1756
|
exports.isGeolocationSupported = isGeolocationSupported;
|
|
2360
1757
|
exports.isNfcSupported = isNfcSupported;
|
|
2361
|
-
exports.
|
|
2362
|
-
exports.isPublicRallyConfigShape = isPublicRallyConfigShape;
|
|
1758
|
+
exports.isPublicConfig = isPublicConfig;
|
|
2363
1759
|
exports.isQrSupported = isQrSupported;
|
|
2364
1760
|
exports.isRewardState = isRewardState;
|
|
2365
1761
|
exports.isStampRallyState = isStampRallyState;
|
|
2366
1762
|
exports.issueClaimTicketNumber = issueClaimTicketNumber;
|
|
2367
|
-
exports.migrateRallyConfig = migrateRallyConfig;
|
|
2368
1763
|
exports.normalizePasscode = normalizePasscode;
|
|
2369
1764
|
exports.processStamp = processStamp;
|
|
2370
1765
|
exports.readNfcContext = readNfcContext;
|
|
2371
1766
|
exports.readQrContext = readQrContext;
|
|
2372
1767
|
exports.reconcileRewardStates = reconcileRewardStates;
|
|
2373
1768
|
exports.resolveLocalizedText = resolveLocalizedText;
|
|
2374
|
-
exports.
|
|
1769
|
+
exports.storageKey = storageKey;
|
|
2375
1770
|
exports.toLocalizedString = toLocalizedString;
|
|
2376
|
-
exports.
|
|
2377
|
-
exports.validateAdminRallyConfig = validateAdminRallyConfig;
|
|
2378
|
-
exports.validatePublicRallyConfig = validatePublicRallyConfig;
|
|
2379
|
-
exports.validateRallyConfig = validateRallyConfig;
|
|
1771
|
+
exports.toPublicConfig = toPublicConfig;
|
|
2380
1772
|
exports.verifyPasscode = verifyPasscode;
|
|
2381
1773
|
exports.verifySecureToken = verifySecureToken;
|
|
2382
1774
|
exports.verifySnapshotToken = verifySnapshotToken;
|