@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.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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
function assertNever(value) {
|
|
30
|
-
throw new Error(`Unexpected condition: ${JSON.stringify(value)}`);
|
|
31
|
-
}
|
|
32
|
-
function evaluateConditionDetailed(condition2, context, now) {
|
|
33
|
-
switch (condition2.type) {
|
|
34
|
-
case "instant":
|
|
35
|
-
return context.type === "instant" ? { ok: true, value: { conditionType: "instant" } } : contextTypeMismatch("instant", "instant", context.type);
|
|
36
|
-
case "token":
|
|
37
|
-
if (context.type !== "token") {
|
|
38
|
-
return contextTypeMismatch("token", "token", context.type);
|
|
39
|
-
}
|
|
40
|
-
return context.token === condition2.token ? { ok: true, value: { conditionType: "token" } } : {
|
|
41
|
-
ok: false,
|
|
42
|
-
error: {
|
|
43
|
-
code: "CONDITION_MISMATCH",
|
|
44
|
-
conditionType: "token",
|
|
45
|
-
reason: "TOKEN_MISMATCH"
|
|
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
|
-
}
|
|
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)));
|
|
9
|
+
}
|
|
10
|
+
function mismatch(conditionType, reason, extra = {}) {
|
|
11
|
+
return { ok: false, error: { code: "CONDITION_MISMATCH", conditionType, reason, ...extra } };
|
|
12
|
+
}
|
|
13
|
+
function evaluateConditionDetailed(condition, context) {
|
|
14
|
+
switch (condition.type) {
|
|
15
|
+
case "qr":
|
|
16
|
+
return context.type === "qr" && context.token === condition.secretToken ? { ok: true, value: { conditionType: "qr" } } : mismatch("qr", "INVALID_PROOF");
|
|
17
|
+
case "passcode":
|
|
18
|
+
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");
|
|
19
|
+
case "nfc":
|
|
20
|
+
return context.type === "nfc" && context.tagId === condition.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(condition.latitude) || !Number.isFinite(condition.longitude) || !Number.isFinite(condition.radiusMeters) || condition.radiusMeters < 0 || context.type !== "gps" || !Number.isFinite(context.latitude) || !Number.isFinite(context.longitude))
|
|
25
|
+
return mismatch("gps", "INVALID_GEO_INPUT");
|
|
62
26
|
const distanceMeters = calculateDistanceMeters(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
context.
|
|
66
|
-
context.
|
|
27
|
+
condition.latitude,
|
|
28
|
+
condition.longitude,
|
|
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,
|
|
78
|
-
radiusMeters: condition2.radiusMeters,
|
|
79
|
-
differenceMeters: distanceMeters - 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, now);
|
|
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(now);
|
|
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,
|
|
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,
|
|
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,
|
|
162
|
-
startsAt: condition2.startsAt,
|
|
163
|
-
endsAt: condition2.endsAt
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
const childResult = evaluateConditionDetailed(condition2.condition, context, now);
|
|
168
|
-
return childResult.ok ? { ok: true, value: { conditionType: "time_window" } } : childResult;
|
|
32
|
+
return distanceMeters <= condition.radiusMeters ? { ok: true, value: { conditionType: "gps", distanceMeters } } : mismatch("gps", "OUTSIDE_RADIUS", {
|
|
33
|
+
distanceMeters,
|
|
34
|
+
radiusMeters: condition.radiusMeters
|
|
35
|
+
});
|
|
169
36
|
}
|
|
170
|
-
default:
|
|
171
|
-
return assertNever(condition2);
|
|
172
37
|
}
|
|
173
38
|
}
|
|
174
|
-
function evaluateCondition(
|
|
175
|
-
return evaluateConditionDetailed(
|
|
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(condition, context) {
|
|
40
|
+
return evaluateConditionDetailed(condition, 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((spot) => spot.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((spot) => !acquired.has(spot.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
|
|
|
@@ -281,7 +93,7 @@ function createUniqueClaimTicketNumber(rewardId, issuedAt) {
|
|
|
281
93
|
}
|
|
282
94
|
function issueClaimTicketNumber(reward, currentState, options = {}) {
|
|
283
95
|
if (currentState.claimTicketNumber !== void 0) return currentState;
|
|
284
|
-
const claimTicketNumber =
|
|
96
|
+
const claimTicketNumber = createClaimTicketNumber(reward.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 {
|
|
@@ -489,14 +301,8 @@ function normalizePasscode(input, caseSensitive = false) {
|
|
|
489
301
|
const normalized = input.normalize("NFKC").trim();
|
|
490
302
|
return caseSensitive ? normalized : normalized.toUpperCase();
|
|
491
303
|
}
|
|
492
|
-
function verifyPasscode(inputCode,
|
|
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
|
-
};
|
|
304
|
+
function verifyPasscode(inputCode, condition) {
|
|
305
|
+
return normalizePasscode(inputCode, condition.caseSensitive) === normalizePasscode(condition.code, condition.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;
|
|
@@ -622,80 +428,47 @@ async function readQrContext(videoElement, options = {}) {
|
|
|
622
428
|
|
|
623
429
|
// src/engine/transition.ts
|
|
624
430
|
function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now) {
|
|
625
|
-
const
|
|
431
|
+
const states = new Map(currentStates.map((state) => [state.rewardId, state]));
|
|
626
432
|
return rewards.map((reward) => {
|
|
627
|
-
const current =
|
|
628
|
-
if (current?.status === "CONSUMED" || current?.status === "EXPIRED")
|
|
629
|
-
|
|
630
|
-
}
|
|
631
|
-
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now)) {
|
|
433
|
+
const current = states.get(reward.id);
|
|
434
|
+
if (current?.status === "CONSUMED" || current?.status === "EXPIRED") return current;
|
|
435
|
+
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now))
|
|
632
436
|
return { rewardId: reward.id, status: "EXPIRED" };
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if (
|
|
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;
|
|
437
|
+
if (reward.stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= reward.stockLimit)
|
|
438
|
+
return { rewardId: reward.id, status: "EXPIRED" };
|
|
439
|
+
if (acquiredStampCount >= reward.requiredStampCount)
|
|
644
440
|
return {
|
|
645
441
|
rewardId: reward.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
445
|
return { rewardId: reward.id, status: "LOCKED" };
|
|
653
446
|
});
|
|
654
447
|
}
|
|
655
448
|
function consumeReward(params) {
|
|
656
449
|
const { reward, currentState } = params;
|
|
657
|
-
if (currentState.status === "CONSUMED")
|
|
658
|
-
return {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (
|
|
664
|
-
return {
|
|
665
|
-
ok: false,
|
|
666
|
-
error: { code: "NOT_AVAILABLE", rewardId: reward.id }
|
|
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) {
|
|
450
|
+
if (currentState.status === "CONSUMED")
|
|
451
|
+
return { ok: false, error: { code: "ALREADY_CONSUMED", rewardId: reward.id } };
|
|
452
|
+
if (currentState.status !== "AVAILABLE")
|
|
453
|
+
return { ok: false, error: { code: "NOT_AVAILABLE", rewardId: reward.id } };
|
|
454
|
+
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(params.now))
|
|
455
|
+
return { ok: false, error: { code: "EXPIRED", rewardId: reward.id } };
|
|
456
|
+
if (reward.stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= reward.stockLimit)
|
|
675
457
|
return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward.id } };
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
return {
|
|
679
|
-
ok: false,
|
|
680
|
-
error: { code: "USER_LIMIT_REACHED", reason: "LIMIT_EXCEEDED", rewardId: reward.id }
|
|
681
|
-
};
|
|
682
|
-
}
|
|
458
|
+
if (reward.userClaimLimit !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= reward.userClaimLimit)
|
|
459
|
+
return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId: reward.id } };
|
|
683
460
|
if (reward.redemptionMethod === "staff_passcode") {
|
|
684
|
-
|
|
685
|
-
if (passcodeResult === null || !passcodeResult.success) {
|
|
461
|
+
if (reward.staffPasscode === void 0 || !verifyPasscode(params.inputPasscode ?? "", { code: reward.staffPasscode }).success)
|
|
686
462
|
return {
|
|
687
463
|
ok: false,
|
|
688
464
|
error: {
|
|
689
465
|
code: "INVALID_PASSCODE",
|
|
690
466
|
rewardId: reward.id,
|
|
691
|
-
message:
|
|
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 (reward.redemptionMethod === "view_only") return { ok: true, value: currentState };
|
|
699
472
|
return {
|
|
700
473
|
ok: true,
|
|
701
474
|
value: {
|
|
@@ -703,71 +476,40 @@ function consumeReward(params) {
|
|
|
703
476
|
status: "CONSUMED",
|
|
704
477
|
consumedAt: params.now,
|
|
705
478
|
claimTicketNumber: createUniqueClaimTicketNumber(reward.id, params.now),
|
|
706
|
-
|
|
707
|
-
...params.userId === void 0 ? {} : { userRedemptionCount: (currentState.userRedemptionCount ?? 0) + 1 },
|
|
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 spot = config.spots.find((item) => item.id === spotId);
|
|
486
|
+
if (spot === 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 (spot.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
491
|
+
return { ok: false, error: { code: "PREREQUISITES_NOT_MET", spotId } };
|
|
492
|
+
for (const condition of spot.conditions) {
|
|
493
|
+
if (condition.type === "custom" || !evaluateConditionDetailed(condition, context).ok)
|
|
727
494
|
return {
|
|
728
495
|
ok: false,
|
|
729
|
-
error: {
|
|
730
|
-
code: "
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
}
|
|
496
|
+
error: condition.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, now);
|
|
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: now });
|
|
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: now });
|
|
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,72 +942,89 @@ var IndexedDBAdapter = class {
|
|
|
1197
942
|
};
|
|
1198
943
|
|
|
1199
944
|
// src/client/client.ts
|
|
1200
|
-
|
|
945
|
+
function isStorage(value) {
|
|
946
|
+
return "load" in value && "save" in value && "remove" in value;
|
|
947
|
+
}
|
|
948
|
+
function id(prefix) {
|
|
949
|
+
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
|
950
|
+
}
|
|
951
|
+
function proof(value) {
|
|
952
|
+
if (typeof value === "string") return value;
|
|
953
|
+
if (typeof value === "object" && value !== null) {
|
|
954
|
+
const item = value;
|
|
955
|
+
for (const key of ["token", "code", "passcode", "value", "tagId"])
|
|
956
|
+
if (typeof item[key] === "string") return item[key];
|
|
957
|
+
}
|
|
958
|
+
return "";
|
|
959
|
+
}
|
|
960
|
+
function matches(condition, value) {
|
|
961
|
+
if (condition.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 - condition.latitude);
|
|
969
|
+
const dLon = radians(longitude - condition.longitude);
|
|
970
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
|
|
971
|
+
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition.radiusMeters;
|
|
972
|
+
}
|
|
973
|
+
return proof(value).trim() !== "";
|
|
974
|
+
}
|
|
975
|
+
function emptyState(config, userId, now) {
|
|
976
|
+
return {
|
|
977
|
+
rallyId: config.id,
|
|
978
|
+
userId,
|
|
979
|
+
records: [],
|
|
980
|
+
rewards: reconcileRewardStates(config.rewards, [], 0, now),
|
|
981
|
+
updatedAt: now
|
|
982
|
+
};
|
|
983
|
+
}
|
|
1201
984
|
var StampRallyClient = class {
|
|
1202
985
|
#listeners = /* @__PURE__ */ new Set();
|
|
1203
986
|
#eventListeners = /* @__PURE__ */ new Set();
|
|
1204
|
-
#config;
|
|
1205
987
|
#storage;
|
|
1206
|
-
#
|
|
1207
|
-
#
|
|
988
|
+
#options;
|
|
989
|
+
#config;
|
|
990
|
+
#userId;
|
|
991
|
+
#state = null;
|
|
1208
992
|
#initialization = null;
|
|
1209
|
-
#
|
|
1210
|
-
constructor(config,
|
|
993
|
+
#queue = Promise.resolve();
|
|
994
|
+
constructor(config, storageOrOptions = {}) {
|
|
1211
995
|
this.#config = config;
|
|
1212
|
-
this.#
|
|
1213
|
-
this.#
|
|
1214
|
-
|
|
1215
|
-
getState() {
|
|
1216
|
-
return this.#currentState;
|
|
996
|
+
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
|
|
997
|
+
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
998
|
+
this.#userId = this.#options.userId ?? null;
|
|
1217
999
|
}
|
|
1218
1000
|
getConfig() {
|
|
1219
1001
|
return this.#config;
|
|
1220
1002
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1003
|
+
getState() {
|
|
1004
|
+
return this.#state;
|
|
1005
|
+
}
|
|
1006
|
+
getUserId() {
|
|
1007
|
+
return this.#userId;
|
|
1008
|
+
}
|
|
1009
|
+
subscribe(listener) {
|
|
1226
1010
|
this.#listeners.add(listener);
|
|
1227
|
-
return () =>
|
|
1228
|
-
this.#listeners.delete(listener);
|
|
1229
|
-
};
|
|
1011
|
+
return () => this.#listeners.delete(listener);
|
|
1230
1012
|
}
|
|
1231
1013
|
subscribeEvents(listener) {
|
|
1232
1014
|
this.#eventListeners.add(listener);
|
|
1233
1015
|
return () => this.#eventListeners.delete(listener);
|
|
1234
1016
|
}
|
|
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
1017
|
init() {
|
|
1254
1018
|
return this.initialize();
|
|
1255
1019
|
}
|
|
1256
1020
|
initialize() {
|
|
1257
|
-
if (this.#
|
|
1258
|
-
return Promise.resolve(this.#currentState);
|
|
1259
|
-
}
|
|
1021
|
+
if (this.#state !== null) return Promise.resolve(this.#state);
|
|
1260
1022
|
if (this.#initialization === null) {
|
|
1261
|
-
this.#initialization = this.#storage.load(this.#config.id).then((
|
|
1262
|
-
const
|
|
1263
|
-
this.#
|
|
1264
|
-
this.#emit(
|
|
1265
|
-
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;
|
|
1266
1028
|
}).catch((error) => {
|
|
1267
1029
|
this.#initialization = null;
|
|
1268
1030
|
throw error;
|
|
@@ -1270,103 +1032,233 @@ var StampRallyClient = class {
|
|
|
1270
1032
|
}
|
|
1271
1033
|
return this.#initialization;
|
|
1272
1034
|
}
|
|
1273
|
-
|
|
1035
|
+
switchUser(newUserId) {
|
|
1274
1036
|
return this.#enqueue(async () => {
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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();
|
|
1280
1052
|
}
|
|
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
1053
|
});
|
|
1287
1054
|
}
|
|
1288
|
-
|
|
1055
|
+
checkIn(spotId, proofData, options = {}) {
|
|
1289
1056
|
return this.#enqueue(async () => {
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
|
|
1057
|
+
const current = await this.initialize();
|
|
1058
|
+
const spot = this.#config.spots.find((item) => item.id === spotId);
|
|
1059
|
+
if (spot === void 0)
|
|
1060
|
+
return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
|
|
1061
|
+
if (current.records.some((record2) => record2.stampId === spotId))
|
|
1062
|
+
return this.#fail({
|
|
1063
|
+
code: "STAMP_ALREADY_ACQUIRED",
|
|
1064
|
+
spotId,
|
|
1065
|
+
message: "Spot was already claimed."
|
|
1066
|
+
});
|
|
1067
|
+
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
1068
|
+
if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
1069
|
+
return this.#fail({
|
|
1070
|
+
code: "PREREQUISITES_NOT_MET",
|
|
1071
|
+
spotId,
|
|
1072
|
+
message: "Prerequisite spots are not complete."
|
|
1073
|
+
});
|
|
1074
|
+
for (const condition of spot.conditions) {
|
|
1075
|
+
if (condition.type === "custom") {
|
|
1076
|
+
const validator = this.#options.customValidators?.[condition.validatorName] ?? this.#options.customValidator;
|
|
1077
|
+
if (validator === void 0)
|
|
1078
|
+
return this.#fail({
|
|
1079
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1080
|
+
spotId,
|
|
1081
|
+
message: "No custom validator is registered."
|
|
1082
|
+
});
|
|
1083
|
+
const context = {
|
|
1084
|
+
rallyId: this.#config.id,
|
|
1085
|
+
spotId,
|
|
1086
|
+
proofData,
|
|
1087
|
+
condition: { type: "custom", validatorName: condition.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(condition, proofData))
|
|
1098
|
+
return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
|
|
1099
|
+
}
|
|
1100
|
+
const now = options.now ?? this.#now();
|
|
1101
|
+
const request = {
|
|
1102
|
+
rallyId: this.#config.id,
|
|
1103
|
+
userId: this.#userId,
|
|
1104
|
+
spotId,
|
|
1105
|
+
proofData,
|
|
1106
|
+
idempotencyKey: options.idempotencyKey ?? id("check-in"),
|
|
1107
|
+
now,
|
|
1108
|
+
state: current
|
|
1109
|
+
};
|
|
1110
|
+
const remote = this.#options.syncAdapter?.checkIn;
|
|
1111
|
+
if (options.sync !== false && remote !== void 0) {
|
|
1112
|
+
const result = await remote(request);
|
|
1113
|
+
return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
|
|
1293
1114
|
}
|
|
1294
|
-
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1115
|
+
const record = { stampId: spotId, acquiredAt: now };
|
|
1116
|
+
const next = this.#reconcile({
|
|
1117
|
+
...current,
|
|
1118
|
+
records: [...current.records, record],
|
|
1119
|
+
updatedAt: now
|
|
1120
|
+
});
|
|
1121
|
+
await this.#storage.save(next);
|
|
1122
|
+
return this.#commitCheckIn({ ok: true, value: { state: next, record } });
|
|
1300
1123
|
});
|
|
1301
1124
|
}
|
|
1302
|
-
|
|
1125
|
+
claimReward(rewardId, options = {}) {
|
|
1303
1126
|
return this.#enqueue(async () => {
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1306
|
-
|
|
1127
|
+
const current = await this.initialize();
|
|
1128
|
+
const configured = this.#config.rewards.find((item) => item.id === rewardId);
|
|
1129
|
+
if (configured === void 0)
|
|
1130
|
+
return this.#fail({ code: "REWARD_NOT_FOUND", rewardId, message: "Reward was not found." });
|
|
1131
|
+
const now = options.now ?? this.#now();
|
|
1132
|
+
const state = current.rewards.find((item) => item.rewardId === rewardId) ?? {
|
|
1133
|
+
rewardId,
|
|
1134
|
+
status: "LOCKED"
|
|
1135
|
+
};
|
|
1136
|
+
const local = consumeReward({
|
|
1137
|
+
reward: configured,
|
|
1138
|
+
currentState: state,
|
|
1139
|
+
now,
|
|
1140
|
+
...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
|
|
1141
|
+
...options.staffId === void 0 ? {} : { staffId: options.staffId }
|
|
1142
|
+
});
|
|
1143
|
+
if (!local.ok) return this.#fail(local.error);
|
|
1144
|
+
const request = {
|
|
1145
|
+
rallyId: this.#config.id,
|
|
1146
|
+
userId: this.#userId,
|
|
1147
|
+
rewardId,
|
|
1148
|
+
idempotencyKey: options.idempotencyKey ?? id("claim"),
|
|
1149
|
+
now,
|
|
1150
|
+
options,
|
|
1151
|
+
state: current
|
|
1152
|
+
};
|
|
1153
|
+
const remote = this.#options.syncAdapter?.claimReward;
|
|
1154
|
+
if (options.sync !== false && remote !== void 0) {
|
|
1155
|
+
const result = await remote(request);
|
|
1156
|
+
return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
|
|
1307
1157
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1158
|
+
const next = {
|
|
1159
|
+
...current,
|
|
1160
|
+
rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
|
|
1161
|
+
updatedAt: now
|
|
1162
|
+
};
|
|
1163
|
+
await this.#storage.save(next);
|
|
1164
|
+
return this.#commitClaim({ ok: true, value: { state: next, reward: local.value } });
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
sync(adapter = this.#options.syncAdapter) {
|
|
1168
|
+
return this.#enqueue(async () => {
|
|
1169
|
+
const current = await this.initialize();
|
|
1170
|
+
if (adapter?.sync === void 0) {
|
|
1171
|
+
this.#emitEvent({ type: "sync", state: current });
|
|
1172
|
+
return;
|
|
1312
1173
|
}
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
this.#
|
|
1317
|
-
this.#
|
|
1318
|
-
|
|
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 });
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
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;
|
|
1319
1199
|
});
|
|
1320
1200
|
}
|
|
1321
1201
|
#enqueue(operation) {
|
|
1322
|
-
const next = this.#
|
|
1323
|
-
this.#
|
|
1202
|
+
const next = this.#queue.then(operation, operation);
|
|
1203
|
+
this.#queue = next.then(
|
|
1324
1204
|
() => void 0,
|
|
1325
1205
|
() => void 0
|
|
1326
1206
|
);
|
|
1327
1207
|
return next;
|
|
1328
1208
|
}
|
|
1329
|
-
#
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
const
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
...this.#config.rewards === void 0 ? {} : {
|
|
1342
|
-
rewards: reconcileRewardStates(this.#config.rewards, [], 0, now)
|
|
1343
|
-
},
|
|
1344
|
-
updatedAt: now
|
|
1345
|
-
};
|
|
1346
|
-
return state;
|
|
1347
|
-
}
|
|
1348
|
-
#reconcileState(state, now) {
|
|
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
|
-
}
|
|
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);
|
|
1215
|
+
}
|
|
1216
|
+
#reconcile(state) {
|
|
1217
|
+
const ids = new Set(this.#config.spots.map((spot) => spot.id));
|
|
1218
|
+
const records = state.records.filter(
|
|
1219
|
+
(record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
|
|
1220
|
+
);
|
|
1359
1221
|
return {
|
|
1360
|
-
...state,
|
|
1222
|
+
...cloneState(state),
|
|
1223
|
+
userId: this.#userId,
|
|
1361
1224
|
records,
|
|
1362
1225
|
rewards: reconcileRewardStates(
|
|
1363
|
-
this.#config.rewards
|
|
1364
|
-
state.rewards
|
|
1226
|
+
this.#config.rewards,
|
|
1227
|
+
state.rewards,
|
|
1365
1228
|
records.length,
|
|
1366
|
-
|
|
1229
|
+
state.updatedAt
|
|
1367
1230
|
)
|
|
1368
1231
|
};
|
|
1369
1232
|
}
|
|
1233
|
+
#now() {
|
|
1234
|
+
return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1235
|
+
}
|
|
1236
|
+
#fail(error) {
|
|
1237
|
+
this.#emitEvent({ type: "error", error });
|
|
1238
|
+
return { ok: false, error };
|
|
1239
|
+
}
|
|
1240
|
+
#commitCheckIn(result) {
|
|
1241
|
+
if (result.ok) {
|
|
1242
|
+
this.#state = result.value.state;
|
|
1243
|
+
this.#emit(this.#state);
|
|
1244
|
+
}
|
|
1245
|
+
this.#emitEvent({ type: "checkIn", result });
|
|
1246
|
+
return result;
|
|
1247
|
+
}
|
|
1248
|
+
#commitClaim(result) {
|
|
1249
|
+
if (result.ok) {
|
|
1250
|
+
this.#state = result.value.state;
|
|
1251
|
+
this.#emit(this.#state);
|
|
1252
|
+
}
|
|
1253
|
+
this.#emitEvent({ type: "rewardClaimed", result });
|
|
1254
|
+
return result;
|
|
1255
|
+
}
|
|
1256
|
+
#emit(state) {
|
|
1257
|
+
for (const listener of this.#listeners) listener(state);
|
|
1258
|
+
}
|
|
1259
|
+
#emitEvent(event) {
|
|
1260
|
+
for (const listener of this.#eventListeners) listener(event);
|
|
1261
|
+
}
|
|
1370
1262
|
};
|
|
1371
1263
|
|
|
1372
1264
|
// src/crypto/token.ts
|
|
@@ -1502,367 +1394,18 @@ async function decryptPayload(body, secret) {
|
|
|
1502
1394
|
}
|
|
1503
1395
|
|
|
1504
1396
|
// src/domain/i18n.ts
|
|
1505
|
-
function resolveLocalizedText(
|
|
1506
|
-
if (
|
|
1507
|
-
if (typeof
|
|
1508
|
-
const fallback = fallbackLocale === void 0 ? Object.values(
|
|
1509
|
-
return
|
|
1510
|
-
}
|
|
1511
|
-
function toLocalizedString(
|
|
1512
|
-
if (
|
|
1513
|
-
return typeof
|
|
1514
|
-
ja:
|
|
1515
|
-
en:
|
|
1516
|
-
...
|
|
1517
|
-
};
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
// src/domain/validation.ts
|
|
1521
|
-
var CURRENT_RALLY_CONFIG_VERSION = 2;
|
|
1522
|
-
function isObject(value) {
|
|
1523
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1524
|
-
}
|
|
1525
|
-
function add(errors, path, code, message) {
|
|
1526
|
-
errors.push({ path, code, message });
|
|
1527
|
-
}
|
|
1528
|
-
function hasText(value) {
|
|
1529
|
-
if (typeof value === "string") return value.trim() !== "";
|
|
1530
|
-
if (!isObject(value)) return false;
|
|
1531
|
-
return Object.values(value).some((item) => typeof item === "string" && item.trim() !== "");
|
|
1532
|
-
}
|
|
1533
|
-
function validateCondition(value, path, errors) {
|
|
1534
|
-
if (!isObject(value) || typeof value.type !== "string") {
|
|
1535
|
-
add(errors, path, "INVALID_TYPE", "Condition must be an object with a type.");
|
|
1536
|
-
return;
|
|
1537
|
-
}
|
|
1538
|
-
switch (value.type) {
|
|
1539
|
-
case "instant":
|
|
1540
|
-
return;
|
|
1541
|
-
case "token":
|
|
1542
|
-
if (typeof value.token !== "string" || value.token.trim() === "") {
|
|
1543
|
-
add(errors, `${path}.token`, "REQUIRED", "Token must not be empty.");
|
|
1544
|
-
}
|
|
1545
|
-
return;
|
|
1546
|
-
case "geo": {
|
|
1547
|
-
const latitude = value.latitude;
|
|
1548
|
-
const longitude = value.longitude;
|
|
1549
|
-
if (typeof latitude !== "number" || !Number.isFinite(latitude) || latitude < -90 || latitude > 90 || typeof longitude !== "number" || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
|
1550
|
-
add(
|
|
1551
|
-
errors,
|
|
1552
|
-
path,
|
|
1553
|
-
"INVALID_COORDINATES",
|
|
1554
|
-
"Latitude must be between -90 and 90 and longitude between -180 and 180."
|
|
1555
|
-
);
|
|
1556
|
-
}
|
|
1557
|
-
if (typeof value.radiusMeters !== "number" || !Number.isFinite(value.radiusMeters) || value.radiusMeters <= 0) {
|
|
1558
|
-
add(errors, `${path}.radiusMeters`, "INVALID_RADIUS", "Radius must be greater than zero.");
|
|
1559
|
-
}
|
|
1560
|
-
return;
|
|
1561
|
-
}
|
|
1562
|
-
case "composite":
|
|
1563
|
-
if (value.operator !== "AND" && value.operator !== "OR") {
|
|
1564
|
-
add(errors, `${path}.operator`, "INVALID_TYPE", "Operator must be AND or OR.");
|
|
1565
|
-
}
|
|
1566
|
-
if (!Array.isArray(value.conditions)) {
|
|
1567
|
-
add(errors, `${path}.conditions`, "INVALID_TYPE", "Composite conditions must be an array.");
|
|
1568
|
-
return;
|
|
1569
|
-
}
|
|
1570
|
-
value.conditions.forEach((child, index) => {
|
|
1571
|
-
validateCondition(child, `${path}.conditions[${index}]`, errors);
|
|
1572
|
-
});
|
|
1573
|
-
return;
|
|
1574
|
-
case "time_window":
|
|
1575
|
-
if (typeof value.startsAt !== "string" || Number.isNaN(Date.parse(value.startsAt))) {
|
|
1576
|
-
add(errors, `${path}.startsAt`, "INVALID_DATE", "Start must be a valid ISO date.");
|
|
1577
|
-
}
|
|
1578
|
-
if (typeof value.endsAt !== "string" || Number.isNaN(Date.parse(value.endsAt))) {
|
|
1579
|
-
add(errors, `${path}.endsAt`, "INVALID_DATE", "End must be a valid ISO date.");
|
|
1580
|
-
}
|
|
1581
|
-
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)) {
|
|
1582
|
-
add(errors, path, "INVALID_DATE", "Time window start must be before its end.");
|
|
1583
|
-
}
|
|
1584
|
-
validateCondition(value.condition, `${path}.condition`, errors);
|
|
1585
|
-
return;
|
|
1586
|
-
default:
|
|
1587
|
-
add(errors, `${path}.type`, "INVALID_TYPE", "Unsupported condition type.");
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
function validateSpot(value, index, errors) {
|
|
1591
|
-
const path = `stamps[${index}]`;
|
|
1592
|
-
if (!isObject(value)) {
|
|
1593
|
-
add(errors, path, "INVALID_TYPE", "Spot must be an object.");
|
|
1594
|
-
return;
|
|
1595
|
-
}
|
|
1596
|
-
if (typeof value.id !== "string") add(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
|
|
1597
|
-
else if (value.id.trim() === "")
|
|
1598
|
-
add(errors, `${path}.id`, "EMPTY_STRING", "Spot ID must not be empty.");
|
|
1599
|
-
if (value.name === void 0) add(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
|
|
1600
|
-
else if (!hasText(value.name))
|
|
1601
|
-
add(errors, `${path}.name`, "EMPTY_STRING", "Spot name must not be empty.");
|
|
1602
|
-
if (value.order !== void 0 && (typeof value.order !== "number" || !Number.isFinite(value.order))) {
|
|
1603
|
-
add(errors, `${path}.order`, "INVALID_TYPE", "Spot order must be a finite number.");
|
|
1604
|
-
}
|
|
1605
|
-
validateCondition(value.condition, `${path}.condition`, errors);
|
|
1606
|
-
for (const field of ["dependsOn", "requiresStampIds"]) {
|
|
1607
|
-
if (value[field] !== void 0 && (!Array.isArray(value[field]) || value[field].some((item) => typeof item !== "string"))) {
|
|
1608
|
-
add(errors, `${path}.${field}`, "INVALID_TYPE", `${field} must be an array of IDs.`);
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
for (const field of ["order", "orderIndex"]) {
|
|
1612
|
-
if (value[field] !== void 0 && (typeof value[field] !== "number" || !Number.isFinite(value[field]))) {
|
|
1613
|
-
add(errors, `${path}.${field}`, "INVALID_TYPE", `${field} must be a finite number.`);
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
}
|
|
1617
|
-
function validateReward(value, index, stampCount, errors) {
|
|
1618
|
-
const path = `rewards[${index}]`;
|
|
1619
|
-
if (!isObject(value)) {
|
|
1620
|
-
add(errors, path, "INVALID_TYPE", "Reward must be an object.");
|
|
1621
|
-
return;
|
|
1622
|
-
}
|
|
1623
|
-
if (typeof value.id !== "string" || value.id.trim() === "") {
|
|
1624
|
-
add(errors, `${path}.id`, "REQUIRED", "Reward ID must not be empty.");
|
|
1625
|
-
}
|
|
1626
|
-
if (!hasText(value.title)) {
|
|
1627
|
-
add(errors, `${path}.title`, "EMPTY_STRING", "Reward title must not be empty.");
|
|
1628
|
-
}
|
|
1629
|
-
if (!hasText(value.description)) {
|
|
1630
|
-
add(errors, `${path}.description`, "EMPTY_STRING", "Reward description must not be empty.");
|
|
1631
|
-
}
|
|
1632
|
-
const required = value.requiredStampCount;
|
|
1633
|
-
if (typeof required !== "number" || !Number.isInteger(required) || required < 0 || required > stampCount) {
|
|
1634
|
-
add(
|
|
1635
|
-
errors,
|
|
1636
|
-
`${path}.requiredStampCount`,
|
|
1637
|
-
"INVALID_REWARD",
|
|
1638
|
-
"Required stamps must be an integer within the rally."
|
|
1639
|
-
);
|
|
1640
|
-
}
|
|
1641
|
-
if (value.validUntil !== void 0 && (typeof value.validUntil !== "string" || Number.isNaN(Date.parse(value.validUntil)))) {
|
|
1642
|
-
add(errors, `${path}.validUntil`, "INVALID_DATE", "Reward expiry must be a valid ISO date.");
|
|
1643
|
-
}
|
|
1644
|
-
for (const field of ["maxStock", "limitPerUser", "stockLimit", "userClaimLimit"]) {
|
|
1645
|
-
const count = value[field];
|
|
1646
|
-
if (count !== void 0 && (typeof count !== "number" || !Number.isInteger(count) || count <= 0)) {
|
|
1647
|
-
add(errors, `${path}.${field}`, "INVALID_REWARD", `${field} must be a positive integer.`);
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
function collectDependencies(spot) {
|
|
1652
|
-
const dependencies = /* @__PURE__ */ new Set();
|
|
1653
|
-
for (const field of ["dependsOn", "requiresStampIds"]) {
|
|
1654
|
-
const values = spot[field];
|
|
1655
|
-
if (Array.isArray(values)) {
|
|
1656
|
-
for (const value of values) if (typeof value === "string") dependencies.add(value);
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1659
|
-
return [...dependencies];
|
|
1660
|
-
}
|
|
1661
|
-
function validateDag(stamps, errors) {
|
|
1662
|
-
const graph = /* @__PURE__ */ new Map();
|
|
1663
|
-
for (const value of stamps) {
|
|
1664
|
-
if (!isObject(value) || typeof value.id !== "string") continue;
|
|
1665
|
-
graph.set(value.id, [...graph.get(value.id) ?? [], ...collectDependencies(value)]);
|
|
1666
|
-
}
|
|
1667
|
-
const visiting = /* @__PURE__ */ new Set();
|
|
1668
|
-
const visited = /* @__PURE__ */ new Set();
|
|
1669
|
-
const visit = (id, path) => {
|
|
1670
|
-
if (visiting.has(id)) {
|
|
1671
|
-
add(errors, path, "CYCLE_DETECTED", `Dependency cycle detected at '${id}'.`);
|
|
1672
|
-
return;
|
|
1673
|
-
}
|
|
1674
|
-
if (visited.has(id)) return;
|
|
1675
|
-
visiting.add(id);
|
|
1676
|
-
for (const dependency of graph.get(id) ?? [])
|
|
1677
|
-
if (graph.has(dependency)) visit(dependency, path);
|
|
1678
|
-
visiting.delete(id);
|
|
1679
|
-
visited.add(id);
|
|
1680
|
-
};
|
|
1681
|
-
for (const id of graph.keys()) visit(id, `stamps[${id}]`);
|
|
1682
|
-
}
|
|
1683
|
-
function validateRallyConfig(config) {
|
|
1684
|
-
const errors = [];
|
|
1685
|
-
if (!isObject(config))
|
|
1686
|
-
return {
|
|
1687
|
-
valid: false,
|
|
1688
|
-
errors: [{ path: "", code: "INVALID_TYPE", message: "Rally config must be an object." }]
|
|
1689
|
-
};
|
|
1690
|
-
if (typeof config.id !== "string") add(errors, "id", "REQUIRED", "Rally ID is required.");
|
|
1691
|
-
else if (config.id.trim() === "")
|
|
1692
|
-
add(errors, "id", "EMPTY_STRING", "Rally ID must not be empty.");
|
|
1693
|
-
if (config.title !== void 0 && !hasText(config.title))
|
|
1694
|
-
add(errors, "title", "EMPTY_STRING", "Rally title must not be empty.");
|
|
1695
|
-
if (config.version !== void 0 && config.version !== CURRENT_RALLY_CONFIG_VERSION) {
|
|
1696
|
-
add(
|
|
1697
|
-
errors,
|
|
1698
|
-
"version",
|
|
1699
|
-
"INVALID_VERSION",
|
|
1700
|
-
`Config version must be ${CURRENT_RALLY_CONFIG_VERSION}.`
|
|
1701
|
-
);
|
|
1702
|
-
}
|
|
1703
|
-
for (const field of ["startDate", "endDate"]) {
|
|
1704
|
-
if (config[field] !== void 0 && (typeof config[field] !== "string" || Number.isNaN(Date.parse(config[field])))) {
|
|
1705
|
-
add(errors, field, "INVALID_DATE", `${field} must be a valid ISO date.`);
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
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)) {
|
|
1709
|
-
add(errors, "startDate", "INVALID_DATE", "startDate must be before endDate.");
|
|
1710
|
-
}
|
|
1711
|
-
if (!Array.isArray(config.stamps)) {
|
|
1712
|
-
add(errors, "stamps", "INVALID_TYPE", "stamps must be an array.");
|
|
1713
|
-
} else {
|
|
1714
|
-
config.stamps.forEach((spot, index) => {
|
|
1715
|
-
validateSpot(spot, index, errors);
|
|
1716
|
-
});
|
|
1717
|
-
const ids = config.stamps.map(
|
|
1718
|
-
(spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : ""
|
|
1719
|
-
);
|
|
1720
|
-
ids.forEach((id, index) => {
|
|
1721
|
-
if (id !== "" && ids.indexOf(id) !== index)
|
|
1722
|
-
add(errors, `stamps[${index}].id`, "DUPLICATE_ID", `Duplicate spot ID '${id}'.`);
|
|
1723
|
-
});
|
|
1724
|
-
validateDag(config.stamps, errors);
|
|
1725
|
-
}
|
|
1726
|
-
if (config.rewards !== void 0 && !Array.isArray(config.rewards)) {
|
|
1727
|
-
add(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
|
|
1728
|
-
} else if (Array.isArray(config.rewards)) {
|
|
1729
|
-
const stampCount = Array.isArray(config.stamps) ? config.stamps.length : 0;
|
|
1730
|
-
config.rewards.forEach((reward, index) => {
|
|
1731
|
-
validateReward(reward, index, stampCount, errors);
|
|
1732
|
-
});
|
|
1733
|
-
const ids = config.rewards.map(
|
|
1734
|
-
(reward) => isObject(reward) && typeof reward.id === "string" ? reward.id : ""
|
|
1735
|
-
);
|
|
1736
|
-
ids.forEach((id, index) => {
|
|
1737
|
-
if (id !== "" && ids.indexOf(id) !== index)
|
|
1738
|
-
add(errors, `rewards[${index}].id`, "DUPLICATE_ID", `Duplicate reward ID '${id}'.`);
|
|
1739
|
-
});
|
|
1740
|
-
if (Array.isArray(config.stamps)) {
|
|
1741
|
-
const stampIds = new Set(
|
|
1742
|
-
config.stamps.map((spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : "")
|
|
1743
|
-
);
|
|
1744
|
-
ids.forEach((id, index) => {
|
|
1745
|
-
if (id !== "" && stampIds.has(id))
|
|
1746
|
-
add(
|
|
1747
|
-
errors,
|
|
1748
|
-
`rewards[${index}].id`,
|
|
1749
|
-
"DUPLICATE_ID",
|
|
1750
|
-
`Reward ID '${id}' is already used by a spot.`
|
|
1751
|
-
);
|
|
1752
|
-
});
|
|
1753
|
-
}
|
|
1754
|
-
}
|
|
1755
|
-
return { valid: errors.length === 0, errors };
|
|
1756
|
-
}
|
|
1757
|
-
|
|
1758
|
-
// src/domain/migration.ts
|
|
1759
|
-
function isObject2(value) {
|
|
1760
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1761
|
-
}
|
|
1762
|
-
function text(value) {
|
|
1763
|
-
if (typeof value === "string") return value;
|
|
1764
|
-
if (!isObject2(value)) return void 0;
|
|
1765
|
-
const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
|
|
1766
|
-
return entries.length === 0 ? void 0 : Object.fromEntries(entries);
|
|
1767
|
-
}
|
|
1768
|
-
function condition(value) {
|
|
1769
|
-
if (!isObject2(value) || typeof value.type !== "string") return { type: "instant" };
|
|
1770
|
-
switch (value.type) {
|
|
1771
|
-
case "instant":
|
|
1772
|
-
return { type: "instant" };
|
|
1773
|
-
case "token":
|
|
1774
|
-
case "passcode":
|
|
1775
|
-
return {
|
|
1776
|
-
type: "token",
|
|
1777
|
-
token: typeof value.token === "string" ? value.token : typeof value.passcode === "string" ? value.passcode : ""
|
|
1778
|
-
};
|
|
1779
|
-
case "geo":
|
|
1780
|
-
return {
|
|
1781
|
-
type: "geo",
|
|
1782
|
-
latitude: typeof value.latitude === "number" ? value.latitude : 0,
|
|
1783
|
-
longitude: typeof value.longitude === "number" ? value.longitude : 0,
|
|
1784
|
-
radiusMeters: typeof value.radiusMeters === "number" ? value.radiusMeters : typeof value.radius === "number" ? value.radius : 1
|
|
1785
|
-
};
|
|
1786
|
-
case "composite":
|
|
1787
|
-
return {
|
|
1788
|
-
type: "composite",
|
|
1789
|
-
operator: value.operator === "OR" ? "OR" : "AND",
|
|
1790
|
-
conditions: Array.isArray(value.conditions) ? value.conditions.map(condition) : []
|
|
1791
|
-
};
|
|
1792
|
-
case "time_window":
|
|
1793
|
-
return {
|
|
1794
|
-
type: "time_window",
|
|
1795
|
-
startsAt: typeof value.startsAt === "string" ? value.startsAt : "1970-01-01T00:00:00.000Z",
|
|
1796
|
-
endsAt: typeof value.endsAt === "string" ? value.endsAt : "9999-12-31T23:59:59.999Z",
|
|
1797
|
-
condition: condition(value.condition)
|
|
1798
|
-
};
|
|
1799
|
-
default:
|
|
1800
|
-
return { type: "instant" };
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
function migrateSpot(value, index) {
|
|
1804
|
-
const source2 = isObject2(value) ? value : {};
|
|
1805
|
-
const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `spot-${index + 1}`;
|
|
1806
|
-
const description = text(source2.description);
|
|
1807
|
-
const hint = text(source2.hint);
|
|
1808
|
-
return {
|
|
1809
|
-
id,
|
|
1810
|
-
name: text(source2.name) ?? `Spot ${index + 1}`,
|
|
1811
|
-
...description === void 0 ? {} : { description },
|
|
1812
|
-
...hint === void 0 ? {} : { hint },
|
|
1813
|
-
condition: condition(
|
|
1814
|
-
source2.condition ?? (typeof source2.token === "string" ? { type: "token", token: source2.token } : void 0)
|
|
1815
|
-
),
|
|
1816
|
-
...typeof source2.orderIndex === "number" ? { orderIndex: source2.orderIndex } : typeof source2.order === "number" ? { order: source2.order } : {},
|
|
1817
|
-
...typeof source2.deckId === "string" ? { deckId: source2.deckId } : {},
|
|
1818
|
-
...typeof source2.groupId === "string" ? { groupId: source2.groupId } : {},
|
|
1819
|
-
...typeof source2.guideId === "string" ? { guideId: source2.guideId } : {},
|
|
1820
|
-
...typeof source2.iconUrl === "string" ? { iconUrl: source2.iconUrl } : {},
|
|
1821
|
-
...typeof source2.imageUrl === "string" ? { imageUrl: source2.imageUrl } : {},
|
|
1822
|
-
...typeof source2.externalUrl === "string" ? { externalUrl: source2.externalUrl } : {},
|
|
1823
|
-
...typeof source2.redirectUrlAfterClaim === "string" ? { redirectUrlAfterClaim: source2.redirectUrlAfterClaim } : {},
|
|
1824
|
-
...isObject2(source2.metadata) ? { metadata: source2.metadata } : {},
|
|
1825
|
-
...Array.isArray(source2.dependsOn) ? { dependsOn: source2.dependsOn.filter((item) => typeof item === "string") } : {}
|
|
1826
|
-
};
|
|
1827
|
-
}
|
|
1828
|
-
function migrateReward(value, index) {
|
|
1829
|
-
const source2 = isObject2(value) ? value : {};
|
|
1830
|
-
return {
|
|
1831
|
-
id: typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `reward-${index + 1}`,
|
|
1832
|
-
title: text(source2.title) ?? `Reward ${index + 1}`,
|
|
1833
|
-
description: text(source2.description) ?? "",
|
|
1834
|
-
type: source2.type === "digital" ? "digital" : "in_person",
|
|
1835
|
-
redemptionMethod: source2.redemptionMethod === "staff_passcode" || source2.redemptionMethod === "view_only" || source2.redemptionMethod === "server_claim" ? source2.redemptionMethod : "manual_slide",
|
|
1836
|
-
requiredStampCount: typeof source2.requiredStampCount === "number" && Number.isFinite(source2.requiredStampCount) ? Math.max(0, Math.trunc(source2.requiredStampCount)) : 0,
|
|
1837
|
-
...typeof source2.digitalContentUrl === "string" ? { digitalContentUrl: source2.digitalContentUrl } : {},
|
|
1838
|
-
...typeof source2.staffPasscode === "string" ? { staffPasscode: source2.staffPasscode } : {},
|
|
1839
|
-
...typeof source2.validUntil === "string" ? { validUntil: source2.validUntil } : {},
|
|
1840
|
-
...typeof source2.maxStock === "number" ? { maxStock: source2.maxStock } : {},
|
|
1841
|
-
...typeof source2.limitPerUser === "number" ? { limitPerUser: source2.limitPerUser } : {},
|
|
1842
|
-
...typeof source2.stockLimit === "number" ? { stockLimit: source2.stockLimit } : {},
|
|
1843
|
-
...typeof source2.userClaimLimit === "number" ? { userClaimLimit: source2.userClaimLimit } : {},
|
|
1844
|
-
...typeof source2.claimTicketNumber === "string" ? { claimTicketNumber: source2.claimTicketNumber } : {}
|
|
1845
|
-
};
|
|
1846
|
-
}
|
|
1847
|
-
function migrateRallyConfig(raw) {
|
|
1848
|
-
const source2 = isObject2(raw) ? raw : {};
|
|
1849
|
-
const rawStamps = Array.isArray(source2.stamps) ? source2.stamps : Array.isArray(source2.spots) ? source2.spots : [];
|
|
1850
|
-
const rawRewards = Array.isArray(source2.rewards) ? source2.rewards : [];
|
|
1851
|
-
const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : "migrated-rally";
|
|
1852
|
-
const title = text(source2.title);
|
|
1853
|
-
const description = text(source2.description);
|
|
1854
|
-
const theme = isObject2(source2.theme) ? source2.theme : void 0;
|
|
1855
|
-
return {
|
|
1856
|
-
id,
|
|
1857
|
-
...title === void 0 ? {} : { title },
|
|
1858
|
-
...description === void 0 ? {} : { description },
|
|
1859
|
-
stamps: rawStamps.map(migrateSpot),
|
|
1860
|
-
...rawRewards.length === 0 ? {} : { rewards: rawRewards.map(migrateReward) },
|
|
1861
|
-
...typeof source2.isSequential === "boolean" ? { isSequential: source2.isSequential } : {},
|
|
1862
|
-
...theme === void 0 ? {} : { theme },
|
|
1863
|
-
version: CURRENT_RALLY_CONFIG_VERSION,
|
|
1864
|
-
...typeof source2.startDate === "string" ? { startDate: source2.startDate } : typeof source2.startsAt === "string" ? { startDate: source2.startsAt } : {},
|
|
1865
|
-
...typeof source2.endDate === "string" ? { endDate: source2.endDate } : typeof source2.endsAt === "string" ? { endDate: source2.endsAt } : {}
|
|
1397
|
+
function resolveLocalizedText(text, locale, fallbackLocale) {
|
|
1398
|
+
if (text === void 0 || text === "") return "";
|
|
1399
|
+
if (typeof text === "string") return text;
|
|
1400
|
+
const fallback = fallbackLocale === void 0 ? Object.values(text).find((value) => typeof value === "string") : text[fallbackLocale];
|
|
1401
|
+
return text[locale] || fallback || "";
|
|
1402
|
+
}
|
|
1403
|
+
function toLocalizedString(text) {
|
|
1404
|
+
if (text === void 0) return { ja: "", en: "" };
|
|
1405
|
+
return typeof text === "string" ? { ja: text, en: "" } : {
|
|
1406
|
+
ja: text["ja"] ?? "",
|
|
1407
|
+
en: text["en"] ?? "",
|
|
1408
|
+
...text
|
|
1866
1409
|
};
|
|
1867
1410
|
}
|
|
1868
1411
|
|
|
@@ -1877,69 +1420,98 @@ var DEFAULT_SHEET_THEME = {
|
|
|
1877
1420
|
unclaimedOpacity: 1,
|
|
1878
1421
|
fontFamily: "serif"
|
|
1879
1422
|
};
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
function toPublicCondition(condition2) {
|
|
1883
|
-
switch (condition2.type) {
|
|
1423
|
+
function publicCondition(condition) {
|
|
1424
|
+
switch (condition.type) {
|
|
1884
1425
|
case "qr":
|
|
1885
|
-
return { type: "qr", qrEntryUrl:
|
|
1426
|
+
return condition.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition.qrEntryUrl };
|
|
1886
1427
|
case "passcode":
|
|
1887
1428
|
return { type: "passcode" };
|
|
1888
1429
|
case "gps":
|
|
1889
1430
|
return {
|
|
1890
1431
|
type: "gps",
|
|
1891
|
-
latitude:
|
|
1892
|
-
longitude:
|
|
1893
|
-
radiusMeters:
|
|
1432
|
+
latitude: condition.latitude,
|
|
1433
|
+
longitude: condition.longitude,
|
|
1434
|
+
radiusMeters: condition.radiusMeters
|
|
1894
1435
|
};
|
|
1436
|
+
case "nfc":
|
|
1437
|
+
return { type: "nfc" };
|
|
1895
1438
|
case "custom":
|
|
1896
|
-
return { type: "custom", validatorName:
|
|
1439
|
+
return { type: "custom", validatorName: condition.validatorName };
|
|
1897
1440
|
}
|
|
1898
1441
|
}
|
|
1899
|
-
function
|
|
1442
|
+
function toPublicConfig(config) {
|
|
1900
1443
|
return {
|
|
1901
|
-
|
|
1444
|
+
id: config.id,
|
|
1445
|
+
version: config.version,
|
|
1446
|
+
title: config.title,
|
|
1447
|
+
...config.description === void 0 ? {} : { description: config.description },
|
|
1448
|
+
...config.theme === void 0 ? {} : { theme: config.theme },
|
|
1902
1449
|
spots: config.spots.map((spot) => ({
|
|
1903
1450
|
...spot,
|
|
1904
|
-
conditions: spot.conditions.map(
|
|
1451
|
+
conditions: spot.conditions.map(publicCondition)
|
|
1905
1452
|
})),
|
|
1906
1453
|
rewards: config.rewards.map(
|
|
1907
|
-
({
|
|
1908
|
-
)
|
|
1454
|
+
({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward }) => reward
|
|
1455
|
+
),
|
|
1456
|
+
...config.metadata === void 0 ? {} : { metadata: config.metadata },
|
|
1457
|
+
...config.serverEndpoint === void 0 ? {} : { serverEndpoint: config.serverEndpoint }
|
|
1909
1458
|
};
|
|
1910
1459
|
}
|
|
1911
|
-
function
|
|
1460
|
+
function assertPublicConfig(config) {
|
|
1461
|
+
if (!isPublicConfig(config)) throw new Error("Configuration contains private rally fields.");
|
|
1462
|
+
}
|
|
1463
|
+
function isPublicConfig(value) {
|
|
1912
1464
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1913
1465
|
const candidate = value;
|
|
1914
|
-
|
|
1466
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1467
|
+
const containsPrivateField = (item) => {
|
|
1468
|
+
if (typeof item !== "object" || item === null) return false;
|
|
1469
|
+
if (seen.has(item)) return false;
|
|
1470
|
+
seen.add(item);
|
|
1471
|
+
if (Array.isArray(item)) return item.some(containsPrivateField);
|
|
1472
|
+
const record = item;
|
|
1473
|
+
if (["staffPasscode", "secretToken", "serverMetadata", "secretParams", "digitalContentUrl"].some(
|
|
1474
|
+
(key) => key in record
|
|
1475
|
+
))
|
|
1476
|
+
return true;
|
|
1477
|
+
return Object.values(record).some(containsPrivateField);
|
|
1478
|
+
};
|
|
1479
|
+
if (containsPrivateField(candidate)) return false;
|
|
1480
|
+
for (const key of [
|
|
1481
|
+
"staffPasscode",
|
|
1482
|
+
"secretToken",
|
|
1483
|
+
"serverMetadata",
|
|
1484
|
+
"secretParams",
|
|
1485
|
+
"code",
|
|
1486
|
+
"tagId"
|
|
1487
|
+
]) {
|
|
1488
|
+
if (key in candidate) return false;
|
|
1489
|
+
}
|
|
1490
|
+
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))
|
|
1915
1491
|
return false;
|
|
1916
|
-
return
|
|
1492
|
+
return candidate.spots.every((spot) => {
|
|
1917
1493
|
if (typeof spot !== "object" || spot === null || Array.isArray(spot)) return false;
|
|
1918
|
-
const
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1494
|
+
const item = spot;
|
|
1495
|
+
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)
|
|
1496
|
+
return false;
|
|
1497
|
+
return Array.isArray(item.conditions) && item.conditions.every((condition) => {
|
|
1498
|
+
if (typeof condition !== "object" || condition === null || Array.isArray(condition))
|
|
1499
|
+
return false;
|
|
1500
|
+
const value2 = condition;
|
|
1501
|
+
if ("secretToken" in value2 || "code" in value2 || "tagId" in value2 || "secretParams" in value2)
|
|
1502
|
+
return false;
|
|
1503
|
+
const type = value2.type;
|
|
1504
|
+
if (type === "qr" || type === "passcode" || type === "nfc") return true;
|
|
1505
|
+
if (type === "custom") return typeof value2.validatorName === "string";
|
|
1506
|
+
return type === "gps" && typeof value2.latitude === "number" && typeof value2.longitude === "number" && typeof value2.radiusMeters === "number";
|
|
1923
1507
|
});
|
|
1924
1508
|
}) && candidate.rewards.every((reward) => {
|
|
1925
1509
|
if (typeof reward !== "object" || reward === null || Array.isArray(reward)) return false;
|
|
1926
1510
|
const item = reward;
|
|
1927
|
-
return item.
|
|
1511
|
+
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);
|
|
1928
1512
|
});
|
|
1929
1513
|
}
|
|
1930
1514
|
|
|
1931
|
-
// src/domain/publicConfig.ts
|
|
1932
|
-
function stripSensitiveConfig(config) {
|
|
1933
|
-
if ("spots" in config && !("stamps" in config)) {
|
|
1934
|
-
return toPublicRallyConfig(config);
|
|
1935
|
-
}
|
|
1936
|
-
const rewards = config.rewards?.map(({ staffPasscode: _staffPasscode, ...reward }) => reward);
|
|
1937
|
-
return {
|
|
1938
|
-
...config,
|
|
1939
|
-
...rewards === void 0 ? {} : { rewards }
|
|
1940
|
-
};
|
|
1941
|
-
}
|
|
1942
|
-
|
|
1943
1515
|
// src/domain/themePresets.ts
|
|
1944
1516
|
var THEME_PRESETS = [
|
|
1945
1517
|
{
|
|
@@ -2039,180 +1611,6 @@ var THEME_PRESETS = [
|
|
|
2039
1611
|
}
|
|
2040
1612
|
];
|
|
2041
1613
|
|
|
2042
|
-
// src/domain/universalValidation.ts
|
|
2043
|
-
function isObject3(value) {
|
|
2044
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2045
|
-
}
|
|
2046
|
-
function add2(errors, path, code, message) {
|
|
2047
|
-
errors.push({ path, code, message });
|
|
2048
|
-
}
|
|
2049
|
-
function hasText2(value) {
|
|
2050
|
-
if (typeof value === "string") return value.trim() !== "";
|
|
2051
|
-
return isObject3(value) && Object.values(value).some((item) => typeof item === "string" && item.trim() !== "");
|
|
2052
|
-
}
|
|
2053
|
-
function validateCondition2(condition2, path, errors) {
|
|
2054
|
-
if (!isObject3(condition2) || typeof condition2.type !== "string") {
|
|
2055
|
-
add2(errors, path, "INVALID_TYPE", "Condition must have a type.");
|
|
2056
|
-
return;
|
|
2057
|
-
}
|
|
2058
|
-
switch (condition2.type) {
|
|
2059
|
-
case "qr":
|
|
2060
|
-
if (typeof condition2.secretToken !== "string" || condition2.secretToken.trim() === "")
|
|
2061
|
-
add2(errors, `${path}.secretToken`, "REQUIRED", "QR secretToken is required.");
|
|
2062
|
-
if (condition2.qrEntryUrl !== void 0 && typeof condition2.qrEntryUrl !== "string")
|
|
2063
|
-
add2(errors, `${path}.qrEntryUrl`, "INVALID_TYPE", "QR entry URL must be a string.");
|
|
2064
|
-
return;
|
|
2065
|
-
case "passcode":
|
|
2066
|
-
if (typeof condition2.code !== "string" || condition2.code.trim() === "")
|
|
2067
|
-
add2(errors, `${path}.code`, "REQUIRED", "Passcode is required.");
|
|
2068
|
-
return;
|
|
2069
|
-
case "gps":
|
|
2070
|
-
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)
|
|
2071
|
-
add2(errors, path, "INVALID_COORDINATES", "GPS coordinates are invalid.");
|
|
2072
|
-
if (typeof condition2.radiusMeters !== "number" || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters <= 0)
|
|
2073
|
-
add2(errors, `${path}.radiusMeters`, "INVALID_RADIUS", "GPS radius must be positive.");
|
|
2074
|
-
return;
|
|
2075
|
-
case "custom":
|
|
2076
|
-
if (typeof condition2.validatorName !== "string" || condition2.validatorName.trim() === "")
|
|
2077
|
-
add2(errors, `${path}.validatorName`, "REQUIRED", "Custom validatorName is required.");
|
|
2078
|
-
return;
|
|
2079
|
-
default:
|
|
2080
|
-
add2(errors, `${path}.type`, "INVALID_TYPE", "Unsupported verification condition.");
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
function validateDag2(spots, errors) {
|
|
2084
|
-
const graph = /* @__PURE__ */ new Map();
|
|
2085
|
-
for (const spot of spots) {
|
|
2086
|
-
if (!isObject3(spot) || typeof spot.id !== "string") continue;
|
|
2087
|
-
const prerequisites = Array.isArray(spot.prerequisites) ? spot.prerequisites.filter((item) => typeof item === "string") : [];
|
|
2088
|
-
graph.set(spot.id, prerequisites);
|
|
2089
|
-
}
|
|
2090
|
-
const visiting = /* @__PURE__ */ new Set();
|
|
2091
|
-
const visited = /* @__PURE__ */ new Set();
|
|
2092
|
-
const visit = (id) => {
|
|
2093
|
-
if (visiting.has(id)) {
|
|
2094
|
-
add2(
|
|
2095
|
-
errors,
|
|
2096
|
-
`spots.${id}.prerequisites`,
|
|
2097
|
-
"CYCLE_DETECTED",
|
|
2098
|
-
`Dependency cycle detected at '${id}'.`
|
|
2099
|
-
);
|
|
2100
|
-
return;
|
|
2101
|
-
}
|
|
2102
|
-
if (visited.has(id)) return;
|
|
2103
|
-
visiting.add(id);
|
|
2104
|
-
for (const prerequisite of graph.get(id) ?? [])
|
|
2105
|
-
if (graph.has(prerequisite)) visit(prerequisite);
|
|
2106
|
-
visiting.delete(id);
|
|
2107
|
-
visited.add(id);
|
|
2108
|
-
};
|
|
2109
|
-
for (const id of graph.keys()) visit(id);
|
|
2110
|
-
}
|
|
2111
|
-
function validateAdminRallyConfig(value) {
|
|
2112
|
-
const errors = [];
|
|
2113
|
-
if (!isObject3(value))
|
|
2114
|
-
return {
|
|
2115
|
-
valid: false,
|
|
2116
|
-
errors: [{ path: "", code: "INVALID_TYPE", message: "Admin config must be an object." }]
|
|
2117
|
-
};
|
|
2118
|
-
if (typeof value.id !== "string" || value.id.trim() === "")
|
|
2119
|
-
add2(errors, "id", "REQUIRED", "Rally ID is required.");
|
|
2120
|
-
if (typeof value.version !== "string" || value.version.trim() === "")
|
|
2121
|
-
add2(errors, "version", "INVALID_VERSION", "Version is required.");
|
|
2122
|
-
if (!Array.isArray(value.spots)) {
|
|
2123
|
-
add2(errors, "spots", "INVALID_TYPE", "spots must be an array.");
|
|
2124
|
-
} else {
|
|
2125
|
-
const ids = [];
|
|
2126
|
-
value.spots.forEach((spot, index) => {
|
|
2127
|
-
const path = `spots[${index}]`;
|
|
2128
|
-
if (!isObject3(spot)) {
|
|
2129
|
-
add2(errors, path, "INVALID_TYPE", "Spot must be an object.");
|
|
2130
|
-
return;
|
|
2131
|
-
}
|
|
2132
|
-
if (typeof spot.id !== "string" || spot.id.trim() === "")
|
|
2133
|
-
add2(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
|
|
2134
|
-
else if (ids.includes(spot.id))
|
|
2135
|
-
add2(errors, `${path}.id`, "DUPLICATE_ID", `Duplicate spot ID '${spot.id}'.`);
|
|
2136
|
-
else ids.push(spot.id);
|
|
2137
|
-
if (!hasText2(spot.name)) add2(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
|
|
2138
|
-
if (typeof spot.orderIndex !== "number" || !Number.isInteger(spot.orderIndex))
|
|
2139
|
-
add2(errors, `${path}.orderIndex`, "INVALID_TYPE", "orderIndex must be an integer.");
|
|
2140
|
-
if (!Array.isArray(spot.conditions) || spot.conditions.length === 0)
|
|
2141
|
-
add2(errors, `${path}.conditions`, "REQUIRED", "At least one condition is required.");
|
|
2142
|
-
else {
|
|
2143
|
-
spot.conditions.forEach((condition2, conditionIndex) => {
|
|
2144
|
-
validateCondition2(condition2, `${path}.conditions[${conditionIndex}]`, errors);
|
|
2145
|
-
});
|
|
2146
|
-
}
|
|
2147
|
-
});
|
|
2148
|
-
validateDag2(value.spots, errors);
|
|
2149
|
-
}
|
|
2150
|
-
if (!Array.isArray(value.rewards))
|
|
2151
|
-
add2(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
|
|
2152
|
-
else {
|
|
2153
|
-
const ids = [];
|
|
2154
|
-
value.rewards.forEach((reward, index) => {
|
|
2155
|
-
const path = `rewards[${index}]`;
|
|
2156
|
-
if (!isObject3(reward)) {
|
|
2157
|
-
add2(errors, path, "INVALID_TYPE", "Reward must be an object.");
|
|
2158
|
-
return;
|
|
2159
|
-
}
|
|
2160
|
-
if (typeof reward.id !== "string" || reward.id.trim() === "")
|
|
2161
|
-
add2(errors, `${path}.id`, "REQUIRED", "Reward ID is required.");
|
|
2162
|
-
else if (ids.includes(reward.id))
|
|
2163
|
-
add2(errors, `${path}.id`, "DUPLICATE_ID", `Duplicate reward ID '${reward.id}'.`);
|
|
2164
|
-
else ids.push(reward.id);
|
|
2165
|
-
if (!hasText2(reward.title))
|
|
2166
|
-
add2(errors, `${path}.title`, "REQUIRED", "Reward title is required.");
|
|
2167
|
-
if (typeof reward.requiredStampCount !== "number" || !Number.isInteger(reward.requiredStampCount) || reward.requiredStampCount < 0)
|
|
2168
|
-
add2(
|
|
2169
|
-
errors,
|
|
2170
|
-
`${path}.requiredStampCount`,
|
|
2171
|
-
"INVALID_REWARD",
|
|
2172
|
-
"requiredStampCount must be a non-negative integer."
|
|
2173
|
-
);
|
|
2174
|
-
});
|
|
2175
|
-
}
|
|
2176
|
-
return { valid: errors.length === 0, errors };
|
|
2177
|
-
}
|
|
2178
|
-
function validatePublicRallyConfig(value) {
|
|
2179
|
-
const errors = [];
|
|
2180
|
-
if (!isObject3(value))
|
|
2181
|
-
return {
|
|
2182
|
-
valid: false,
|
|
2183
|
-
errors: [{ path: "", code: "INVALID_TYPE", message: "Public config must be an object." }]
|
|
2184
|
-
};
|
|
2185
|
-
if ("secretKey" in value || "verificationSecrets" in value)
|
|
2186
|
-
add2(
|
|
2187
|
-
errors,
|
|
2188
|
-
"",
|
|
2189
|
-
"SECRET_IN_PUBLIC_CONFIG",
|
|
2190
|
-
"Public config must not contain server verification secrets."
|
|
2191
|
-
);
|
|
2192
|
-
if (!Array.isArray(value.spots)) add2(errors, "spots", "INVALID_TYPE", "spots must be an array.");
|
|
2193
|
-
else
|
|
2194
|
-
value.spots.forEach((spot, index) => {
|
|
2195
|
-
if (!isObject3(spot) || !Array.isArray(spot.conditions)) return;
|
|
2196
|
-
spot.conditions.forEach((condition2, conditionIndex) => {
|
|
2197
|
-
if (!isObject3(condition2)) return;
|
|
2198
|
-
if ("secretToken" in condition2 || "code" in condition2 || "secretParams" in condition2)
|
|
2199
|
-
add2(
|
|
2200
|
-
errors,
|
|
2201
|
-
`spots[${index}].conditions[${conditionIndex}]`,
|
|
2202
|
-
"SECRET_IN_PUBLIC_CONFIG",
|
|
2203
|
-
"Public condition contains verification secret material."
|
|
2204
|
-
);
|
|
2205
|
-
});
|
|
2206
|
-
});
|
|
2207
|
-
return { valid: errors.length === 0, errors };
|
|
2208
|
-
}
|
|
2209
|
-
function isAdminRallyConfig(value) {
|
|
2210
|
-
return validateAdminRallyConfig(value).valid;
|
|
2211
|
-
}
|
|
2212
|
-
function isPublicRallyConfigShape(value) {
|
|
2213
|
-
return validatePublicRallyConfig(value).valid;
|
|
2214
|
-
}
|
|
2215
|
-
|
|
2216
1614
|
// src/security/snapshotToken.ts
|
|
2217
1615
|
var encoder2 = new TextEncoder();
|
|
2218
1616
|
function cryptoApi2() {
|
|
@@ -2332,6 +1730,6 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
|
|
|
2332
1730
|
}
|
|
2333
1731
|
}
|
|
2334
1732
|
|
|
2335
|
-
export {
|
|
1733
|
+
export { 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, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, storageKey, toLocalizedString, toPublicConfig, verifyPasscode, verifySecureToken, verifySnapshotToken };
|
|
2336
1734
|
//# sourceMappingURL=index.js.map
|
|
2337
1735
|
//# sourceMappingURL=index.js.map
|