@jarwizz/create-jarshop 0.1.4 → 0.1.6

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