@teincfood/core 0.8.4 → 0.9.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/dist/db/operations.d.ts +8 -0
- package/dist/db/operations.js +11 -0
- package/dist/domain/orders/service.js +4 -3
- package/dist/hooks/analytics.hooks.d.ts +72 -0
- package/dist/hooks/analytics.hooks.js +17 -8
- package/dist/hooks/dashboard.hooks.d.ts +14 -0
- package/dist/hooks/dashboard.hooks.js +14 -7
- package/dist/hooks/orders.hooks.js +6 -3
- package/dist/hooks/realtime-stubs.js +48 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/reference/api.js +12 -2
- package/dist/reference/entitlement-clock.d.ts +39 -0
- package/dist/reference/entitlement-clock.js +116 -0
- package/dist/reference/entitlement-crypto.d.ts +10 -0
- package/dist/reference/entitlement-crypto.js +111 -0
- package/dist/reference/entitlements.d.ts +51 -0
- package/dist/reference/entitlements.js +124 -0
- package/dist/reference/operations.d.ts +7 -0
- package/dist/reference/operations.js +26 -0
- package/dist/reference/service.js +33 -1
- package/dist/repositories/order.repository.js +11 -0
- package/dist/sync/command-queue.service.js +13 -13
- package/dist/sync/engine.js +18 -9
- package/dist/sync/online-gates.d.ts +9 -0
- package/dist/sync/online-gates.js +36 -3
- package/dist/sync/order-snapshots.service.d.ts +10 -0
- package/dist/sync/order-snapshots.service.js +96 -0
- package/dist/sync/sync-jobs.service.js +35 -8
- package/dist/sync/transition-endpoints.d.ts +9 -1
- package/dist/sync/transition-endpoints.js +16 -5
- package/dist/sync/transition-errors.d.ts +32 -0
- package/dist/sync/transition-errors.js +61 -0
- package/dist/sync/transport-rest.d.ts +5 -3
- package/dist/sync/transport-rest.js +10 -3
- package/dist/utils/error.js +3 -0
- package/dist/utils/order-transitions.js +17 -1
- package/package.json +1 -1
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
* ever fulfill them without the backend.
|
|
8
8
|
*/
|
|
9
9
|
import { getConnectivityState } from "../adapters/connectivity";
|
|
10
|
+
import { TRANSITION_ERROR_CODES, TransitionError } from "./transition-errors";
|
|
10
11
|
/** Command types that require the cloud, unconditionally. */
|
|
11
12
|
export const ONLINE_REQUIRED = {
|
|
13
|
+
"delivery:assign": "Assign rider requires online",
|
|
14
|
+
"delivery:confirm_handover": "Handover confirmation requires online",
|
|
15
|
+
// Aliases used by the domain/LAN action path (transitionLocalOrder).
|
|
12
16
|
"order:assign_rider": "Assign rider requires online",
|
|
13
17
|
"order:confirm_handover": "Handover confirmation requires online",
|
|
14
18
|
"chat:send": "Chat requires online",
|
|
@@ -22,6 +26,8 @@ export const ONLINE_REQUIRED = {
|
|
|
22
26
|
"wallet:payout": "Payouts require online",
|
|
23
27
|
"payment:charge_online": "Online payment requires online",
|
|
24
28
|
"device:lease": "Device lease requires online (first lease only)",
|
|
29
|
+
"subscription:manage": "Plan changes and checkout require online",
|
|
30
|
+
"subscription:checkout": "Plan checkout requires online",
|
|
25
31
|
};
|
|
26
32
|
export function isOnlineCapable() {
|
|
27
33
|
const s = getConnectivityState();
|
|
@@ -36,20 +42,47 @@ export function assertOnlineFor(commandType, payload) {
|
|
|
36
42
|
const fixed = ONLINE_REQUIRED[commandType];
|
|
37
43
|
if (fixed) {
|
|
38
44
|
if (!isOnlineCapable())
|
|
39
|
-
throw new
|
|
45
|
+
throw new TransitionError(TRANSITION_ERROR_CODES.ONLINE_REQUIRED, fixed);
|
|
40
46
|
return;
|
|
41
47
|
}
|
|
42
48
|
if (commandType === "order:create" && payload) {
|
|
43
49
|
const orderType = payload.order_type;
|
|
44
50
|
if (orderType === "delivery" || payload.delivery_params) {
|
|
45
51
|
if (!isOnlineCapable())
|
|
46
|
-
throw new
|
|
52
|
+
throw new TransitionError(TRANSITION_ERROR_CODES.ONLINE_REQUIRED, "Delivery orders require online");
|
|
47
53
|
return;
|
|
48
54
|
}
|
|
49
55
|
const method = payload.pos_payment_method;
|
|
50
56
|
if (typeof method === "string" && method !== "cash") {
|
|
51
57
|
if (!isOnlineCapable())
|
|
52
|
-
throw new
|
|
58
|
+
throw new TransitionError(TRANSITION_ERROR_CODES.ONLINE_REQUIRED, "Online payment requires online — use cash while offline");
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Offline entitlement gate (fail-closed). Host apps evaluate the cached
|
|
64
|
+
* signed envelope via `evaluateEntitlements` and pass `entitled` here for
|
|
65
|
+
* gated commands (menu/member/promo/terminal/dispatch creation, online
|
|
66
|
+
* orders). Expired, stale (> max_offline_days), or unverified => throw.
|
|
67
|
+
* RBAC (`hasCapabilitySync`) stays fail-open (backend 403 backstop);
|
|
68
|
+
* entitlements are the opposite: no proof => no access.
|
|
69
|
+
*/
|
|
70
|
+
export function assertEntitledFor(commandType, entitled, evaluationReason) {
|
|
71
|
+
const gated = new Set([
|
|
72
|
+
"order:create_online",
|
|
73
|
+
"menu:create_item",
|
|
74
|
+
"menu:update_item",
|
|
75
|
+
"menu:delete_item",
|
|
76
|
+
"menu:create_category",
|
|
77
|
+
"promo:create",
|
|
78
|
+
"member:invite",
|
|
79
|
+
"member:manage",
|
|
80
|
+
"terminal:link",
|
|
81
|
+
"fleet:dispatch_create",
|
|
82
|
+
]);
|
|
83
|
+
if (!gated.has(commandType))
|
|
84
|
+
return;
|
|
85
|
+
if (!entitled) {
|
|
86
|
+
throw new TransitionError(TRANSITION_ERROR_CODES.SUBSCRIPTION_REQUIRED, evaluationReason ?? "Subscription required — upgrade or reconnect to verify");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -33,8 +33,18 @@ declare class OrderSnapshotsService {
|
|
|
33
33
|
/**
|
|
34
34
|
* Patch the latest status of a known order inside every list snapshot for the
|
|
35
35
|
* business, so offline devices see live status changes without a refetch.
|
|
36
|
+
* Also applies delivery linkage (delivery.assigned / delivery.status_changed
|
|
37
|
+
* / delivery.pickup_confirmed): backend emits delivery events WITHOUT an
|
|
38
|
+
* order.status_changed for assign + merchant/rider single-confirms, so
|
|
39
|
+
* without this other terminals keep offering "Assign rider" forever.
|
|
36
40
|
*/
|
|
37
41
|
private applyOrderStatusToReadModel;
|
|
42
|
+
private businessIdOf;
|
|
43
|
+
private orderIdOf;
|
|
44
|
+
/** delivery.assigned {order_id, rider_id}, entity = delivery id. */
|
|
45
|
+
private applyDeliveryAssignedToReadModel;
|
|
46
|
+
/** delivery.status_changed {order_id, status} / delivery.pickup_confirmed {order_id, rider_confirmed, merchant_confirmed, status}. */
|
|
47
|
+
private applyDeliveryStatusToReadModel;
|
|
38
48
|
/**
|
|
39
49
|
* Write through a fetched order list so it is available offline. Used by the
|
|
40
50
|
* seller and POS order queries. `scope` distinguishes complementary lists
|
|
@@ -145,9 +145,21 @@ class OrderSnapshotsService {
|
|
|
145
145
|
/**
|
|
146
146
|
* Patch the latest status of a known order inside every list snapshot for the
|
|
147
147
|
* business, so offline devices see live status changes without a refetch.
|
|
148
|
+
* Also applies delivery linkage (delivery.assigned / delivery.status_changed
|
|
149
|
+
* / delivery.pickup_confirmed): backend emits delivery events WITHOUT an
|
|
150
|
+
* order.status_changed for assign + merchant/rider single-confirms, so
|
|
151
|
+
* without this other terminals keep offering "Assign rider" forever.
|
|
148
152
|
*/
|
|
149
153
|
async applyOrderStatusToReadModel(db, event) {
|
|
150
154
|
const payload = event.payload;
|
|
155
|
+
if (event.event_type === "delivery.assigned") {
|
|
156
|
+
await this.applyDeliveryAssignedToReadModel(db, event);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (event.event_type === "delivery.status_changed" || event.event_type === "delivery.pickup_confirmed") {
|
|
160
|
+
await this.applyDeliveryStatusToReadModel(db, event);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
151
163
|
const orderId = payload?.order_id;
|
|
152
164
|
const toStatus = payload?.to_status ??
|
|
153
165
|
payload?.status;
|
|
@@ -156,6 +168,90 @@ class OrderSnapshotsService {
|
|
|
156
168
|
}
|
|
157
169
|
await this.applyOrderStatusToReadModelInternal(db, orderId, toStatus, event);
|
|
158
170
|
}
|
|
171
|
+
businessIdOf(event) {
|
|
172
|
+
const payload = event.payload;
|
|
173
|
+
return (payload?.business_id ??
|
|
174
|
+
payload?.businessId ??
|
|
175
|
+
null);
|
|
176
|
+
}
|
|
177
|
+
orderIdOf(event) {
|
|
178
|
+
const payload = event.payload;
|
|
179
|
+
return payload?.order_id ?? event.entity_id;
|
|
180
|
+
}
|
|
181
|
+
/** delivery.assigned {order_id, rider_id}, entity = delivery id. */
|
|
182
|
+
async applyDeliveryAssignedToReadModel(db, event) {
|
|
183
|
+
try {
|
|
184
|
+
const payload = event.payload;
|
|
185
|
+
const orderId = this.orderIdOf(event);
|
|
186
|
+
const businessId = this.businessIdOf(event);
|
|
187
|
+
const deliveryId = event.entity_id ?? payload?.delivery_id;
|
|
188
|
+
if (!orderId || !businessId || !deliveryId)
|
|
189
|
+
return;
|
|
190
|
+
try {
|
|
191
|
+
const { updateOrderDelivery } = await import("../db/operations");
|
|
192
|
+
await updateOrderDelivery(db, businessId, orderId, { delivery_id: deliveryId });
|
|
193
|
+
}
|
|
194
|
+
catch { }
|
|
195
|
+
const lists = await getEntitySnapshotsByType(db, "seller_orders", businessId);
|
|
196
|
+
for (const list of lists) {
|
|
197
|
+
const data = list.payload.data ?? [];
|
|
198
|
+
const idx = data.findIndex((o) => o.id === orderId);
|
|
199
|
+
if (idx === -1)
|
|
200
|
+
continue;
|
|
201
|
+
const updated = data.map((o, i) => i === idx ? { ...o, delivery_id: deliveryId, delivery_status: "assigned" } : o);
|
|
202
|
+
await upsertEntitySnapshot(db, {
|
|
203
|
+
entityType: "seller_orders",
|
|
204
|
+
entityId: list.entity_id,
|
|
205
|
+
businessId: list.business_id,
|
|
206
|
+
lastEventId: event.event_id,
|
|
207
|
+
lastEventTimestamp: event.timestamp,
|
|
208
|
+
payload: { ...list.payload, data: updated },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
syncLogger.error("OrderSnapshots", "Failed to apply delivery.assigned to read model", { error: String(error) });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** delivery.status_changed {order_id, status} / delivery.pickup_confirmed {order_id, rider_confirmed, merchant_confirmed, status}. */
|
|
217
|
+
async applyDeliveryStatusToReadModel(db, event) {
|
|
218
|
+
try {
|
|
219
|
+
const payload = event.payload;
|
|
220
|
+
const orderId = this.orderIdOf(event);
|
|
221
|
+
const businessId = this.businessIdOf(event);
|
|
222
|
+
if (!orderId || !businessId)
|
|
223
|
+
return;
|
|
224
|
+
const deliveryStatus = payload?.status;
|
|
225
|
+
const patch = {};
|
|
226
|
+
if (deliveryStatus)
|
|
227
|
+
patch.delivery_status = deliveryStatus;
|
|
228
|
+
if (typeof payload?.merchant_confirmed === "boolean")
|
|
229
|
+
patch.merchant_handover_confirmed_at = payload.merchant_confirmed ? event.timestamp : null;
|
|
230
|
+
if (typeof payload?.rider_confirmed === "boolean")
|
|
231
|
+
patch.rider_handover_confirmed_at = payload.rider_confirmed ? event.timestamp : null;
|
|
232
|
+
if (Object.keys(patch).length === 0)
|
|
233
|
+
return;
|
|
234
|
+
const lists = await getEntitySnapshotsByType(db, "seller_orders", businessId);
|
|
235
|
+
for (const list of lists) {
|
|
236
|
+
const data = list.payload.data ?? [];
|
|
237
|
+
const idx = data.findIndex((o) => o.id === orderId);
|
|
238
|
+
if (idx === -1)
|
|
239
|
+
continue;
|
|
240
|
+
const updated = data.map((o, i) => (i === idx ? { ...o, ...patch } : o));
|
|
241
|
+
await upsertEntitySnapshot(db, {
|
|
242
|
+
entityType: "seller_orders",
|
|
243
|
+
entityId: list.entity_id,
|
|
244
|
+
businessId: list.business_id,
|
|
245
|
+
lastEventId: event.event_id,
|
|
246
|
+
lastEventTimestamp: event.timestamp,
|
|
247
|
+
payload: { ...list.payload, data: updated },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
syncLogger.error("OrderSnapshots", "Failed to apply delivery status to read model", { error: String(error) });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
159
255
|
/**
|
|
160
256
|
* Write through a fetched order list so it is available offline. Used by the
|
|
161
257
|
* seller and POS order queries. `scope` distinguishes complementary lists
|
|
@@ -15,7 +15,8 @@ import { recordAttempt } from "../utils/attempt-stats";
|
|
|
15
15
|
import { BackoffTracker } from "./backoff";
|
|
16
16
|
import { generateOrderNumber } from "./order-number";
|
|
17
17
|
import { orderSnapshotsService } from "./order-snapshots.service";
|
|
18
|
-
import { getTransitionPath } from "./transition-endpoints";
|
|
18
|
+
import { getTransitionPath, DELIVERY_COMMANDS } from "./transition-endpoints";
|
|
19
|
+
import { extractApiErrorCode } from "../utils/error";
|
|
19
20
|
import { canBusinessSync, isAuthenticated } from "./sync-guard";
|
|
20
21
|
const inflight = new Map();
|
|
21
22
|
const retryTimers = new Map();
|
|
@@ -167,6 +168,33 @@ async function tryRest(businessId, payload, commandType) {
|
|
|
167
168
|
const orderId = (payload.orderId ?? payload.order_id ?? payload.entity_id ?? payload.id);
|
|
168
169
|
if (!orderId)
|
|
169
170
|
return { ok: false, message: "missing orderId" };
|
|
171
|
+
if (DELIVERY_COMMANDS.has(commandType)) {
|
|
172
|
+
const db = await openSyncDatabase();
|
|
173
|
+
const isAssign = commandType === "delivery:assign" || commandType === "order:assign_rider";
|
|
174
|
+
if (isAssign) {
|
|
175
|
+
// POST /deliveries/assign {order_id, rider_id} — rider_id must have
|
|
176
|
+
// been captured at tap time; without it the job can never succeed.
|
|
177
|
+
const riderId = (payload.rider_id ?? payload.riderId);
|
|
178
|
+
if (!riderId)
|
|
179
|
+
return { ok: false, status: 422, code: "MISSING_RIDER", message: "Assign rider job has no rider_id — re-assign from the app" };
|
|
180
|
+
await http.post(`/deliveries/assign`, { order_id: orderId, rider_id: riderId });
|
|
181
|
+
return { ok: true };
|
|
182
|
+
}
|
|
183
|
+
// POST /deliveries/:deliveryId/confirm-handover — resolve the delivery
|
|
184
|
+
// id from the local order row (sync payloads carry the order id).
|
|
185
|
+
let deliveryId = payload.delivery_id;
|
|
186
|
+
if (!deliveryId) {
|
|
187
|
+
try {
|
|
188
|
+
const row = await db.getFirstAsync(`SELECT delivery_id FROM orders WHERE business_id=? AND id=?;`, businessId, orderId);
|
|
189
|
+
deliveryId = row?.delivery_id ?? undefined;
|
|
190
|
+
}
|
|
191
|
+
catch { }
|
|
192
|
+
}
|
|
193
|
+
if (!deliveryId)
|
|
194
|
+
return { ok: false, status: 422, code: "NO_DELIVERY", message: "Handover job has no delivery — rider not assigned yet" };
|
|
195
|
+
await http.post(`/deliveries/${deliveryId}/confirm-handover`, {});
|
|
196
|
+
return { ok: true };
|
|
197
|
+
}
|
|
170
198
|
const path = getTransitionPath(commandType, orderId);
|
|
171
199
|
if (!path)
|
|
172
200
|
return { ok: false, message: `unknown command ${commandType}` };
|
|
@@ -176,7 +204,8 @@ async function tryRest(businessId, payload, commandType) {
|
|
|
176
204
|
catch (e) {
|
|
177
205
|
const err = e;
|
|
178
206
|
const status = err.status ?? err.response?.status;
|
|
179
|
-
|
|
207
|
+
// Backend code via every envelope shape (core ApiError, axios, app ApiError).
|
|
208
|
+
const bodyCode = extractApiErrorCode(e);
|
|
180
209
|
const bodyMsg = err.response?.data?.error?.message;
|
|
181
210
|
return {
|
|
182
211
|
ok: false,
|
|
@@ -267,9 +296,9 @@ export async function drainOne(businessId, entityId) {
|
|
|
267
296
|
catch { }
|
|
268
297
|
return;
|
|
269
298
|
}
|
|
270
|
-
// 3) Handle 409 RRR regen same UUID (POS create only)
|
|
299
|
+
// 3) Handle 409 RRR regen same UUID (POS create only, exact backend code)
|
|
271
300
|
syncLogger.debug("SyncJobs", "check 409", { businessId, entityId, status: rest.status, code: rest.code });
|
|
272
|
-
const is409 = rest.status === 409 &&
|
|
301
|
+
const is409 = rest.status === 409 && rest.code === "ORDER_NUMBER_TAKEN";
|
|
273
302
|
if (is409) {
|
|
274
303
|
const on = (payload.order_number ?? payload.local_order_number);
|
|
275
304
|
const parsed = on ? String(on).match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/) : null;
|
|
@@ -329,10 +358,8 @@ export async function drainOne(businessId, entityId) {
|
|
|
329
358
|
// 404 is only retryable for order:create (FIFO race: order not yet created
|
|
330
359
|
// on backend). For transitions, 404 means the order was deleted or never
|
|
331
360
|
// synced — permanent. 409 handled above for RRR regen. 401/408/429 need
|
|
332
|
-
// special handling below.
|
|
333
|
-
const
|
|
334
|
-
const msgUpper = String(rest.message ?? "").toUpperCase();
|
|
335
|
-
const isBusinessClosed = codeUpper.includes("BUSINESS_CLOSED") || msgUpper.includes("BUSINESS_CLOSED") || msgUpper.includes("BUSINESS IS CLOSED") || codeUpper.includes("HOURS_NOT_SYNCED");
|
|
361
|
+
// special handling below. Backend codes are exact (no message matching).
|
|
362
|
+
const isBusinessClosed = rest.code === "BUSINESS_CLOSED" || rest.code === "HOURS_NOT_SYNCED";
|
|
336
363
|
const is4xx = rest.status && rest.status >= 400 && rest.status < 500;
|
|
337
364
|
const isRetryable4xx = rest.status === 401 || rest.status === 408 || rest.status === 409 || rest.status === 429
|
|
338
365
|
|| (rest.status === 404 && row.command_type === "order:create")
|
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single source used by both the sync_jobs drain (tryRest) and the REST
|
|
5
5
|
* transport (executeOrderTransition) so endpoint definitions cannot drift.
|
|
6
|
+
*
|
|
7
|
+
* Delivery commands do NOT live under /orders/:id — backend routes are
|
|
8
|
+
* POST /deliveries/assign {order_id, rider_id} and
|
|
9
|
+
* POST /deliveries/:deliveryId/confirm-handover. tryRest builds those
|
|
10
|
+
* bodies/paths (resolving delivery_id from SQLite when needed).
|
|
6
11
|
*/
|
|
12
|
+
/** Command types fulfilled by the delivery endpoints (strictly online). */
|
|
13
|
+
export declare const DELIVERY_COMMANDS: Set<string>;
|
|
7
14
|
/**
|
|
8
15
|
* Build the REST path for a given command type and order/delivery ID.
|
|
9
|
-
* Returns null for unknown command types
|
|
16
|
+
* Returns null for unknown command types AND for delivery commands, which
|
|
17
|
+
* need body/path resolution in tryRest (rider_id, delivery_id lookup).
|
|
10
18
|
*/
|
|
11
19
|
export declare function getTransitionPath(commandType: string, entityId: string): string | null;
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single source used by both the sync_jobs drain (tryRest) and the REST
|
|
5
5
|
* transport (executeOrderTransition) so endpoint definitions cannot drift.
|
|
6
|
+
*
|
|
7
|
+
* Delivery commands do NOT live under /orders/:id — backend routes are
|
|
8
|
+
* POST /deliveries/assign {order_id, rider_id} and
|
|
9
|
+
* POST /deliveries/:deliveryId/confirm-handover. tryRest builds those
|
|
10
|
+
* bodies/paths (resolving delivery_id from SQLite when needed).
|
|
6
11
|
*/
|
|
7
12
|
const ORDER_ENDPOINTS = {
|
|
8
13
|
"order:accept": "accept",
|
|
@@ -13,16 +18,22 @@ const ORDER_ENDPOINTS = {
|
|
|
13
18
|
"order:cancel": "cancel",
|
|
14
19
|
"order:confirm_cash": "confirm-cash-payment",
|
|
15
20
|
"order:pay": "confirm-cash-payment",
|
|
16
|
-
"order:assign_rider": "assign",
|
|
17
21
|
};
|
|
22
|
+
/** Command types fulfilled by the delivery endpoints (strictly online). */
|
|
23
|
+
export const DELIVERY_COMMANDS = new Set([
|
|
24
|
+
"delivery:assign",
|
|
25
|
+
"delivery:confirm_handover",
|
|
26
|
+
"order:assign_rider",
|
|
27
|
+
"order:confirm_handover",
|
|
28
|
+
]);
|
|
18
29
|
/**
|
|
19
30
|
* Build the REST path for a given command type and order/delivery ID.
|
|
20
|
-
* Returns null for unknown command types
|
|
31
|
+
* Returns null for unknown command types AND for delivery commands, which
|
|
32
|
+
* need body/path resolution in tryRest (rider_id, delivery_id lookup).
|
|
21
33
|
*/
|
|
22
34
|
export function getTransitionPath(commandType, entityId) {
|
|
23
|
-
if (commandType
|
|
24
|
-
return
|
|
25
|
-
}
|
|
35
|
+
if (DELIVERY_COMMANDS.has(commandType))
|
|
36
|
+
return null;
|
|
26
37
|
const suffix = ORDER_ENDPOINTS[commandType];
|
|
27
38
|
if (!suffix)
|
|
28
39
|
return null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coded errors for order/delivery transitions and sync control flow.
|
|
3
|
+
*
|
|
4
|
+
* Never branch on error message text: every backend action error carries a
|
|
5
|
+
* machine-readable `code` ({error: {code, message}}), and local guard
|
|
6
|
+
* rejections use the same codes below. Callers switch on `error.code`.
|
|
7
|
+
*
|
|
8
|
+
* Codes mirror the backend where semantics match (INVALID_TRANSITION,
|
|
9
|
+
* FORBIDDEN, ORDER_NOT_FOUND); client-only conditions use their own codes
|
|
10
|
+
* (ONLINE_REQUIRED, NO_ACTIVE_BUSINESS, SUBSCRIPTION_REQUIRED,
|
|
11
|
+
* REPLAY_DEFERRED, MISSING_RIDER, NO_DELIVERY).
|
|
12
|
+
*/
|
|
13
|
+
export declare const TRANSITION_ERROR_CODES: {
|
|
14
|
+
readonly INVALID_TRANSITION: "INVALID_TRANSITION";
|
|
15
|
+
readonly ONLINE_REQUIRED: "ONLINE_REQUIRED";
|
|
16
|
+
readonly NO_ACTIVE_BUSINESS: "NO_ACTIVE_BUSINESS";
|
|
17
|
+
readonly ORDER_NOT_FOUND: "ORDER_NOT_FOUND";
|
|
18
|
+
readonly FORBIDDEN: "FORBIDDEN";
|
|
19
|
+
readonly SUBSCRIPTION_REQUIRED: "SUBSCRIPTION_REQUIRED";
|
|
20
|
+
readonly REPLAY_DEFERRED: "REPLAY_DEFERRED";
|
|
21
|
+
readonly MISSING_RIDER: "MISSING_RIDER";
|
|
22
|
+
readonly NO_DELIVERY: "NO_DELIVERY";
|
|
23
|
+
};
|
|
24
|
+
export type TransitionErrorCode = (typeof TRANSITION_ERROR_CODES)[keyof typeof TRANSITION_ERROR_CODES];
|
|
25
|
+
export declare class TransitionError extends Error {
|
|
26
|
+
code: TransitionErrorCode | string;
|
|
27
|
+
constructor(code: TransitionErrorCode | string, message: string);
|
|
28
|
+
}
|
|
29
|
+
/** Read a machine-readable error code off any thrown value. */
|
|
30
|
+
export declare function errorCodeOf(error: unknown): string | undefined;
|
|
31
|
+
export declare function isFinalGuardRejection(error: unknown): boolean;
|
|
32
|
+
export declare function isReplayDeferred(error: unknown): boolean;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coded errors for order/delivery transitions and sync control flow.
|
|
3
|
+
*
|
|
4
|
+
* Never branch on error message text: every backend action error carries a
|
|
5
|
+
* machine-readable `code` ({error: {code, message}}), and local guard
|
|
6
|
+
* rejections use the same codes below. Callers switch on `error.code`.
|
|
7
|
+
*
|
|
8
|
+
* Codes mirror the backend where semantics match (INVALID_TRANSITION,
|
|
9
|
+
* FORBIDDEN, ORDER_NOT_FOUND); client-only conditions use their own codes
|
|
10
|
+
* (ONLINE_REQUIRED, NO_ACTIVE_BUSINESS, SUBSCRIPTION_REQUIRED,
|
|
11
|
+
* REPLAY_DEFERRED, MISSING_RIDER, NO_DELIVERY).
|
|
12
|
+
*/
|
|
13
|
+
export const TRANSITION_ERROR_CODES = {
|
|
14
|
+
INVALID_TRANSITION: "INVALID_TRANSITION",
|
|
15
|
+
ONLINE_REQUIRED: "ONLINE_REQUIRED",
|
|
16
|
+
NO_ACTIVE_BUSINESS: "NO_ACTIVE_BUSINESS",
|
|
17
|
+
ORDER_NOT_FOUND: "ORDER_NOT_FOUND",
|
|
18
|
+
FORBIDDEN: "FORBIDDEN",
|
|
19
|
+
SUBSCRIPTION_REQUIRED: "SUBSCRIPTION_REQUIRED",
|
|
20
|
+
REPLAY_DEFERRED: "REPLAY_DEFERRED",
|
|
21
|
+
MISSING_RIDER: "MISSING_RIDER",
|
|
22
|
+
NO_DELIVERY: "NO_DELIVERY",
|
|
23
|
+
};
|
|
24
|
+
export class TransitionError extends Error {
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "TransitionError";
|
|
28
|
+
this.code = code;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
import { extractApiErrorCode } from "../utils/error";
|
|
32
|
+
/** Read a machine-readable error code off any thrown value. */
|
|
33
|
+
export function errorCodeOf(error) {
|
|
34
|
+
if (!error || typeof error !== "object")
|
|
35
|
+
return undefined;
|
|
36
|
+
const code = error.code;
|
|
37
|
+
if (typeof code === "string")
|
|
38
|
+
return code;
|
|
39
|
+
// Backend-shaped errors nested under response/data ({error: {code}}).
|
|
40
|
+
return extractApiErrorCode(error);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Guard rejections that must stand: the transport must surface them instead
|
|
44
|
+
* of POSTing past them (the complete-pickup 422 storm on delivery orders).
|
|
45
|
+
* ORDER_NOT_FOUND is intentionally excluded — the device may simply never
|
|
46
|
+
* have seen the order, in which case direct-REST fallback is correct.
|
|
47
|
+
*/
|
|
48
|
+
const FINAL_GUARD_CODES = new Set([
|
|
49
|
+
TRANSITION_ERROR_CODES.INVALID_TRANSITION,
|
|
50
|
+
TRANSITION_ERROR_CODES.ONLINE_REQUIRED,
|
|
51
|
+
TRANSITION_ERROR_CODES.NO_ACTIVE_BUSINESS,
|
|
52
|
+
TRANSITION_ERROR_CODES.FORBIDDEN,
|
|
53
|
+
TRANSITION_ERROR_CODES.SUBSCRIPTION_REQUIRED,
|
|
54
|
+
]);
|
|
55
|
+
export function isFinalGuardRejection(error) {
|
|
56
|
+
const code = errorCodeOf(error);
|
|
57
|
+
return code !== undefined && FINAL_GUARD_CODES.has(code);
|
|
58
|
+
}
|
|
59
|
+
export function isReplayDeferred(error) {
|
|
60
|
+
return errorCodeOf(error) === TRANSITION_ERROR_CODES.REPLAY_DEFERRED;
|
|
61
|
+
}
|
|
@@ -14,9 +14,11 @@ export declare class RestTransport implements Transport {
|
|
|
14
14
|
private dispatchCommand;
|
|
15
15
|
/**
|
|
16
16
|
* Order transitions go through the domain service first (single write path:
|
|
17
|
-
* SQLite + sync_jobs).
|
|
18
|
-
* handling a cloud-created order before
|
|
19
|
-
*
|
|
17
|
+
* SQLite + sync_jobs). Direct-REST fallback is ONLY for devices that never
|
|
18
|
+
* saw the order (LAN node handling a cloud-created order before first pull).
|
|
19
|
+
* Local guard rejections (Cannot <action>, online-required, auth/business,
|
|
20
|
+
* capability) are rethrown — POSTing past them is what caused the
|
|
21
|
+
* complete-pickup 422 storm on delivery orders.
|
|
20
22
|
*/
|
|
21
23
|
private executeOrderTransition;
|
|
22
24
|
private wrapResponse;
|
|
@@ -9,6 +9,7 @@ import { sellerService, kioskService, menuService } from "../adapters/services";
|
|
|
9
9
|
import { syncLogger } from "../utils/sync-logger";
|
|
10
10
|
import { generateEventId } from "../utils/uuid";
|
|
11
11
|
import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
|
|
12
|
+
import { isFinalGuardRejection } from "./transition-errors";
|
|
12
13
|
import { openSyncDatabase } from "../db/connection";
|
|
13
14
|
import { getOrderById, updateOrderStatus } from "../db/operations";
|
|
14
15
|
export class RestTransport {
|
|
@@ -134,9 +135,11 @@ export class RestTransport {
|
|
|
134
135
|
}
|
|
135
136
|
/**
|
|
136
137
|
* Order transitions go through the domain service first (single write path:
|
|
137
|
-
* SQLite + sync_jobs).
|
|
138
|
-
* handling a cloud-created order before
|
|
139
|
-
*
|
|
138
|
+
* SQLite + sync_jobs). Direct-REST fallback is ONLY for devices that never
|
|
139
|
+
* saw the order (LAN node handling a cloud-created order before first pull).
|
|
140
|
+
* Local guard rejections (Cannot <action>, online-required, auth/business,
|
|
141
|
+
* capability) are rethrown — POSTing past them is what caused the
|
|
142
|
+
* complete-pickup 422 storm on delivery orders.
|
|
140
143
|
*/
|
|
141
144
|
async executeOrderTransition(command, action, timestamp, restFallback, eventType = "order.status_changed") {
|
|
142
145
|
const { business_id, entity_id } = command;
|
|
@@ -146,6 +149,10 @@ export class RestTransport {
|
|
|
146
149
|
return this.wrapResponse(eventType, data, order.id, timestamp, command);
|
|
147
150
|
}
|
|
148
151
|
catch (error) {
|
|
152
|
+
// Guard rejections (by code, never message text) must stand — never
|
|
153
|
+
// POST past them. Only ORDER_NOT_FOUND falls back to REST.
|
|
154
|
+
if (isFinalGuardRejection(error))
|
|
155
|
+
throw error;
|
|
149
156
|
syncLogger.warn("RestTransport", "domain transition unavailable, REST fallback", {
|
|
150
157
|
command_type: command.command_type,
|
|
151
158
|
entity_id,
|
package/dist/utils/error.js
CHANGED
|
@@ -39,6 +39,9 @@ export function extractApiErrorCode(error) {
|
|
|
39
39
|
if (!error || typeof error !== "object")
|
|
40
40
|
return undefined;
|
|
41
41
|
const err = error;
|
|
42
|
+
// Core ApiError / TransitionError carry code on top level.
|
|
43
|
+
if (typeof err.code === "string")
|
|
44
|
+
return err.code;
|
|
42
45
|
const response = err.response;
|
|
43
46
|
if (response?.data && typeof response.data === "object") {
|
|
44
47
|
const data = response.data;
|
|
@@ -74,11 +74,27 @@ export function nextActionFor(order) {
|
|
|
74
74
|
if (status === "preparing")
|
|
75
75
|
return { label: "Mark ready", key: "mark_ready" };
|
|
76
76
|
if (status === "ready") {
|
|
77
|
+
// Delivery orders can NEVER complete pickup: backend rejects with
|
|
78
|
+
// INVALID_TRANSITION ("Only self-pickup orders can be completed this way").
|
|
79
|
+
// Ready delivery without a rider → assign one (online-only); with a rider
|
|
80
|
+
// → merchant handover confirm (online-only). Walk-in only below.
|
|
81
|
+
if (isDeliveryOrder(order)) {
|
|
82
|
+
const hasDelivery = !!order.delivery_id;
|
|
83
|
+
if (hasDelivery)
|
|
84
|
+
return { label: "Handover to rider", key: "confirm_handover" };
|
|
85
|
+
return { label: "Assign rider", key: "assign_rider" };
|
|
86
|
+
}
|
|
77
87
|
const orderType = order.order_type ?? null;
|
|
78
|
-
// dine_in walk-in → Complete, else Pickup (takeout
|
|
88
|
+
// dine_in walk-in → Complete, else Pickup (takeout)
|
|
79
89
|
const label = orderType === "dine_in" ? "Complete" : "Picked up";
|
|
80
90
|
return { label, key: "complete_pickup" };
|
|
81
91
|
}
|
|
92
|
+
if (status === "waiting_pickup" || status === "on_the_way") {
|
|
93
|
+
if (isDeliveryOrder(order)) {
|
|
94
|
+
return { label: "Handover to rider", key: "confirm_handover" };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
82
98
|
return null;
|
|
83
99
|
}
|
|
84
100
|
export function canTransition(order, action) {
|
package/package.json
CHANGED