@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.
- package/README.md +26 -21
- package/dist/adapters/auth.d.ts +10 -0
- package/dist/adapters/auth.js +43 -0
- package/dist/adapters/devices.d.ts +3 -1
- package/dist/adapters/devices.js +187 -3
- package/dist/adapters/filesystem.d.ts +4 -0
- package/dist/adapters/kv.d.ts +10 -0
- package/dist/adapters/kv.js +23 -7
- package/dist/adapters/local-node.d.ts +4 -6
- package/dist/adapters/local-node.js +9 -32
- package/dist/adapters/notify.d.ts +12 -0
- package/dist/adapters/notify.js +33 -0
- package/dist/adapters/query-keys.d.ts +4 -0
- package/dist/adapters/query-keys.js +4 -0
- package/dist/adapters/services.d.ts +99 -15
- package/dist/adapters/services.js +244 -4
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +172 -0
- package/dist/catalog/hooks.d.ts +10 -0
- package/dist/catalog/hooks.js +82 -11
- package/dist/db/migrations.js +274 -0
- package/dist/db/operations.d.ts +53 -2
- package/dist/db/operations.js +177 -8
- package/dist/domain/business/index.d.ts +5 -0
- package/dist/domain/business/index.js +5 -0
- package/dist/domain/orders/lifecycle.d.ts +6 -0
- package/dist/domain/orders/lifecycle.js +6 -0
- package/dist/domain/orders/service.d.ts +24 -0
- package/dist/domain/orders/service.js +242 -0
- package/dist/domain/payments/index.d.ts +6 -0
- package/dist/domain/payments/index.js +5 -0
- package/dist/domain/reference/index.d.ts +8 -0
- package/dist/domain/reference/index.js +8 -0
- package/dist/e2e/harness.d.ts +1 -0
- package/dist/e2e/harness.js +61 -0
- package/dist/hooks/analytics.hooks.d.ts +25 -0
- package/dist/hooks/analytics.hooks.js +214 -0
- package/dist/hooks/business-hours.hooks.d.ts +8 -0
- package/dist/hooks/business-hours.hooks.js +45 -0
- package/dist/hooks/dashboard.hooks.d.ts +30 -0
- package/dist/hooks/dashboard.hooks.js +132 -0
- package/dist/hooks/delivery.hooks.d.ts +225 -0
- package/dist/hooks/delivery.hooks.js +80 -0
- package/dist/hooks/fleet.hooks.d.ts +37 -0
- package/dist/hooks/fleet.hooks.js +229 -0
- package/dist/hooks/image.hooks.d.ts +20 -0
- package/dist/hooks/image.hooks.js +62 -0
- package/dist/hooks/kiosk.hooks.d.ts +22 -5
- package/dist/hooks/kiosk.hooks.js +74 -5
- package/dist/hooks/members.hooks.d.ts +48 -0
- package/dist/hooks/members.hooks.js +302 -0
- package/dist/hooks/offline-orders.hooks.d.ts +9 -0
- package/dist/hooks/offline-orders.hooks.js +51 -0
- package/dist/hooks/orders.hooks.d.ts +19 -39
- package/dist/hooks/orders.hooks.js +126 -321
- package/dist/hooks/places.hooks.d.ts +21 -0
- package/dist/hooks/places.hooks.js +112 -0
- package/dist/hooks/realtime-stubs.d.ts +28 -0
- package/dist/hooks/realtime-stubs.js +85 -0
- package/dist/hooks/rider.hooks.d.ts +470 -0
- package/dist/hooks/rider.hooks.js +373 -0
- package/dist/hooks/use-infinite-list.d.ts +243 -0
- package/dist/hooks/use-infinite-list.js +58 -0
- package/dist/hooks/wallet.hooks.d.ts +219 -0
- package/dist/hooks/wallet.hooks.js +56 -0
- package/dist/image-cache/service.d.ts +8 -0
- package/dist/image-cache/service.js +15 -3
- package/dist/index.d.ts +60 -5
- package/dist/index.js +78 -5
- package/dist/local-node/adapters.d.ts +87 -0
- package/dist/local-node/adapters.js +4 -0
- package/dist/local-node/index.d.ts +5 -0
- package/dist/local-node/index.js +5 -0
- package/dist/local-node/security.d.ts +25 -0
- package/dist/local-node/security.js +149 -0
- package/dist/local-node/service.d.ts +83 -0
- package/dist/local-node/service.js +706 -0
- package/dist/local-node/store.d.ts +50 -0
- package/dist/local-node/store.js +284 -0
- package/dist/local-node/types.d.ts +92 -0
- package/dist/local-node/types.js +4 -0
- package/dist/printer/receipt.d.ts +4 -0
- package/dist/printer/receipt.js +75 -0
- package/dist/printer/service.js +1 -1
- package/dist/providers/query-client.d.ts +13 -0
- package/dist/providers/query-client.js +58 -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 +269 -0
- package/dist/realtime/useEventsConnection.d.ts +1 -0
- package/dist/realtime/useEventsConnection.js +50 -0
- package/dist/reference/api.d.ts +2 -0
- package/dist/reference/api.js +2 -0
- package/dist/reference/capabilities.d.ts +12 -7
- package/dist/reference/capabilities.js +28 -13
- package/dist/reference/hooks.d.ts +34 -0
- package/dist/reference/hooks.js +124 -0
- package/dist/reference/menu-apply.d.ts +29 -0
- package/dist/reference/menu-apply.js +289 -0
- package/dist/reference/menu-record.d.ts +69 -0
- package/dist/reference/menu-record.js +163 -0
- package/dist/reference/operations.d.ts +3 -0
- package/dist/reference/operations.js +35 -1
- package/dist/reference/service.js +36 -9
- package/dist/reference/types.d.ts +1 -1
- package/dist/repositories/order.repository.d.ts +8 -0
- package/dist/repositories/order.repository.js +24 -2
- package/dist/repositories/pos.repository.d.ts +4 -0
- package/dist/repositories/pos.repository.js +4 -0
- package/dist/sync/backoff.d.ts +21 -0
- package/dist/sync/backoff.js +42 -0
- package/dist/sync/command-builder.js +10 -2
- package/dist/sync/command-queue.service.d.ts +12 -0
- package/dist/sync/command-queue.service.js +115 -18
- package/dist/sync/device-lease.service.d.ts +3 -3
- package/dist/sync/device-lease.service.js +80 -20
- package/dist/sync/engine.d.ts +3 -1
- package/dist/sync/engine.js +44 -4
- package/dist/sync/full-sync.d.ts +6 -0
- package/dist/sync/full-sync.js +72 -0
- package/dist/sync/health-probe.d.ts +24 -0
- package/dist/sync/health-probe.js +138 -0
- package/dist/sync/offline-capable.js +25 -0
- package/dist/sync/online-gates.d.ts +17 -0
- package/dist/sync/online-gates.js +55 -0
- package/dist/sync/order-number.d.ts +4 -4
- package/dist/sync/order-number.js +33 -13
- package/dist/sync/order-snapshots.service.d.ts +1 -0
- package/dist/sync/order-snapshots.service.js +47 -2
- package/dist/sync/pending-orders.service.d.ts +18 -0
- package/dist/sync/pending-orders.service.js +148 -3
- package/dist/sync/reconciliation/index.d.ts +5 -0
- package/dist/sync/reconciliation/index.js +5 -0
- package/dist/sync/snapshots/entity.d.ts +6 -0
- package/dist/sync/snapshots/entity.js +6 -0
- package/dist/sync/sync-jobs.service.d.ts +18 -0
- package/dist/sync/sync-jobs.service.js +539 -0
- package/dist/sync/transition-endpoints.d.ts +11 -0
- package/dist/sync/transition-endpoints.js +30 -0
- package/dist/sync/transport-local-node.js +3 -2
- package/dist/sync/transport-rest.d.ts +7 -0
- package/dist/sync/transport-rest.js +90 -25
- package/dist/sync/use-sync-reconciliation.js +58 -16
- package/dist/test-helpers/memory-driver.js +182 -4
- package/dist/test-helpers/seed-hours.d.ts +1 -0
- package/dist/test-helpers/seed-hours.js +23 -0
- package/dist/types/api/business.api.d.ts +42 -0
- package/dist/types/api/menu.api.d.ts +15 -0
- package/dist/types/api/seller.api.d.ts +20 -0
- package/dist/types/places.d.ts +19 -0
- package/dist/types/places.js +2 -0
- package/dist/types/rider.d.ts +67 -0
- package/dist/types/rider.js +2 -0
- package/dist/utils/attempt-stats.d.ts +23 -0
- package/dist/utils/attempt-stats.js +61 -0
- package/dist/utils/business-hours.d.ts +37 -0
- package/dist/utils/business-hours.js +96 -0
- package/dist/utils/debug-log.d.ts +23 -0
- package/dist/utils/debug-log.js +153 -0
- package/dist/utils/order-filter.d.ts +1 -1
- package/dist/utils/order-filter.js +1 -1
- package/dist/utils/order-transitions.d.ts +30 -0
- package/dist/utils/order-transitions.js +185 -0
- package/dist/utils/sync-logger.js +5 -4
- package/dist/validation/offline.d.ts +46 -0
- package/dist/validation/offline.js +79 -0
- package/package.json +6 -1
- package/dist/pricing/engine.test.d.ts +0 -1
- package/dist/pricing/engine.test.js +0 -142
- package/dist/reference/lww.test.d.ts +0 -1
- package/dist/reference/lww.test.js +0 -23
- package/dist/reference/operations.test.d.ts +0 -1
- package/dist/reference/operations.test.js +0 -66
- package/dist/standalone.integration.test.d.ts +0 -1
- package/dist/standalone.integration.test.js +0 -235
- package/dist/sync/device-lease.test.d.ts +0 -1
- package/dist/sync/device-lease.test.js +0 -38
- package/dist/sync/offline-integration.test.d.ts +0 -1
- package/dist/sync/offline-integration.test.js +0 -52
- package/dist/sync/order-number.test.d.ts +0 -1
- package/dist/sync/order-number.test.js +0 -51
- package/dist/sync/perms.test.d.ts +0 -1
- package/dist/sync/perms.test.js +0 -15
- package/dist/utils/currency.test.d.ts +0 -1
- package/dist/utils/currency.test.js +0 -17
- package/dist/utils/hlc.test.d.ts +0 -1
- package/dist/utils/hlc.test.js +0 -29
- package/dist/utils/phone.test.d.ts +0 -1
- package/dist/utils/phone.test.js +0 -19
- package/dist/utils/responsive.d.ts +0 -13
- package/dist/utils/responsive.js +0 -18
- package/dist/utils/uuid.test.d.ts +0 -1
- package/dist/utils/uuid.test.js +0 -13
- package/dist/utils/validation.test.d.ts +0 -1
- package/dist/utils/validation.test.js +0 -23
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference-data React hooks — single source for both apps.
|
|
3
|
+
* Sync hooks use core connectivity/business adapters; cache reads are pure
|
|
4
|
+
* SQLite (never network). Image-query invalidation uses the shared
|
|
5
|
+
* ["cachedImage"] prefix both apps use.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useCallback } from "react";
|
|
8
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
|
+
import { useSyncExternalStore } from "react";
|
|
10
|
+
import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
|
|
11
|
+
import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
|
|
12
|
+
import { openSyncDatabase } from "../db/connection";
|
|
13
|
+
import { referenceSyncService } from "./service";
|
|
14
|
+
import { getReferenceData } from "./operations";
|
|
15
|
+
export const REFERENCE_KEYS = {
|
|
16
|
+
all: ["reference"],
|
|
17
|
+
sync: ["reference", "sync"],
|
|
18
|
+
data: (key) => ["reference", "data", key],
|
|
19
|
+
};
|
|
20
|
+
function useActiveBusinessId() {
|
|
21
|
+
const id = useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
|
|
22
|
+
return id ?? undefined;
|
|
23
|
+
}
|
|
24
|
+
function useConnectivitySnapshot() {
|
|
25
|
+
return useSyncExternalStore(subscribeConnectivity, getConnectivityState, getConnectivityState);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Trigger reference data synchronization when the active business changes.
|
|
29
|
+
* Does not return data; fires a background sync. Set `enabled` to false to
|
|
30
|
+
* disable automatic sync.
|
|
31
|
+
*/
|
|
32
|
+
export function useReferenceDataSync(businessId, options = {}) {
|
|
33
|
+
const { enabled = true, ...syncOptions } = options;
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!enabled || !businessId)
|
|
36
|
+
return;
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
referenceSyncService
|
|
39
|
+
.sync({ businessId, ...syncOptions })
|
|
40
|
+
.then((result) => {
|
|
41
|
+
if (cancelled)
|
|
42
|
+
return;
|
|
43
|
+
if (result.failed.length > 0) {
|
|
44
|
+
try {
|
|
45
|
+
console.debug("[ReferenceSync] failed keys (non-fatal):", result.failed);
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {
|
|
51
|
+
if (cancelled)
|
|
52
|
+
return;
|
|
53
|
+
});
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
};
|
|
57
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
58
|
+
}, [businessId, enabled, JSON.stringify(syncOptions)]);
|
|
59
|
+
}
|
|
60
|
+
/** Manually trigger reference data sync. */
|
|
61
|
+
export function useSyncReferenceData() {
|
|
62
|
+
return useCallback(async (options) => {
|
|
63
|
+
return referenceSyncService.sync(options);
|
|
64
|
+
}, []);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* App-level reference sync: downloads every reference-backed dataset into
|
|
68
|
+
* the SQLite cache so the business operates fully offline. Call once from
|
|
69
|
+
* the app root/layouts. Strict reachable gate — `unknown` (cold start)
|
|
70
|
+
* must not fire sync, otherwise airplane launch hangs on versions fetch.
|
|
71
|
+
*/
|
|
72
|
+
export function useBusinessReferenceDataSync() {
|
|
73
|
+
const businessId = useActiveBusinessId();
|
|
74
|
+
const connectivity = useConnectivitySnapshot();
|
|
75
|
+
const queryClient = useQueryClient();
|
|
76
|
+
const isOnline = connectivity.isOnline;
|
|
77
|
+
const isCloudReachable = connectivity.isCloudReachable;
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!businessId || !isOnline || isCloudReachable !== "reachable")
|
|
80
|
+
return;
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
referenceSyncService
|
|
83
|
+
.sync({ businessId })
|
|
84
|
+
.then((result) => {
|
|
85
|
+
if (cancelled)
|
|
86
|
+
return;
|
|
87
|
+
try {
|
|
88
|
+
void queryClient.invalidateQueries({ queryKey: ["cachedImage"] });
|
|
89
|
+
}
|
|
90
|
+
catch { }
|
|
91
|
+
if (result.failed.length > 0) {
|
|
92
|
+
try {
|
|
93
|
+
console.debug("[ReferenceSync] failed keys (non-fatal):", result.failed);
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
.catch(() => {
|
|
99
|
+
if (cancelled)
|
|
100
|
+
return;
|
|
101
|
+
});
|
|
102
|
+
return () => {
|
|
103
|
+
cancelled = true;
|
|
104
|
+
};
|
|
105
|
+
}, [businessId, isOnline, isCloudReachable, queryClient]);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Read a reference data record from the local SQLite cache for the active
|
|
109
|
+
* business. Never hits the network.
|
|
110
|
+
*/
|
|
111
|
+
export function useReferenceData(key) {
|
|
112
|
+
const businessId = useActiveBusinessId();
|
|
113
|
+
return useQuery({
|
|
114
|
+
queryKey: [...REFERENCE_KEYS.data(key), businessId],
|
|
115
|
+
queryFn: async () => {
|
|
116
|
+
if (!businessId)
|
|
117
|
+
return null;
|
|
118
|
+
const db = await openSyncDatabase();
|
|
119
|
+
return getReferenceData(db, businessId, key);
|
|
120
|
+
},
|
|
121
|
+
staleTime: Infinity,
|
|
122
|
+
enabled: !!businessId,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReferenceDataKey } from "./types";
|
|
2
|
+
import type { Command } from "../sync/types";
|
|
3
|
+
export declare const MENU_ITEM_KEY: ReferenceDataKey;
|
|
4
|
+
export declare const MENU_CATEGORY_KEY: ReferenceDataKey;
|
|
5
|
+
/**
|
|
6
|
+
* Apply a menu:* command to the local reference cache. Returns affected keys
|
|
7
|
+
* (for reference_data.changed fan-out) and whether anything was written.
|
|
8
|
+
* Idempotent by entity id: re-applying the same command converges.
|
|
9
|
+
*/
|
|
10
|
+
export declare function applyMenuCommandLocal(businessId: string, command: Pick<Command, "command_type" | "entity_id" | "command_id" | "payload">): Promise<{
|
|
11
|
+
keys: ReferenceDataKey[];
|
|
12
|
+
applied: boolean;
|
|
13
|
+
}>;
|
|
14
|
+
/**
|
|
15
|
+
* Revert an optimistic menu write after a definitive backend rejection:
|
|
16
|
+
* restores the stashed prior record, or deletes the row for creates.
|
|
17
|
+
* Peers converge via reference_fetch/pull on the next sync.
|
|
18
|
+
*/
|
|
19
|
+
export declare function revertMenuCommand(businessId: string, commandType: string, entityId: string): Promise<boolean>;
|
|
20
|
+
/** Keys whose cache a menu command affects (for fan-out without applying). */
|
|
21
|
+
export declare function menuCommandKeys(commandType: string): ReferenceDataKey[];
|
|
22
|
+
/**
|
|
23
|
+
* One-time janitor: remove cached menu rows that are not backend-shaped
|
|
24
|
+
* MenuItems (written by older builds that cached raw wire payloads). This is
|
|
25
|
+
* explicit removal of invalid data — never translation. The outbox remains
|
|
26
|
+
* the source of truth: queued creates replay to the backend and the next
|
|
27
|
+
* pull restores canonical rows. Returns the purge count for logging.
|
|
28
|
+
*/
|
|
29
|
+
export declare function purgeNonCanonicalMenuCache(businessId: string): Promise<number>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local application of menu commands to the durable reference cache.
|
|
3
|
+
*
|
|
4
|
+
* Used by (a) handleNodeCommand when this device executes a peer's menu
|
|
5
|
+
* command over LAN, and (b) the engine queue branch for optimistic local
|
|
6
|
+
* apply while fully offline. Shapes mirror the cloud fetchers
|
|
7
|
+
* (reference/service fetchFallback): menu:items payload {data:[...]},
|
|
8
|
+
* menu:categories payload {data:[...]} (or bare array — tolerated).
|
|
9
|
+
*
|
|
10
|
+
* Versions are intentionally left untouched: optimistic/LAN applies converge
|
|
11
|
+
* peers via explicit reference_data.changed events + reference_fetch (which
|
|
12
|
+
* serve payloads directly, no version compare). Real versions come from the
|
|
13
|
+
* backend when the command replays through the cloud path.
|
|
14
|
+
*/
|
|
15
|
+
import { openSyncDatabase } from "../db/connection";
|
|
16
|
+
import { getReferenceData, setReferenceData } from "./operations";
|
|
17
|
+
import { syncLogger } from "../utils/sync-logger";
|
|
18
|
+
import { generateUUID } from "../utils/uuid";
|
|
19
|
+
import { hlcToString, tickHLC, hlcFromString } from "../utils/hlc";
|
|
20
|
+
import { getAppInstanceId } from "../adapters/devices";
|
|
21
|
+
export const MENU_ITEM_KEY = "menu:items";
|
|
22
|
+
export const MENU_CATEGORY_KEY = "menu:categories";
|
|
23
|
+
function asArray(payload) {
|
|
24
|
+
if (Array.isArray(payload))
|
|
25
|
+
return payload;
|
|
26
|
+
const data = payload?.data;
|
|
27
|
+
return Array.isArray(data) ? data : [];
|
|
28
|
+
}
|
|
29
|
+
function withArray(payload, arr) {
|
|
30
|
+
if (Array.isArray(payload))
|
|
31
|
+
return arr;
|
|
32
|
+
if (payload && typeof payload === "object")
|
|
33
|
+
return { ...payload, data: arr };
|
|
34
|
+
return { data: arr };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Extract the caller-built cache record from a menu command payload. The
|
|
38
|
+
* reference cache holds backend-shaped records ONLY (MenuItem / MenuCategory
|
|
39
|
+
* exactly as the serializer returns them) — never wire payloads. Callers
|
|
40
|
+
* build the record alongside the wire payload; core performs no translation.
|
|
41
|
+
* A command without a record writes nothing (cloud convergence fills the
|
|
42
|
+
* cache on the next pull).
|
|
43
|
+
*/
|
|
44
|
+
function cacheRecord(payload) {
|
|
45
|
+
const rec = payload.cacheRecord;
|
|
46
|
+
if (!rec || typeof rec !== "object" || Array.isArray(rec))
|
|
47
|
+
return null;
|
|
48
|
+
return rec;
|
|
49
|
+
}
|
|
50
|
+
async function patchKey(businessId, key, patch) {
|
|
51
|
+
try {
|
|
52
|
+
const db = await openSyncDatabase();
|
|
53
|
+
const rec = await getReferenceData(db, businessId, key);
|
|
54
|
+
// Seed empty row if first-ever offline create before any pull.
|
|
55
|
+
if (!rec) {
|
|
56
|
+
const next = patch([]);
|
|
57
|
+
const hlc = hlcToString(tickHLC(Date.now(), null, getAppInstanceId()));
|
|
58
|
+
await setReferenceData(db, businessId, {
|
|
59
|
+
key,
|
|
60
|
+
version: hlc,
|
|
61
|
+
payload: withArray({ data: [] }, next),
|
|
62
|
+
downloadedAt: new Date().toISOString(),
|
|
63
|
+
lastEventId: `evt_${hlc}`,
|
|
64
|
+
lastHlc: hlc,
|
|
65
|
+
});
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
const next = patch(asArray(rec.payload));
|
|
69
|
+
const hlc = hlcToString(tickHLC(Date.now(), hlcFromString(rec.lastHlc ?? rec.version) ?? null, getAppInstanceId()));
|
|
70
|
+
await setReferenceData(db, businessId, {
|
|
71
|
+
key,
|
|
72
|
+
version: hlc,
|
|
73
|
+
payload: withArray(rec.payload, next),
|
|
74
|
+
downloadedAt: new Date().toISOString(),
|
|
75
|
+
lastEventId: `evt_${hlc}`,
|
|
76
|
+
lastHlc: hlc,
|
|
77
|
+
});
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
syncLogger.debug("MenuApply", "local menu patch failed", { businessId, key, err: String(e) });
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Apply a menu:* command to the local reference cache. Returns affected keys
|
|
87
|
+
* (for reference_data.changed fan-out) and whether anything was written.
|
|
88
|
+
* Idempotent by entity id: re-applying the same command converges.
|
|
89
|
+
*/
|
|
90
|
+
export async function applyMenuCommandLocal(businessId, command) {
|
|
91
|
+
const payload = (command.payload ?? {});
|
|
92
|
+
const idOf = (v) => (typeof v === "string" && v ? v : null);
|
|
93
|
+
switch (command.command_type) {
|
|
94
|
+
case "menu:create_item": {
|
|
95
|
+
const id = idOf(command.entity_id) ?? idOf(payload.id) ?? generateUUID();
|
|
96
|
+
const record = cacheRecord(payload);
|
|
97
|
+
if (!record) {
|
|
98
|
+
syncLogger.debug("MenuApply", "create_item without cacheRecord — skipping cache write", { businessId, id: command.entity_id });
|
|
99
|
+
return { keys: [MENU_ITEM_KEY], applied: false };
|
|
100
|
+
}
|
|
101
|
+
const item = { ...record, id };
|
|
102
|
+
const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => {
|
|
103
|
+
stashPrior(arr, businessId, id);
|
|
104
|
+
return arr.some((o) => String(o.id) === id) ? arr.map((o) => (String(o.id) === id ? item : o)) : [...arr, item];
|
|
105
|
+
});
|
|
106
|
+
return { keys: [MENU_ITEM_KEY], applied };
|
|
107
|
+
}
|
|
108
|
+
case "menu:update_item": {
|
|
109
|
+
const id = idOf(command.entity_id) ?? idOf(payload.id) ?? idOf(payload.item_id);
|
|
110
|
+
if (!id)
|
|
111
|
+
return { keys: [MENU_ITEM_KEY], applied: false };
|
|
112
|
+
const record = cacheRecord(payload);
|
|
113
|
+
if (!record) {
|
|
114
|
+
syncLogger.debug("MenuApply", "update_item without cacheRecord — skipping cache write", { businessId, id });
|
|
115
|
+
return { keys: [MENU_ITEM_KEY], applied: false };
|
|
116
|
+
}
|
|
117
|
+
const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => {
|
|
118
|
+
stashPrior(arr, businessId, id);
|
|
119
|
+
return arr.map((o) => (String(o.id) === id ? { ...record, id } : o));
|
|
120
|
+
});
|
|
121
|
+
return { keys: [MENU_ITEM_KEY], applied };
|
|
122
|
+
}
|
|
123
|
+
case "menu:delete_item": {
|
|
124
|
+
const id = idOf(command.entity_id) ?? idOf(payload.id);
|
|
125
|
+
if (!id)
|
|
126
|
+
return { keys: [MENU_ITEM_KEY], applied: false };
|
|
127
|
+
const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => arr.filter((o) => String(o.id) !== id));
|
|
128
|
+
return { keys: [MENU_ITEM_KEY], applied };
|
|
129
|
+
}
|
|
130
|
+
case "menu:create_category": {
|
|
131
|
+
const id = idOf(command.entity_id) ?? idOf(payload.id) ?? generateUUID();
|
|
132
|
+
const record = cacheRecord(payload);
|
|
133
|
+
if (!record) {
|
|
134
|
+
syncLogger.debug("MenuApply", "create_category without cacheRecord — skipping cache write", { businessId });
|
|
135
|
+
return { keys: [MENU_CATEGORY_KEY], applied: false };
|
|
136
|
+
}
|
|
137
|
+
const cat = { ...record, id };
|
|
138
|
+
const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => {
|
|
139
|
+
stashPrior(arr, businessId, id);
|
|
140
|
+
return arr.some((o) => String(o.id) === id) ? arr.map((o) => (String(o.id) === id ? cat : o)) : [...arr, cat];
|
|
141
|
+
});
|
|
142
|
+
return { keys: [MENU_CATEGORY_KEY], applied };
|
|
143
|
+
}
|
|
144
|
+
case "menu:update_category": {
|
|
145
|
+
const id = idOf(command.entity_id) ?? idOf(payload.id) ?? idOf(payload.category_id);
|
|
146
|
+
if (!id)
|
|
147
|
+
return { keys: [MENU_CATEGORY_KEY], applied: false };
|
|
148
|
+
const record = cacheRecord(payload);
|
|
149
|
+
if (!record) {
|
|
150
|
+
syncLogger.debug("MenuApply", "update_category without cacheRecord — skipping cache write", { businessId, id });
|
|
151
|
+
return { keys: [MENU_CATEGORY_KEY], applied: false };
|
|
152
|
+
}
|
|
153
|
+
const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => {
|
|
154
|
+
stashPrior(arr, businessId, id);
|
|
155
|
+
return arr.map((o) => (String(o.id) === id ? { ...record, id } : o));
|
|
156
|
+
});
|
|
157
|
+
return { keys: [MENU_CATEGORY_KEY], applied };
|
|
158
|
+
}
|
|
159
|
+
case "menu:delete_category": {
|
|
160
|
+
const id = idOf(command.entity_id) ?? idOf(payload.id) ?? idOf(payload.category_id);
|
|
161
|
+
if (!id)
|
|
162
|
+
return { keys: [MENU_CATEGORY_KEY], applied: false };
|
|
163
|
+
const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => arr.filter((o) => String(o.id) !== id));
|
|
164
|
+
return { keys: [MENU_CATEGORY_KEY], applied };
|
|
165
|
+
}
|
|
166
|
+
default:
|
|
167
|
+
return { keys: [], applied: false };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Prior-record stash for toast-and-revert: before overwriting a cached row,
|
|
172
|
+
* the previous record is kept in memory so a backend rejection can restore
|
|
173
|
+
* it. Creates stash a tombstone (revert deletes the row).
|
|
174
|
+
*/
|
|
175
|
+
const priorRecords = new Map();
|
|
176
|
+
function stashPrior(arr, businessId, id) {
|
|
177
|
+
try {
|
|
178
|
+
const prev = arr.find((o) => String(o.id) === id);
|
|
179
|
+
priorRecords.set(`${businessId}:${id}`, prev ? { ...prev } : null);
|
|
180
|
+
}
|
|
181
|
+
catch { }
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Revert an optimistic menu write after a definitive backend rejection:
|
|
185
|
+
* restores the stashed prior record, or deletes the row for creates.
|
|
186
|
+
* Peers converge via reference_fetch/pull on the next sync.
|
|
187
|
+
*/
|
|
188
|
+
export async function revertMenuCommand(businessId, commandType, entityId) {
|
|
189
|
+
const key = `${businessId}:${entityId}`;
|
|
190
|
+
try {
|
|
191
|
+
if (commandType === "menu:create_item") {
|
|
192
|
+
const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => arr.filter((o) => String(o.id) !== entityId));
|
|
193
|
+
priorRecords.delete(key);
|
|
194
|
+
return applied;
|
|
195
|
+
}
|
|
196
|
+
if (commandType === "menu:update_item") {
|
|
197
|
+
const prior = priorRecords.get(key);
|
|
198
|
+
priorRecords.delete(key);
|
|
199
|
+
if (prior === undefined)
|
|
200
|
+
return false;
|
|
201
|
+
const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => {
|
|
202
|
+
if (prior === null)
|
|
203
|
+
return arr.filter((o) => String(o.id) !== entityId);
|
|
204
|
+
return arr.some((o) => String(o.id) === entityId)
|
|
205
|
+
? arr.map((o) => (String(o.id) === entityId ? { ...prior, id: entityId } : o))
|
|
206
|
+
: [...arr, { ...prior, id: entityId }];
|
|
207
|
+
});
|
|
208
|
+
return applied;
|
|
209
|
+
}
|
|
210
|
+
if (commandType === "menu:create_category") {
|
|
211
|
+
const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => arr.filter((o) => String(o.id) !== entityId));
|
|
212
|
+
priorRecords.delete(key);
|
|
213
|
+
return applied;
|
|
214
|
+
}
|
|
215
|
+
if (commandType === "menu:update_category") {
|
|
216
|
+
const prior = priorRecords.get(key);
|
|
217
|
+
priorRecords.delete(key);
|
|
218
|
+
if (prior === undefined)
|
|
219
|
+
return false;
|
|
220
|
+
const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => {
|
|
221
|
+
if (prior === null)
|
|
222
|
+
return arr.filter((o) => String(o.id) !== entityId);
|
|
223
|
+
return arr.some((o) => String(o.id) === entityId)
|
|
224
|
+
? arr.map((o) => (String(o.id) === entityId ? { ...prior, id: entityId } : o))
|
|
225
|
+
: [...arr, { ...prior, id: entityId }];
|
|
226
|
+
});
|
|
227
|
+
return applied;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch { }
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
/** Keys whose cache a menu command affects (for fan-out without applying). */
|
|
234
|
+
export function menuCommandKeys(commandType) {
|
|
235
|
+
if (commandType.startsWith("menu:") && commandType.includes("categor"))
|
|
236
|
+
return [MENU_CATEGORY_KEY];
|
|
237
|
+
if (commandType.startsWith("menu:"))
|
|
238
|
+
return [MENU_ITEM_KEY];
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
function isCanonicalMenuItem(o) {
|
|
242
|
+
if (!o || typeof o !== "object")
|
|
243
|
+
return false;
|
|
244
|
+
const r = o;
|
|
245
|
+
const bp = r.basePrice;
|
|
246
|
+
return (typeof r.id === "string" &&
|
|
247
|
+
typeof r.name === "string" &&
|
|
248
|
+
!!bp &&
|
|
249
|
+
typeof bp.amountMinor === "number" &&
|
|
250
|
+
typeof bp.currencyCode === "string" &&
|
|
251
|
+
Array.isArray(r.variants) &&
|
|
252
|
+
Array.isArray(r.extras) &&
|
|
253
|
+
Array.isArray(r.dietary_tags));
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* One-time janitor: remove cached menu rows that are not backend-shaped
|
|
257
|
+
* MenuItems (written by older builds that cached raw wire payloads). This is
|
|
258
|
+
* explicit removal of invalid data — never translation. The outbox remains
|
|
259
|
+
* the source of truth: queued creates replay to the backend and the next
|
|
260
|
+
* pull restores canonical rows. Returns the purge count for logging.
|
|
261
|
+
*/
|
|
262
|
+
export async function purgeNonCanonicalMenuCache(businessId) {
|
|
263
|
+
try {
|
|
264
|
+
const db = await openSyncDatabase();
|
|
265
|
+
const rec = await getReferenceData(db, businessId, MENU_ITEM_KEY);
|
|
266
|
+
if (!rec)
|
|
267
|
+
return 0;
|
|
268
|
+
const arr = asArray(rec.payload);
|
|
269
|
+
const kept = arr.filter(isCanonicalMenuItem);
|
|
270
|
+
const purged = arr.length - kept.length;
|
|
271
|
+
if (purged === 0)
|
|
272
|
+
return 0;
|
|
273
|
+
const hlc = hlcToString(tickHLC(Date.now(), hlcFromString(rec.lastHlc ?? rec.version) ?? null, getAppInstanceId()));
|
|
274
|
+
await setReferenceData(db, businessId, {
|
|
275
|
+
key: MENU_ITEM_KEY,
|
|
276
|
+
version: hlc,
|
|
277
|
+
payload: withArray(rec.payload, kept),
|
|
278
|
+
downloadedAt: new Date().toISOString(),
|
|
279
|
+
lastEventId: `evt_${hlc}`,
|
|
280
|
+
lastHlc: hlc,
|
|
281
|
+
});
|
|
282
|
+
syncLogger.warn("MenuApply", "purged non-canonical cached menu rows", { businessId, purged, kept: kept.length });
|
|
283
|
+
return purged;
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
syncLogger.debug("MenuApply", "menu cache purge failed", { businessId, err: String(e) });
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu cache-record builders — the ONLY place MenuItem/MenuCategory records
|
|
3
|
+
* are constructed outside the backend.
|
|
4
|
+
*
|
|
5
|
+
* The reference cache holds backend-shaped records exactly as the serializer
|
|
6
|
+
* returns them (see TeincFoodBackend menu_serializer.ex). Form/mutation
|
|
7
|
+
* layers own every field at write time, so they build the record here —
|
|
8
|
+
* core never translates wire payloads into cache records.
|
|
9
|
+
*/
|
|
10
|
+
import type { MenuCategory, MenuItem } from "../types/api/menu.api";
|
|
11
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
12
|
+
export interface MenuItemRecordInput {
|
|
13
|
+
id?: string;
|
|
14
|
+
businessId: string;
|
|
15
|
+
name: string;
|
|
16
|
+
description?: string | null;
|
|
17
|
+
priceMinor: number;
|
|
18
|
+
currencyCode?: string;
|
|
19
|
+
categoryId: string;
|
|
20
|
+
categoryName?: string;
|
|
21
|
+
dietaryTags?: string[];
|
|
22
|
+
variants?: Array<{
|
|
23
|
+
id?: string;
|
|
24
|
+
name: string;
|
|
25
|
+
price_adjustment?: number;
|
|
26
|
+
}>;
|
|
27
|
+
extras?: Array<{
|
|
28
|
+
id?: string;
|
|
29
|
+
name: string;
|
|
30
|
+
price_minor?: number;
|
|
31
|
+
}>;
|
|
32
|
+
available?: boolean;
|
|
33
|
+
imageUrl?: string | null;
|
|
34
|
+
now?: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function buildMenuItemRecord(input: MenuItemRecordInput): MenuItem;
|
|
37
|
+
export interface MenuCategoryRecordInput {
|
|
38
|
+
id?: string;
|
|
39
|
+
name: string;
|
|
40
|
+
sort_order?: number;
|
|
41
|
+
}
|
|
42
|
+
export declare function buildMenuCategoryRecord(input: MenuCategoryRecordInput): MenuCategory;
|
|
43
|
+
/** Every cached MenuItem for a business across cached + network queries. */
|
|
44
|
+
export declare function getCachedMenuItems(qc: QueryClient, businessId: string): MenuItem[];
|
|
45
|
+
export declare function getCachedMenuItem(qc: QueryClient, businessId: string, id: string): MenuItem | undefined;
|
|
46
|
+
export declare function getCachedCategories(qc: QueryClient, businessId: string): MenuCategory[];
|
|
47
|
+
export declare function getCachedCategoryName(qc: QueryClient, businessId: string, categoryId: string): string;
|
|
48
|
+
export interface MenuItemEnvelopeInput extends MenuItemRecordInput {
|
|
49
|
+
/** Extra wire-only keys (e.g. entity id). */
|
|
50
|
+
wireExtra?: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the { wire, cacheRecord } pair for a menu item write. Callers pass
|
|
54
|
+
* backend adjustment-shaped variants; absolute form prices are converted by
|
|
55
|
+
* the caller (form layer owns both fields).
|
|
56
|
+
*
|
|
57
|
+
* Variant/extra ids are preserved when they are backend UUIDs. The backend
|
|
58
|
+
* update path deletes existing rows absent from the incoming list, so
|
|
59
|
+
* stripping ids on edit would wipe + recreate every variant/extra on each
|
|
60
|
+
* save (breaking any external references to those ids).
|
|
61
|
+
*/
|
|
62
|
+
export declare function buildMenuItemEnvelope(input: MenuItemEnvelopeInput): {
|
|
63
|
+
wire: Record<string, unknown>;
|
|
64
|
+
cacheRecord: MenuItem;
|
|
65
|
+
};
|
|
66
|
+
export declare function buildMenuCategoryEnvelope(input: MenuCategoryRecordInput): {
|
|
67
|
+
wire: Record<string, unknown>;
|
|
68
|
+
cacheRecord: MenuCategory;
|
|
69
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu cache-record builders — the ONLY place MenuItem/MenuCategory records
|
|
3
|
+
* are constructed outside the backend.
|
|
4
|
+
*
|
|
5
|
+
* The reference cache holds backend-shaped records exactly as the serializer
|
|
6
|
+
* returns them (see TeincFoodBackend menu_serializer.ex). Form/mutation
|
|
7
|
+
* layers own every field at write time, so they build the record here —
|
|
8
|
+
* core never translates wire payloads into cache records.
|
|
9
|
+
*/
|
|
10
|
+
import { generateUUID } from "../utils/uuid";
|
|
11
|
+
import { CATALOG_KEYS } from "../catalog/hooks";
|
|
12
|
+
export function buildMenuItemRecord(input) {
|
|
13
|
+
const currencyCode = input.currencyCode || "GHS";
|
|
14
|
+
const now = input.now ?? new Date().toISOString();
|
|
15
|
+
return {
|
|
16
|
+
id: input.id ?? generateUUID(),
|
|
17
|
+
name: input.name,
|
|
18
|
+
description: input.description ?? null,
|
|
19
|
+
basePrice: { amountMinor: input.priceMinor, currencyCode },
|
|
20
|
+
image_url: input.imageUrl ?? null,
|
|
21
|
+
is_available: input.available ?? true,
|
|
22
|
+
dietary_tags: input.dietaryTags ?? [],
|
|
23
|
+
category_id: input.categoryId,
|
|
24
|
+
business_id: input.businessId,
|
|
25
|
+
category: { id: input.categoryId, name: input.categoryName ?? "" },
|
|
26
|
+
variants: (input.variants ?? []).map((v) => ({
|
|
27
|
+
id: v.id ?? generateUUID(),
|
|
28
|
+
name: v.name,
|
|
29
|
+
// Matches backend serializer: variant price = base + adjustment.
|
|
30
|
+
price: { amountMinor: input.priceMinor + (v.price_adjustment ?? 0), currencyCode },
|
|
31
|
+
})),
|
|
32
|
+
extras: (input.extras ?? []).map((e) => ({
|
|
33
|
+
id: e.id ?? generateUUID(),
|
|
34
|
+
name: e.name,
|
|
35
|
+
pricingType: "fixed",
|
|
36
|
+
amountMinor: e.price_minor ?? 0,
|
|
37
|
+
currencyCode,
|
|
38
|
+
})),
|
|
39
|
+
inserted_at: now,
|
|
40
|
+
updated_at: now,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function buildMenuCategoryRecord(input) {
|
|
44
|
+
return {
|
|
45
|
+
id: input.id ?? generateUUID(),
|
|
46
|
+
name: input.name,
|
|
47
|
+
sort_order: input.sort_order ?? 0,
|
|
48
|
+
is_global: false,
|
|
49
|
+
is_system: false,
|
|
50
|
+
pending_global_request: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// ─── Cache lookups (mutation layers read current records to build envelopes)
|
|
54
|
+
function collectItems(blob, out) {
|
|
55
|
+
if (!blob || typeof blob !== "object")
|
|
56
|
+
return;
|
|
57
|
+
if (Array.isArray(blob)) {
|
|
58
|
+
for (const i of blob)
|
|
59
|
+
if (i?.id)
|
|
60
|
+
out.push(i);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const rec = blob;
|
|
64
|
+
if (Array.isArray(rec.data))
|
|
65
|
+
collectItems(rec.data, out);
|
|
66
|
+
if (Array.isArray(rec.pages))
|
|
67
|
+
for (const p of rec.pages)
|
|
68
|
+
collectItems(p?.data, out);
|
|
69
|
+
}
|
|
70
|
+
/** Every cached MenuItem for a business across cached + network queries. */
|
|
71
|
+
export function getCachedMenuItems(qc, businessId) {
|
|
72
|
+
const out = [];
|
|
73
|
+
try {
|
|
74
|
+
for (const [, data] of qc.getQueriesData({ queryKey: CATALOG_KEYS.items(businessId) })) {
|
|
75
|
+
collectItems(data, out);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
return out.filter((i) => (seen.has(i.id) ? false : (seen.add(i.id), true)));
|
|
81
|
+
}
|
|
82
|
+
export function getCachedMenuItem(qc, businessId, id) {
|
|
83
|
+
return getCachedMenuItems(qc, businessId).find((i) => i.id === id);
|
|
84
|
+
}
|
|
85
|
+
function collectCategories(blob, out) {
|
|
86
|
+
if (!blob || typeof blob !== "object")
|
|
87
|
+
return;
|
|
88
|
+
if (Array.isArray(blob)) {
|
|
89
|
+
for (const c of blob)
|
|
90
|
+
if (c?.id)
|
|
91
|
+
out.push(c);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const rec = blob;
|
|
95
|
+
if (Array.isArray(rec.data))
|
|
96
|
+
collectCategories(rec.data, out);
|
|
97
|
+
}
|
|
98
|
+
export function getCachedCategories(qc, businessId) {
|
|
99
|
+
const out = [];
|
|
100
|
+
try {
|
|
101
|
+
for (const [, data] of qc.getQueriesData({ queryKey: CATALOG_KEYS.categories(businessId) })) {
|
|
102
|
+
collectCategories(data, out);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch { }
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
return out.filter((c) => (seen.has(c.id) ? false : (seen.add(c.id), true)));
|
|
108
|
+
}
|
|
109
|
+
export function getCachedCategoryName(qc, businessId, categoryId) {
|
|
110
|
+
return getCachedCategories(qc, businessId).find((c) => c.id === categoryId)?.name ?? "";
|
|
111
|
+
}
|
|
112
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
113
|
+
/**
|
|
114
|
+
* A variant/extra id is only safe to send when it is a real backend UUID.
|
|
115
|
+
* Form-local ids (e.g. `local-<ts>`) must be omitted: the backend treats a
|
|
116
|
+
* missing/empty id as "insert new", but Repo.get/3 raises on a non-UUID.
|
|
117
|
+
*/
|
|
118
|
+
function wireId(id) {
|
|
119
|
+
return id && UUID_RE.test(id) ? id : undefined;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Build the { wire, cacheRecord } pair for a menu item write. Callers pass
|
|
123
|
+
* backend adjustment-shaped variants; absolute form prices are converted by
|
|
124
|
+
* the caller (form layer owns both fields).
|
|
125
|
+
*
|
|
126
|
+
* Variant/extra ids are preserved when they are backend UUIDs. The backend
|
|
127
|
+
* update path deletes existing rows absent from the incoming list, so
|
|
128
|
+
* stripping ids on edit would wipe + recreate every variant/extra on each
|
|
129
|
+
* save (breaking any external references to those ids).
|
|
130
|
+
*/
|
|
131
|
+
export function buildMenuItemEnvelope(input) {
|
|
132
|
+
const id = input.id ?? generateUUID();
|
|
133
|
+
const cacheRecord = buildMenuItemRecord({ ...input, id });
|
|
134
|
+
const wire = {
|
|
135
|
+
id,
|
|
136
|
+
name: input.name,
|
|
137
|
+
description: input.description ?? "",
|
|
138
|
+
price_minor: input.priceMinor,
|
|
139
|
+
category_id: input.categoryId,
|
|
140
|
+
currency_code: input.currencyCode ?? "GHS",
|
|
141
|
+
dietary_tags: input.dietaryTags ?? [],
|
|
142
|
+
variants: (input.variants ?? []).map((v) => {
|
|
143
|
+
const vid = wireId(v.id);
|
|
144
|
+
return vid
|
|
145
|
+
? { id: vid, name: v.name, price_adjustment: v.price_adjustment ?? 0 }
|
|
146
|
+
: { name: v.name, price_adjustment: v.price_adjustment ?? 0 };
|
|
147
|
+
}),
|
|
148
|
+
extras: (input.extras ?? []).map((e) => {
|
|
149
|
+
const eid = wireId(e.id);
|
|
150
|
+
return eid
|
|
151
|
+
? { id: eid, name: e.name, price_minor: e.price_minor ?? 0 }
|
|
152
|
+
: { name: e.name, price_minor: e.price_minor ?? 0 };
|
|
153
|
+
}),
|
|
154
|
+
available: input.available ?? true,
|
|
155
|
+
image_url: input.imageUrl ?? null,
|
|
156
|
+
...(input.wireExtra ?? {}),
|
|
157
|
+
};
|
|
158
|
+
return { wire, cacheRecord };
|
|
159
|
+
}
|
|
160
|
+
export function buildMenuCategoryEnvelope(input) {
|
|
161
|
+
const cacheRecord = buildMenuCategoryRecord(input);
|
|
162
|
+
return { wire: { id: cacheRecord.id, name: input.name, ...(input.sort_order !== undefined ? { sort_order: input.sort_order } : {}) }, cacheRecord };
|
|
163
|
+
}
|