@teincfood/core 0.7.8 → 0.7.9
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/adapters/auth.d.ts +2 -0
- package/dist/adapters/auth.js +7 -0
- package/dist/adapters/services.d.ts +3 -0
- package/dist/adapters/services.js +3 -0
- package/dist/hooks/orders.hooks.d.ts +1 -0
- package/dist/hooks/orders.hooks.js +24 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/realtime/events.service.d.ts +18 -0
- package/dist/realtime/events.service.js +76 -0
- package/dist/realtime/order-events.service.d.ts +6 -0
- package/dist/realtime/order-events.service.js +17 -0
- package/dist/realtime/phoenix-socket.d.ts +55 -0
- package/dist/realtime/phoenix-socket.js +240 -0
- package/dist/realtime/useEventsConnection.d.ts +1 -0
- package/dist/realtime/useEventsConnection.js +41 -0
- package/dist/reference/capabilities.js +1 -0
- package/dist/repositories/order.repository.d.ts +1 -0
- package/dist/repositories/order.repository.js +9 -0
- package/dist/sync/command-queue.service.js +34 -2
- package/dist/sync/order-snapshots.service.d.ts +1 -0
- package/dist/sync/order-snapshots.service.js +30 -0
- package/dist/sync/pending-orders.service.js +81 -2
- package/dist/sync/transport-rest.js +12 -5
- package/dist/sync/use-sync-reconciliation.js +43 -15
- package/package.json +1 -1
package/dist/adapters/auth.d.ts
CHANGED
package/dist/adapters/auth.js
CHANGED
|
@@ -8,3 +8,10 @@ export function setAuthUser(u) {
|
|
|
8
8
|
export const useAuthStore = {
|
|
9
9
|
getState: () => ({ user }),
|
|
10
10
|
};
|
|
11
|
+
let getAccessToken = null;
|
|
12
|
+
export function setAuthProvider(fn) {
|
|
13
|
+
getAccessToken = fn;
|
|
14
|
+
}
|
|
15
|
+
export function getAuthToken() {
|
|
16
|
+
return getAccessToken ? getAccessToken() : null;
|
|
17
|
+
}
|
|
@@ -40,6 +40,9 @@ export declare const sellerService: {
|
|
|
40
40
|
data: unknown;
|
|
41
41
|
}>;
|
|
42
42
|
confirmHandover: (_deliveryId: string) => Promise<unknown>;
|
|
43
|
+
confirmCashPayment: (_orderId: string) => Promise<{
|
|
44
|
+
data: unknown;
|
|
45
|
+
}>;
|
|
43
46
|
};
|
|
44
47
|
export declare const kioskService: {
|
|
45
48
|
assignKioskOrderToRider: (_orderId: string, _riderId: string) => Promise<unknown>;
|
|
@@ -49,6 +49,9 @@ export const sellerService = {
|
|
|
49
49
|
confirmHandover: async (_deliveryId) => {
|
|
50
50
|
notImpl("sellerService.confirmHandover");
|
|
51
51
|
},
|
|
52
|
+
confirmCashPayment: async (_orderId) => {
|
|
53
|
+
notImpl("sellerService.confirmCashPayment");
|
|
54
|
+
},
|
|
52
55
|
};
|
|
53
56
|
// kiosk.service
|
|
54
57
|
export const kioskService = {
|
|
@@ -63,6 +63,7 @@ export declare function useAssignRiderMutation(): import("@tanstack/react-query"
|
|
|
63
63
|
orderId: string;
|
|
64
64
|
riderId: string;
|
|
65
65
|
}, unknown>;
|
|
66
|
+
export declare function useConfirmCashPaymentMutation(): import("@tanstack/react-query").UseMutationResult<SellerOrder, Error, string, unknown>;
|
|
66
67
|
export declare function useUpdateOrderStatusMutation(): {
|
|
67
68
|
mutate: (orderId: string, status: string) => Promise<unknown>;
|
|
68
69
|
isPending: boolean;
|
|
@@ -277,9 +277,13 @@ export function useSellerOrderQuery(orderId, enabled = true) {
|
|
|
277
277
|
const businessId = useBusinessId();
|
|
278
278
|
const connectivity = useConnectivity();
|
|
279
279
|
const offline = isOfflineState(connectivity);
|
|
280
|
+
const pendingOrders = useCorePendingOrders(businessId);
|
|
280
281
|
return useQuery({
|
|
281
282
|
queryKey: SELLER_KEYS.order(orderId, businessId),
|
|
282
283
|
queryFn: async () => {
|
|
284
|
+
const pendingHit = pendingOrders.find((o) => o.id === orderId || o.order_number === orderId);
|
|
285
|
+
if (pendingHit)
|
|
286
|
+
return { data: pendingHit };
|
|
283
287
|
if (offline) {
|
|
284
288
|
const list = businessId ? await orderSnapshotsService.getSellerList(businessId, businessId) : null;
|
|
285
289
|
const found = list?.find((o) => o.id === orderId || o.order_number === orderId);
|
|
@@ -295,6 +299,9 @@ export function useSellerOrderQuery(orderId, enabled = true) {
|
|
|
295
299
|
const found = list?.find((o) => o.id === orderId || o.order_number === orderId);
|
|
296
300
|
if (found)
|
|
297
301
|
return { data: found };
|
|
302
|
+
const pendingAgain = getPendingOrdersSnapshot().orders.find((o) => o.id === orderId || o.order_number === orderId);
|
|
303
|
+
if (pendingAgain)
|
|
304
|
+
return { data: pendingAgain };
|
|
298
305
|
throw e;
|
|
299
306
|
}
|
|
300
307
|
},
|
|
@@ -369,6 +376,23 @@ export function useAssignRiderMutation() {
|
|
|
369
376
|
},
|
|
370
377
|
});
|
|
371
378
|
}
|
|
379
|
+
export function useConfirmCashPaymentMutation() {
|
|
380
|
+
const businessId = useBusinessId();
|
|
381
|
+
const queryClient = useQueryClient();
|
|
382
|
+
return useMutation({
|
|
383
|
+
mutationFn: (orderId) => {
|
|
384
|
+
if (!businessId)
|
|
385
|
+
throw new Error("No active business");
|
|
386
|
+
return orderRepository.confirmCashPayment({ orderId, businessId });
|
|
387
|
+
},
|
|
388
|
+
onSettled: (_d, _e, orderId) => {
|
|
389
|
+
queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
|
|
390
|
+
queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
|
|
391
|
+
if (orderId)
|
|
392
|
+
queryClient.invalidateQueries({ queryKey: SELLER_KEYS.order(orderId) });
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
}
|
|
372
396
|
export function useUpdateOrderStatusMutation() {
|
|
373
397
|
const accept = useAcceptOrderMutation();
|
|
374
398
|
const reject = useRejectOrderMutation();
|
package/dist/index.d.ts
CHANGED
|
@@ -69,6 +69,7 @@ export * from "./adapters/http";
|
|
|
69
69
|
export * from "./adapters/local-node";
|
|
70
70
|
export * from "./adapters/services";
|
|
71
71
|
export * from "./adapters/devices";
|
|
72
|
+
export { setAuthProvider, getAuthToken } from "./adapters/auth";
|
|
72
73
|
export { imageCacheService } from "./adapters/image-cache";
|
|
73
74
|
export * from "./adapters/business";
|
|
74
75
|
export * from "./adapters/pending-orders";
|
|
@@ -83,6 +84,10 @@ export * from "./printer/hooks";
|
|
|
83
84
|
export * from "./cart/store";
|
|
84
85
|
export * from "./cart/hooks";
|
|
85
86
|
export * from "./catalog/hooks";
|
|
87
|
+
export { phoenixSocket } from "./realtime/phoenix-socket";
|
|
88
|
+
export { eventsService } from "./realtime/events.service";
|
|
89
|
+
export { orderEventsService } from "./realtime/order-events.service";
|
|
90
|
+
export { useEventsConnection } from "./realtime/useEventsConnection";
|
|
86
91
|
export { getCachedImagePath, ensureImageCacheDir, getCachedImageInfo, cacheRemoteImage, cacheImageBatch, clearImageCache, pruneImageCache, resolveImageSource, stableCacheUrl } from "./image-cache/service";
|
|
87
92
|
import { type SqliteDriver } from "./adapters/sqlite";
|
|
88
93
|
import { type KVDriver } from "./adapters/kv";
|
package/dist/index.js
CHANGED
|
@@ -72,6 +72,7 @@ export * from "./adapters/http";
|
|
|
72
72
|
export * from "./adapters/local-node";
|
|
73
73
|
export * from "./adapters/services";
|
|
74
74
|
export * from "./adapters/devices";
|
|
75
|
+
export { setAuthProvider, getAuthToken } from "./adapters/auth";
|
|
75
76
|
export { imageCacheService } from "./adapters/image-cache";
|
|
76
77
|
export * from "./adapters/business";
|
|
77
78
|
export * from "./adapters/pending-orders";
|
|
@@ -90,6 +91,11 @@ export * from "./cart/store";
|
|
|
90
91
|
export * from "./cart/hooks";
|
|
91
92
|
// ── Catalog ──
|
|
92
93
|
export * from "./catalog/hooks";
|
|
94
|
+
// ── Realtime (single source — PHX WebSocket + business:{id}) ──
|
|
95
|
+
export { phoenixSocket } from "./realtime/phoenix-socket";
|
|
96
|
+
export { eventsService } from "./realtime/events.service";
|
|
97
|
+
export { orderEventsService } from "./realtime/order-events.service";
|
|
98
|
+
export { useEventsConnection } from "./realtime/useEventsConnection";
|
|
93
99
|
// ── Image cache (durable) ──
|
|
94
100
|
export { getCachedImagePath, ensureImageCacheDir, getCachedImageInfo, cacheRemoteImage, cacheImageBatch, clearImageCache, pruneImageCache, resolveImageSource, stableCacheUrl } from "./image-cache/service";
|
|
95
101
|
// ── Init helper ──
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type EventCallback = (event: Record<string, unknown>) => void;
|
|
2
|
+
declare class EventsService {
|
|
3
|
+
private refCount;
|
|
4
|
+
private topic;
|
|
5
|
+
private joined;
|
|
6
|
+
private lastSequence;
|
|
7
|
+
private activeBusinessId;
|
|
8
|
+
private listeners;
|
|
9
|
+
private wildcardListeners;
|
|
10
|
+
private frameHandlerUnsub;
|
|
11
|
+
connect(userId: string, businessId?: string | null): () => void;
|
|
12
|
+
on(eventType: string, callback: EventCallback): () => void;
|
|
13
|
+
onAny(callback: EventCallback): () => void;
|
|
14
|
+
isConnected(): boolean;
|
|
15
|
+
resetSequence(): void;
|
|
16
|
+
}
|
|
17
|
+
export declare const eventsService: EventsService;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { phoenixSocket } from "./phoenix-socket";
|
|
2
|
+
class EventsService {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.refCount = 0;
|
|
5
|
+
this.topic = null;
|
|
6
|
+
this.joined = false;
|
|
7
|
+
this.lastSequence = 0;
|
|
8
|
+
this.activeBusinessId = null;
|
|
9
|
+
this.listeners = new Map();
|
|
10
|
+
this.wildcardListeners = new Set();
|
|
11
|
+
this.frameHandlerUnsub = null;
|
|
12
|
+
}
|
|
13
|
+
connect(userId, businessId) {
|
|
14
|
+
this.refCount++;
|
|
15
|
+
const topic = `events:${userId}`;
|
|
16
|
+
if (businessId !== undefined)
|
|
17
|
+
this.activeBusinessId = businessId ?? null;
|
|
18
|
+
if (this.refCount === 1 && !this.frameHandlerUnsub) {
|
|
19
|
+
this.topic = topic;
|
|
20
|
+
this.frameHandlerUnsub = phoenixSocket.onFrame(topic, ([_joinRef, _ref, _topic, event, payload]) => {
|
|
21
|
+
const data = payload;
|
|
22
|
+
const payloadBid = data?.business_id;
|
|
23
|
+
if (this.activeBusinessId && payloadBid && payloadBid !== this.activeBusinessId)
|
|
24
|
+
return;
|
|
25
|
+
const sequence = data?._sequence;
|
|
26
|
+
if (typeof sequence === "number" && sequence <= this.lastSequence)
|
|
27
|
+
return;
|
|
28
|
+
if (typeof sequence === "number" && sequence > this.lastSequence)
|
|
29
|
+
this.lastSequence = sequence;
|
|
30
|
+
else if (typeof data?._sequence === "number")
|
|
31
|
+
this.lastSequence = data._sequence;
|
|
32
|
+
const typeListeners = this.listeners.get(event);
|
|
33
|
+
if (typeListeners)
|
|
34
|
+
typeListeners.forEach((cb) => cb(data));
|
|
35
|
+
this.wildcardListeners.forEach((cb) => cb(data));
|
|
36
|
+
});
|
|
37
|
+
const joinPayload = {};
|
|
38
|
+
if (this.lastSequence > 0)
|
|
39
|
+
joinPayload.last_sequence = this.lastSequence;
|
|
40
|
+
if (this.activeBusinessId)
|
|
41
|
+
joinPayload.business_id = this.activeBusinessId;
|
|
42
|
+
phoenixSocket.connect().then(() => phoenixSocket.joinChannel(topic, joinPayload)).then(() => { this.joined = true; }).catch(() => { });
|
|
43
|
+
}
|
|
44
|
+
else if (businessId !== undefined) {
|
|
45
|
+
// business switched while already joined — update filter without rejoin (next events filtered). If backend requires business:{id} subscription, re-join could be forced — not needed as fallback business_authorized check still passes.
|
|
46
|
+
this.activeBusinessId = businessId ?? null;
|
|
47
|
+
}
|
|
48
|
+
let disconnected = false;
|
|
49
|
+
return () => {
|
|
50
|
+
if (disconnected)
|
|
51
|
+
return;
|
|
52
|
+
disconnected = true;
|
|
53
|
+
this.refCount = Math.max(0, this.refCount - 1);
|
|
54
|
+
if (this.refCount === 0) {
|
|
55
|
+
if (this.topic && this.joined)
|
|
56
|
+
phoenixSocket.leaveChannel(this.topic);
|
|
57
|
+
this.frameHandlerUnsub?.();
|
|
58
|
+
this.frameHandlerUnsub = null;
|
|
59
|
+
this.joined = false;
|
|
60
|
+
this.topic = null;
|
|
61
|
+
// reset sequence on full disconnect so next user doesn't dedup? Keep until next connect with same user - if user changes, lastSequence should reset. Caller should ensure new userId -> new instance or reset.
|
|
62
|
+
// Keep for replay.
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
on(eventType, callback) {
|
|
67
|
+
if (!this.listeners.has(eventType))
|
|
68
|
+
this.listeners.set(eventType, new Set());
|
|
69
|
+
this.listeners.get(eventType).add(callback);
|
|
70
|
+
return () => { this.listeners.get(eventType)?.delete(callback); };
|
|
71
|
+
}
|
|
72
|
+
onAny(callback) { this.wildcardListeners.add(callback); return () => this.wildcardListeners.delete(callback); }
|
|
73
|
+
isConnected() { return this.joined && phoenixSocket.isConnected(); }
|
|
74
|
+
resetSequence() { this.lastSequence = 0; }
|
|
75
|
+
}
|
|
76
|
+
export const eventsService = new EventsService();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { eventsService } from "./events.service";
|
|
2
|
+
const ORDER_EVENT_TYPES = [
|
|
3
|
+
"order.created",
|
|
4
|
+
"order.status_changed",
|
|
5
|
+
"delivery.assigned",
|
|
6
|
+
"delivery.status_changed",
|
|
7
|
+
"delivery.pickup_confirmed",
|
|
8
|
+
"payment.completed",
|
|
9
|
+
"payment.failed",
|
|
10
|
+
"reference_data.changed",
|
|
11
|
+
];
|
|
12
|
+
export const orderEventsService = {
|
|
13
|
+
onOrderUpdates(callback) {
|
|
14
|
+
const unsubs = ORDER_EVENT_TYPES.map((type) => eventsService.on(type, (data) => callback({ ...data, type })));
|
|
15
|
+
return () => unsubs.forEach((fn) => fn());
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type ConnectionState = "disconnected" | "connecting" | "connected" | "reconnecting";
|
|
2
|
+
export type PhoenixV2Frame = [string | null, string | null, string, string, Record<string, unknown>];
|
|
3
|
+
export type PhoenixOutbound = {
|
|
4
|
+
topic: string;
|
|
5
|
+
event: string;
|
|
6
|
+
payload: Record<string, unknown>;
|
|
7
|
+
ref?: string | null;
|
|
8
|
+
joinRef?: string | null;
|
|
9
|
+
};
|
|
10
|
+
type FrameHandler = (frame: PhoenixV2Frame) => void;
|
|
11
|
+
declare class PhoenixSocket {
|
|
12
|
+
socket: WebSocket | null;
|
|
13
|
+
private refCounter;
|
|
14
|
+
private reconnectAttempt;
|
|
15
|
+
private reconnectTimer;
|
|
16
|
+
private heartbeatTimer;
|
|
17
|
+
private heartbeatPend;
|
|
18
|
+
private heartbeatMissed;
|
|
19
|
+
private static readonly MAX_MISSED;
|
|
20
|
+
private intentionalClose;
|
|
21
|
+
private connectGen;
|
|
22
|
+
private connectPromise;
|
|
23
|
+
private connectionState;
|
|
24
|
+
private connectionListeners;
|
|
25
|
+
private pendingJoins;
|
|
26
|
+
private pendingPushes;
|
|
27
|
+
joinedChannels: Set<string>;
|
|
28
|
+
private pendingRejoin;
|
|
29
|
+
private topicJoinRefs;
|
|
30
|
+
private frameHandlers;
|
|
31
|
+
private getRef;
|
|
32
|
+
private buildSocketUrl;
|
|
33
|
+
private setState;
|
|
34
|
+
onConnectionStateChange(fn: (s: ConnectionState) => void): () => void;
|
|
35
|
+
getConnectionState(): ConnectionState;
|
|
36
|
+
connect(): Promise<void>;
|
|
37
|
+
disconnect(): void;
|
|
38
|
+
retryConnection(): void;
|
|
39
|
+
private scheduleReconnect;
|
|
40
|
+
private cancelReconnect;
|
|
41
|
+
private startHeartbeat;
|
|
42
|
+
private stopHeartbeat;
|
|
43
|
+
private sendRaw;
|
|
44
|
+
push(topic: string, event: string, payload: Record<string, unknown>): void;
|
|
45
|
+
pushWithReply(topic: string, event: string, payload: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
46
|
+
private handleFrame;
|
|
47
|
+
onFrame(topic: string, handler: FrameHandler): () => void;
|
|
48
|
+
joinChannel(topic: string, payload?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
49
|
+
leaveChannel(topic: string): void;
|
|
50
|
+
private rejoinPending;
|
|
51
|
+
isConnected(): boolean;
|
|
52
|
+
hasJoined(topic: string): boolean;
|
|
53
|
+
}
|
|
54
|
+
export declare const phoenixSocket: PhoenixSocket;
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { WS_BASE_URL } from "../utils/constants";
|
|
2
|
+
import { getAuthToken } from "../adapters/auth";
|
|
3
|
+
const RECONNECT_BASE = 1000;
|
|
4
|
+
const RECONNECT_MAX = 10000;
|
|
5
|
+
const HEARTBEAT_MS = 30000;
|
|
6
|
+
class PhoenixSocket {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.socket = null;
|
|
9
|
+
this.refCounter = 0;
|
|
10
|
+
this.reconnectAttempt = 0;
|
|
11
|
+
this.reconnectTimer = null;
|
|
12
|
+
this.heartbeatTimer = null;
|
|
13
|
+
this.heartbeatPend = false;
|
|
14
|
+
this.heartbeatMissed = 0;
|
|
15
|
+
this.intentionalClose = false;
|
|
16
|
+
this.connectGen = 0;
|
|
17
|
+
this.connectPromise = null;
|
|
18
|
+
this.connectionState = "disconnected";
|
|
19
|
+
this.connectionListeners = new Set();
|
|
20
|
+
this.pendingJoins = new Map();
|
|
21
|
+
this.pendingPushes = new Map();
|
|
22
|
+
this.joinedChannels = new Set();
|
|
23
|
+
this.pendingRejoin = new Set();
|
|
24
|
+
this.topicJoinRefs = new Map();
|
|
25
|
+
this.frameHandlers = new Map();
|
|
26
|
+
}
|
|
27
|
+
getRef() { return String(++this.refCounter); }
|
|
28
|
+
buildSocketUrl(token) {
|
|
29
|
+
const base = WS_BASE_URL.replace(/\/+$/, "");
|
|
30
|
+
const path = base.endsWith("/ws") ? "/websocket" : "/ws/websocket";
|
|
31
|
+
return `${base}${path}?token=${encodeURIComponent(token)}&vsn=2.0.0`;
|
|
32
|
+
}
|
|
33
|
+
setState(s) {
|
|
34
|
+
this.connectionState = s;
|
|
35
|
+
this.connectionListeners.forEach((fn) => fn(s));
|
|
36
|
+
}
|
|
37
|
+
onConnectionStateChange(fn) {
|
|
38
|
+
this.connectionListeners.add(fn);
|
|
39
|
+
return () => this.connectionListeners.delete(fn);
|
|
40
|
+
}
|
|
41
|
+
getConnectionState() { return this.connectionState; }
|
|
42
|
+
connect() {
|
|
43
|
+
if (this.socket?.readyState === WebSocket.OPEN)
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
if (this.connectPromise)
|
|
46
|
+
return this.connectPromise;
|
|
47
|
+
this.cancelReconnect();
|
|
48
|
+
this.intentionalClose = false;
|
|
49
|
+
this.setState("connecting");
|
|
50
|
+
this.connectPromise = new Promise((resolve, reject) => {
|
|
51
|
+
const token = getAuthToken();
|
|
52
|
+
if (!token) {
|
|
53
|
+
this.setState("disconnected");
|
|
54
|
+
this.connectPromise = null;
|
|
55
|
+
reject(new Error("Not authenticated"));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const myGen = ++this.connectGen;
|
|
59
|
+
const url = this.buildSocketUrl(token);
|
|
60
|
+
this.socket = new WebSocket(url);
|
|
61
|
+
this.socket.onopen = () => {
|
|
62
|
+
if (myGen !== this.connectGen)
|
|
63
|
+
return;
|
|
64
|
+
this.reconnectAttempt = 0;
|
|
65
|
+
this.setState("connected");
|
|
66
|
+
this.startHeartbeat();
|
|
67
|
+
this.rejoinPending().catch(() => { });
|
|
68
|
+
this.connectPromise = null;
|
|
69
|
+
resolve();
|
|
70
|
+
};
|
|
71
|
+
this.socket.onerror = () => { if (myGen !== this.connectGen)
|
|
72
|
+
return; if (this.connectionState === "connecting") {
|
|
73
|
+
this.setState("disconnected");
|
|
74
|
+
this.connectPromise = null;
|
|
75
|
+
} };
|
|
76
|
+
this.socket.onmessage = (ev) => { try {
|
|
77
|
+
const frame = JSON.parse(ev.data);
|
|
78
|
+
this.handleFrame(frame);
|
|
79
|
+
}
|
|
80
|
+
catch { } };
|
|
81
|
+
this.socket.onclose = () => {
|
|
82
|
+
if (myGen !== this.connectGen)
|
|
83
|
+
return;
|
|
84
|
+
this.stopHeartbeat();
|
|
85
|
+
if (!this.intentionalClose) {
|
|
86
|
+
this.joinedChannels.forEach((t) => this.pendingRejoin.add(t));
|
|
87
|
+
this.joinedChannels.clear();
|
|
88
|
+
this.scheduleReconnect();
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
this.joinedChannels.clear();
|
|
92
|
+
this.pendingRejoin.clear();
|
|
93
|
+
this.setState("disconnected");
|
|
94
|
+
}
|
|
95
|
+
this.connectPromise = null;
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
return this.connectPromise;
|
|
99
|
+
}
|
|
100
|
+
disconnect() {
|
|
101
|
+
this.intentionalClose = true;
|
|
102
|
+
this.cancelReconnect();
|
|
103
|
+
this.stopHeartbeat();
|
|
104
|
+
this.joinedChannels.clear();
|
|
105
|
+
this.pendingRejoin.clear();
|
|
106
|
+
this.pendingJoins.clear();
|
|
107
|
+
this.pendingPushes.clear();
|
|
108
|
+
this.topicJoinRefs.clear();
|
|
109
|
+
this.frameHandlers.clear();
|
|
110
|
+
if (this.socket) {
|
|
111
|
+
this.socket.close();
|
|
112
|
+
this.socket = null;
|
|
113
|
+
}
|
|
114
|
+
this.setState("disconnected");
|
|
115
|
+
this.connectPromise = null;
|
|
116
|
+
}
|
|
117
|
+
retryConnection() { this.cancelReconnect(); this.reconnectAttempt = 0; this.connect().catch(() => { }); }
|
|
118
|
+
scheduleReconnect() {
|
|
119
|
+
if (this.intentionalClose)
|
|
120
|
+
return;
|
|
121
|
+
this.setState("reconnecting");
|
|
122
|
+
const delay = Math.min(RECONNECT_BASE * Math.pow(2, this.reconnectAttempt), RECONNECT_MAX);
|
|
123
|
+
this.reconnectAttempt++;
|
|
124
|
+
this.reconnectTimer = setTimeout(() => this.connect().catch(() => { }), delay);
|
|
125
|
+
}
|
|
126
|
+
cancelReconnect() { if (this.reconnectTimer) {
|
|
127
|
+
clearTimeout(this.reconnectTimer);
|
|
128
|
+
this.reconnectTimer = null;
|
|
129
|
+
} }
|
|
130
|
+
startHeartbeat() {
|
|
131
|
+
this.heartbeatPend = false;
|
|
132
|
+
this.heartbeatMissed = 0;
|
|
133
|
+
this.heartbeatTimer = setInterval(() => {
|
|
134
|
+
if (this.heartbeatPend) {
|
|
135
|
+
this.heartbeatMissed++;
|
|
136
|
+
if (this.heartbeatMissed >= PhoenixSocket.MAX_MISSED)
|
|
137
|
+
this.socket?.close();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.heartbeatPend = true;
|
|
141
|
+
this.sendRaw({ topic: "phoenix", event: "heartbeat", payload: {} });
|
|
142
|
+
}, HEARTBEAT_MS);
|
|
143
|
+
}
|
|
144
|
+
stopHeartbeat() { if (this.heartbeatTimer) {
|
|
145
|
+
clearInterval(this.heartbeatTimer);
|
|
146
|
+
this.heartbeatTimer = null;
|
|
147
|
+
} this.heartbeatPend = false; this.heartbeatMissed = 0; }
|
|
148
|
+
sendRaw(msg) {
|
|
149
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
150
|
+
const joinRef = msg.topic && msg.topic !== "phoenix" ? (msg.joinRef ?? this.topicJoinRefs.get(msg.topic) ?? null) : (msg.joinRef ?? null);
|
|
151
|
+
const frame = [joinRef, msg.ref ?? null, msg.topic, msg.event, msg.payload];
|
|
152
|
+
this.socket.send(JSON.stringify(frame));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
push(topic, event, payload) { const ref = this.getRef(); this.sendRaw({ topic, event, payload, ref, joinRef: this.topicJoinRefs.get(topic) }); }
|
|
156
|
+
pushWithReply(topic, event, payload) {
|
|
157
|
+
const ref = this.getRef();
|
|
158
|
+
const msg = { topic, event, payload, ref, joinRef: this.topicJoinRefs.get(topic) };
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
this.pendingPushes.set(ref, { resolve, reject });
|
|
161
|
+
this.sendRaw(msg);
|
|
162
|
+
setTimeout(() => { if (this.pendingPushes.has(ref)) {
|
|
163
|
+
this.pendingPushes.delete(ref);
|
|
164
|
+
reject(new Error(`Request timed out: ${event} on ${topic}`));
|
|
165
|
+
} }, 5000);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
handleFrame([joinRef, ref, topic, event, payload]) {
|
|
169
|
+
if (event === "phx_reply" && topic === "phoenix") {
|
|
170
|
+
this.heartbeatPend = false;
|
|
171
|
+
this.heartbeatMissed = 0;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (event === "phx_reply" && ref) {
|
|
175
|
+
const pending = this.pendingPushes.get(ref);
|
|
176
|
+
if (pending) {
|
|
177
|
+
const status = payload?.status;
|
|
178
|
+
const response = (payload?.response ?? {});
|
|
179
|
+
if (status === "ok")
|
|
180
|
+
pending.resolve(response);
|
|
181
|
+
else
|
|
182
|
+
pending.reject(new Error(String(response.reason ?? "Request failed")));
|
|
183
|
+
this.pendingPushes.delete(ref);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (event === "phx_reply" && payload?.status === "ok") {
|
|
188
|
+
const pending = this.pendingJoins.get(topic);
|
|
189
|
+
if (pending) {
|
|
190
|
+
const response = (payload?.response ?? {});
|
|
191
|
+
pending.resolve(response);
|
|
192
|
+
this.pendingJoins.delete(topic);
|
|
193
|
+
this.joinedChannels.add(topic);
|
|
194
|
+
if (joinRef)
|
|
195
|
+
this.topicJoinRefs.set(topic, joinRef);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (event === "phx_reply" && payload?.status === "error") {
|
|
200
|
+
const pending = this.pendingJoins.get(topic);
|
|
201
|
+
if (pending) {
|
|
202
|
+
const response = (payload?.response ?? {});
|
|
203
|
+
pending.reject(new Error(String(response.reason ?? "Join failed")));
|
|
204
|
+
this.pendingJoins.delete(topic);
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const handlers = this.frameHandlers.get(topic);
|
|
209
|
+
if (handlers)
|
|
210
|
+
handlers.forEach((cb) => cb([joinRef, ref, topic, event, payload]));
|
|
211
|
+
}
|
|
212
|
+
onFrame(topic, handler) {
|
|
213
|
+
if (!this.frameHandlers.has(topic))
|
|
214
|
+
this.frameHandlers.set(topic, new Set());
|
|
215
|
+
this.frameHandlers.get(topic).add(handler);
|
|
216
|
+
return () => { this.frameHandlers.get(topic)?.delete(handler); };
|
|
217
|
+
}
|
|
218
|
+
async joinChannel(topic, payload = {}) {
|
|
219
|
+
if (this.joinedChannels.has(topic))
|
|
220
|
+
return Promise.resolve({});
|
|
221
|
+
if (this.pendingJoins.has(topic))
|
|
222
|
+
return new Promise((resolve, reject) => { this.pendingJoins.set(topic, { resolve, reject }); });
|
|
223
|
+
const ref = this.getRef();
|
|
224
|
+
const msg = { topic, event: "phx_join", payload, ref };
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
this.pendingJoins.set(topic, { resolve, reject });
|
|
227
|
+
this.sendRaw(msg);
|
|
228
|
+
setTimeout(() => { if (this.pendingJoins.has(topic)) {
|
|
229
|
+
this.pendingJoins.delete(topic);
|
|
230
|
+
reject(new Error(`Failed to join channel ${topic} - timeout`));
|
|
231
|
+
} }, 5000);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
leaveChannel(topic) { this.joinedChannels.delete(topic); this.pendingRejoin.delete(topic); this.topicJoinRefs.delete(topic); }
|
|
235
|
+
async rejoinPending() { const topics = Array.from(this.pendingRejoin); this.pendingRejoin.clear(); await Promise.allSettled(topics.map((t) => this.joinChannel(t))); }
|
|
236
|
+
isConnected() { return this.socket?.readyState === WebSocket.OPEN; }
|
|
237
|
+
hasJoined(topic) { return this.joinedChannels.has(topic); }
|
|
238
|
+
}
|
|
239
|
+
PhoenixSocket.MAX_MISSED = 3;
|
|
240
|
+
export const phoenixSocket = new PhoenixSocket();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useEventsConnection(): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { eventsService } from "./events.service";
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
|
|
5
|
+
import { getAuthToken } from "../adapters/auth";
|
|
6
|
+
function useBusinessId() {
|
|
7
|
+
return useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
|
|
8
|
+
}
|
|
9
|
+
function useIsAuthenticated() {
|
|
10
|
+
// poll auth token presence; host should setAuthProvider — if no provider, assume authenticated when token exists
|
|
11
|
+
return !!getAuthToken();
|
|
12
|
+
}
|
|
13
|
+
export function useEventsConnection() {
|
|
14
|
+
const activeBusinessId = useBusinessId();
|
|
15
|
+
// derive userId from auth token? Host must provide via setAuthProvider that encodes user, but we need userId for topic.
|
|
16
|
+
// For now, read from global if host set it via setAuthUserId adapter. Fallback: token payload decode.
|
|
17
|
+
// Minimal: require host to call eventsService.connect with userId directly via wrapper — this hook is convenience that reads business only.
|
|
18
|
+
// To keep zero-config, we attempt to read from window localStorage token decode — fallback no-op.
|
|
19
|
+
const userId = deriveUserId();
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!userId)
|
|
22
|
+
return;
|
|
23
|
+
const disconnect = eventsService.connect(userId, activeBusinessId);
|
|
24
|
+
return () => disconnect();
|
|
25
|
+
}, [userId, activeBusinessId]);
|
|
26
|
+
}
|
|
27
|
+
function deriveUserId() {
|
|
28
|
+
try {
|
|
29
|
+
const token = getAuthToken();
|
|
30
|
+
if (!token)
|
|
31
|
+
return null;
|
|
32
|
+
const parts = token.split(".");
|
|
33
|
+
if (parts.length < 2)
|
|
34
|
+
return null;
|
|
35
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
36
|
+
return payload.sub || payload.user_id || payload.id || null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -93,6 +93,7 @@ export const CAPABILITY_BY_COMMAND = {
|
|
|
93
93
|
"menu:update_item": "menu_manage",
|
|
94
94
|
"menu:delete_item": "menu_manage",
|
|
95
95
|
"menu:create_category": "menu_manage",
|
|
96
|
+
"payment:confirm_cash": "orders_accept",
|
|
96
97
|
};
|
|
97
98
|
export async function hasCapabilitySync(businessId, commandType) {
|
|
98
99
|
const required = CAPABILITY_BY_COMMAND[commandType];
|
|
@@ -126,4 +126,13 @@ export const orderRepository = {
|
|
|
126
126
|
}
|
|
127
127
|
return event.payload;
|
|
128
128
|
},
|
|
129
|
+
async confirmCashPayment(input) {
|
|
130
|
+
const command = buildCommand({
|
|
131
|
+
commandType: "payment:confirm_cash",
|
|
132
|
+
businessId: input.businessId,
|
|
133
|
+
entityId: input.orderId,
|
|
134
|
+
payload: {},
|
|
135
|
+
});
|
|
136
|
+
return executeCommand(command);
|
|
137
|
+
},
|
|
129
138
|
};
|
|
@@ -25,6 +25,10 @@ const QUEUED_STATUS_BY_COMMAND = {
|
|
|
25
25
|
"delivery:assign": "assigned",
|
|
26
26
|
"delivery:confirm_handover": "delivered",
|
|
27
27
|
};
|
|
28
|
+
const PAYMENT_QUEUED_PAYLOAD = {
|
|
29
|
+
is_paid: true,
|
|
30
|
+
to_is_paid: true,
|
|
31
|
+
};
|
|
28
32
|
export function getQueuedStatus(commandType) {
|
|
29
33
|
return QUEUED_STATUS_BY_COMMAND[commandType] ?? null;
|
|
30
34
|
}
|
|
@@ -42,6 +46,24 @@ function buildOptimisticEvent(command) {
|
|
|
42
46
|
timestamp: new Date().toISOString(),
|
|
43
47
|
};
|
|
44
48
|
}
|
|
49
|
+
if (command.command_type === "payment:confirm_cash") {
|
|
50
|
+
return {
|
|
51
|
+
event_id: generateEventId(),
|
|
52
|
+
event_type: "payment.completed_queued",
|
|
53
|
+
event_version: "1.0",
|
|
54
|
+
entity_id: command.entity_id ?? command.command_id,
|
|
55
|
+
user_id: command.user_id,
|
|
56
|
+
device_id: command.device_id,
|
|
57
|
+
payload: {
|
|
58
|
+
...payload,
|
|
59
|
+
command_id: command.command_id,
|
|
60
|
+
is_paid: true,
|
|
61
|
+
paid_at: new Date().toISOString(),
|
|
62
|
+
order_id: command.entity_id,
|
|
63
|
+
},
|
|
64
|
+
timestamp: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
45
67
|
const queuedStatus = getQueuedStatus(command.command_type);
|
|
46
68
|
return {
|
|
47
69
|
event_id: generateEventId(),
|
|
@@ -58,11 +80,13 @@ function buildOptimisticEvent(command) {
|
|
|
58
80
|
to_status: queuedStatus,
|
|
59
81
|
order_id: command.entity_id,
|
|
60
82
|
delivery_id: command.entity_id,
|
|
83
|
+
...(queuedStatus ? {} : {}),
|
|
61
84
|
},
|
|
62
85
|
timestamp: new Date().toISOString(),
|
|
63
86
|
};
|
|
64
87
|
}
|
|
65
88
|
function buildOptimisticOrder(command, payload) {
|
|
89
|
+
const snapshots = payload.item_snapshots ?? null;
|
|
66
90
|
const items = payload.items ?? [];
|
|
67
91
|
const totalMinor = payload.total_minor ?? 0;
|
|
68
92
|
const subtotalMinor = payload.subtotal_minor ?? totalMinor;
|
|
@@ -109,14 +133,22 @@ function buildOptimisticOrder(command, payload) {
|
|
|
109
133
|
delivery_lat: null,
|
|
110
134
|
delivery_lng: null,
|
|
111
135
|
user: null,
|
|
112
|
-
items:
|
|
136
|
+
items: (snapshots ? snapshots.map((s, index) => ({
|
|
137
|
+
id: `tmp-${command.command_id}-${index}`,
|
|
138
|
+
menu_item: { id: s.menu_item_id, name: s.name },
|
|
139
|
+
quantity: s.quantity,
|
|
140
|
+
unit_price: s.unit_price_minor,
|
|
141
|
+
subtotal: s.unit_price_minor * s.quantity,
|
|
142
|
+
variant_name: s.variant_name,
|
|
143
|
+
addons: (s.addons ?? []).map((a) => ({ name: a.name, price_minor: a.price_minor })),
|
|
144
|
+
})) : items.map((item, index) => ({
|
|
113
145
|
id: `tmp-${command.command_id}-${index}`,
|
|
114
146
|
menu_item: { id: item.menu_item_id, name: "" },
|
|
115
147
|
quantity: item.quantity,
|
|
116
148
|
unit_price: 0,
|
|
117
149
|
subtotal: 0,
|
|
118
150
|
addons: [],
|
|
119
|
-
})),
|
|
151
|
+
}))),
|
|
120
152
|
inserted_at: now,
|
|
121
153
|
};
|
|
122
154
|
}
|
|
@@ -29,6 +29,7 @@ declare class OrderSnapshotsService {
|
|
|
29
29
|
*/
|
|
30
30
|
applyQueuedStatusEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
|
|
31
31
|
private applyOrderStatusToReadModelInternal;
|
|
32
|
+
applyPaymentEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
|
|
32
33
|
/**
|
|
33
34
|
* Patch the latest status of a known order inside every list snapshot for the
|
|
34
35
|
* business, so offline devices see live status changes without a refetch.
|
|
@@ -97,6 +97,36 @@ class OrderSnapshotsService {
|
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
+
async applyPaymentEvent(event, origin = "local") {
|
|
101
|
+
const payload = event.payload;
|
|
102
|
+
const orderId = payload?.order_id ?? event.entity_id;
|
|
103
|
+
if (!orderId)
|
|
104
|
+
return;
|
|
105
|
+
const paidAt = payload?.paid_at || new Date().toISOString();
|
|
106
|
+
try {
|
|
107
|
+
const db = await openSyncDatabase();
|
|
108
|
+
await insertEvent(db, { event, origin });
|
|
109
|
+
const lists = await getEntitySnapshotsByType(db, "seller_orders");
|
|
110
|
+
for (const list of lists) {
|
|
111
|
+
const data = list.payload.data ?? [];
|
|
112
|
+
const idx = data.findIndex((o) => o.id === orderId || o.order_number === payload?.order_number);
|
|
113
|
+
if (idx === -1)
|
|
114
|
+
continue;
|
|
115
|
+
const updated = data.map((o, i) => i === idx ? { ...o, is_paid: true, paid_at: paidAt, payment: { ...o.payment, is_paid: true, paid_at: paidAt } } : o);
|
|
116
|
+
await upsertEntitySnapshot(db, {
|
|
117
|
+
entityType: "seller_orders",
|
|
118
|
+
entityId: list.entity_id,
|
|
119
|
+
businessId: list.business_id,
|
|
120
|
+
lastEventId: event.event_id,
|
|
121
|
+
lastEventTimestamp: event.timestamp,
|
|
122
|
+
payload: { ...list.payload, data: updated },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
syncLogger.error("OrderSnapshots", "Failed to apply payment", { error: String(error) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
100
130
|
/**
|
|
101
131
|
* Patch the latest status of a known order inside every list snapshot for the
|
|
102
132
|
* business, so offline devices see live status changes without a refetch.
|
|
@@ -36,6 +36,7 @@ const QUEUED_STATUS_EVENT_TYPES = new Set([
|
|
|
36
36
|
"delivery.assign_queued",
|
|
37
37
|
"delivery.confirm_handover_queued",
|
|
38
38
|
]);
|
|
39
|
+
const PAYMENT_QUEUED_TYPES = new Set(["payment.completed_queued"]);
|
|
39
40
|
let invalidator = null;
|
|
40
41
|
/**
|
|
41
42
|
* Register a callback used to invalidate TanStack Query caches when a pending
|
|
@@ -99,8 +100,12 @@ export async function hydratePendingOrders() {
|
|
|
99
100
|
export async function applyOrderStateChangeEvent(event, origin = "local") {
|
|
100
101
|
try {
|
|
101
102
|
const isQueued = QUEUED_STATUS_EVENT_TYPES.has(event.event_type);
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
const isPaymentQueued = PAYMENT_QUEUED_TYPES.has(event.event_type);
|
|
104
|
+
if (!ORDER_STATE_EVENT_TYPES.has(event.event_type) && !isQueued && !isPaymentQueued) {
|
|
105
|
+
// Also handle cloud payment.completed for is_paid patch
|
|
106
|
+
if (event.event_type !== "payment.completed")
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
104
109
|
syncLogger.debug("OrderState", "Order state changed", {
|
|
105
110
|
origin,
|
|
106
111
|
event_type: event.event_type,
|
|
@@ -111,6 +116,19 @@ export async function applyOrderStateChangeEvent(event, origin = "local") {
|
|
|
111
116
|
// Queued transitions carry to_status/order_id so they patch instantly offline.
|
|
112
117
|
if (isQueued) {
|
|
113
118
|
await orderSnapshotsService.applyQueuedStatusEvent(event, origin);
|
|
119
|
+
patchPendingStatus(event);
|
|
120
|
+
invalidator?.();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (isPaymentQueued) {
|
|
124
|
+
await orderSnapshotsService.applyPaymentEvent(event, origin);
|
|
125
|
+
patchPendingPaid(event);
|
|
126
|
+
invalidator?.();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (event.event_type === "payment.completed") {
|
|
130
|
+
await orderSnapshotsService.applyPaymentEvent(event, origin);
|
|
131
|
+
patchPendingPaid(event);
|
|
114
132
|
invalidator?.();
|
|
115
133
|
return;
|
|
116
134
|
}
|
|
@@ -124,6 +142,46 @@ export async function applyOrderStateChangeEvent(event, origin = "local") {
|
|
|
124
142
|
});
|
|
125
143
|
}
|
|
126
144
|
}
|
|
145
|
+
function patchPendingStatus(event) {
|
|
146
|
+
try {
|
|
147
|
+
const payload = event.payload;
|
|
148
|
+
const toStatus = payload.to_status || payload.status;
|
|
149
|
+
const orderId = payload.order_id || event.entity_id;
|
|
150
|
+
if (!toStatus || !orderId)
|
|
151
|
+
return;
|
|
152
|
+
const store = usePendingOrdersStore.getState();
|
|
153
|
+
const target = store.orders.find((o) => o.id === orderId || o.order_number === orderId);
|
|
154
|
+
if (!target)
|
|
155
|
+
return;
|
|
156
|
+
const patched = { ...target, status: toStatus };
|
|
157
|
+
store.addOrder(patched);
|
|
158
|
+
}
|
|
159
|
+
catch { }
|
|
160
|
+
}
|
|
161
|
+
function patchPendingPaid(event) {
|
|
162
|
+
try {
|
|
163
|
+
const payload = event.payload;
|
|
164
|
+
const orderId = payload.order_id || event.entity_id;
|
|
165
|
+
if (!orderId)
|
|
166
|
+
return;
|
|
167
|
+
const now = new Date().toISOString();
|
|
168
|
+
const store = usePendingOrdersStore.getState();
|
|
169
|
+
const target = store.orders.find((o) => o.id === orderId || o.order_number === payload.order_number || o.id === event.entity_id);
|
|
170
|
+
const altTarget = !target ? store.orders.find((o) => o.id === orderId) : null;
|
|
171
|
+
const t = target ?? altTarget;
|
|
172
|
+
if (!t)
|
|
173
|
+
return;
|
|
174
|
+
const paidAt = payload.paid_at || now;
|
|
175
|
+
const patched = {
|
|
176
|
+
...t,
|
|
177
|
+
is_paid: true,
|
|
178
|
+
paid_at: paidAt,
|
|
179
|
+
payment: { ...t.payment, is_paid: true, paid_at: paidAt },
|
|
180
|
+
};
|
|
181
|
+
store.addOrder(patched);
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
}
|
|
127
185
|
export async function applyPendingOrderEvent(event, origin = "local") {
|
|
128
186
|
try {
|
|
129
187
|
if (event.event_type === PENDING_EVENT_TYPE) {
|
|
@@ -144,6 +202,27 @@ export async function applyPendingOrderEvent(event, origin = "local") {
|
|
|
144
202
|
if (QUEUED_STATUS_EVENT_TYPES.has(event.event_type)) {
|
|
145
203
|
// Queued transitions: patch the read model so listing reflects new status offline.
|
|
146
204
|
await orderSnapshotsService.applyQueuedStatusEvent(event, origin);
|
|
205
|
+
// Also patch pending store so isPending overlay shows new status immediately.
|
|
206
|
+
try {
|
|
207
|
+
const payload = event.payload;
|
|
208
|
+
const toStatus = payload.to_status || payload.status;
|
|
209
|
+
const orderId = payload.order_id || event.entity_id;
|
|
210
|
+
if (toStatus && orderId) {
|
|
211
|
+
const store = usePendingOrdersStore.getState();
|
|
212
|
+
const target = store.orders.find((o) => o.id === orderId || o.order_number === orderId);
|
|
213
|
+
if (target)
|
|
214
|
+
store.addOrder({ ...target, status: toStatus });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch { }
|
|
218
|
+
const db = await openSyncDatabase();
|
|
219
|
+
await insertEvent(db, { event, origin });
|
|
220
|
+
invalidator?.();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (PAYMENT_QUEUED_TYPES.has(event.event_type) || event.event_type === "payment.completed") {
|
|
224
|
+
await orderSnapshotsService.applyPaymentEvent(event, origin);
|
|
225
|
+
patchPendingPaid(event);
|
|
147
226
|
const db = await openSyncDatabase();
|
|
148
227
|
await insertEvent(db, { event, origin });
|
|
149
228
|
invalidator?.();
|
|
@@ -50,11 +50,12 @@ export class RestTransport {
|
|
|
50
50
|
case "order:create": {
|
|
51
51
|
const posPayload = {
|
|
52
52
|
...payload,
|
|
53
|
+
id: command.command_id,
|
|
53
54
|
command_id: command.command_id,
|
|
54
55
|
local_order_number: payload.order_number,
|
|
55
56
|
};
|
|
56
57
|
const response = await posService.createPOSOrder(business_id, posPayload);
|
|
57
|
-
return this.wrapResponse("order.created", response.data, entity_id, timestamp, command);
|
|
58
|
+
return this.wrapResponse("order.created", response.data, entity_id ?? command.command_id, timestamp, command);
|
|
58
59
|
}
|
|
59
60
|
case "order:accept": {
|
|
60
61
|
const response = await sellerService.acceptOrder(entity_id);
|
|
@@ -89,8 +90,9 @@ export class RestTransport {
|
|
|
89
90
|
return this.wrapResponse("delivery.status_changed", response, entity_id, timestamp, command);
|
|
90
91
|
}
|
|
91
92
|
case "menu:create_item": {
|
|
92
|
-
const
|
|
93
|
-
|
|
93
|
+
const createPayload = { ...payload, id: payload.id ?? command.command_id, command_id: command.command_id };
|
|
94
|
+
const response = await menuService.createMenuItem(business_id, createPayload);
|
|
95
|
+
return this.wrapResponse("menu.item_created", response, command.command_id, timestamp, command);
|
|
94
96
|
}
|
|
95
97
|
case "menu:update_item": {
|
|
96
98
|
const response = await menuService.updateMenuItem(entity_id, payload);
|
|
@@ -101,8 +103,13 @@ export class RestTransport {
|
|
|
101
103
|
return this.wrapResponse("menu.item_deleted", response, entity_id, timestamp, command);
|
|
102
104
|
}
|
|
103
105
|
case "menu:create_category": {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
+
const createPayload = { ...payload, id: payload.id ?? command.command_id, command_id: command.command_id };
|
|
107
|
+
const response = await menuService.createCategory(business_id, createPayload);
|
|
108
|
+
return this.wrapResponse("menu.category_created", response, command.command_id, timestamp, command);
|
|
109
|
+
}
|
|
110
|
+
case "payment:confirm_cash": {
|
|
111
|
+
const response = await sellerService.confirmCashPayment(entity_id);
|
|
112
|
+
return this.wrapResponse("payment.completed", response, entity_id, timestamp, command);
|
|
106
113
|
}
|
|
107
114
|
default:
|
|
108
115
|
throw new Error(`RestTransport: unsupported command type ${command_type}`);
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { useEffect } from "react";
|
|
11
11
|
import { useQueryClient } from "@tanstack/react-query";
|
|
12
12
|
import { syncEngine } from "./engine";
|
|
13
|
+
import { getActiveBusiness } from "../adapters/business";
|
|
13
14
|
import { applyPendingOrderEvent, applyOrderStateChangeEvent, registerPendingOrdersInvalidator, } from "./pending-orders.service";
|
|
14
15
|
import { localNodeService } from "../adapters/local-node";
|
|
15
16
|
import { getSyncDatabase } from "../db/connection";
|
|
@@ -24,16 +25,42 @@ function isLocalOrderEvent(event) {
|
|
|
24
25
|
const localOrderNumber = payload.local_order_number;
|
|
25
26
|
return typeof localOrderNumber === "string" && /^[0-9]{2}-[0-9]{3}-[0-9]{3}$/.test(localOrderNumber);
|
|
26
27
|
}
|
|
28
|
+
const RECONCILE_DEBOUNCE_MS = 400;
|
|
27
29
|
export function useSyncReconciliation() {
|
|
28
30
|
const queryClient = useQueryClient();
|
|
29
31
|
useEffect(() => {
|
|
32
|
+
let timer;
|
|
33
|
+
const pending = new Set();
|
|
34
|
+
const flush = () => {
|
|
35
|
+
timer = undefined;
|
|
36
|
+
if (pending.has("reference")) {
|
|
37
|
+
queryClient.invalidateQueries({ queryKey: MENU_KEYS.all });
|
|
38
|
+
queryClient.invalidateQueries({ queryKey: REFERENCE_KEYS.all });
|
|
39
|
+
queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
|
|
40
|
+
queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
|
|
41
|
+
}
|
|
42
|
+
if (pending.has("pending")) {
|
|
43
|
+
queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
|
|
44
|
+
queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
|
|
45
|
+
queryClient.invalidateQueries({ queryKey: KIOSK_KEYS.all });
|
|
46
|
+
queryClient.invalidateQueries({ queryKey: SELLER_ORDERS_KEYS.all });
|
|
47
|
+
queryClient.invalidateQueries({ queryKey: BUSINESS_DELIVERIES_KEYS.all });
|
|
48
|
+
}
|
|
49
|
+
if (pending.has("order")) {
|
|
50
|
+
// order.created with local number already covered by pending, but keep fine-grained
|
|
51
|
+
// POS.orders(businessId) is also covered by pending's broad POS.all, no extra needed.
|
|
52
|
+
}
|
|
53
|
+
pending.clear();
|
|
54
|
+
};
|
|
55
|
+
const schedule = (kind) => {
|
|
56
|
+
pending.add(kind);
|
|
57
|
+
if (!timer)
|
|
58
|
+
timer = setTimeout(flush, RECONCILE_DEBOUNCE_MS);
|
|
59
|
+
};
|
|
30
60
|
// Reference changes re-sync offline data and refresh caches on this device
|
|
31
61
|
// and (via the LAN relay) on every connected terminal.
|
|
32
62
|
const unregisterReferenceInvalidator = registerReferenceInvalidator(() => {
|
|
33
|
-
|
|
34
|
-
queryClient.invalidateQueries({ queryKey: REFERENCE_KEYS.all });
|
|
35
|
-
queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
|
|
36
|
-
queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
|
|
63
|
+
schedule("reference");
|
|
37
64
|
});
|
|
38
65
|
// Pending-order reconciliation invalidates POS + seller order lists so the
|
|
39
66
|
// server-confirmed order replaces the optimistic one. Invalidate from the
|
|
@@ -42,17 +69,21 @@ export function useSyncReconciliation() {
|
|
|
42
69
|
// keys. Also cover kiosk/seller-orders/business-deliveries so boards
|
|
43
70
|
// refresh even when there is no Local Node (offline queue + Phoenix).
|
|
44
71
|
const unregisterInvalidator = registerPendingOrdersInvalidator(() => {
|
|
45
|
-
|
|
46
|
-
queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
|
|
47
|
-
queryClient.invalidateQueries({ queryKey: KIOSK_KEYS.all });
|
|
48
|
-
queryClient.invalidateQueries({ queryKey: SELLER_ORDERS_KEYS.all });
|
|
49
|
-
queryClient.invalidateQueries({ queryKey: BUSINESS_DELIVERIES_KEYS.all });
|
|
72
|
+
schedule("pending");
|
|
50
73
|
});
|
|
51
74
|
// Reference-change events are published through the Sync Engine but are
|
|
52
75
|
// not backed by a command, so the engine has no transport dependency on
|
|
53
76
|
// the reference module. Register the singleton once.
|
|
54
77
|
registerReferenceEventEngine(syncEngine);
|
|
55
78
|
const unsubscribe = syncEngine.subscribe(async (event) => {
|
|
79
|
+
// LAN strict: ignore events for other business on same subnet
|
|
80
|
+
try {
|
|
81
|
+
const activeId = getActiveBusiness()?.id;
|
|
82
|
+
const payloadBid = event.payload?.business_id;
|
|
83
|
+
if (activeId && payloadBid && payloadBid !== activeId)
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
56
87
|
syncLogger.event(event, "received");
|
|
57
88
|
// The node relays engine events (e.g. order.created when a queued
|
|
58
89
|
// command is replayed) to LAN clients so they reconcile their pending
|
|
@@ -90,15 +121,12 @@ export function useSyncReconciliation() {
|
|
|
90
121
|
error: String(error),
|
|
91
122
|
});
|
|
92
123
|
}
|
|
93
|
-
|
|
94
|
-
const businessId = payload.business_id;
|
|
95
|
-
if (businessId) {
|
|
96
|
-
queryClient.invalidateQueries({ queryKey: POS_KEYS.orders(businessId) });
|
|
97
|
-
queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
|
|
98
|
-
}
|
|
124
|
+
schedule("pending");
|
|
99
125
|
}
|
|
100
126
|
});
|
|
101
127
|
return () => {
|
|
128
|
+
if (timer)
|
|
129
|
+
clearTimeout(timer);
|
|
102
130
|
unsubscribe();
|
|
103
131
|
unregisterInvalidator();
|
|
104
132
|
unregisterReferenceInvalidator();
|
package/package.json
CHANGED