From 9e25251f1bbaf91432af6d3fe441158e52e82c32 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sat, 23 May 2026 18:27:13 +0200 Subject: [PATCH] Documentation and TODOs --- src/services/invitation-sync-client.ts | 10 +++ src/services/order-invitation-tracker.ts | 82 ++++++++++++++++++------ 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/services/invitation-sync-client.ts b/src/services/invitation-sync-client.ts index 594ac6b..bd19a8e 100644 --- a/src/services/invitation-sync-client.ts +++ b/src/services/invitation-sync-client.ts @@ -3,6 +3,9 @@ import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; import { SSESession, type SSEvent } from "../utils/sse-session.js"; +/** + * Listeners for the invitation sync client. + */ export type InvitationSyncListeners = { onInvitationUpdated?: (invitation: XOInvitation) => void; onConnected?: () => void; @@ -10,8 +13,14 @@ export type InvitationSyncListeners = { onDisconnected?: () => void; }; +/** + * TODO: Why are there two classes in this file? I would much rather split this up by responsibility, or merge the two of them somehow. + * Leaving for now because I also don't hate the separation of concerns. + */ + /** * Live SSE subscription for a single invitation. + * TODO: Why isnt this using the event emitter? */ export class InvitationSyncSubscription { private sse: SSESession; @@ -127,6 +136,7 @@ export class InvitationSyncClient { /** * Parse invitation from SSE event payloads (supports xo-cli wrapped and direct formats). + * TODO: Move into a class with a static method. */ function parseInvitationFromSseEvent(event: SSEvent): XOInvitation | undefined { if (!event.data) { diff --git a/src/services/order-invitation-tracker.ts b/src/services/order-invitation-tracker.ts index 01481dc..59aed52 100644 --- a/src/services/order-invitation-tracker.ts +++ b/src/services/order-invitation-tracker.ts @@ -29,26 +29,38 @@ export class OrderInvitationTracker { this.initialCommitCount = deps.invitation.commits?.length ?? 0; } + /** + * Start the tracker by publishing the invitation to the sync server and subscribing to updates. + * @param deps - The dependencies for the tracker + * @returns The tracker + */ static async start( deps: OrderInvitationTrackerDeps, ): Promise { + // Create a new tracker. const tracker = new OrderInvitationTracker(deps); try { + // Publish the invitation to the sync server. await deps.syncClient.publish(deps.invitation); } catch (error) { + // If the invitation fails to publish, continue anyway. deps.debug( "Failed to publish invitation to sync server (continuing): %o", error, ); } + // Subscribe to updates for the invitation. + // TODO: Why isnt this using the event emitter? tracker.subscription = deps.syncClient.subscribe( deps.invitation.invitationIdentifier, { + // Handle updates for the invitation. onInvitationUpdated: (invitation) => { void tracker.handleUpdate(invitation); }, + // Handle errors from the sync server. onError: (error) => { deps.debug( "Sync subscription error for order %s: %o", @@ -59,10 +71,16 @@ export class OrderInvitationTracker { }, ); + // Connect to the sync server. await tracker.subscription.connect(); + + // Return the tracker. return tracker; } + /** + * Stop the tracker by closing the subscription and clearing the dispense timer. + */ stop(): void { this.stopped = true; this.subscription?.close(); @@ -75,26 +93,32 @@ export class OrderInvitationTracker { } private async handleUpdate(invitation: XOInvitation): Promise { + // If the tracker has been stopped, return. if (this.stopped) { return; } - const mergedCommits = mergeCommits( + // Merge the commits from the initial invitation and the updated invitation. + const mergedCommits = OrderInvitationTracker.mergeCommits( this.deps.invitation.commits ?? [], invitation.commits ?? [], ); + + // Update the invitation with the merged commits. this.deps.invitation = { ...this.deps.invitation, ...invitation, commits: mergedCommits, }; + // Get the order from the database. const order = await this.deps.database.db .selectFrom("orders") .selectAll() .where("id", "=", this.deps.orderId) .executeTakeFirst(); + // If the order is not found, or the order is completed or cancelled, stop the tracker. if ( !order || order.status === "completed" || @@ -108,6 +132,7 @@ export class OrderInvitationTracker { // Basing it off of the commit count is flawed and useless. const hasCustomerActivity = mergedCommits.length > this.initialCommitCount; + // If the order is pending and there is customer activity, mark the order as paid and set the dispense timer. if (order.status === "pending" && hasCustomerActivity) { await this.deps.database.db .updateTable("orders") @@ -123,22 +148,31 @@ export class OrderInvitationTracker { } } + /** + * Complete the order by marking it as completed and decrementing the stock of the items. + * TODO: There is a performance improvement to be made here by passing in the order from handleUpdate. Its probably worth almost nothing though + * @returns A promise that resolves when the order has been completed + */ private async completeOrder(): Promise { + // If the tracker has been stopped, return. if (this.stopped) { return; } + // Get the order from the database. const order = await this.deps.database.db .selectFrom("orders") .selectAll() .where("id", "=", this.deps.orderId) .executeTakeFirst(); + // If the order is not found, or the order is completed, stop the tracker. if (!order || order.status === "completed") { this.stop(); return; } + // Mark the order as completed. await this.deps.database.db .updateTable("orders") .set({ status: "completed" }) @@ -147,18 +181,24 @@ export class OrderInvitationTracker { this.deps.debug("Order %s mock dispensed (completed)", this.deps.orderId); + // Decrement the stock of the items. try { + // Parse the line items from the order. const lineItems = JSON.parse(order.items) as Array<{ id: string; quantity: number; }>; + + // Loop through the line items and decrement the stock of the items. for (const line of lineItems) { + // Get the item from the database. const item = await this.deps.database.db .selectFrom("items") .selectAll() .where("id", "=", line.id) .executeTakeFirst(); + // If the item is found, decrement the stock of the item. if (item) { await this.deps.database.db .updateTable("items") @@ -168,27 +208,31 @@ export class OrderInvitationTracker { } } } catch (error) { - this.deps.debug( - "Failed to decrement stock for order %s: %o", - this.deps.orderId, - error, - ); + // If the error is not an order payment error, return a 500 error. + this.deps.debug("Failed to decrement stock for order %s: %o", this.deps.orderId, error); } + // Stop the tracker. this.stop(); } -} -function mergeCommits( - initial: XOInvitationCommit[], - additional: XOInvitationCommit[], -): XOInvitationCommit[] { - const map = new Map(); - for (const commit of initial) { - map.set(commit.commitIdentifier, commit); + /** + * Merge two arrays of commits into a single array of commits. + * TODO: Make sure the commits are actually immutable. If they aren't this would allow the "additional" commits to modify the initial commits. + * @param initial - The initial commits + * @param additional - The additional commits + * @returns The merged commits + */ + static mergeCommits(initial: XOInvitationCommit[], additional: XOInvitationCommit[]): XOInvitationCommit[] { + // Create a map of the initial commits. + const map = Object.fromEntries(initial.map(commit => [commit.commitIdentifier, commit])); + + // Add the additional commits to the map. + for (const commit of additional) { + map[commit.commitIdentifier] = commit; + } + + // Return the merged commits. + return Object.values(map); } - for (const commit of additional) { - map.set(commit.commitIdentifier, commit); - } - return Array.from(map.values()); -} +};