@jarwizz/create-jarshop 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/dist/template/src/email-smoke.ts +1 -0
  2. package/dist/template/src/migrations/1787800000000-jarshop-order-email-event-key.ts +60 -0
  3. package/dist/template/src/migrations/1787900000000-jarshop-email-brand-configuration.ts +32 -0
  4. package/dist/template/src/plugins/jarshop-checkout/confirmation.ts +13 -0
  5. package/dist/template/src/plugins/jarshop-checkout/dashboard/index.tsx +213 -32
  6. package/dist/template/src/plugins/jarshop-checkout/dashboard/order-email-delivery-status.ts +17 -0
  7. package/dist/template/src/plugins/jarshop-checkout/index.ts +49 -1
  8. package/dist/template/src/plugins/jarshop-checkout/jarshop-checkout.plugin.ts +160 -12
  9. package/dist/template/src/plugins/jarshop-checkout/order-email-configuration.entity.ts +61 -0
  10. package/dist/template/src/plugins/jarshop-checkout/order-email-configuration.ts +190 -0
  11. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-core.ts +321 -13
  12. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-status.ts +3 -1
  13. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery.entity.ts +15 -4
  14. package/dist/template/src/plugins/jarshop-checkout/order-email-lifecycle.ts +335 -0
  15. package/dist/template/src/plugins/jarshop-checkout/order-email-outbox.ts +308 -15
  16. package/dist/template/src/plugins/jarshop-checkout/order-email-worker.ts +86 -8
  17. package/dist/template/src/plugins/jarshop-checkout/order-lifecycle-core.ts +131 -0
  18. package/dist/template/src/plugins/jarshop-checkout/order-lifecycle-process.ts +102 -0
  19. package/dist/template-manifest.json +1 -1
  20. package/package.json +1 -1
@@ -0,0 +1,335 @@
1
+ import { HistoryEntryType } from "@vendure/common/lib/generated-types";
2
+ import {
3
+ Inject,
4
+ Injectable,
5
+ Logger,
6
+ OnApplicationBootstrap,
7
+ OnModuleDestroy,
8
+ } from "@nestjs/common";
9
+ import {
10
+ EventBus,
11
+ FulfillmentStateTransitionEvent,
12
+ HistoryEntryEvent,
13
+ isGraphQlErrorResult,
14
+ Order,
15
+ OrderService,
16
+ OrderStateTransitionEvent,
17
+ TransactionalConnection,
18
+ type OrderLine,
19
+ type RequestContext,
20
+ } from "@vendure/core";
21
+ import { JarShopOrderEmailDelivery } from "./order-email-delivery.entity.js";
22
+ import { JarShopEmailBrandConfiguration } from "./order-email-configuration.entity.js";
23
+ import { isLifecycleEmailEnabled } from "./order-email-configuration.js";
24
+ import {
25
+ createPendingCancellationEmailDelivery,
26
+ createPendingFulfillmentEmailDelivery,
27
+ decryptOrderEmailOutboxPayload,
28
+ isSnapshotOrderEmailOutboxPayload,
29
+ type OrderEmailLineSnapshot,
30
+ } from "./order-email-outbox.js";
31
+ import { toOrderEmailLanguageCode } from "./order-email-delivery-core.js";
32
+ import {
33
+ getCourierOrderAlignmentState,
34
+ resolveShippingMethodCodeForOrderLines,
35
+ toFulfillmentLifecycleEmailKind,
36
+ } from "./order-lifecycle-core.js";
37
+
38
+ type CancellationHistoryInput = {
39
+ type: HistoryEntryType.ORDER_CANCELLATION;
40
+ data: {
41
+ lines: ReadonlyArray<{ orderLineId: string | number; quantity: number }>;
42
+ shippingCancelled: boolean;
43
+ };
44
+ };
45
+
46
+ @Injectable()
47
+ export class JarShopOrderEmailLifecycleOutbox
48
+ implements OnApplicationBootstrap, OnModuleDestroy
49
+ {
50
+ private orderStateSubscription: { unsubscribe(): void } | undefined;
51
+
52
+ constructor(
53
+ @Inject(TransactionalConnection)
54
+ private readonly connection: TransactionalConnection,
55
+ @Inject(EventBus)
56
+ private readonly eventBus: EventBus,
57
+ @Inject(OrderService)
58
+ private readonly orderService: OrderService,
59
+ ) {}
60
+
61
+ onApplicationBootstrap(): void {
62
+ this.eventBus.registerBlockingEventHandler({
63
+ event: FulfillmentStateTransitionEvent,
64
+ id: "jarshop-order-email-fulfillment-transition",
65
+ handler: (event) => this.enqueueFulfillmentTransition(event),
66
+ });
67
+ this.eventBus.registerBlockingEventHandler({
68
+ event: HistoryEntryEvent,
69
+ id: "jarshop-order-email-order-cancellation",
70
+ handler: (event) => this.enqueueCancellation(event),
71
+ });
72
+ this.orderStateSubscription = this.eventBus
73
+ .ofType(OrderStateTransitionEvent)
74
+ .subscribe((event) => {
75
+ if (event.toState !== "PaymentSettled") return;
76
+ void this.alignSettledCourierOrder(event).catch(() => {
77
+ Logger.error(
78
+ "Settled courier order could not be aligned with its fulfillments",
79
+ "JarShopOrderEmailLifecycleOutbox",
80
+ );
81
+ });
82
+ });
83
+ }
84
+
85
+ onModuleDestroy(): void {
86
+ this.orderStateSubscription?.unsubscribe();
87
+ }
88
+
89
+ async enqueueFulfillmentTransition(
90
+ event: FulfillmentStateTransitionEvent,
91
+ ): Promise<void> {
92
+ if (
93
+ !["ReadyForPickup", "PickedUp", "Shipped", "Delivered"].includes(
94
+ event.toState,
95
+ )
96
+ ) {
97
+ return;
98
+ }
99
+ const orders = await this.connection.getRepository(event.ctx, Order).find({
100
+ where: { fulfillments: { id: event.fulfillment.id } },
101
+ relations: [
102
+ "customer",
103
+ "lines.productVariant",
104
+ "shippingLines.shippingMethod",
105
+ "shippingLines.orderLines",
106
+ "fulfillments.lines",
107
+ ],
108
+ });
109
+
110
+ for (const order of orders) {
111
+ const fulfillment = order.fulfillments.find(
112
+ (candidate) => String(candidate.id) === String(event.fulfillment.id),
113
+ );
114
+ if (!fulfillment) continue;
115
+ const shippingMethodCode = resolveShippingMethodCodeForOrderLines(
116
+ order.shippingLines.map((line) => ({
117
+ code: line.shippingMethod.code,
118
+ orderLineIds: line.orderLines.map((orderLine) => orderLine.id),
119
+ })),
120
+ fulfillment.lines.map((line) => line.orderLineId),
121
+ );
122
+ const kind = toFulfillmentLifecycleEmailKind(
123
+ shippingMethodCode,
124
+ event.toState,
125
+ );
126
+ if (!kind) continue;
127
+ if (!(await this.isEnabled(event.ctx, kind))) continue;
128
+ const identity = await this.getDeliveryIdentity(event.ctx, order);
129
+ const delivery = createPendingFulfillmentEmailDelivery({
130
+ orderId: Number(order.id),
131
+ kind,
132
+ snapshot: {
133
+ ...identity,
134
+ orderCode: order.code,
135
+ currencyCode: order.currencyCode,
136
+ totalWithTax: order.totalWithTax,
137
+ shippingWithTax: order.shippingWithTax,
138
+ shippingMethodCode,
139
+ fulfillmentId: String(fulfillment.id),
140
+ trackingCode: fulfillment.trackingCode ?? "",
141
+ lines: toEmailLineSnapshots(order.lines, fulfillment.lines),
142
+ },
143
+ });
144
+ await this.insertOnce(event.ctx, delivery);
145
+ }
146
+ }
147
+
148
+ async enqueueCancellation(event: HistoryEntryEvent): Promise<void> {
149
+ const input = toCancellationHistoryInput(event);
150
+ if (!input) return;
151
+ if (!(await this.isEnabled(event.ctx, "order-cancelled"))) return;
152
+
153
+ const orderId = (
154
+ event.entity as typeof event.entity & {
155
+ order?: { id?: string | number };
156
+ }
157
+ ).order?.id;
158
+ if (orderId === undefined) {
159
+ throw new Error("order cancellation history relation is missing");
160
+ }
161
+ const order = await this.connection
162
+ .getRepository(event.ctx, Order)
163
+ .findOne({
164
+ where: { id: orderId },
165
+ relations: ["customer", "lines.productVariant"],
166
+ });
167
+ if (!order) return;
168
+
169
+ const identity = await this.getDeliveryIdentity(event.ctx, order);
170
+ const delivery = createPendingCancellationEmailDelivery({
171
+ orderId: Number(order.id),
172
+ snapshot: {
173
+ ...identity,
174
+ orderCode: order.code,
175
+ currencyCode: order.currencyCode,
176
+ totalWithTax: order.totalWithTax,
177
+ shippingWithTax: order.shippingWithTax,
178
+ historyEntryId: String(event.entity.id),
179
+ shippingCancelled: input.data.shippingCancelled,
180
+ lines: toEmailLineSnapshots(order.lines, input.data.lines),
181
+ },
182
+ });
183
+ await this.insertOnce(event.ctx, delivery);
184
+ }
185
+
186
+ async alignSettledCourierOrder(
187
+ event: OrderStateTransitionEvent,
188
+ ): Promise<void> {
189
+ const order = await this.connection.getEntityOrThrow(
190
+ event.ctx,
191
+ Order,
192
+ event.order.id,
193
+ {
194
+ relations: [
195
+ "lines",
196
+ "shippingLines.shippingMethod",
197
+ "fulfillments",
198
+ "fulfillments.lines",
199
+ ],
200
+ },
201
+ );
202
+ if (
203
+ order.shippingLines.some((line) => line.shippingMethod.code === "pickup")
204
+ ) {
205
+ return;
206
+ }
207
+ const targetState = getCourierOrderAlignmentState(
208
+ order.lines.map((line) => ({
209
+ orderLineId: line.id,
210
+ quantity: line.quantity,
211
+ })),
212
+ order.fulfillments.flatMap((fulfillment) =>
213
+ fulfillment.lines.map((line) => ({
214
+ orderLineId: line.orderLineId,
215
+ quantity: line.quantity,
216
+ state: fulfillment.state,
217
+ })),
218
+ ),
219
+ );
220
+ if (!targetState || targetState === order.state) return;
221
+
222
+ const result = await this.orderService.transitionToState(
223
+ event.ctx,
224
+ order.id,
225
+ targetState,
226
+ );
227
+ if (isGraphQlErrorResult(result)) {
228
+ throw new Error("settled courier order alignment failed");
229
+ }
230
+ }
231
+
232
+ private async getDeliveryIdentity(ctx: RequestContext, order: Order) {
233
+ const confirmationDelivery = await this.connection
234
+ .getRepository(ctx, JarShopOrderEmailDelivery)
235
+ .findOne({
236
+ where: {
237
+ orderId: Number(order.id),
238
+ kind: "order-confirmation",
239
+ },
240
+ });
241
+ if (confirmationDelivery) {
242
+ const payload = decryptOrderEmailOutboxPayload(
243
+ confirmationDelivery.payloadCiphertext,
244
+ );
245
+ if (isSnapshotOrderEmailOutboxPayload(payload)) {
246
+ return {
247
+ recipient: payload.snapshot.recipient,
248
+ languageCode: payload.snapshot.languageCode,
249
+ };
250
+ }
251
+ }
252
+ if (!order.customer?.emailAddress) {
253
+ throw new Error("order lifecycle email recipient is missing");
254
+ }
255
+ return {
256
+ recipient: order.customer.emailAddress,
257
+ languageCode: toOrderEmailLanguageCode(
258
+ confirmationDelivery?.languageCode ?? ctx.languageCode,
259
+ ),
260
+ };
261
+ }
262
+
263
+ private async isEnabled(
264
+ ctx: RequestContext,
265
+ kind: Parameters<typeof isLifecycleEmailEnabled>[1],
266
+ ): Promise<boolean> {
267
+ const configuration = await this.connection
268
+ .getRepository(ctx, JarShopEmailBrandConfiguration)
269
+ .findOne({ where: { channelId: Number(ctx.channelId) } });
270
+ return isLifecycleEmailEnabled(configuration, kind);
271
+ }
272
+
273
+ private async insertOnce(
274
+ ctx: RequestContext,
275
+ delivery: ReturnType<
276
+ | typeof createPendingFulfillmentEmailDelivery
277
+ | typeof createPendingCancellationEmailDelivery
278
+ >,
279
+ ): Promise<void> {
280
+ await this.connection
281
+ .getRepository(ctx, JarShopOrderEmailDelivery)
282
+ .createQueryBuilder()
283
+ .insert()
284
+ .values(delivery)
285
+ .orIgnore()
286
+ .execute();
287
+ }
288
+ }
289
+
290
+ export function toEmailLineSnapshots(
291
+ orderLines: readonly OrderLine[],
292
+ eventLines: ReadonlyArray<{
293
+ orderLineId: string | number;
294
+ quantity: number;
295
+ }>,
296
+ ): OrderEmailLineSnapshot[] {
297
+ return eventLines.map((eventLine) => {
298
+ const orderLine = orderLines.find(
299
+ (candidate) => String(candidate.id) === String(eventLine.orderLineId),
300
+ );
301
+ if (!orderLine) {
302
+ throw new Error("order lifecycle email line is missing");
303
+ }
304
+ return {
305
+ name: orderLine.productVariant.name,
306
+ quantity: eventLine.quantity,
307
+ totalWithTax: Math.round(
308
+ orderLine.proratedUnitPriceWithTax * eventLine.quantity,
309
+ ),
310
+ };
311
+ });
312
+ }
313
+
314
+ function toCancellationHistoryInput(
315
+ event: HistoryEntryEvent,
316
+ ): CancellationHistoryInput | undefined {
317
+ if (
318
+ event.historyType !== "order" ||
319
+ event.type !== "created" ||
320
+ typeof event.input !== "object" ||
321
+ event.input === null ||
322
+ !("type" in event.input) ||
323
+ event.input.type !== HistoryEntryType.ORDER_CANCELLATION ||
324
+ !("data" in event.input) ||
325
+ typeof event.input.data !== "object" ||
326
+ event.input.data === null ||
327
+ !("lines" in event.input.data) ||
328
+ !Array.isArray(event.input.data.lines) ||
329
+ !("shippingCancelled" in event.input.data) ||
330
+ typeof event.input.data.shippingCancelled !== "boolean"
331
+ ) {
332
+ return undefined;
333
+ }
334
+ return event.input as CancellationHistoryInput;
335
+ }