@teincfood/core 0.7.8 → 0.8.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.
Files changed (198) hide show
  1. package/README.md +26 -21
  2. package/dist/adapters/auth.d.ts +10 -0
  3. package/dist/adapters/auth.js +43 -0
  4. package/dist/adapters/devices.d.ts +3 -1
  5. package/dist/adapters/devices.js +187 -3
  6. package/dist/adapters/filesystem.d.ts +4 -0
  7. package/dist/adapters/kv.d.ts +10 -0
  8. package/dist/adapters/kv.js +23 -7
  9. package/dist/adapters/local-node.d.ts +4 -6
  10. package/dist/adapters/local-node.js +9 -32
  11. package/dist/adapters/notify.d.ts +12 -0
  12. package/dist/adapters/notify.js +33 -0
  13. package/dist/adapters/query-keys.d.ts +4 -0
  14. package/dist/adapters/query-keys.js +4 -0
  15. package/dist/adapters/services.d.ts +99 -15
  16. package/dist/adapters/services.js +244 -4
  17. package/dist/auth/session.d.ts +66 -0
  18. package/dist/auth/session.js +172 -0
  19. package/dist/catalog/hooks.d.ts +10 -0
  20. package/dist/catalog/hooks.js +82 -11
  21. package/dist/db/migrations.js +274 -0
  22. package/dist/db/operations.d.ts +53 -2
  23. package/dist/db/operations.js +177 -8
  24. package/dist/domain/business/index.d.ts +5 -0
  25. package/dist/domain/business/index.js +5 -0
  26. package/dist/domain/orders/lifecycle.d.ts +6 -0
  27. package/dist/domain/orders/lifecycle.js +6 -0
  28. package/dist/domain/orders/service.d.ts +24 -0
  29. package/dist/domain/orders/service.js +242 -0
  30. package/dist/domain/payments/index.d.ts +6 -0
  31. package/dist/domain/payments/index.js +5 -0
  32. package/dist/domain/reference/index.d.ts +8 -0
  33. package/dist/domain/reference/index.js +8 -0
  34. package/dist/e2e/harness.d.ts +1 -0
  35. package/dist/e2e/harness.js +61 -0
  36. package/dist/hooks/analytics.hooks.d.ts +25 -0
  37. package/dist/hooks/analytics.hooks.js +214 -0
  38. package/dist/hooks/business-hours.hooks.d.ts +8 -0
  39. package/dist/hooks/business-hours.hooks.js +45 -0
  40. package/dist/hooks/dashboard.hooks.d.ts +30 -0
  41. package/dist/hooks/dashboard.hooks.js +132 -0
  42. package/dist/hooks/delivery.hooks.d.ts +225 -0
  43. package/dist/hooks/delivery.hooks.js +80 -0
  44. package/dist/hooks/fleet.hooks.d.ts +37 -0
  45. package/dist/hooks/fleet.hooks.js +229 -0
  46. package/dist/hooks/image.hooks.d.ts +20 -0
  47. package/dist/hooks/image.hooks.js +62 -0
  48. package/dist/hooks/kiosk.hooks.d.ts +22 -5
  49. package/dist/hooks/kiosk.hooks.js +74 -5
  50. package/dist/hooks/members.hooks.d.ts +48 -0
  51. package/dist/hooks/members.hooks.js +302 -0
  52. package/dist/hooks/offline-orders.hooks.d.ts +9 -0
  53. package/dist/hooks/offline-orders.hooks.js +51 -0
  54. package/dist/hooks/orders.hooks.d.ts +19 -39
  55. package/dist/hooks/orders.hooks.js +126 -321
  56. package/dist/hooks/places.hooks.d.ts +21 -0
  57. package/dist/hooks/places.hooks.js +112 -0
  58. package/dist/hooks/realtime-stubs.d.ts +28 -0
  59. package/dist/hooks/realtime-stubs.js +85 -0
  60. package/dist/hooks/rider.hooks.d.ts +470 -0
  61. package/dist/hooks/rider.hooks.js +373 -0
  62. package/dist/hooks/use-infinite-list.d.ts +243 -0
  63. package/dist/hooks/use-infinite-list.js +58 -0
  64. package/dist/hooks/wallet.hooks.d.ts +219 -0
  65. package/dist/hooks/wallet.hooks.js +56 -0
  66. package/dist/image-cache/service.d.ts +8 -0
  67. package/dist/image-cache/service.js +15 -3
  68. package/dist/index.d.ts +60 -5
  69. package/dist/index.js +78 -5
  70. package/dist/local-node/adapters.d.ts +87 -0
  71. package/dist/local-node/adapters.js +4 -0
  72. package/dist/local-node/index.d.ts +5 -0
  73. package/dist/local-node/index.js +5 -0
  74. package/dist/local-node/security.d.ts +25 -0
  75. package/dist/local-node/security.js +149 -0
  76. package/dist/local-node/service.d.ts +83 -0
  77. package/dist/local-node/service.js +706 -0
  78. package/dist/local-node/store.d.ts +50 -0
  79. package/dist/local-node/store.js +284 -0
  80. package/dist/local-node/types.d.ts +92 -0
  81. package/dist/local-node/types.js +4 -0
  82. package/dist/printer/receipt.d.ts +4 -0
  83. package/dist/printer/receipt.js +75 -0
  84. package/dist/printer/service.js +1 -1
  85. package/dist/providers/query-client.d.ts +13 -0
  86. package/dist/providers/query-client.js +58 -0
  87. package/dist/realtime/events.service.d.ts +18 -0
  88. package/dist/realtime/events.service.js +76 -0
  89. package/dist/realtime/order-events.service.d.ts +6 -0
  90. package/dist/realtime/order-events.service.js +17 -0
  91. package/dist/realtime/phoenix-socket.d.ts +55 -0
  92. package/dist/realtime/phoenix-socket.js +269 -0
  93. package/dist/realtime/useEventsConnection.d.ts +1 -0
  94. package/dist/realtime/useEventsConnection.js +50 -0
  95. package/dist/reference/api.d.ts +2 -0
  96. package/dist/reference/api.js +2 -0
  97. package/dist/reference/capabilities.d.ts +12 -7
  98. package/dist/reference/capabilities.js +28 -13
  99. package/dist/reference/hooks.d.ts +34 -0
  100. package/dist/reference/hooks.js +124 -0
  101. package/dist/reference/menu-apply.d.ts +29 -0
  102. package/dist/reference/menu-apply.js +289 -0
  103. package/dist/reference/menu-record.d.ts +69 -0
  104. package/dist/reference/menu-record.js +163 -0
  105. package/dist/reference/operations.d.ts +3 -0
  106. package/dist/reference/operations.js +35 -1
  107. package/dist/reference/service.js +36 -9
  108. package/dist/reference/types.d.ts +1 -1
  109. package/dist/repositories/order.repository.d.ts +8 -0
  110. package/dist/repositories/order.repository.js +24 -2
  111. package/dist/repositories/pos.repository.d.ts +4 -0
  112. package/dist/repositories/pos.repository.js +4 -0
  113. package/dist/sync/backoff.d.ts +21 -0
  114. package/dist/sync/backoff.js +42 -0
  115. package/dist/sync/command-builder.js +10 -2
  116. package/dist/sync/command-queue.service.d.ts +12 -0
  117. package/dist/sync/command-queue.service.js +115 -18
  118. package/dist/sync/device-lease.service.d.ts +3 -3
  119. package/dist/sync/device-lease.service.js +80 -20
  120. package/dist/sync/engine.d.ts +3 -1
  121. package/dist/sync/engine.js +44 -4
  122. package/dist/sync/full-sync.d.ts +6 -0
  123. package/dist/sync/full-sync.js +72 -0
  124. package/dist/sync/health-probe.d.ts +24 -0
  125. package/dist/sync/health-probe.js +138 -0
  126. package/dist/sync/offline-capable.js +25 -0
  127. package/dist/sync/online-gates.d.ts +17 -0
  128. package/dist/sync/online-gates.js +55 -0
  129. package/dist/sync/order-number.d.ts +4 -4
  130. package/dist/sync/order-number.js +33 -13
  131. package/dist/sync/order-snapshots.service.d.ts +1 -0
  132. package/dist/sync/order-snapshots.service.js +47 -2
  133. package/dist/sync/pending-orders.service.d.ts +18 -0
  134. package/dist/sync/pending-orders.service.js +148 -3
  135. package/dist/sync/reconciliation/index.d.ts +5 -0
  136. package/dist/sync/reconciliation/index.js +5 -0
  137. package/dist/sync/snapshots/entity.d.ts +6 -0
  138. package/dist/sync/snapshots/entity.js +6 -0
  139. package/dist/sync/sync-jobs.service.d.ts +18 -0
  140. package/dist/sync/sync-jobs.service.js +539 -0
  141. package/dist/sync/transition-endpoints.d.ts +11 -0
  142. package/dist/sync/transition-endpoints.js +30 -0
  143. package/dist/sync/transport-local-node.js +3 -2
  144. package/dist/sync/transport-rest.d.ts +7 -0
  145. package/dist/sync/transport-rest.js +90 -25
  146. package/dist/sync/use-sync-reconciliation.js +58 -16
  147. package/dist/test-helpers/memory-driver.js +182 -4
  148. package/dist/test-helpers/seed-hours.d.ts +1 -0
  149. package/dist/test-helpers/seed-hours.js +23 -0
  150. package/dist/types/api/business.api.d.ts +42 -0
  151. package/dist/types/api/menu.api.d.ts +15 -0
  152. package/dist/types/api/seller.api.d.ts +20 -0
  153. package/dist/types/places.d.ts +19 -0
  154. package/dist/types/places.js +2 -0
  155. package/dist/types/rider.d.ts +67 -0
  156. package/dist/types/rider.js +2 -0
  157. package/dist/utils/attempt-stats.d.ts +23 -0
  158. package/dist/utils/attempt-stats.js +61 -0
  159. package/dist/utils/business-hours.d.ts +37 -0
  160. package/dist/utils/business-hours.js +96 -0
  161. package/dist/utils/debug-log.d.ts +23 -0
  162. package/dist/utils/debug-log.js +153 -0
  163. package/dist/utils/order-filter.d.ts +1 -1
  164. package/dist/utils/order-filter.js +1 -1
  165. package/dist/utils/order-transitions.d.ts +30 -0
  166. package/dist/utils/order-transitions.js +185 -0
  167. package/dist/utils/sync-logger.js +5 -4
  168. package/dist/validation/offline.d.ts +46 -0
  169. package/dist/validation/offline.js +79 -0
  170. package/package.json +6 -1
  171. package/dist/pricing/engine.test.d.ts +0 -1
  172. package/dist/pricing/engine.test.js +0 -142
  173. package/dist/reference/lww.test.d.ts +0 -1
  174. package/dist/reference/lww.test.js +0 -23
  175. package/dist/reference/operations.test.d.ts +0 -1
  176. package/dist/reference/operations.test.js +0 -66
  177. package/dist/standalone.integration.test.d.ts +0 -1
  178. package/dist/standalone.integration.test.js +0 -235
  179. package/dist/sync/device-lease.test.d.ts +0 -1
  180. package/dist/sync/device-lease.test.js +0 -38
  181. package/dist/sync/offline-integration.test.d.ts +0 -1
  182. package/dist/sync/offline-integration.test.js +0 -52
  183. package/dist/sync/order-number.test.d.ts +0 -1
  184. package/dist/sync/order-number.test.js +0 -51
  185. package/dist/sync/perms.test.d.ts +0 -1
  186. package/dist/sync/perms.test.js +0 -15
  187. package/dist/utils/currency.test.d.ts +0 -1
  188. package/dist/utils/currency.test.js +0 -17
  189. package/dist/utils/hlc.test.d.ts +0 -1
  190. package/dist/utils/hlc.test.js +0 -29
  191. package/dist/utils/phone.test.d.ts +0 -1
  192. package/dist/utils/phone.test.js +0 -19
  193. package/dist/utils/responsive.d.ts +0 -13
  194. package/dist/utils/responsive.js +0 -18
  195. package/dist/utils/uuid.test.d.ts +0 -1
  196. package/dist/utils/uuid.test.js +0 -13
  197. package/dist/utils/validation.test.d.ts +0 -1
  198. package/dist/utils/validation.test.js +0 -23
@@ -5,9 +5,12 @@
5
5
  * resulting domain events. This transport is used when the cloud API is
6
6
  * reachable.
7
7
  */
8
- import { posService, sellerService, kioskService, menuService } from "../adapters/services";
8
+ import { sellerService, kioskService, menuService } from "../adapters/services";
9
9
  import { syncLogger } from "../utils/sync-logger";
10
10
  import { generateEventId } from "../utils/uuid";
11
+ import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
12
+ import { openSyncDatabase } from "../db/connection";
13
+ import { getOrderById, updateOrderStatus } from "../db/operations";
11
14
  export class RestTransport {
12
15
  constructor() {
13
16
  this.name = "rest";
@@ -45,40 +48,38 @@ export class RestTransport {
45
48
  }
46
49
  async dispatchCommand(command) {
47
50
  const { command_type, business_id, entity_id, payload } = command;
48
- const timestamp = new Date().toISOString();
51
+ // Use the command's original timestamp (set at creation time) so the event
52
+ // reflects when the mutation actually happened, not when it was dispatched.
53
+ const timestamp = command.timestamp || new Date().toISOString();
49
54
  switch (command_type) {
55
+ // Single create path — createLocalOrder owns SQLite + sync_jobs + the
56
+ // operating-hours gate. No direct-REST fallback: guard rejections
57
+ // (BUSINESS_CLOSED / HOURS_NOT_SYNCED) must stand, never be bypassed.
50
58
  case "order:create": {
51
- const posPayload = {
52
- ...payload,
59
+ const order = await createLocalOrder(business_id, {
60
+ ...(payload ?? {}),
53
61
  command_id: command.command_id,
54
- local_order_number: payload.order_number,
55
- };
56
- const response = await posService.createPOSOrder(business_id, posPayload);
57
- return this.wrapResponse("order.created", response.data, entity_id, timestamp, command);
62
+ id: entity_id ?? command.command_id,
63
+ });
64
+ return this.wrapResponse("order.created", order, order.id, timestamp, command);
58
65
  }
59
66
  case "order:accept": {
60
- const response = await sellerService.acceptOrder(entity_id);
61
- return this.wrapResponse("order.status_changed", response.data, entity_id, timestamp, command);
67
+ return this.executeOrderTransition(command, "accept", timestamp, () => sellerService.acceptOrder(entity_id));
62
68
  }
63
69
  case "order:reject": {
64
- const response = await sellerService.rejectOrder(entity_id);
65
- return this.wrapResponse("order.status_changed", response.data, entity_id, timestamp, command);
70
+ return this.executeOrderTransition(command, "reject", timestamp, () => sellerService.rejectOrder(entity_id));
66
71
  }
67
72
  case "order:start_preparing": {
68
- const response = await sellerService.startPreparingOrder(entity_id);
69
- return this.wrapResponse("order.status_changed", response.data, entity_id, timestamp, command);
73
+ return this.executeOrderTransition(command, "start_preparing", timestamp, () => sellerService.startPreparingOrder(entity_id));
70
74
  }
71
75
  case "order:mark_ready": {
72
- const response = await sellerService.markOrderReady(entity_id);
73
- return this.wrapResponse("order.status_changed", response.data, entity_id, timestamp, command);
76
+ return this.executeOrderTransition(command, "mark_ready", timestamp, () => sellerService.markOrderReady(entity_id));
74
77
  }
75
78
  case "order:complete_pickup": {
76
- const response = await sellerService.completeOrderPickup(entity_id);
77
- return this.wrapResponse("order.status_changed", response.data, entity_id, timestamp, command);
79
+ return this.executeOrderTransition(command, "complete_pickup", timestamp, () => sellerService.completeOrderPickup(entity_id));
78
80
  }
79
81
  case "order:cancel": {
80
- const response = await sellerService.cancelOrder(entity_id);
81
- return this.wrapResponse("order.status_changed", response.data, entity_id, timestamp, command);
82
+ return this.executeOrderTransition(command, "cancel", timestamp, () => sellerService.cancelOrder(entity_id));
82
83
  }
83
84
  case "delivery:assign": {
84
85
  const response = await kioskService.assignKioskOrderToRider(entity_id, payload.rider_id);
@@ -89,11 +90,18 @@ 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 response = await menuService.createMenuItem(business_id, payload);
93
- return this.wrapResponse("menu.item_created", response, entity_id, timestamp, command);
93
+ // cacheRecord is local-cache only — strip it so the backend receives
94
+ // the flat wire payload it accepts (it owns canonical on converge).
95
+ const { cacheRecord: _omitCreate, ...wireCreate } = payload;
96
+ void _omitCreate;
97
+ const createPayload = { ...wireCreate, id: wireCreate.id ?? command.command_id, command_id: command.command_id };
98
+ const response = await menuService.createMenuItem(business_id, createPayload);
99
+ return this.wrapResponse("menu.item_created", response, command.command_id, timestamp, command);
94
100
  }
95
101
  case "menu:update_item": {
96
- const response = await menuService.updateMenuItem(entity_id, payload);
102
+ const { cacheRecord: _omitUpdate, ...wireUpdate } = payload;
103
+ void _omitUpdate;
104
+ const response = await menuService.updateMenuItem(entity_id, wireUpdate);
97
105
  return this.wrapResponse("menu.item_updated", response, entity_id, timestamp, command);
98
106
  }
99
107
  case "menu:delete_item": {
@@ -101,13 +109,70 @@ export class RestTransport {
101
109
  return this.wrapResponse("menu.item_deleted", response, entity_id, timestamp, command);
102
110
  }
103
111
  case "menu:create_category": {
104
- const response = await menuService.createCategory(business_id, payload);
105
- return this.wrapResponse("menu.category_created", response, entity_id, timestamp, command);
112
+ const { cacheRecord: _omitCatCreate, ...wireCatCreate } = payload;
113
+ void _omitCatCreate;
114
+ const createPayload = { ...wireCatCreate, id: wireCatCreate.id ?? command.command_id, command_id: command.command_id };
115
+ const response = await menuService.createCategory(business_id, createPayload);
116
+ return this.wrapResponse("menu.category_created", response, command.command_id, timestamp, command);
117
+ }
118
+ case "menu:update_category": {
119
+ const { cacheRecord: _omitCatUpdate, ...wireCatUpdate } = payload;
120
+ void _omitCatUpdate;
121
+ const response = await menuService.updateCategory(entity_id, wireCatUpdate);
122
+ return this.wrapResponse("menu.category_updated", response, entity_id, timestamp, command);
123
+ }
124
+ case "menu:delete_category": {
125
+ const response = await menuService.deleteCategory(entity_id);
126
+ return this.wrapResponse("menu.category_deleted", response, entity_id, timestamp, command);
127
+ }
128
+ case "payment:confirm_cash": {
129
+ return this.executeOrderTransition(command, "confirm_cash", timestamp, () => sellerService.confirmCashPayment(entity_id), "payment.completed");
106
130
  }
107
131
  default:
108
132
  throw new Error(`RestTransport: unsupported command type ${command_type}`);
109
133
  }
110
134
  }
135
+ /**
136
+ * Order transitions go through the domain service first (single write path:
137
+ * SQLite + sync_jobs). If this device never saw the order (e.g. a LAN node
138
+ * handling a cloud-created order before its first pull), fall back to the
139
+ * previous direct-REST behavior so availability never regresses.
140
+ */
141
+ async executeOrderTransition(command, action, timestamp, restFallback, eventType = "order.status_changed") {
142
+ const { business_id, entity_id } = command;
143
+ try {
144
+ const order = await transitionLocalOrder(business_id, entity_id, action);
145
+ const data = { ...order, order_id: order.id };
146
+ return this.wrapResponse(eventType, data, order.id, timestamp, command);
147
+ }
148
+ catch (error) {
149
+ syncLogger.warn("RestTransport", "domain transition unavailable, REST fallback", {
150
+ command_type: command.command_type,
151
+ entity_id,
152
+ error: error instanceof Error ? error.message : String(error),
153
+ });
154
+ const response = await restFallback();
155
+ const raw = response.data;
156
+ const data = { ...raw };
157
+ const orderId = data.id ?? entity_id;
158
+ if (!data.order_id)
159
+ data.order_id = orderId;
160
+ if (business_id && orderId) {
161
+ try {
162
+ const db = await openSyncDatabase();
163
+ const existing = await getOrderById(db, business_id, orderId);
164
+ if (existing) {
165
+ const newStatus = data.status ?? existing.status;
166
+ if (newStatus !== existing.status) {
167
+ await updateOrderStatus(db, business_id, orderId, newStatus);
168
+ }
169
+ }
170
+ }
171
+ catch { }
172
+ }
173
+ return this.wrapResponse(eventType, data, orderId, timestamp, command);
174
+ }
175
+ }
111
176
  wrapResponse(eventType, data, entityId, timestamp, command) {
112
177
  return {
113
178
  event_id: generateEventId(),
@@ -10,12 +10,14 @@
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";
16
17
  import { markEventsSynced } from "../db/operations";
17
18
  import { syncLogger } from "../utils/sync-logger";
18
- import { POS_KEYS, SELLER_KEYS, KIOSK_KEYS, SELLER_ORDERS_KEYS, BUSINESS_DELIVERIES_KEYS, MENU_KEYS, REFERENCE_KEYS, } from "../adapters/query-keys";
19
+ import { subscribeDbChanges } from "../db/operations";
20
+ import { POS_KEYS, SELLER_KEYS, KIOSK_KEYS, SELLER_ORDERS_KEYS, BUSINESS_DELIVERIES_KEYS, MENU_KEYS, REFERENCE_KEYS, SELLER_DASHBOARD_KEYS, } from "../adapters/query-keys";
19
21
  import { applyReferenceChangeEvent, registerReferenceInvalidator, registerReferenceEventEngine, REFERENCE_CHANGED_EVENT_TYPE, } from "../reference/events";
20
22
  function isLocalOrderEvent(event) {
21
23
  const payload = event.payload;
@@ -24,16 +26,44 @@ function isLocalOrderEvent(event) {
24
26
  const localOrderNumber = payload.local_order_number;
25
27
  return typeof localOrderNumber === "string" && /^[0-9]{2}-[0-9]{3}-[0-9]{3}$/.test(localOrderNumber);
26
28
  }
29
+ const RECONCILE_DEBOUNCE_MS = 400;
27
30
  export function useSyncReconciliation() {
28
31
  const queryClient = useQueryClient();
29
32
  useEffect(() => {
33
+ let timer;
34
+ const pending = new Set();
35
+ const flush = () => {
36
+ timer = undefined;
37
+ if (pending.has("reference")) {
38
+ queryClient.invalidateQueries({ queryKey: MENU_KEYS.all });
39
+ queryClient.invalidateQueries({ queryKey: REFERENCE_KEYS.all });
40
+ queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
41
+ queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
42
+ queryClient.invalidateQueries({ queryKey: SELLER_DASHBOARD_KEYS.all });
43
+ }
44
+ if (pending.has("pending")) {
45
+ queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
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 });
50
+ queryClient.invalidateQueries({ queryKey: SELLER_DASHBOARD_KEYS.all });
51
+ }
52
+ if (pending.has("order")) {
53
+ // order.created with local number already covered by pending, but keep fine-grained
54
+ // POS.orders(businessId) is also covered by pending's broad POS.all, no extra needed.
55
+ }
56
+ pending.clear();
57
+ };
58
+ const schedule = (kind) => {
59
+ pending.add(kind);
60
+ if (!timer)
61
+ timer = setTimeout(flush, RECONCILE_DEBOUNCE_MS);
62
+ };
30
63
  // Reference changes re-sync offline data and refresh caches on this device
31
64
  // and (via the LAN relay) on every connected terminal.
32
65
  const unregisterReferenceInvalidator = registerReferenceInvalidator(() => {
33
- queryClient.invalidateQueries({ queryKey: MENU_KEYS.all });
34
- queryClient.invalidateQueries({ queryKey: REFERENCE_KEYS.all });
35
- queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
36
- queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
66
+ schedule("reference");
37
67
  });
38
68
  // Pending-order reconciliation invalidates POS + seller order lists so the
39
69
  // server-confirmed order replaces the optimistic one. Invalidate from the
@@ -42,17 +72,21 @@ export function useSyncReconciliation() {
42
72
  // keys. Also cover kiosk/seller-orders/business-deliveries so boards
43
73
  // refresh even when there is no Local Node (offline queue + Phoenix).
44
74
  const unregisterInvalidator = registerPendingOrdersInvalidator(() => {
45
- queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
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 });
75
+ schedule("pending");
50
76
  });
51
77
  // Reference-change events are published through the Sync Engine but are
52
78
  // not backed by a command, so the engine has no transport dependency on
53
79
  // the reference module. Register the singleton once.
54
80
  registerReferenceEventEngine(syncEngine);
55
81
  const unsubscribe = syncEngine.subscribe(async (event) => {
82
+ // LAN strict: ignore events for other business on same subnet
83
+ try {
84
+ const activeId = getActiveBusiness()?.id;
85
+ const payloadBid = event.payload?.business_id;
86
+ if (activeId && payloadBid && payloadBid !== activeId)
87
+ return;
88
+ }
89
+ catch { }
56
90
  syncLogger.event(event, "received");
57
91
  // The node relays engine events (e.g. order.created when a queued
58
92
  // command is replayed) to LAN clients so they reconcile their pending
@@ -74,6 +108,10 @@ export function useSyncReconciliation() {
74
108
  // Order/delivery state changed: refresh order caches so this terminal
75
109
  // shows the same order states as the device that made the change.
76
110
  await applyOrderStateChangeEvent(event, "local");
111
+ // Any order/delivery event should refresh dashboard recent orders immediately (fresh on transition)
112
+ if (event.event_type.startsWith("order.") || event.event_type.startsWith("delivery.")) {
113
+ schedule("pending");
114
+ }
77
115
  // Route both queued (optimistic) and reconciled (created) order events
78
116
  // into the pending-orders store.
79
117
  if (event.event_type === "order.create_queued" ||
@@ -90,18 +128,22 @@ export function useSyncReconciliation() {
90
128
  error: String(error),
91
129
  });
92
130
  }
93
- const payload = event.payload;
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
- }
131
+ schedule("pending");
99
132
  }
100
133
  });
134
+ const dbUnsub = subscribeDbChanges((info) => {
135
+ if (info.table.startsWith("reference_data"))
136
+ schedule("reference");
137
+ else if (info.table === "orders" || info.table.startsWith("entity_snapshots"))
138
+ schedule("pending");
139
+ });
101
140
  return () => {
141
+ if (timer)
142
+ clearTimeout(timer);
102
143
  unsubscribe();
103
144
  unregisterInvalidator();
104
145
  unregisterReferenceInvalidator();
146
+ dbUnsub();
105
147
  };
106
148
  }, [queryClient]);
107
149
  }
@@ -5,6 +5,8 @@ export function createMemoryDriver() {
5
5
  entity_snapshots: [],
6
6
  commands: [],
7
7
  events: [],
8
+ orders: [],
9
+ sync_jobs: [],
8
10
  };
9
11
  let autoId = { commands: 1, events: 1, migrations: 1 };
10
12
  const driver = {
@@ -93,9 +95,13 @@ export function createMemoryDriver() {
93
95
  tables.commands.push(row);
94
96
  return { lastInsertRowId: row.id, changes: 1 };
95
97
  }
96
- // commands update status
98
+ // commands update status (4- or 5-placeholder variants — always take the
99
+ // LAST arg as command_id; older code assumed 5).
97
100
  if (s.includes("UPDATE commands SET status = ?")) {
98
- const [status, error, _now, _now2, command_id] = args;
101
+ const argv = args;
102
+ const status = argv[0];
103
+ const error = argv[1];
104
+ const command_id = argv[argv.length - 1];
99
105
  // also handles variants with retry_count increment
100
106
  const row = tables.commands.find((r) => r.command_id === command_id);
101
107
  if (row) {
@@ -126,10 +132,17 @@ export function createMemoryDriver() {
126
132
  return { lastInsertRowId: 0, changes: 1 };
127
133
  }
128
134
  if (s.includes("UPDATE commands SET payload")) {
129
- const [payload, command_id] = args;
135
+ // handles both old (payload, command_id) and new (payload, error, updated_at, command_id)
136
+ const payload = args[0];
137
+ const command_id = args[args.length - 1];
130
138
  const row = tables.commands.find((r) => r.command_id === command_id);
131
- if (row)
139
+ if (row) {
132
140
  row.payload = payload;
141
+ if (args.length === 4) {
142
+ row.error = args[1];
143
+ row.updated_at = args[2];
144
+ }
145
+ }
133
146
  return { lastInsertRowId: 0, changes: 1 };
134
147
  }
135
148
  // events insert
@@ -162,6 +175,101 @@ export function createMemoryDriver() {
162
175
  row.synced_to_cloud = 1;
163
176
  return { lastInsertRowId: 0, changes: ids.length };
164
177
  }
178
+ // orders insert (primary DB)
179
+ if (s.includes("INSERT INTO orders")) {
180
+ const [id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, hlc] = args;
181
+ const existing = tables.orders.find((r) => r.id === id);
182
+ // LWW check via hlc (if existing and incoming older, skip)
183
+ if (existing) {
184
+ if (hlc && existing.hlc && hlc < existing.hlc)
185
+ return { lastInsertRowId: 0, changes: 0 };
186
+ Object.assign(existing, { business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, hlc });
187
+ return { lastInsertRowId: 0, changes: 1 };
188
+ }
189
+ tables.orders.push({ id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, hlc, deleted_at: null });
190
+ return { lastInsertRowId: 1, changes: 1 };
191
+ }
192
+ // sync_jobs insert
193
+ if (s.includes("INSERT OR IGNORE INTO sync_jobs")) {
194
+ const [id, business_id, command_type, entity_id, payload, created_at, updated_at] = args;
195
+ if (tables.sync_jobs.find((r) => r.id === id))
196
+ return { lastInsertRowId: 0, changes: 0 };
197
+ tables.sync_jobs.push({ id, business_id, command_type, entity_id, payload, status: "pending", attempts: 0, created_at, updated_at });
198
+ return { lastInsertRowId: 1, changes: 1 };
199
+ }
200
+ // sync_jobs update status=completed
201
+ if (s.includes("UPDATE sync_jobs SET status='completed'")) {
202
+ const [updated_at, id] = args;
203
+ const row = tables.sync_jobs.find((r) => r.id === id);
204
+ if (row) {
205
+ row.status = "completed";
206
+ row.updated_at = updated_at;
207
+ }
208
+ return { lastInsertRowId: 0, changes: 1 };
209
+ }
210
+ if (s.includes("UPDATE sync_jobs SET payload=")) {
211
+ const [payload, updated_at, id] = args;
212
+ const row = tables.sync_jobs.find((r) => r.id === id);
213
+ if (row) {
214
+ row.payload = payload;
215
+ row.updated_at = updated_at;
216
+ }
217
+ return { lastInsertRowId: 0, changes: 1 };
218
+ }
219
+ if (s.includes("UPDATE sync_jobs SET attempts = attempts + 1")) {
220
+ const [last_error, updated_at, id] = args;
221
+ const row = tables.sync_jobs.find((r) => r.id === id);
222
+ if (row) {
223
+ row.attempts = (row.attempts ?? 0) + 1;
224
+ row.last_error = last_error;
225
+ row.updated_at = updated_at;
226
+ }
227
+ return { lastInsertRowId: 0, changes: 1 };
228
+ }
229
+ // orders updates
230
+ if (s.includes("UPDATE orders SET status = ?")) {
231
+ const [status, hlc, business_id, id1, id2] = args;
232
+ const row = tables.orders.find((r) => r.business_id === business_id && (r.id === id1 || r.order_number === id2));
233
+ if (row) {
234
+ row.status = status;
235
+ row.hlc = hlc;
236
+ }
237
+ return { lastInsertRowId: 0, changes: 1 };
238
+ }
239
+ if (s.includes("UPDATE orders SET payment = ?")) {
240
+ const [payment, hlc, business_id, id1, id2] = args;
241
+ const row = tables.orders.find((r) => r.business_id === business_id && (r.id === id1 || r.order_number === id2));
242
+ if (row) {
243
+ row.payment = payment;
244
+ row.hlc = hlc;
245
+ }
246
+ return { lastInsertRowId: 0, changes: 1 };
247
+ }
248
+ if (s.includes("UPDATE orders SET order_number=")) {
249
+ const [order_number, hlc, business_id, id] = args;
250
+ const row = tables.orders.find((r) => r.business_id === business_id && r.id === id);
251
+ if (row) {
252
+ row.order_number = order_number;
253
+ row.hlc = hlc;
254
+ }
255
+ return { lastInsertRowId: 0, changes: 1 };
256
+ }
257
+ // cleanup deletes for v7
258
+ if (s.includes("DELETE FROM orders WHERE order_number = '[object Object]'")) {
259
+ const before = tables.orders.length;
260
+ tables.orders = tables.orders.filter((r) => r.order_number !== "[object Object]");
261
+ return { lastInsertRowId: 0, changes: before - tables.orders.length };
262
+ }
263
+ if (s.includes("DELETE FROM sync_jobs WHERE payload LIKE")) {
264
+ const before = tables.sync_jobs.length;
265
+ tables.sync_jobs = tables.sync_jobs.filter((r) => !String(r.payload).includes("[object Object]"));
266
+ return { lastInsertRowId: 0, changes: before - tables.sync_jobs.length };
267
+ }
268
+ if (s.includes("DELETE FROM entity_snapshots WHERE payload LIKE")) {
269
+ const before = tables.entity_snapshots.length;
270
+ tables.entity_snapshots = tables.entity_snapshots.filter((r) => !String(r.payload).includes("[object Object]"));
271
+ return { lastInsertRowId: 0, changes: before - tables.entity_snapshots.length };
272
+ }
165
273
  return { lastInsertRowId: 0, changes: 0 };
166
274
  },
167
275
  async getFirstAsync(sql, ...args) {
@@ -186,6 +294,11 @@ export function createMemoryDriver() {
186
294
  const row = tables.entity_snapshots.find((r) => r.entity_type === entity_type && r.entity_id === entity_id);
187
295
  return (row ? { last_event_timestamp: row.last_event_timestamp } : null);
188
296
  }
297
+ if (s.includes("SELECT * FROM entity_snapshots WHERE entity_type = ? AND entity_id = ? AND business_id =")) {
298
+ const [entity_type, entity_id, business_id] = args;
299
+ const row = tables.entity_snapshots.find((r) => r.entity_type === entity_type && r.entity_id === entity_id && r.business_id === business_id);
300
+ return row ?? null;
301
+ }
189
302
  if (s.includes("SELECT * FROM entity_snapshots WHERE entity_type = ? AND entity_id =")) {
190
303
  const [entity_type, entity_id] = args;
191
304
  const row = tables.entity_snapshots.find((r) => r.entity_type === entity_type && r.entity_id === entity_id);
@@ -196,6 +309,25 @@ export function createMemoryDriver() {
196
309
  const row = tables.commands.find((r) => r.id === id);
197
310
  return row ?? null;
198
311
  }
312
+ // orders count
313
+ if (s.includes("SELECT COUNT(*) as c FROM orders WHERE")) {
314
+ const business_id = args[0];
315
+ const c = tables.orders.filter((r) => r.business_id === business_id).length;
316
+ return { c };
317
+ }
318
+ // orders get by id or order_number
319
+ if (s.includes("SELECT * FROM orders WHERE business_id = ? AND (id = ? OR order_number = ?)")) {
320
+ const [business_id, id1, id2] = args;
321
+ const row = tables.orders.find((r) => r.business_id === business_id && (r.id === id1 || r.order_number === id2));
322
+ return row ?? null;
323
+ }
324
+ // sync_jobs pending single
325
+ if (s.includes("SELECT * FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND status='pending'")) {
326
+ const [business_id, entity_id] = args;
327
+ const row = tables.sync_jobs.find((r) => r.business_id === business_id && r.entity_id === entity_id && r.status === "pending");
328
+ return row ?? null;
329
+ }
330
+ // generic orders exists check for migrations backfill: SELECT * FROM orders WHERE business_id=? (not needed)
199
331
  return null;
200
332
  },
201
333
  async getAllAsync(sql, ...args) {
@@ -204,6 +336,11 @@ export function createMemoryDriver() {
204
336
  const [business_id] = args;
205
337
  return tables.reference_data.filter((r) => r.business_id === business_id).map((r) => ({ key: r.key }));
206
338
  }
339
+ if (s.includes("SELECT * FROM entity_snapshots WHERE entity_type = ? AND business_id = ? ORDER BY last_event_timestamp DESC")) {
340
+ const [entity_type, business_id] = args;
341
+ const rows = tables.entity_snapshots.filter((r) => r.entity_type === entity_type && r.business_id === business_id).sort((a, b) => (a.last_event_timestamp < b.last_event_timestamp ? 1 : -1));
342
+ return rows;
343
+ }
207
344
  if (s.includes("SELECT * FROM entity_snapshots WHERE entity_type = ? ORDER BY last_event_timestamp DESC")) {
208
345
  const [entity_type] = args;
209
346
  const rows = tables.entity_snapshots.filter((r) => r.entity_type === entity_type).sort((a, b) => (a.last_event_timestamp < b.last_event_timestamp ? 1 : -1));
@@ -230,6 +367,47 @@ export function createMemoryDriver() {
230
367
  rows = rows.slice(0, limit);
231
368
  return rows;
232
369
  }
370
+ // orders list with filters (simple business_id filter + pagination)
371
+ if (s.includes("SELECT * FROM orders WHERE")) {
372
+ const business_id = args[0];
373
+ // args includes where-clause params plus LIMIT and OFFSET at end
374
+ // For test, just filter by business_id and apply basic status/q if present in sql string via args slicing?
375
+ // Simpler: return all orders for that business, sorted by inserted_at desc, handle LIMIT/OFFSET manually
376
+ let rows = tables.orders.filter((r) => r.business_id === business_id);
377
+ // handle status filter if sql contains status IN
378
+ if (s.includes("status IN")) {
379
+ // find args that are status values (excluding business_id, limit, offset)
380
+ // Our sql: SELECT * FROM orders WHERE business_id = ? AND status IN (?,?) ... LIMIT ? OFFSET ?
381
+ // So statuses are args[1]..args[?]
382
+ // Count statuses by parsing SQL placeholder count?
383
+ // For test, just extract known statuses from args that look like status strings
384
+ const possibleStatuses = ["pending", "awaiting_payment", "accepted", "preparing", "ready", "waiting_pickup", "on_the_way", "arrived", "delivered", "cancelled", "assigned", "picked_up", "en_route", "failed"];
385
+ const statusArgs = args.filter((v) => possibleStatuses.includes(String(v)));
386
+ if (statusArgs.length)
387
+ rows = rows.filter((r) => statusArgs.includes(r.status));
388
+ }
389
+ rows = rows.sort((a, b) => (a.inserted_at < b.inserted_at ? 1 : -1));
390
+ const limit = args[args.length - 2];
391
+ const offset = args[args.length - 1];
392
+ if (typeof limit === "number" && typeof offset === "number") {
393
+ rows = rows.slice(offset, offset + limit);
394
+ }
395
+ return rows;
396
+ }
397
+ // sync_jobs pending list (generic, handles LIMIT 1/20)
398
+ if (s.includes("SELECT entity_id FROM sync_jobs") && s.includes("status='pending'")) {
399
+ const [business_id] = args;
400
+ const m = s.match(/LIMIT\s+(\d+)/);
401
+ const lim = m ? parseInt(m[1], 10) : 20;
402
+ let rows = tables.sync_jobs.filter((r) => r.business_id === business_id && r.status === "pending").sort((a, b) => (a.created_at < b.created_at ? -1 : 1)).map((r) => ({ entity_id: r.entity_id }));
403
+ rows = rows.slice(0, lim);
404
+ return rows;
405
+ }
406
+ if (s.includes("SELECT * FROM sync_jobs WHERE") || s.includes("SELECT id, command_type, status, created_at FROM sync_jobs")) {
407
+ const [business_id] = args;
408
+ const rows = tables.sync_jobs.filter((r) => r.business_id === business_id);
409
+ return rows;
410
+ }
233
411
  return [];
234
412
  },
235
413
  };
@@ -0,0 +1 @@
1
+ export declare function seedOpenHours(businessId: string): Promise<void>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Test helper — seeds 24/7 operating hours into SQLite reference_data.
3
+ * Creation is fail-closed: without cached hours every create throws
4
+ * HOURS_NOT_SYNCED, so tests that create orders must seed first.
5
+ */
6
+ import { openSyncDatabase } from "../db/connection";
7
+ import { setReferenceData } from "../reference/operations";
8
+ export async function seedOpenHours(businessId) {
9
+ const db = await openSyncDatabase();
10
+ const entries = Array.from({ length: 7 }, (_, dayOfWeek) => ({
11
+ day_of_week: dayOfWeek,
12
+ // "24:00:00" close keeps string-compare open for the whole UTC day.
13
+ open_time: "00:00:00",
14
+ close_time: "24:00:00",
15
+ is_closed: false,
16
+ }));
17
+ await setReferenceData(db, businessId, {
18
+ key: "operating-hours",
19
+ version: "test-open",
20
+ payload: { data: entries },
21
+ downloadedAt: new Date().toISOString(),
22
+ });
23
+ }
@@ -8,6 +8,8 @@ export interface Business {
8
8
  status: string;
9
9
  currency: string;
10
10
  is_open: boolean;
11
+ /** Seller-facing open/close toggle (present when backend serializes it). */
12
+ manually_closed?: boolean;
11
13
  owner_id: string;
12
14
  business_number: string;
13
15
  latitude: number;
@@ -135,3 +137,43 @@ export interface TaxRuleRequest {
135
137
  display_order?: number;
136
138
  };
137
139
  }
140
+ export interface BusinessMemberInvitation {
141
+ id: string;
142
+ business_id: string;
143
+ invited_email: string;
144
+ role: string;
145
+ status: string;
146
+ expires_at: string;
147
+ inserted_at: string;
148
+ invited_by?: {
149
+ id: string;
150
+ full_name: string;
151
+ email: string;
152
+ } | null;
153
+ }
154
+ export interface BusinessMemberInvitationListResponse {
155
+ data: BusinessMemberInvitation[];
156
+ }
157
+ export interface UserInvitation {
158
+ id: string;
159
+ type: 'business_owner' | 'fleet_rider' | 'business_member' | 'rider_linked';
160
+ status: string;
161
+ business_name: string | null;
162
+ business_id: string | null;
163
+ code: string | null;
164
+ role?: string;
165
+ is_fleet_managed: boolean;
166
+ created_at: string | null;
167
+ }
168
+ export interface RoleCapabilityItem {
169
+ key: string;
170
+ label: string;
171
+ description?: string;
172
+ }
173
+ export interface RoleCapability {
174
+ label: string;
175
+ capabilities: RoleCapabilityItem[];
176
+ }
177
+ export interface RolesResponse {
178
+ data: Record<string, RoleCapability>;
179
+ }
@@ -78,13 +78,28 @@ export interface CatalogItemRequest {
78
78
  currency_code: string;
79
79
  dietary_tags?: string[];
80
80
  variants: {
81
+ id?: string;
81
82
  name: string;
82
83
  price_adjustment: number;
83
84
  }[];
84
85
  extras: {
86
+ id?: string;
85
87
  name: string;
86
88
  price_minor: number;
87
89
  }[];
88
90
  available: boolean;
89
91
  image_url?: string | null;
90
92
  }
93
+ /** menu:create_item / menu:update_item command payload. */
94
+ export interface MenuItemCommandPayload extends CatalogItemRequest {
95
+ cacheRecord: MenuItem;
96
+ }
97
+ /** menu:create_category / menu:update_category command payload. */
98
+ export interface MenuCategoryCommandPayload {
99
+ name: string;
100
+ description?: string;
101
+ sort_order?: number;
102
+ categoryId?: string;
103
+ id?: string;
104
+ cacheRecord: MenuCategory;
105
+ }