@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.
- package/dist/template/src/email-smoke.ts +1 -0
- package/dist/template/src/migrations/1787800000000-jarshop-order-email-event-key.ts +60 -0
- package/dist/template/src/migrations/1787900000000-jarshop-email-brand-configuration.ts +32 -0
- package/dist/template/src/migrations/1788000000000-jarshop-email-brand-schema-parity.ts +21 -0
- package/dist/template/src/plugins/jarshop-checkout/dashboard/index.tsx +213 -32
- package/dist/template/src/plugins/jarshop-checkout/dashboard/order-email-delivery-status.ts +17 -0
- package/dist/template/src/plugins/jarshop-checkout/index.ts +49 -1
- package/dist/template/src/plugins/jarshop-checkout/jarshop-checkout.plugin.ts +146 -5
- package/dist/template/src/plugins/jarshop-checkout/order-email-configuration.entity.ts +61 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-configuration.ts +190 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-core.ts +321 -13
- package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-status.ts +3 -1
- package/dist/template/src/plugins/jarshop-checkout/order-email-delivery.entity.ts +15 -4
- package/dist/template/src/plugins/jarshop-checkout/order-email-lifecycle.ts +355 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-outbox.ts +308 -15
- package/dist/template/src/plugins/jarshop-checkout/order-email-worker.ts +86 -8
- package/dist/template/src/plugins/jarshop-checkout/order-lifecycle-core.ts +152 -0
- package/dist/template/src/plugins/jarshop-checkout/order-lifecycle-process.ts +102 -0
- package/dist/template-manifest.json +1 -1
- 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
|
+
}
|