@teincfood/core 0.10.17 → 0.10.19
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/services.d.ts +1 -1
- package/dist/adapters/services.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/repositories/order.repository.js +5 -1
- package/dist/sync/device-lease.service.d.ts +19 -0
- package/dist/sync/device-lease.service.js +121 -33
- package/dist/sync/startup-readiness.d.ts +9 -0
- package/dist/sync/startup-readiness.js +4 -1
- package/dist/sync/sync-jobs.service.js +6 -1
- package/dist/sync/transport-rest.js +2 -1
- package/package.json +1 -1
|
@@ -36,7 +36,7 @@ export declare const sellerService: {
|
|
|
36
36
|
}>;
|
|
37
37
|
};
|
|
38
38
|
export declare const kioskService: {
|
|
39
|
-
assignKioskOrderToRider: (_orderId: string, _riderId: string) => Promise<unknown>;
|
|
39
|
+
assignKioskOrderToRider: (_orderId: string, _riderId: string, _idempotencyKey?: string) => Promise<unknown>;
|
|
40
40
|
};
|
|
41
41
|
export declare const menuService: {
|
|
42
42
|
fetchBusinessMenuItems: (_businessId: string, _page: number) => Promise<unknown>;
|
|
@@ -54,7 +54,7 @@ export const sellerService = {
|
|
|
54
54
|
};
|
|
55
55
|
// kiosk.service
|
|
56
56
|
export const kioskService = {
|
|
57
|
-
assignKioskOrderToRider: async (_orderId, _riderId) => {
|
|
57
|
+
assignKioskOrderToRider: async (_orderId, _riderId, _idempotencyKey) => {
|
|
58
58
|
notImpl("kioskService.assignKioskOrderToRider");
|
|
59
59
|
},
|
|
60
60
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export * from "./sync/command-builder";
|
|
|
59
59
|
export { commandQueueService } from "./sync/command-queue.service";
|
|
60
60
|
export { buildCommand } from "./sync/command-builder";
|
|
61
61
|
export { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber, generateOrderNumber, generateOrderNumberForBusiness, isOfflineOrderNumber, parseOrderNumber, resetCalloutForTests, } from "./sync/order-number";
|
|
62
|
-
export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease } from "./sync/device-lease.service";
|
|
62
|
+
export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease, getLastLeaseFailure, type DeviceLeaseFailure, } from "./sync/device-lease.service";
|
|
63
63
|
export { useDeviceLease, ensureDeviceLease } from "./sync/use-device-lease";
|
|
64
64
|
export { getStartupReadiness, ensureStartupReady, REQUIRED_ORDER_KEYS, type StartupReadiness, } from "./sync/startup-readiness";
|
|
65
65
|
export { useStartupReadiness, type UseStartupReadinessOptions, type UseStartupReadinessResult, } from "./hooks/startup-readiness.hooks";
|
package/dist/index.js
CHANGED
|
@@ -61,7 +61,7 @@ export * from "./sync/command-builder";
|
|
|
61
61
|
export { commandQueueService } from "./sync/command-queue.service";
|
|
62
62
|
export { buildCommand } from "./sync/command-builder";
|
|
63
63
|
export { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber, generateOrderNumber, generateOrderNumberForBusiness, isOfflineOrderNumber, parseOrderNumber, resetCalloutForTests, } from "./sync/order-number";
|
|
64
|
-
export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease } from "./sync/device-lease.service";
|
|
64
|
+
export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease, getLastLeaseFailure, } from "./sync/device-lease.service";
|
|
65
65
|
export { useDeviceLease, ensureDeviceLease } from "./sync/use-device-lease";
|
|
66
66
|
export { getStartupReadiness, ensureStartupReady, REQUIRED_ORDER_KEYS, } from "./sync/startup-readiness";
|
|
67
67
|
export { useStartupReadiness, } from "./hooks/startup-readiness.hooks";
|
|
@@ -141,8 +141,12 @@ export const orderRepository = {
|
|
|
141
141
|
commandType: "delivery:assign",
|
|
142
142
|
businessId: input.businessId,
|
|
143
143
|
entityId: input.orderId,
|
|
144
|
-
payload: { rider_id: input.riderId },
|
|
144
|
+
payload: { rider_id: input.riderId, idempotency_key: "" },
|
|
145
145
|
});
|
|
146
|
+
// One user action = one stable key. Replays of this command (timeout,
|
|
147
|
+
// reconnect, LAN relay) must converge on the same delivery instead of
|
|
148
|
+
// creating another one.
|
|
149
|
+
command.payload.idempotency_key = command.command_id;
|
|
146
150
|
const events = await syncEngine.execute(command);
|
|
147
151
|
const event = events[0];
|
|
148
152
|
if (!event) {
|
|
@@ -4,6 +4,25 @@
|
|
|
4
4
|
* KV persists device_number per businessId.
|
|
5
5
|
*/
|
|
6
6
|
import { getAppInstanceIdAsync } from "../adapters/devices";
|
|
7
|
+
/**
|
|
8
|
+
* Why the last device-lease attempt failed. Surfaced by startup readiness so a
|
|
9
|
+
* plan/terminal limit is not reported as "offline data incomplete".
|
|
10
|
+
*/
|
|
11
|
+
export interface DeviceLeaseFailure {
|
|
12
|
+
code: "TERMINAL_LIMIT_REACHED" | "DEVICE_LIMIT_REACHED" | "AUTH_REQUIRED" | "OFFLINE" | "LEASE_FAILED";
|
|
13
|
+
message: string;
|
|
14
|
+
status?: number;
|
|
15
|
+
limit?: number;
|
|
16
|
+
current?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function getLastLeaseFailure(businessId: string): DeviceLeaseFailure | null;
|
|
19
|
+
/**
|
|
20
|
+
* Normalise the HTTP-client error into a user-presentable lease failure.
|
|
21
|
+
* Desktop injects its own ApiError shape; mobile/core may inject another, so
|
|
22
|
+
* inspect both the error and the backend `{ error: { code, message, details } }`
|
|
23
|
+
* envelope instead of relying on one class.
|
|
24
|
+
*/
|
|
25
|
+
export declare function classifyDeviceLeaseFailure(error: unknown): DeviceLeaseFailure;
|
|
7
26
|
export declare function getDeviceNumber(businessId: string): string | null;
|
|
8
27
|
export declare function isOnlineOnly(businessId: string): boolean;
|
|
9
28
|
export declare function ensureLease(businessId: string): Promise<string | null>;
|
|
@@ -10,6 +10,80 @@ import { getAppInstanceIdAsync } from "../adapters/devices";
|
|
|
10
10
|
import { getConnectivityState } from "../adapters/connectivity";
|
|
11
11
|
const LEASE_KEY = (businessId) => `teinc_device_number:${businessId}`;
|
|
12
12
|
const LEASE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
13
|
+
const leaseFailures = new Map();
|
|
14
|
+
export function getLastLeaseFailure(businessId) {
|
|
15
|
+
return leaseFailures.get(businessId) ?? null;
|
|
16
|
+
}
|
|
17
|
+
function rememberLeaseFailure(businessId, failure) {
|
|
18
|
+
leaseFailures.set(businessId, failure);
|
|
19
|
+
}
|
|
20
|
+
function clearLeaseFailure(businessId) {
|
|
21
|
+
leaseFailures.delete(businessId);
|
|
22
|
+
}
|
|
23
|
+
function asRecord(value) {
|
|
24
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
25
|
+
? value
|
|
26
|
+
: null;
|
|
27
|
+
}
|
|
28
|
+
function asNumber(value) {
|
|
29
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Normalise the HTTP-client error into a user-presentable lease failure.
|
|
33
|
+
* Desktop injects its own ApiError shape; mobile/core may inject another, so
|
|
34
|
+
* inspect both the error and the backend `{ error: { code, message, details } }`
|
|
35
|
+
* envelope instead of relying on one class.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyDeviceLeaseFailure(error) {
|
|
38
|
+
const e = (asRecord(error) ?? {});
|
|
39
|
+
const responseData = asRecord(e.data) ?? asRecord(e.response?.data);
|
|
40
|
+
const errorBody = asRecord(responseData?.error);
|
|
41
|
+
const details = asRecord(errorBody?.details);
|
|
42
|
+
const status = asNumber(e.status);
|
|
43
|
+
const code = typeof errorBody?.code === "string"
|
|
44
|
+
? errorBody.code
|
|
45
|
+
: typeof e.code === "string"
|
|
46
|
+
? e.code
|
|
47
|
+
: undefined;
|
|
48
|
+
const message = typeof errorBody?.message === "string"
|
|
49
|
+
? errorBody.message
|
|
50
|
+
: typeof e.message === "string"
|
|
51
|
+
? e.message
|
|
52
|
+
: "";
|
|
53
|
+
const limit = asNumber(details?.limit);
|
|
54
|
+
const current = asNumber(details?.current);
|
|
55
|
+
if (status === 402 || code === "TERMINAL_LIMIT_REACHED") {
|
|
56
|
+
return {
|
|
57
|
+
code: "TERMINAL_LIMIT_REACHED",
|
|
58
|
+
message: message ||
|
|
59
|
+
"This business has reached the number of linked terminals allowed by its plan.",
|
|
60
|
+
status,
|
|
61
|
+
limit,
|
|
62
|
+
current,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (status === 429 || code === "DEVICE_LIMIT_REACHED") {
|
|
66
|
+
return {
|
|
67
|
+
code: "DEVICE_LIMIT_REACHED",
|
|
68
|
+
message: message || "This business has reached the maximum number of linked terminals.",
|
|
69
|
+
status,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (status === 401 ||
|
|
73
|
+
message.includes("TOKEN_MISSING") ||
|
|
74
|
+
message.includes("No authentication token")) {
|
|
75
|
+
return {
|
|
76
|
+
code: "AUTH_REQUIRED",
|
|
77
|
+
message: message || "Sign-in is still finishing. Retry in a moment.",
|
|
78
|
+
status,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
code: "LEASE_FAILED",
|
|
83
|
+
message: message || "The terminal could not be activated.",
|
|
84
|
+
status,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
13
87
|
export function getDeviceNumber(businessId) {
|
|
14
88
|
const v = getSync(LEASE_KEY(businessId));
|
|
15
89
|
if (v && /^(0[1-9]|[1-9][0-9])$/.test(v))
|
|
@@ -23,6 +97,7 @@ export async function ensureLease(businessId) {
|
|
|
23
97
|
// Offline-first: keep leased DD even offline — if online check/lease fails, return cached
|
|
24
98
|
const existingSync = getDeviceNumber(businessId);
|
|
25
99
|
if (existingSync) {
|
|
100
|
+
clearLeaseFailure(businessId);
|
|
26
101
|
// Opportunistically refresh touch if online, but never discard cached on failure
|
|
27
102
|
if (getConnectivityState().isOnline) {
|
|
28
103
|
try {
|
|
@@ -48,6 +123,7 @@ export async function ensureLease(businessId) {
|
|
|
48
123
|
setSync(LEASE_KEY(businessId), asyncCached);
|
|
49
124
|
}
|
|
50
125
|
catch { }
|
|
126
|
+
clearLeaseFailure(businessId);
|
|
51
127
|
return asyncCached;
|
|
52
128
|
}
|
|
53
129
|
}
|
|
@@ -55,13 +131,18 @@ export async function ensureLease(businessId) {
|
|
|
55
131
|
// No cached DD — must lease online. If offline, keep null (caller blocks POS with clear message)
|
|
56
132
|
if (!getConnectivityState().isOnline) {
|
|
57
133
|
syncLogger.debug("DeviceLease", "Offline and no cached DD — need online once", { businessId });
|
|
134
|
+
rememberLeaseFailure(businessId, {
|
|
135
|
+
code: "OFFLINE",
|
|
136
|
+
message: "Connect to the internet once so this terminal can be activated.",
|
|
137
|
+
});
|
|
58
138
|
return null;
|
|
59
139
|
}
|
|
60
140
|
try {
|
|
61
141
|
const http = getHttpClient();
|
|
62
142
|
const deviceUuid = await getAppInstanceIdAsync();
|
|
63
143
|
const res = await http.post(`/businesses/${businessId}/devices/lease`, { device_uuid: deviceUuid, app_instance_id: deviceUuid });
|
|
64
|
-
const
|
|
144
|
+
const payload = res.data;
|
|
145
|
+
const data = payload.data ?? payload;
|
|
65
146
|
const dn = data.device_number;
|
|
66
147
|
if (dn && /^(0[1-9]|[1-9][0-9])$/.test(dn)) {
|
|
67
148
|
setSync(LEASE_KEY(businessId), dn);
|
|
@@ -70,51 +151,57 @@ export async function ensureLease(businessId) {
|
|
|
70
151
|
await getItem(LEASE_KEY(businessId));
|
|
71
152
|
}
|
|
72
153
|
catch { }
|
|
154
|
+
clearLeaseFailure(businessId);
|
|
73
155
|
syncLogger.info("DeviceLease", "Leased device number", { businessId, device_number: dn });
|
|
74
156
|
return dn;
|
|
75
157
|
}
|
|
158
|
+
rememberLeaseFailure(businessId, {
|
|
159
|
+
code: "LEASE_FAILED",
|
|
160
|
+
message: "The server did not return a terminal number. Try again.",
|
|
161
|
+
});
|
|
76
162
|
}
|
|
77
163
|
catch (e) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
catch { }
|
|
90
|
-
const again = getDeviceNumber(businessId);
|
|
91
|
-
if (again)
|
|
92
|
-
return again;
|
|
93
|
-
return null;
|
|
164
|
+
const failure = classifyDeviceLeaseFailure(e);
|
|
165
|
+
rememberLeaseFailure(businessId, failure);
|
|
166
|
+
if (failure.code === "TERMINAL_LIMIT_REACHED" || failure.code === "DEVICE_LIMIT_REACHED") {
|
|
167
|
+
syncLogger.warn("DeviceLease", "Terminal limit reached — activation needs a free slot", {
|
|
168
|
+
businessId,
|
|
169
|
+
code: failure.code,
|
|
170
|
+
limit: failure.limit,
|
|
171
|
+
current: failure.current,
|
|
172
|
+
});
|
|
173
|
+
return cachedLeaseFallback(businessId);
|
|
94
174
|
}
|
|
95
|
-
if (
|
|
96
|
-
syncLogger.
|
|
97
|
-
return
|
|
175
|
+
if (failure.code === "AUTH_REQUIRED") {
|
|
176
|
+
syncLogger.debug("DeviceLease", "No auth yet — lease deferred until login", { businessId });
|
|
177
|
+
return cachedLeaseFallback(businessId);
|
|
98
178
|
}
|
|
99
|
-
// Network/offline failure while leasing — keep any cached that may exist
|
|
179
|
+
// Network/offline failure while leasing — keep any cached that may exist.
|
|
100
180
|
syncLogger.warn("DeviceLease", "Lease failed — keep cached if any", {
|
|
101
181
|
businessId,
|
|
102
182
|
error: String(e),
|
|
103
183
|
});
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
184
|
+
return cachedLeaseFallback(businessId);
|
|
185
|
+
}
|
|
186
|
+
return cachedLeaseFallback(businessId);
|
|
187
|
+
}
|
|
188
|
+
async function cachedLeaseFallback(businessId) {
|
|
189
|
+
try {
|
|
190
|
+
const fallback = await getItem(LEASE_KEY(businessId));
|
|
191
|
+
if (fallback && /^(0[1-9]|[1-9][0-9])$/.test(fallback)) {
|
|
192
|
+
try {
|
|
193
|
+
setSync(LEASE_KEY(businessId), fallback);
|
|
112
194
|
}
|
|
195
|
+
catch { }
|
|
196
|
+
clearLeaseFailure(businessId);
|
|
197
|
+
return fallback;
|
|
113
198
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
const again = getDeviceNumber(businessId);
|
|
202
|
+
if (again) {
|
|
203
|
+
clearLeaseFailure(businessId);
|
|
204
|
+
return again;
|
|
118
205
|
}
|
|
119
206
|
return null;
|
|
120
207
|
}
|
|
@@ -141,6 +228,7 @@ export async function refreshLease(businessId) {
|
|
|
141
228
|
export function clearLeaseForTests(businessId) {
|
|
142
229
|
removeItemSync(LEASE_KEY(businessId));
|
|
143
230
|
removeItemSync(`${LEASE_KEY(businessId)}:leased_at`);
|
|
231
|
+
clearLeaseFailure(businessId);
|
|
144
232
|
}
|
|
145
233
|
export const deviceLeaseService = {
|
|
146
234
|
getDeviceNumber,
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Seller-only. Fleet/rider surfaces are fully online and never gate here.
|
|
13
13
|
*/
|
|
14
|
+
import { type DeviceLeaseFailure } from "./device-lease.service";
|
|
15
|
+
import { type ConnectivityState } from "../adapters/connectivity";
|
|
14
16
|
import type { ReferenceDataKey } from "../reference/types";
|
|
15
17
|
/**
|
|
16
18
|
* Reference keys required before the app presents itself as ready to take
|
|
@@ -31,6 +33,13 @@ export interface StartupReadiness {
|
|
|
31
33
|
* by itself block readiness.
|
|
32
34
|
*/
|
|
33
35
|
entitlementUnverified: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Why the terminal lease failed, when `needsLease` is true. Lets the UI show
|
|
38
|
+
* "terminal limit reached" instead of the generic offline-data message.
|
|
39
|
+
*/
|
|
40
|
+
leaseError: DeviceLeaseFailure | null;
|
|
41
|
+
/** Connectivity snapshot used for this check. */
|
|
42
|
+
network: ConnectivityState;
|
|
34
43
|
}
|
|
35
44
|
/** Evaluate the local cache only (no network). */
|
|
36
45
|
export declare function getStartupReadiness(businessId: string): Promise<StartupReadiness>;
|
|
@@ -15,7 +15,7 @@ import { openSyncDatabase } from "../db/connection";
|
|
|
15
15
|
import { getReferenceData } from "../reference/operations";
|
|
16
16
|
import { referenceSyncService } from "../reference/service";
|
|
17
17
|
import { evaluateBusinessEntitlements } from "../reference/entitlement-guard";
|
|
18
|
-
import { ensureLease } from "./device-lease.service";
|
|
18
|
+
import { ensureLease, getLastLeaseFailure, } from "./device-lease.service";
|
|
19
19
|
import { getConnectivityState } from "../adapters/connectivity";
|
|
20
20
|
import { syncLogger } from "../utils/sync-logger";
|
|
21
21
|
/**
|
|
@@ -52,6 +52,7 @@ export async function getStartupReadiness(businessId) {
|
|
|
52
52
|
syncLogger.warn("StartupReadiness", "lease check failed", { err: String(e) });
|
|
53
53
|
}
|
|
54
54
|
const needsLease = !deviceNumber;
|
|
55
|
+
const leaseError = needsLease ? getLastLeaseFailure(businessId) : null;
|
|
55
56
|
let entitlementUnverified = missingKeys.includes("subscription:entitlements");
|
|
56
57
|
if (!entitlementUnverified) {
|
|
57
58
|
try {
|
|
@@ -67,6 +68,8 @@ export async function getStartupReadiness(businessId) {
|
|
|
67
68
|
missingKeys,
|
|
68
69
|
needsLease,
|
|
69
70
|
entitlementUnverified,
|
|
71
|
+
leaseError,
|
|
72
|
+
network: getConnectivityState(),
|
|
70
73
|
};
|
|
71
74
|
}
|
|
72
75
|
/**
|
|
@@ -217,7 +217,12 @@ async function tryRest(businessId, payload, commandType) {
|
|
|
217
217
|
const riderId = (payload.rider_id ?? payload.riderId);
|
|
218
218
|
if (!riderId)
|
|
219
219
|
return { ok: false, status: 422, code: "MISSING_RIDER", message: "Assign rider job has no rider_id — re-assign from the app" };
|
|
220
|
-
|
|
220
|
+
const idempotencyKey = (payload.idempotency_key ?? payload.idempotencyKey);
|
|
221
|
+
await http.post(`/deliveries/assign`, {
|
|
222
|
+
order_id: orderId,
|
|
223
|
+
rider_id: riderId,
|
|
224
|
+
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
|
225
|
+
});
|
|
221
226
|
return { ok: true };
|
|
222
227
|
}
|
|
223
228
|
// POST /deliveries/:deliveryId/confirm-handover — resolve the delivery
|
|
@@ -84,7 +84,8 @@ export class RestTransport {
|
|
|
84
84
|
return this.executeOrderTransition(command, "cancel", timestamp, () => sellerService.cancelOrder(entity_id));
|
|
85
85
|
}
|
|
86
86
|
case "delivery:assign": {
|
|
87
|
-
const
|
|
87
|
+
const assignPayload = payload;
|
|
88
|
+
const response = await kioskService.assignKioskOrderToRider(entity_id, assignPayload.rider_id, assignPayload.idempotency_key ?? command.command_id);
|
|
88
89
|
return this.wrapResponse("delivery.assigned", response, entity_id, timestamp, command);
|
|
89
90
|
}
|
|
90
91
|
case "delivery:confirm_handover": {
|
package/package.json
CHANGED