CampgroundTickets/backend/src/services/redeemQueue.ts
Hank 3397e3e3ec Initial Camp Scan ticketing system
Backend (Fastify + TS): FluentForms webhook -> NocoDB row + QR + MailerSend
email; PIN auth; scan/lookup/redeem with per-code serialization; reusable QR
codes with count-based check-in; admin search.

App (Expo, one codebase): Android APK + iPhone PWA. Login, camera scanner
(native + web barcode-detector split), green/red overlay with sound + haptics,
admin lookup/redeem. Session token persisted per device.

Ops: multi-stage Dockerfile serving API + PWA same-origin, compose bound to
127.0.0.1; Forgejo Actions runner + tag-triggered signed APK build for Obtainium.
Docs in README.md and INSTALL.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 03:47:59 +00:00

48 lines
1.5 KiB
TypeScript

/**
* Per-key serialization. NocoDB has no atomic increment, so all read-modify-write
* operations for a given ticket code must run one at a time. Operations on
* different codes run concurrently.
*
* IMPORTANT: correctness depends on a SINGLE backend instance. Never scale this
* service to multiple replicas — the queue is in-process only.
*/
export class RedeemQueue {
private chains = new Map<string, Promise<unknown>>();
private readonly timeoutMs: number;
constructor(timeoutMs = 10_000) {
this.timeoutMs = timeoutMs;
}
/** Run `fn` after any in-flight op for `key` completes. */
run<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prior = this.chains.get(key) ?? Promise.resolve();
// Chain regardless of whether the prior op resolved or rejected.
const next = prior.catch(() => undefined).then(() => this.withTimeout(fn));
this.chains.set(key, next);
// Clean up the map entry once this is the tail of the chain.
next.finally(() => {
if (this.chains.get(key) === next) this.chains.delete(key);
}).catch(() => undefined);
return next;
}
private withTimeout<T>(fn: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error("redeem operation timed out")),
this.timeoutMs,
);
fn().then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
},
);
});
}
}