@endora-commerce/mod-shipments 0.100.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/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/backend/email-templates/transactional-defaults.d.ts +6 -0
- package/dist/backend/email-templates/transactional-defaults.d.ts.map +1 -0
- package/dist/backend/email-templates/transactional-defaults.js +27 -0
- package/dist/backend/email-templates/transactional-defaults.js.map +1 -0
- package/dist/backend/entities/shipment.entity.d.ts +40 -0
- package/dist/backend/entities/shipment.entity.d.ts.map +1 -0
- package/dist/backend/entities/shipment.entity.js +98 -0
- package/dist/backend/entities/shipment.entity.js.map +1 -0
- package/dist/backend/index.d.ts +71 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +151 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes.d.ts +29 -0
- package/dist/backend/routes.d.ts.map +1 -0
- package/dist/backend/routes.js +35 -0
- package/dist/backend/routes.js.map +1 -0
- package/dist/backend/services/auto-shipment-on-paid.d.ts +28 -0
- package/dist/backend/services/auto-shipment-on-paid.d.ts.map +1 -0
- package/dist/backend/services/auto-shipment-on-paid.js +58 -0
- package/dist/backend/services/auto-shipment-on-paid.js.map +1 -0
- package/dist/backend/services/events.d.ts +40 -0
- package/dist/backend/services/events.d.ts.map +1 -0
- package/dist/backend/services/events.js +2 -0
- package/dist/backend/services/events.js.map +1 -0
- package/dist/backend/services/receive-shipment-handler.d.ts +93 -0
- package/dist/backend/services/receive-shipment-handler.d.ts.map +1 -0
- package/dist/backend/services/receive-shipment-handler.js +203 -0
- package/dist/backend/services/receive-shipment-handler.js.map +1 -0
- package/dist/backend/services/shipment-email-notifier.d.ts +78 -0
- package/dist/backend/services/shipment-email-notifier.d.ts.map +1 -0
- package/dist/backend/services/shipment-email-notifier.js +76 -0
- package/dist/backend/services/shipment-email-notifier.js.map +1 -0
- package/dist/backend/services/shipment-read.service.d.ts +19 -0
- package/dist/backend/services/shipment-read.service.d.ts.map +1 -0
- package/dist/backend/services/shipment-read.service.js +38 -0
- package/dist/backend/services/shipment-read.service.js.map +1 -0
- package/dist/backend/services/shipment-service.d.ts +79 -0
- package/dist/backend/services/shipment-service.d.ts.map +1 -0
- package/dist/backend/services/shipment-service.js +203 -0
- package/dist/backend/services/shipment-service.js.map +1 -0
- package/dist/backend/services/shipment-usage.service.d.ts +23 -0
- package/dist/backend/services/shipment-usage.service.d.ts.map +1 -0
- package/dist/backend/services/shipment-usage.service.js +26 -0
- package/dist/backend/services/shipment-usage.service.js.map +1 -0
- package/dist/backend/services/shipping-email-renderer.d.ts +22 -0
- package/dist/backend/services/shipping-email-renderer.d.ts.map +1 -0
- package/dist/backend/services/shipping-email-renderer.js +14 -0
- package/dist/backend/services/shipping-email-renderer.js.map +1 -0
- package/dist/manifest.d.ts +200 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +63 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260817T194652_shipments_order_fk.d.ts +32 -0
- package/dist/migrations/20260817T194652_shipments_order_fk.d.ts.map +1 -0
- package/dist/migrations/20260817T194652_shipments_order_fk.js +66 -0
- package/dist/migrations/20260817T194652_shipments_order_fk.js.map +1 -0
- package/dist/migrations/20260819T171006_shipments_status_pending_manual.d.ts +19 -0
- package/dist/migrations/20260819T171006_shipments_status_pending_manual.d.ts.map +1 -0
- package/dist/migrations/20260819T171006_shipments_status_pending_manual.js +32 -0
- package/dist/migrations/20260819T171006_shipments_status_pending_manual.js.map +1 -0
- package/dist/migrations/index.d.ts +35 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +38 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/shipments.md +96 -0
- package/package.json +69 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { rethrowIfModuleDisabled } from '@endora-commerce/platform/kernel';
|
|
2
|
+
import { Shipment } from '../entities/shipment.entity.js';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a shipment when payment settles and the order's shipping adapter
|
|
5
|
+
* opts in via `shouldAutoCreateOnPaid()` (feature 068).
|
|
6
|
+
*
|
|
7
|
+
* Wired from `shipments/backend.ts` through `ctx.subscribe('payment.received.v1')`
|
|
8
|
+
* — never via a bare `eventBus.on` (issue #107). Best-effort: ordinary failures
|
|
9
|
+
* are swallowed so payment settlement is never blocked; `ModuleDisabledError`
|
|
10
|
+
* is re-thrown so fail-closed stays fail-closed.
|
|
11
|
+
*
|
|
12
|
+
* The order and the delivery method are read over their owners' ports, exactly
|
|
13
|
+
* as `ShipmentService` reads them (feature 075 Phase C): both rows are ones this
|
|
14
|
+
* operation does not modify, so nothing is lost by leaving them on the owner's
|
|
15
|
+
* EntityManager, and both fail closed when their owner is off.
|
|
16
|
+
*/
|
|
17
|
+
export class AutoShipmentOnPaidNotifier {
|
|
18
|
+
emFactory;
|
|
19
|
+
registry;
|
|
20
|
+
orderRead;
|
|
21
|
+
deliveryMethodRead;
|
|
22
|
+
shipmentService;
|
|
23
|
+
constructor(emFactory, registry, orderRead, deliveryMethodRead, shipmentService) {
|
|
24
|
+
this.emFactory = emFactory;
|
|
25
|
+
this.registry = registry;
|
|
26
|
+
this.orderRead = orderRead;
|
|
27
|
+
this.deliveryMethodRead = deliveryMethodRead;
|
|
28
|
+
this.shipmentService = shipmentService;
|
|
29
|
+
}
|
|
30
|
+
/** Exposed for unit tests. */
|
|
31
|
+
async maybeCreate(orderId) {
|
|
32
|
+
try {
|
|
33
|
+
const order = await this.orderRead.findById(orderId);
|
|
34
|
+
if (!order)
|
|
35
|
+
return;
|
|
36
|
+
const method = await this.deliveryMethodRead.findById(order.deliveryMethodId);
|
|
37
|
+
const adapterKey = method?.adapter ?? order.deliveryMethodId;
|
|
38
|
+
const adapter = this.registry.get(adapterKey);
|
|
39
|
+
if (!adapter?.shouldAutoCreateOnPaid)
|
|
40
|
+
return;
|
|
41
|
+
if (!(await adapter.shouldAutoCreateOnPaid()))
|
|
42
|
+
return;
|
|
43
|
+
const em = this.emFactory();
|
|
44
|
+
const latest = await em.findOne(Shipment, { orderId }, { orderBy: { attemptNo: 'desc' } });
|
|
45
|
+
if (latest && (latest.status === 'pending' || latest.status === 'success'))
|
|
46
|
+
return;
|
|
47
|
+
await this.shipmentService.createShipment(orderId);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
// Narrow tolerance, on purpose: `payment.received.v1` must settle the
|
|
51
|
+
// payment whatever the carrier says, so an ordinary adapter failure is
|
|
52
|
+
// left to the operator's "Generate shipment" button. A switched-off
|
|
53
|
+
// module is not an ordinary failure and is re-thrown first.
|
|
54
|
+
rethrowIfModuleDisabled(error);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=auto-shipment-on-paid.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auto-shipment-on-paid.js","sourceRoot":"","sources":["../../../src/backend/services/auto-shipment-on-paid.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,uBAAuB,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAG1D;;;;;;;;;;;;;GAaG;AACH,MAAM,OAAO,0BAA0B;IAElB;IACA;IACA;IACA;IACA;IALnB,YACmB,SAA8B,EAC9B,QAAqC,EACrC,SAAwB,EACxB,kBAA0C,EAC1C,eAAgC;QAJhC,cAAS,GAAT,SAAS,CAAqB;QAC9B,aAAQ,GAAR,QAAQ,CAA6B;QACrC,cAAS,GAAT,SAAS,CAAe;QACxB,uBAAkB,GAAlB,kBAAkB,CAAwB;QAC1C,oBAAe,GAAf,eAAe,CAAiB;IAChD,CAAC;IAEJ,8BAA8B;IAC9B,KAAK,CAAC,WAAW,CAAC,OAAe;QAC/B,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;YACrD,IAAI,CAAC,KAAK;gBAAE,OAAO;YAEnB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC;YAC9E,MAAM,UAAU,GAAG,MAAM,EAAE,OAAO,IAAI,KAAK,CAAC,gBAAgB,CAAC;YAC7D,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;YAC9C,IAAI,CAAC,OAAO,EAAE,sBAAsB;gBAAE,OAAO;YAC7C,IAAI,CAAC,CAAC,MAAM,OAAO,CAAC,sBAAsB,EAAE,CAAC;gBAAE,OAAO;YAEtD,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;YAC5B,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,OAAO,CAC7B,QAAQ,EACR,EAAE,OAAO,EAAE,EACX,EAAE,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,CACnC,CAAC;YACF,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,SAAS,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,CAAC;gBAAE,OAAO;YAEnF,MAAM,IAAI,CAAC,eAAe,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;QACrD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,sEAAsE;YACtE,uEAAuE;YACvE,oEAAoE;YACpE,4DAA4D;YAC5D,uBAAuB,CAAC,KAAK,CAAC,CAAC;QACjC,CAAC;IACH,CAAC;CACF"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ShipmentStatus } from '@endora-commerce/contracts';
|
|
2
|
+
import type { EventBase, EventBus } from '@endora-commerce/platform/events';
|
|
3
|
+
/**
|
|
4
|
+
* Shipping domain events (feature 035). Emitted on the in-process EventBus
|
|
5
|
+
* inside the handler's transactional scope, so a rolled-back transaction never
|
|
6
|
+
* dispatches. The delivery-side twin of the payment.* events.
|
|
7
|
+
*/
|
|
8
|
+
export interface ShippingEvents extends Record<string, EventBase> {
|
|
9
|
+
'shipment.created.v1': EventBase & {
|
|
10
|
+
orderId: string;
|
|
11
|
+
shipmentId: string;
|
|
12
|
+
deliveryMethodId: string;
|
|
13
|
+
adapter: string;
|
|
14
|
+
attemptNo: number;
|
|
15
|
+
/**
|
|
16
|
+
* The state the row was opened in (issue #250). A subscriber that acts as
|
|
17
|
+
* if the carrier had been asked — the customer's "your order has shipped"
|
|
18
|
+
* e-mail is the one in this module — has to be able to tell a
|
|
19
|
+
* `pending_manual` shipment from a `pending` one, and the event is where
|
|
20
|
+
* it can, without re-reading the row it was just told about.
|
|
21
|
+
*/
|
|
22
|
+
status: ShipmentStatus;
|
|
23
|
+
};
|
|
24
|
+
'shipment.received.v1': EventBase & {
|
|
25
|
+
orderId: string;
|
|
26
|
+
shipmentId: string;
|
|
27
|
+
adapter: string;
|
|
28
|
+
externalReference: string | null;
|
|
29
|
+
attemptNo: number;
|
|
30
|
+
};
|
|
31
|
+
'shipment.failed.v1': EventBase & {
|
|
32
|
+
orderId: string;
|
|
33
|
+
shipmentId: string;
|
|
34
|
+
adapter: string;
|
|
35
|
+
failureReason: string | null;
|
|
36
|
+
attemptNo: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export type ShippingEventBus = EventBus<ShippingEvents>;
|
|
40
|
+
//# sourceMappingURL=events.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../../src/backend/services/events.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAE5E;;;;GAIG;AACH,MAAM,WAAW,cAAe,SAAQ,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC;IAC/D,qBAAqB,EAAE,SAAS,GAAG;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,gBAAgB,EAAE,MAAM,CAAC;QACzB,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;QAClB;;;;;;WAMG;QACH,MAAM,EAAE,cAAc,CAAC;KACxB,CAAC;IACF,sBAAsB,EAAE,SAAS,GAAG;QAClC,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;QACjC,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,oBAAoB,EAAE,SAAS,GAAG;QAChC,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;QAC7B,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;CACH;AAED,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC,cAAc,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"events.js","sourceRoot":"","sources":["../../../src/backend/services/events.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import { type ReceiveShipment } from '@endora-commerce/contracts';
|
|
3
|
+
import type { DeliveryMethodReadPort, OrderTransitionPort } from '@endora-commerce/contracts';
|
|
4
|
+
import { Shipment } from '../entities/shipment.entity.js';
|
|
5
|
+
import type { ShippingEventBus } from './events.js';
|
|
6
|
+
export interface ReceiveShipmentResult {
|
|
7
|
+
shipmentId: string;
|
|
8
|
+
status: Shipment['status'];
|
|
9
|
+
/**
|
|
10
|
+
* Where the order stands once the ingress is done with it, which since
|
|
11
|
+
* feature 085 Phase D is the lifecycle's answer rather than this handler's:
|
|
12
|
+
* the target the method configured when the graph permitted it, the order's
|
|
13
|
+
* unchanged status when it did not, and `null` when the ingress asked for no
|
|
14
|
+
* move at all (a repeated callback, or a method with no status configured).
|
|
15
|
+
*/
|
|
16
|
+
orderStatus: string | null;
|
|
17
|
+
idempotent: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The one message this handler logs, as little of a logger as it needs.
|
|
21
|
+
*
|
|
22
|
+
* `ctx.log` satisfies it structurally, so `shipments/backend.ts` passes the
|
|
23
|
+
* module's own logger and a test passes a recorder. `payments` declares the
|
|
24
|
+
* same shape at its twin seam; neither module imports the other's.
|
|
25
|
+
*/
|
|
26
|
+
export interface CarrierCallbackLogger {
|
|
27
|
+
warn(details: object, message: string): void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* ReceiveShipmentHandler (feature 035, FR-022/FR-023/FR-025).
|
|
31
|
+
*
|
|
32
|
+
* Resolves a Shipment from the ingress payload, applies the outcome, and asks
|
|
33
|
+
* the order lifecycle for the move the method's `statusOnSuccess` /
|
|
34
|
+
* `statusOnFailure` names. Idempotent: a success after a terminal `success` is
|
|
35
|
+
* a no-op; a failure after `success` is rejected (no downgrade). Resolves a
|
|
36
|
+
* late event even when the adapter has since been de-registered (it keys on the
|
|
37
|
+
* persisted Shipment, not the live registry).
|
|
38
|
+
*
|
|
39
|
+
* **The lifecycle answers; this handler does not decide** (feature 085 Phase
|
|
40
|
+
* D). It used to assign `order.status` after asking an `OrderStatusRegistry`
|
|
41
|
+
* whether the code existed, which is a different question from whether the
|
|
42
|
+
* order may go there — so a carrier callback wrote transitions the configured
|
|
43
|
+
* graph forbids while an operator making the same move by hand was refused, and
|
|
44
|
+
* the change was audited nowhere. The order row is no longer read here at all:
|
|
45
|
+
* the port carries the move, the audit entry, the side-effects and the
|
|
46
|
+
* templated `order.status.*.after` announcement.
|
|
47
|
+
*/
|
|
48
|
+
export declare class ReceiveShipmentHandler {
|
|
49
|
+
private readonly emFactory;
|
|
50
|
+
private readonly deliveryMethodRead;
|
|
51
|
+
/**
|
|
52
|
+
* The lifecycle write (feature 085 Phase D), **called after this handler's
|
|
53
|
+
* transaction has committed** and never inside it: the port takes its own
|
|
54
|
+
* `EntityManager`, so a call from inside would write the order on a
|
|
55
|
+
* different pooled connection whose commit the enclosing rollback cannot
|
|
56
|
+
* reach (issue #200). A crash in between leaves the shipment recorded and
|
|
57
|
+
* the order unmoved, which an operator can see and repeat.
|
|
58
|
+
*/
|
|
59
|
+
private readonly orderTransition;
|
|
60
|
+
/** Where a refused transition is recorded; see {@link CarrierCallbackLogger}. */
|
|
61
|
+
private readonly log;
|
|
62
|
+
private readonly events?;
|
|
63
|
+
constructor(emFactory: () => EntityManager, deliveryMethodRead: DeliveryMethodReadPort,
|
|
64
|
+
/**
|
|
65
|
+
* The lifecycle write (feature 085 Phase D), **called after this handler's
|
|
66
|
+
* transaction has committed** and never inside it: the port takes its own
|
|
67
|
+
* `EntityManager`, so a call from inside would write the order on a
|
|
68
|
+
* different pooled connection whose commit the enclosing rollback cannot
|
|
69
|
+
* reach (issue #200). A crash in between leaves the shipment recorded and
|
|
70
|
+
* the order unmoved, which an operator can see and repeat.
|
|
71
|
+
*/
|
|
72
|
+
orderTransition: OrderTransitionPort,
|
|
73
|
+
/** Where a refused transition is recorded; see {@link CarrierCallbackLogger}. */
|
|
74
|
+
log: CarrierCallbackLogger, events?: ShippingEventBus | undefined);
|
|
75
|
+
receive(input: ReceiveShipment): Promise<ReceiveShipmentResult>;
|
|
76
|
+
/**
|
|
77
|
+
* Ask the lifecycle for the move the configured setting names, and answer
|
|
78
|
+
* where the order ended up.
|
|
79
|
+
*
|
|
80
|
+
* Every refusal is a 200 for the carrier (contract §"What the caller does
|
|
81
|
+
* with each outcome"): a provider retries a non-2xx callback, and an order
|
|
82
|
+
* further along than the configured target must not be dragged backwards
|
|
83
|
+
* because a delivery attempt failed. So the refusal is recorded here and the
|
|
84
|
+
* shipment row stands.
|
|
85
|
+
*
|
|
86
|
+
* There is deliberately no `catch`. A `ModuleDisabledError` out of the port's
|
|
87
|
+
* gate is not a refusal — it is the owner being absent — and swallowing it
|
|
88
|
+
* would turn fail-closed into fail-open.
|
|
89
|
+
*/
|
|
90
|
+
private moveOrder;
|
|
91
|
+
private resolveShipment;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=receive-shipment-handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"receive-shipment-handler.d.ts","sourceRoot":"","sources":["../../../src/backend/services/receive-shipment-handler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAe,KAAK,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC/E,OAAO,KAAK,EACV,sBAAsB,EAEtB,mBAAmB,EACpB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAC1D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC3B;;;;;;OAMG;IACH,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACpC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9C;AAKD;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,sBAAsB;IAE/B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,kBAAkB;IACnC;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,iFAAiF;IACjF,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;gBAbP,SAAS,EAAE,MAAM,aAAa,EAC9B,kBAAkB,EAAE,sBAAsB;IAC3D;;;;;;;OAOG;IACc,eAAe,EAAE,mBAAmB;IACrD,iFAAiF;IAChE,GAAG,EAAE,qBAAqB,EAC1B,MAAM,CAAC,EAAE,gBAAgB,YAAA;IAGtC,OAAO,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAyHrE;;;;;;;;;;;;;OAaG;YACW,SAAS;YA8BT,eAAe;CAkB9B"}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { ERROR_CODES } from '@endora-commerce/contracts';
|
|
3
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
4
|
+
import { Shipment } from '../entities/shipment.entity.js';
|
|
5
|
+
/**
|
|
6
|
+
* ReceiveShipmentHandler (feature 035, FR-022/FR-023/FR-025).
|
|
7
|
+
*
|
|
8
|
+
* Resolves a Shipment from the ingress payload, applies the outcome, and asks
|
|
9
|
+
* the order lifecycle for the move the method's `statusOnSuccess` /
|
|
10
|
+
* `statusOnFailure` names. Idempotent: a success after a terminal `success` is
|
|
11
|
+
* a no-op; a failure after `success` is rejected (no downgrade). Resolves a
|
|
12
|
+
* late event even when the adapter has since been de-registered (it keys on the
|
|
13
|
+
* persisted Shipment, not the live registry).
|
|
14
|
+
*
|
|
15
|
+
* **The lifecycle answers; this handler does not decide** (feature 085 Phase
|
|
16
|
+
* D). It used to assign `order.status` after asking an `OrderStatusRegistry`
|
|
17
|
+
* whether the code existed, which is a different question from whether the
|
|
18
|
+
* order may go there — so a carrier callback wrote transitions the configured
|
|
19
|
+
* graph forbids while an operator making the same move by hand was refused, and
|
|
20
|
+
* the change was audited nowhere. The order row is no longer read here at all:
|
|
21
|
+
* the port carries the move, the audit entry, the side-effects and the
|
|
22
|
+
* templated `order.status.*.after` announcement.
|
|
23
|
+
*/
|
|
24
|
+
export class ReceiveShipmentHandler {
|
|
25
|
+
emFactory;
|
|
26
|
+
deliveryMethodRead;
|
|
27
|
+
orderTransition;
|
|
28
|
+
log;
|
|
29
|
+
events;
|
|
30
|
+
constructor(emFactory, deliveryMethodRead,
|
|
31
|
+
/**
|
|
32
|
+
* The lifecycle write (feature 085 Phase D), **called after this handler's
|
|
33
|
+
* transaction has committed** and never inside it: the port takes its own
|
|
34
|
+
* `EntityManager`, so a call from inside would write the order on a
|
|
35
|
+
* different pooled connection whose commit the enclosing rollback cannot
|
|
36
|
+
* reach (issue #200). A crash in between leaves the shipment recorded and
|
|
37
|
+
* the order unmoved, which an operator can see and repeat.
|
|
38
|
+
*/
|
|
39
|
+
orderTransition,
|
|
40
|
+
/** Where a refused transition is recorded; see {@link CarrierCallbackLogger}. */
|
|
41
|
+
log, events) {
|
|
42
|
+
this.emFactory = emFactory;
|
|
43
|
+
this.deliveryMethodRead = deliveryMethodRead;
|
|
44
|
+
this.orderTransition = orderTransition;
|
|
45
|
+
this.log = log;
|
|
46
|
+
this.events = events;
|
|
47
|
+
}
|
|
48
|
+
async receive(input) {
|
|
49
|
+
// command-coverage-ignore: provider shipment-event ingestion — stamps the
|
|
50
|
+
// attempt result on the Shipment row, then asks `orderTransitionPort` for
|
|
51
|
+
// the lifecycle move, which records the `order.status_transition` audit
|
|
52
|
+
// entry co-transactionally with the status write it performs. The reason on
|
|
53
|
+
// this marker used to claim that audit while the handler assigned
|
|
54
|
+
// `order.status` itself and reached the orders flow at no point (feature
|
|
55
|
+
// 085, R3); Phase D made the claim true.
|
|
56
|
+
const em = this.emFactory();
|
|
57
|
+
const result = await em.transactional(async (tx) => {
|
|
58
|
+
const shipment = await this.resolveShipment(tx, input);
|
|
59
|
+
if (!shipment) {
|
|
60
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Shipment not found for the given reference.');
|
|
61
|
+
}
|
|
62
|
+
// Idempotency / terminal-state guards (FR-025).
|
|
63
|
+
if (shipment.status === 'success') {
|
|
64
|
+
if (input.outcome === 'failure') {
|
|
65
|
+
throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, 'Shipment is already generated; it cannot be marked failed.');
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
shipment,
|
|
69
|
+
idempotent: true,
|
|
70
|
+
emit: false,
|
|
71
|
+
transition: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const method = await this.deliveryMethodRead.findById(shipment.deliveryMethodId);
|
|
75
|
+
let transition = null;
|
|
76
|
+
if (input.outcome === 'success') {
|
|
77
|
+
shipment.status = 'success';
|
|
78
|
+
if (input.externalReference !== undefined) {
|
|
79
|
+
shipment.externalReference = input.externalReference ?? null;
|
|
80
|
+
}
|
|
81
|
+
if (input.providerDetails)
|
|
82
|
+
shipment.providerDetails = input.providerDetails;
|
|
83
|
+
if (method?.statusOnSuccess) {
|
|
84
|
+
transition = { to: method.statusOnSuccess, setting: 'status_on_success' };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
shipment.status = 'failure';
|
|
89
|
+
shipment.failureReason = input.failureReason ?? null;
|
|
90
|
+
if (input.providerDetails)
|
|
91
|
+
shipment.providerDetails = input.providerDetails;
|
|
92
|
+
if (method?.statusOnFailure) {
|
|
93
|
+
transition = { to: method.statusOnFailure, setting: 'status_on_failure' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await tx.flush();
|
|
97
|
+
return {
|
|
98
|
+
shipment,
|
|
99
|
+
idempotent: false,
|
|
100
|
+
emit: true,
|
|
101
|
+
adapter: method?.adapter ?? shipment.deliveryMethodId,
|
|
102
|
+
transition,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
/**
|
|
106
|
+
* After the commit, never inside the transaction above (feature 085 R4).
|
|
107
|
+
*
|
|
108
|
+
* The transaction holds the shipment row alone now. It used to hold the
|
|
109
|
+
* order's status with it, which was the ground D-90 gave for importing the
|
|
110
|
+
* `Order` entity here; the lifecycle move is a graph decision with guards
|
|
111
|
+
* and side-effects rather than a column, so it goes through the port and
|
|
112
|
+
* the entity import goes with it.
|
|
113
|
+
*/
|
|
114
|
+
const orderStatus = result.transition
|
|
115
|
+
? await this.moveOrder(result.shipment.orderId, result.transition.to, result.transition.setting, input.outcome === 'failure' ? (input.failureReason ?? null) : null)
|
|
116
|
+
: null;
|
|
117
|
+
if (result.emit && this.events) {
|
|
118
|
+
const base = { eventId: randomUUID(), occurredAt: new Date().toISOString() };
|
|
119
|
+
if (result.shipment.status === 'success') {
|
|
120
|
+
this.events.emit('shipment.received.v1', {
|
|
121
|
+
...base,
|
|
122
|
+
orderId: result.shipment.orderId,
|
|
123
|
+
shipmentId: result.shipment.id,
|
|
124
|
+
adapter: result.adapter ?? '',
|
|
125
|
+
externalReference: result.shipment.externalReference ?? null,
|
|
126
|
+
attemptNo: result.shipment.attemptNo,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
this.events.emit('shipment.failed.v1', {
|
|
131
|
+
...base,
|
|
132
|
+
orderId: result.shipment.orderId,
|
|
133
|
+
shipmentId: result.shipment.id,
|
|
134
|
+
adapter: result.adapter ?? '',
|
|
135
|
+
failureReason: result.shipment.failureReason ?? null,
|
|
136
|
+
attemptNo: result.shipment.attemptNo,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// The templated `order.status.*.after` events (feature 038, T026) are no
|
|
140
|
+
// longer emitted from here: `OrderTransitionService` emits them itself,
|
|
141
|
+
// for the transition it applied, with the same actor. Announcing again
|
|
142
|
+
// would double every subscriber's reaction to one carrier callback.
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
shipmentId: result.shipment.id,
|
|
146
|
+
status: result.shipment.status,
|
|
147
|
+
orderStatus,
|
|
148
|
+
idempotent: result.idempotent,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Ask the lifecycle for the move the configured setting names, and answer
|
|
153
|
+
* where the order ended up.
|
|
154
|
+
*
|
|
155
|
+
* Every refusal is a 200 for the carrier (contract §"What the caller does
|
|
156
|
+
* with each outcome"): a provider retries a non-2xx callback, and an order
|
|
157
|
+
* further along than the configured target must not be dragged backwards
|
|
158
|
+
* because a delivery attempt failed. So the refusal is recorded here and the
|
|
159
|
+
* shipment row stands.
|
|
160
|
+
*
|
|
161
|
+
* There is deliberately no `catch`. A `ModuleDisabledError` out of the port's
|
|
162
|
+
* gate is not a refusal — it is the owner being absent — and swallowing it
|
|
163
|
+
* would turn fail-closed into fail-open.
|
|
164
|
+
*/
|
|
165
|
+
async moveOrder(orderId, to, setting, reason) {
|
|
166
|
+
const outcome = await this.orderTransition.applyStatus({
|
|
167
|
+
orderId,
|
|
168
|
+
to,
|
|
169
|
+
actor: { kind: 'system', source: 'shipment' },
|
|
170
|
+
reason,
|
|
171
|
+
});
|
|
172
|
+
if (outcome.applied)
|
|
173
|
+
return outcome.to;
|
|
174
|
+
if (outcome.reason === 'already_there')
|
|
175
|
+
return outcome.from;
|
|
176
|
+
this.log.warn({
|
|
177
|
+
orderId,
|
|
178
|
+
from: outcome.from,
|
|
179
|
+
to,
|
|
180
|
+
setting,
|
|
181
|
+
refusal: outcome.reason,
|
|
182
|
+
detail: outcome.detail,
|
|
183
|
+
}, 'shipments: the requested order status was not applied; the order keeps the one it has');
|
|
184
|
+
return outcome.from;
|
|
185
|
+
}
|
|
186
|
+
async resolveShipment(tx, input) {
|
|
187
|
+
if (input.shipmentId) {
|
|
188
|
+
return tx.findOne(Shipment, { id: input.shipmentId });
|
|
189
|
+
}
|
|
190
|
+
if (input.orderId && input.externalReference) {
|
|
191
|
+
const byRef = await tx.findOne(Shipment, {
|
|
192
|
+
orderId: input.orderId,
|
|
193
|
+
externalReference: input.externalReference,
|
|
194
|
+
});
|
|
195
|
+
if (byRef)
|
|
196
|
+
return byRef;
|
|
197
|
+
// Fall back to the most recent open attempt for the order.
|
|
198
|
+
return tx.findOne(Shipment, { orderId: input.orderId }, { orderBy: { attemptNo: 'desc' } });
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=receive-shipment-handler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"receive-shipment-handler.js","sourceRoot":"","sources":["../../../src/backend/services/receive-shipment-handler.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import type { CustomerAccountReadPort, OrderReadPort, ShipmentStatus, TransactionalEmailSender } from '@endora-commerce/contracts';
|
|
3
|
+
/**
|
|
4
|
+
* Why the shipment-created e-mail did — or did not — go out (issue #78).
|
|
5
|
+
*
|
|
6
|
+
* `notify` answered `void`, so a parcel whose customer was never told read
|
|
7
|
+
* exactly like one whose customer was: no sender wired, no order behind the id,
|
|
8
|
+
* no address on the customer, the three outcomes `send` reports since issue
|
|
9
|
+
* #67, and anything the bare `catch` absorbed all produced that same `void`.
|
|
10
|
+
*/
|
|
11
|
+
export type ShipmentEmailNotSentReason =
|
|
12
|
+
/**
|
|
13
|
+
* The shipment opened `pending_manual`: no carrier was asked for it, so
|
|
14
|
+
* there is no carrier, no label and no tracking number to tell the customer
|
|
15
|
+
* about (issue #250). Telling a buyer their order has shipped when nothing
|
|
16
|
+
* has been handed to anyone is worse than telling them nothing, and it is
|
|
17
|
+
* not recoverable — the correcting message is one nobody sends.
|
|
18
|
+
*/
|
|
19
|
+
'carrier_not_contacted'
|
|
20
|
+
/** No transactional sender is wired in this composition. */
|
|
21
|
+
| 'no_sender'
|
|
22
|
+
/** The event named an order this process cannot load. */
|
|
23
|
+
| 'order_not_found'
|
|
24
|
+
/** The customer account carries no address to send to. */
|
|
25
|
+
| 'no_recipient'
|
|
26
|
+
/** The operator switched the `shipment_created` e-mail off. */
|
|
27
|
+
| 'deactivated'
|
|
28
|
+
/** No mailer is wired behind the sender. */
|
|
29
|
+
| 'no_transport'
|
|
30
|
+
/** No `shipment_created` template exists yet. */
|
|
31
|
+
| 'no_definition'
|
|
32
|
+
/** The send raised, and the shipment stays created. */
|
|
33
|
+
| 'failed';
|
|
34
|
+
export type ShipmentEmailResult = {
|
|
35
|
+
sent: true;
|
|
36
|
+
} | {
|
|
37
|
+
sent: false;
|
|
38
|
+
reason: ShipmentEmailNotSentReason;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Where a send that did not happen is reported. Injectable so a test can read
|
|
42
|
+
* it; defaults to `console.warn`, which is what the rest of this layer uses.
|
|
43
|
+
*/
|
|
44
|
+
export type ShipmentEmailLog = (message: string, context: Record<string, unknown>) => void;
|
|
45
|
+
export interface ShipmentEmailNotifierDeps {
|
|
46
|
+
emFactory: () => EntityManager;
|
|
47
|
+
/**
|
|
48
|
+
* Feature 075 Phase C — the order and its buyer come from the ports their
|
|
49
|
+
* owners publish, not from `Order` and `CustomerAccount`. Both fail closed
|
|
50
|
+
* when their owner is off, and that is the right answer for a notification:
|
|
51
|
+
* an e-mail addressed from data the platform will not read is worse than no
|
|
52
|
+
* e-mail. `notify`'s `catch` re-throws `ModuleDisabledError` first, so the
|
|
53
|
+
* refusal reaches the subscriber rather than being logged as `failed`.
|
|
54
|
+
*/
|
|
55
|
+
orderRead: OrderReadPort;
|
|
56
|
+
customerAccountRead: CustomerAccountReadPort;
|
|
57
|
+
getTransactionalEmailSender: () => TransactionalEmailSender | undefined;
|
|
58
|
+
log?: ShipmentEmailLog;
|
|
59
|
+
}
|
|
60
|
+
export declare class ShipmentEmailNotifier {
|
|
61
|
+
private readonly deps;
|
|
62
|
+
private readonly log;
|
|
63
|
+
constructor(deps: ShipmentEmailNotifierDeps);
|
|
64
|
+
/**
|
|
65
|
+
* Public since feature 072 (T124), and `attach(eventBus)` is gone with it.
|
|
66
|
+
* That method subscribed to the raw bus, which is how a shipment-created
|
|
67
|
+
* e-mail went out while this module was switched off; the module now
|
|
68
|
+
* subscribes through `ctx.subscribe`, which stops with it.
|
|
69
|
+
*
|
|
70
|
+
* **Best-effort, and now audible.** The subscriber that calls this has no
|
|
71
|
+
* result to inspect, so every non-sent path is written to the log as well as
|
|
72
|
+
* named in the return value; a shipment must not be un-created because the
|
|
73
|
+
* notification failed.
|
|
74
|
+
*/
|
|
75
|
+
notify(orderId: string, shipmentId: string, status: ShipmentStatus): Promise<ShipmentEmailResult>;
|
|
76
|
+
private notSent;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=shipment-email-notifier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shipment-email-notifier.d.ts","sourceRoot":"","sources":["../../../src/backend/services/shipment-email-notifier.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EACV,uBAAuB,EACvB,aAAa,EACb,cAAc,EACd,wBAAwB,EACzB,MAAM,4BAA4B,CAAC;AAIpC;;;;;;;GAOG;AACH,MAAM,MAAM,0BAA0B;AACpC;;;;;;GAMG;AACD,uBAAuB;AACzB,4DAA4D;GAC1D,WAAW;AACb,yDAAyD;GACvD,iBAAiB;AACnB,0DAA0D;GACxD,cAAc;AAChB,+DAA+D;GAC7D,aAAa;AACf,4CAA4C;GAC1C,cAAc;AAChB,iDAAiD;GAC/C,eAAe;AACjB,uDAAuD;GACrD,QAAQ,CAAC;AAEb,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,GACd;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,0BAA0B,CAAA;CAAE,CAAC;AAExD;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;AAE3F,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,aAAa,CAAC;IAC/B;;;;;;;OAOG;IACH,SAAS,EAAE,aAAa,CAAC;IACzB,mBAAmB,EAAE,uBAAuB,CAAC;IAC7C,2BAA2B,EAAE,MAAM,wBAAwB,GAAG,SAAS,CAAC;IACxE,GAAG,CAAC,EAAE,gBAAgB,CAAC;CACxB;AAED,qBAAa,qBAAqB;IAGpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAFjC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAmB;gBAEV,IAAI,EAAE,yBAAyB;IAI5D;;;;;;;;;;OAUG;IACG,MAAM,CACV,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,mBAAmB,CAAC;IAwC/B,OAAO,CAAC,OAAO;CAchB"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Feature 047 — shipment_created transactional email. A net-new email: subscribes
|
|
2
|
+
// to shipment.created.v1 and sends the admin-editable template to the order's
|
|
3
|
+
// customer. Best-effort; event-bus dispatch isolates handler errors.
|
|
4
|
+
import { rethrowIfModuleDisabled } from '@endora-commerce/platform/kernel';
|
|
5
|
+
import { SalesChannel } from '@endora-commerce/platform/kernel';
|
|
6
|
+
export class ShipmentEmailNotifier {
|
|
7
|
+
deps;
|
|
8
|
+
log;
|
|
9
|
+
constructor(deps) {
|
|
10
|
+
this.deps = deps;
|
|
11
|
+
this.log = deps.log ?? ((message, context) => console.warn(message, context));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Public since feature 072 (T124), and `attach(eventBus)` is gone with it.
|
|
15
|
+
* That method subscribed to the raw bus, which is how a shipment-created
|
|
16
|
+
* e-mail went out while this module was switched off; the module now
|
|
17
|
+
* subscribes through `ctx.subscribe`, which stops with it.
|
|
18
|
+
*
|
|
19
|
+
* **Best-effort, and now audible.** The subscriber that calls this has no
|
|
20
|
+
* result to inspect, so every non-sent path is written to the log as well as
|
|
21
|
+
* named in the return value; a shipment must not be un-created because the
|
|
22
|
+
* notification failed.
|
|
23
|
+
*/
|
|
24
|
+
async notify(orderId, shipmentId, status) {
|
|
25
|
+
// First, and before any plumbing is consulted: this is a decision about the
|
|
26
|
+
// shipment, not about the mail. A `pending_manual` row is one no carrier
|
|
27
|
+
// was ever asked for, so "your order has shipped" would be false.
|
|
28
|
+
if (status === 'pending_manual') {
|
|
29
|
+
return this.notSent(orderId, shipmentId, 'carrier_not_contacted');
|
|
30
|
+
}
|
|
31
|
+
const sender = this.deps.getTransactionalEmailSender();
|
|
32
|
+
if (!sender)
|
|
33
|
+
return this.notSent(orderId, shipmentId, 'no_sender');
|
|
34
|
+
try {
|
|
35
|
+
const em = this.deps.emFactory();
|
|
36
|
+
const order = await this.deps.orderRead.findById(orderId);
|
|
37
|
+
if (!order)
|
|
38
|
+
return this.notSent(orderId, shipmentId, 'order_not_found');
|
|
39
|
+
const customer = await this.deps.customerAccountRead.findById(order.placedByCustomerAccountId);
|
|
40
|
+
if (!customer?.email)
|
|
41
|
+
return this.notSent(orderId, shipmentId, 'no_recipient');
|
|
42
|
+
const channel = await em.findOne(SalesChannel, { id: order.salesChannelId });
|
|
43
|
+
const outcome = await sender.send({
|
|
44
|
+
code: 'shipment_created',
|
|
45
|
+
salesChannelId: order.salesChannelId,
|
|
46
|
+
language: channel?.defaultLanguage ?? 'en-US',
|
|
47
|
+
to: customer.email,
|
|
48
|
+
messageId: `shipment_created:${shipmentId}`,
|
|
49
|
+
variables: { order: { businessId: order.businessId } },
|
|
50
|
+
meta: { orderId: order.id, shipmentId, kind: 'shipment_created' },
|
|
51
|
+
});
|
|
52
|
+
if (outcome.status !== 'sent')
|
|
53
|
+
return this.notSent(orderId, shipmentId, outcome.status);
|
|
54
|
+
return { sent: true };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
// A switched-off module is a presence answer about the whole operation,
|
|
58
|
+
// not a message that failed to render; absorbing it would report "sent
|
|
59
|
+
// nothing" where the truthful answer is "this capability is off".
|
|
60
|
+
rethrowIfModuleDisabled(error);
|
|
61
|
+
// Everything else is contained: the shipment exists and must not be
|
|
62
|
+
// undone because the message did not go out. It is named, though.
|
|
63
|
+
return this.notSent(orderId, shipmentId, 'failed', error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
notSent(orderId, shipmentId, reason, error) {
|
|
67
|
+
this.log('[shipments] the shipment-created e-mail was not sent', {
|
|
68
|
+
orderId,
|
|
69
|
+
shipmentId,
|
|
70
|
+
reason,
|
|
71
|
+
...(error === undefined ? {} : { error: error instanceof Error ? error.message : error }),
|
|
72
|
+
});
|
|
73
|
+
return { sent: false, reason };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=shipment-email-notifier.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shipment-email-notifier.js","sourceRoot":"","sources":["../../../src/backend/services/shipment-email-notifier.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import type { ShipmentReadPort, ShipmentRecord } from '@endora-commerce/contracts';
|
|
3
|
+
import { Shipment } from '../entities/shipment.entity.js';
|
|
4
|
+
/**
|
|
5
|
+
* The reads behind `shipmentReadPort` — see the port's contract for why a
|
|
6
|
+
* carrier module asks them and what happens when this module is off.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately not methods on {@link ShipmentService}: that service opens
|
|
9
|
+
* attempts and calls carriers, and a lookup by id has no business resolving an
|
|
10
|
+
* adapter registry, an order port and a delivery-method port to answer.
|
|
11
|
+
*/
|
|
12
|
+
export declare class ShipmentReadService implements ShipmentReadPort {
|
|
13
|
+
private readonly emFactory;
|
|
14
|
+
constructor(emFactory: () => EntityManager);
|
|
15
|
+
findById(id: string): Promise<ShipmentRecord | null>;
|
|
16
|
+
findByExternalReference(reference: string): Promise<ShipmentRecord | null>;
|
|
17
|
+
}
|
|
18
|
+
export declare function toShipmentRecord(shipment: Shipment): ShipmentRecord;
|
|
19
|
+
//# sourceMappingURL=shipment-read.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shipment-read.service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/shipment-read.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACnF,OAAO,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAE1D;;;;;;;GAOG;AACH,qBAAa,mBAAoB,YAAW,gBAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,MAAM,aAAa;IAErD,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IAKpD,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;CAQjF;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,cAAc,CAanE"}
|