@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.
- 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/plugins/jarshop-checkout/confirmation.ts +13 -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 +160 -12
- 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 +335 -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 +131 -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
|
@@ -3,10 +3,75 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
|
3
3
|
const encryptionAlgorithm = "aes-256-gcm";
|
|
4
4
|
const encryptionKeyBytes = 32;
|
|
5
5
|
|
|
6
|
-
export type
|
|
6
|
+
export type LegacyOrderConfirmationOutboxPayload = {
|
|
7
7
|
confirmationToken: string;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
export type OrderEmailLineSnapshot = {
|
|
11
|
+
name: string;
|
|
12
|
+
quantity: number;
|
|
13
|
+
totalWithTax: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type OrderConfirmationEmailSnapshot = {
|
|
17
|
+
recipient: string;
|
|
18
|
+
languageCode: string;
|
|
19
|
+
orderCode: string;
|
|
20
|
+
currencyCode: string;
|
|
21
|
+
totalWithTax: number;
|
|
22
|
+
shippingWithTax: number;
|
|
23
|
+
paymentMethod: string;
|
|
24
|
+
shippingMethod: string;
|
|
25
|
+
confirmationToken: string;
|
|
26
|
+
lines: readonly OrderEmailLineSnapshot[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type FulfillmentLifecycleEmailKind =
|
|
30
|
+
| "pickup-ready"
|
|
31
|
+
| "pickup-picked-up"
|
|
32
|
+
| "shipment-shipped"
|
|
33
|
+
| "shipment-delivered";
|
|
34
|
+
|
|
35
|
+
export type FulfillmentLifecycleEmailSnapshot = {
|
|
36
|
+
recipient: string;
|
|
37
|
+
languageCode: string;
|
|
38
|
+
orderCode: string;
|
|
39
|
+
currencyCode: string;
|
|
40
|
+
totalWithTax: number;
|
|
41
|
+
shippingWithTax: number;
|
|
42
|
+
shippingMethodCode: string;
|
|
43
|
+
fulfillmentId: string;
|
|
44
|
+
trackingCode: string;
|
|
45
|
+
lines: readonly OrderEmailLineSnapshot[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type OrderCancellationEmailSnapshot = {
|
|
49
|
+
recipient: string;
|
|
50
|
+
languageCode: string;
|
|
51
|
+
orderCode: string;
|
|
52
|
+
currencyCode: string;
|
|
53
|
+
totalWithTax: number;
|
|
54
|
+
shippingWithTax: number;
|
|
55
|
+
historyEntryId: string;
|
|
56
|
+
shippingCancelled: boolean;
|
|
57
|
+
lines: readonly OrderEmailLineSnapshot[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type OrderEmailOutboxPayload =
|
|
61
|
+
| LegacyOrderConfirmationOutboxPayload
|
|
62
|
+
| {
|
|
63
|
+
kind: "order-confirmation";
|
|
64
|
+
snapshot: OrderConfirmationEmailSnapshot;
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
kind: FulfillmentLifecycleEmailKind;
|
|
68
|
+
snapshot: FulfillmentLifecycleEmailSnapshot;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
kind: "order-cancelled";
|
|
72
|
+
snapshot: OrderCancellationEmailSnapshot;
|
|
73
|
+
};
|
|
74
|
+
|
|
10
75
|
export function assertOrderEmailOutboxEncryptionConfiguration(): void {
|
|
11
76
|
parseOrderEmailEncryptionKey(process.env.JARSHOP_ORDER_EMAIL_ENCRYPTION_KEY);
|
|
12
77
|
}
|
|
@@ -14,19 +79,19 @@ export function assertOrderEmailOutboxEncryptionConfiguration(): void {
|
|
|
14
79
|
export function createPendingOrderEmailDelivery(input: {
|
|
15
80
|
orderId: number;
|
|
16
81
|
checkoutAttemptId: string;
|
|
17
|
-
|
|
18
|
-
confirmationToken: string;
|
|
82
|
+
snapshot: OrderConfirmationEmailSnapshot;
|
|
19
83
|
now?: Date;
|
|
20
84
|
encodedKey?: string;
|
|
21
85
|
}) {
|
|
22
86
|
return {
|
|
23
87
|
orderId: input.orderId,
|
|
88
|
+
eventKey: createOrderConfirmationEventKey(input.orderId),
|
|
24
89
|
checkoutAttemptId: input.checkoutAttemptId,
|
|
25
90
|
kind: "order-confirmation" as const,
|
|
26
|
-
languageCode: input.languageCode,
|
|
91
|
+
languageCode: input.snapshot.languageCode,
|
|
27
92
|
status: "pending" as const,
|
|
28
93
|
payloadCiphertext: encryptOrderEmailOutboxPayload(
|
|
29
|
-
{
|
|
94
|
+
{ kind: "order-confirmation", snapshot: input.snapshot },
|
|
30
95
|
input.encodedKey,
|
|
31
96
|
),
|
|
32
97
|
attemptCount: 0,
|
|
@@ -38,11 +103,45 @@ export function createPendingOrderEmailDelivery(input: {
|
|
|
38
103
|
};
|
|
39
104
|
}
|
|
40
105
|
|
|
106
|
+
export function createOrderConfirmationEventKey(orderId: number): string {
|
|
107
|
+
if (!Number.isSafeInteger(orderId) || orderId <= 0) {
|
|
108
|
+
throw new Error("order email event order id must be a positive integer");
|
|
109
|
+
}
|
|
110
|
+
return `order:${orderId}:order-confirmation`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createPendingFulfillmentEmailDelivery(input: {
|
|
114
|
+
orderId: number;
|
|
115
|
+
kind: FulfillmentLifecycleEmailKind;
|
|
116
|
+
snapshot: FulfillmentLifecycleEmailSnapshot;
|
|
117
|
+
now?: Date;
|
|
118
|
+
encodedKey?: string;
|
|
119
|
+
}) {
|
|
120
|
+
return createPendingLifecycleEmailDelivery({
|
|
121
|
+
...input,
|
|
122
|
+
eventKey: `order:${input.orderId}:fulfillment:${requireEventSourceId(input.snapshot.fulfillmentId)}:${input.kind}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createPendingCancellationEmailDelivery(input: {
|
|
127
|
+
orderId: number;
|
|
128
|
+
snapshot: OrderCancellationEmailSnapshot;
|
|
129
|
+
now?: Date;
|
|
130
|
+
encodedKey?: string;
|
|
131
|
+
}) {
|
|
132
|
+
return createPendingLifecycleEmailDelivery({
|
|
133
|
+
...input,
|
|
134
|
+
kind: "order-cancelled" as const,
|
|
135
|
+
eventKey: `order-history:${requireEventSourceId(input.snapshot.historyEntryId)}:order-cancelled`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
41
139
|
export function encryptOrderEmailOutboxPayload(
|
|
42
140
|
payload: OrderEmailOutboxPayload,
|
|
43
141
|
encodedKey = process.env.JARSHOP_ORDER_EMAIL_ENCRYPTION_KEY,
|
|
44
142
|
): string {
|
|
45
143
|
const key = parseOrderEmailEncryptionKey(encodedKey);
|
|
144
|
+
const version = "snapshot" in payload ? "v2" : "v1";
|
|
46
145
|
const initializationVector = randomBytes(12);
|
|
47
146
|
const cipher = createCipheriv(encryptionAlgorithm, key, initializationVector);
|
|
48
147
|
const ciphertext = Buffer.concat([
|
|
@@ -51,7 +150,7 @@ export function encryptOrderEmailOutboxPayload(
|
|
|
51
150
|
]);
|
|
52
151
|
const tag = cipher.getAuthTag();
|
|
53
152
|
return [
|
|
54
|
-
|
|
153
|
+
version,
|
|
55
154
|
initializationVector.toString("base64url"),
|
|
56
155
|
tag.toString("base64url"),
|
|
57
156
|
ciphertext.toString("base64url"),
|
|
@@ -65,7 +164,7 @@ export function decryptOrderEmailOutboxPayload(
|
|
|
65
164
|
const [version, initializationVector, tag, ciphertext, ...unexpected] =
|
|
66
165
|
payloadCiphertext.split(".");
|
|
67
166
|
if (
|
|
68
|
-
version !== "v1" ||
|
|
167
|
+
(version !== "v1" && version !== "v2") ||
|
|
69
168
|
!initializationVector ||
|
|
70
169
|
!tag ||
|
|
71
170
|
!ciphertext ||
|
|
@@ -84,19 +183,213 @@ export function decryptOrderEmailOutboxPayload(
|
|
|
84
183
|
decipher.update(Buffer.from(ciphertext, "base64url")),
|
|
85
184
|
decipher.final(),
|
|
86
185
|
]).toString("utf8");
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
!payload.confirmationToken
|
|
91
|
-
) {
|
|
92
|
-
throw new Error("missing confirmation token");
|
|
93
|
-
}
|
|
94
|
-
return { confirmationToken: payload.confirmationToken };
|
|
186
|
+
return version === "v1"
|
|
187
|
+
? parseLegacyOrderConfirmationPayload(JSON.parse(decoded))
|
|
188
|
+
: parseOrderConfirmationSnapshotPayload(JSON.parse(decoded));
|
|
95
189
|
} catch {
|
|
96
190
|
throw new Error("order email outbox payload cannot be decrypted");
|
|
97
191
|
}
|
|
98
192
|
}
|
|
99
193
|
|
|
194
|
+
export function isSnapshotOrderEmailOutboxPayload(
|
|
195
|
+
payload: OrderEmailOutboxPayload,
|
|
196
|
+
): payload is Extract<OrderEmailOutboxPayload, { kind: "order-confirmation" }> {
|
|
197
|
+
return "kind" in payload && payload.kind === "order-confirmation";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function isLegacyOrderConfirmationOutboxPayload(
|
|
201
|
+
payload: OrderEmailOutboxPayload,
|
|
202
|
+
): payload is LegacyOrderConfirmationOutboxPayload {
|
|
203
|
+
return "confirmationToken" in payload;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseLegacyOrderConfirmationPayload(
|
|
207
|
+
value: unknown,
|
|
208
|
+
): LegacyOrderConfirmationOutboxPayload {
|
|
209
|
+
const payload = asRecord(value);
|
|
210
|
+
return { confirmationToken: requireString(payload, "confirmationToken") };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseOrderConfirmationSnapshotPayload(
|
|
214
|
+
value: unknown,
|
|
215
|
+
): Exclude<OrderEmailOutboxPayload, LegacyOrderConfirmationOutboxPayload> {
|
|
216
|
+
const payload = asRecord(value);
|
|
217
|
+
const kind = requireString(payload, "kind");
|
|
218
|
+
const snapshot = asRecord(payload.snapshot);
|
|
219
|
+
if (kind === "order-confirmation") {
|
|
220
|
+
return {
|
|
221
|
+
kind,
|
|
222
|
+
snapshot: parseOrderConfirmationSnapshot(snapshot),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (isFulfillmentLifecycleEmailKind(kind)) {
|
|
226
|
+
return {
|
|
227
|
+
kind,
|
|
228
|
+
snapshot: parseFulfillmentLifecycleSnapshot(snapshot),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (kind === "order-cancelled") {
|
|
232
|
+
return {
|
|
233
|
+
kind,
|
|
234
|
+
snapshot: parseOrderCancellationSnapshot(snapshot),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
throw new Error("unsupported order email kind");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseOrderConfirmationSnapshot(
|
|
241
|
+
snapshot: Record<string, unknown>,
|
|
242
|
+
): OrderConfirmationEmailSnapshot {
|
|
243
|
+
const lines = snapshot.lines;
|
|
244
|
+
if (!Array.isArray(lines)) throw new Error("missing order email lines");
|
|
245
|
+
return {
|
|
246
|
+
recipient: requireString(snapshot, "recipient"),
|
|
247
|
+
languageCode: requireString(snapshot, "languageCode"),
|
|
248
|
+
orderCode: requireString(snapshot, "orderCode"),
|
|
249
|
+
currencyCode: requireString(snapshot, "currencyCode"),
|
|
250
|
+
totalWithTax: requireInteger(snapshot, "totalWithTax"),
|
|
251
|
+
shippingWithTax: requireInteger(snapshot, "shippingWithTax"),
|
|
252
|
+
paymentMethod: requireString(snapshot, "paymentMethod", true),
|
|
253
|
+
shippingMethod: requireString(snapshot, "shippingMethod", true),
|
|
254
|
+
confirmationToken: requireString(snapshot, "confirmationToken"),
|
|
255
|
+
lines: parseOrderEmailLines(lines),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function parseFulfillmentLifecycleSnapshot(
|
|
260
|
+
snapshot: Record<string, unknown>,
|
|
261
|
+
): FulfillmentLifecycleEmailSnapshot {
|
|
262
|
+
return {
|
|
263
|
+
...parseLifecycleOrderSnapshot(snapshot),
|
|
264
|
+
shippingMethodCode: requireString(snapshot, "shippingMethodCode"),
|
|
265
|
+
fulfillmentId: requireString(snapshot, "fulfillmentId"),
|
|
266
|
+
trackingCode: requireString(snapshot, "trackingCode", true),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function parseOrderCancellationSnapshot(
|
|
271
|
+
snapshot: Record<string, unknown>,
|
|
272
|
+
): OrderCancellationEmailSnapshot {
|
|
273
|
+
const shippingCancelled = snapshot.shippingCancelled;
|
|
274
|
+
if (typeof shippingCancelled !== "boolean") {
|
|
275
|
+
throw new Error("invalid order email shippingCancelled");
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
...parseLifecycleOrderSnapshot(snapshot),
|
|
279
|
+
historyEntryId: requireString(snapshot, "historyEntryId"),
|
|
280
|
+
shippingCancelled,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseLifecycleOrderSnapshot(snapshot: Record<string, unknown>) {
|
|
285
|
+
const lines = snapshot.lines;
|
|
286
|
+
if (!Array.isArray(lines)) throw new Error("missing order email lines");
|
|
287
|
+
return {
|
|
288
|
+
recipient: requireString(snapshot, "recipient"),
|
|
289
|
+
languageCode: requireString(snapshot, "languageCode"),
|
|
290
|
+
orderCode: requireString(snapshot, "orderCode"),
|
|
291
|
+
currencyCode: requireString(snapshot, "currencyCode"),
|
|
292
|
+
totalWithTax: requireInteger(snapshot, "totalWithTax"),
|
|
293
|
+
shippingWithTax: requireInteger(snapshot, "shippingWithTax"),
|
|
294
|
+
lines: parseOrderEmailLines(lines),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parseOrderEmailLines(lines: unknown[]): OrderEmailLineSnapshot[] {
|
|
299
|
+
return lines.map((line) => {
|
|
300
|
+
const item = asRecord(line);
|
|
301
|
+
return {
|
|
302
|
+
name: requireString(item, "name"),
|
|
303
|
+
quantity: requireInteger(item, "quantity"),
|
|
304
|
+
totalWithTax: requireInteger(item, "totalWithTax"),
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isFulfillmentLifecycleEmailKind(
|
|
310
|
+
kind: string,
|
|
311
|
+
): kind is FulfillmentLifecycleEmailKind {
|
|
312
|
+
return [
|
|
313
|
+
"pickup-ready",
|
|
314
|
+
"pickup-picked-up",
|
|
315
|
+
"shipment-shipped",
|
|
316
|
+
"shipment-delivered",
|
|
317
|
+
].includes(kind);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function createPendingLifecycleEmailDelivery<
|
|
321
|
+
Kind extends Exclude<
|
|
322
|
+
OrderEmailOutboxPayload,
|
|
323
|
+
LegacyOrderConfirmationOutboxPayload | { kind: "order-confirmation" }
|
|
324
|
+
>["kind"],
|
|
325
|
+
>(input: {
|
|
326
|
+
orderId: number;
|
|
327
|
+
eventKey: string;
|
|
328
|
+
kind: Kind;
|
|
329
|
+
snapshot: Extract<OrderEmailOutboxPayload, { kind: Kind }>["snapshot"];
|
|
330
|
+
now?: Date;
|
|
331
|
+
encodedKey?: string;
|
|
332
|
+
}) {
|
|
333
|
+
if (!Number.isSafeInteger(input.orderId) || input.orderId <= 0) {
|
|
334
|
+
throw new Error("order email event order id must be a positive integer");
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
orderId: input.orderId,
|
|
338
|
+
eventKey: input.eventKey,
|
|
339
|
+
checkoutAttemptId: null,
|
|
340
|
+
kind: input.kind,
|
|
341
|
+
languageCode: input.snapshot.languageCode,
|
|
342
|
+
status: "pending" as const,
|
|
343
|
+
payloadCiphertext: encryptOrderEmailOutboxPayload(
|
|
344
|
+
{ kind: input.kind, snapshot: input.snapshot } as Exclude<
|
|
345
|
+
OrderEmailOutboxPayload,
|
|
346
|
+
LegacyOrderConfirmationOutboxPayload
|
|
347
|
+
>,
|
|
348
|
+
input.encodedKey,
|
|
349
|
+
),
|
|
350
|
+
attemptCount: 0,
|
|
351
|
+
nextAttemptAt: input.now ?? new Date(),
|
|
352
|
+
leaseToken: null,
|
|
353
|
+
leaseExpiresAt: null,
|
|
354
|
+
lastErrorCode: null,
|
|
355
|
+
sentAt: null,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function requireEventSourceId(sourceId: string): string {
|
|
360
|
+
if (!sourceId.trim() || sourceId.length > 128 || /[:\s]/u.test(sourceId)) {
|
|
361
|
+
throw new Error("order email event source id is invalid");
|
|
362
|
+
}
|
|
363
|
+
return sourceId;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
367
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
368
|
+
throw new Error("order email payload must be an object");
|
|
369
|
+
}
|
|
370
|
+
return value as Record<string, unknown>;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function requireString(
|
|
374
|
+
value: Record<string, unknown>,
|
|
375
|
+
key: string,
|
|
376
|
+
allowEmpty = false,
|
|
377
|
+
): string {
|
|
378
|
+
const field = value[key];
|
|
379
|
+
if (typeof field !== "string" || (!allowEmpty && field.length === 0)) {
|
|
380
|
+
throw new Error(`invalid order email ${key}`);
|
|
381
|
+
}
|
|
382
|
+
return field;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function requireInteger(value: Record<string, unknown>, key: string): number {
|
|
386
|
+
const field = value[key];
|
|
387
|
+
if (!Number.isSafeInteger(field)) {
|
|
388
|
+
throw new Error(`invalid order email ${key}`);
|
|
389
|
+
}
|
|
390
|
+
return field as number;
|
|
391
|
+
}
|
|
392
|
+
|
|
100
393
|
export function parseOrderEmailEncryptionKey(encodedKey: string | undefined) {
|
|
101
394
|
const key = encodedKey ? Buffer.from(encodedKey, "base64") : Buffer.alloc(0);
|
|
102
395
|
if (key.length !== encryptionKeyBytes) {
|
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
TransactionalConnection,
|
|
10
10
|
} from "@vendure/core";
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { In } from "typeorm";
|
|
13
|
+
import { JarShopEmailBrandConfiguration } from "./order-email-configuration.entity.js";
|
|
14
|
+
import { isLifecycleEmailEnabled } from "./order-email-configuration.js";
|
|
12
15
|
import { JarShopOrderEmailDelivery } from "./order-email-delivery.entity.js";
|
|
13
16
|
import {
|
|
14
17
|
assertOrderEmailDeliveryConfiguration,
|
|
@@ -17,6 +20,7 @@ import {
|
|
|
17
20
|
type OrderConfirmationEmail,
|
|
18
21
|
registerUniqueScheduledTask,
|
|
19
22
|
renderOrderConfirmationEmail,
|
|
23
|
+
renderOrderLifecycleEmail,
|
|
20
24
|
scheduleOrderEmailRetry,
|
|
21
25
|
toOrderEmailLanguageCode,
|
|
22
26
|
type OrderEmailTransport,
|
|
@@ -26,6 +30,10 @@ import { createConfiguredOrderEmailTransport } from "./order-email-transport.js"
|
|
|
26
30
|
import {
|
|
27
31
|
assertOrderEmailOutboxEncryptionConfiguration,
|
|
28
32
|
decryptOrderEmailOutboxPayload,
|
|
33
|
+
isLegacyOrderConfirmationOutboxPayload,
|
|
34
|
+
isSnapshotOrderEmailOutboxPayload,
|
|
35
|
+
type LegacyOrderConfirmationOutboxPayload,
|
|
36
|
+
type OrderConfirmationEmailSnapshot,
|
|
29
37
|
} from "./order-email-outbox.js";
|
|
30
38
|
|
|
31
39
|
type DeliveryResult =
|
|
@@ -73,10 +81,40 @@ export class JarShopOrderEmailWorker implements OnApplicationBootstrap {
|
|
|
73
81
|
const payload = decryptOrderEmailOutboxPayload(
|
|
74
82
|
delivery.delivery.payloadCiphertext,
|
|
75
83
|
);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
let email: OrderConfirmationEmail;
|
|
85
|
+
if (isSnapshotOrderEmailOutboxPayload(payload)) {
|
|
86
|
+
email = this.renderSnapshotOrderConfirmation(
|
|
87
|
+
payload.snapshot,
|
|
88
|
+
await this.getBrandConfiguration(delivery.delivery.orderId),
|
|
89
|
+
);
|
|
90
|
+
} else if (isLegacyOrderConfirmationOutboxPayload(payload)) {
|
|
91
|
+
email = await this.renderLegacyOrderConfirmation(
|
|
92
|
+
delivery.delivery,
|
|
93
|
+
payload,
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
const brandConfiguration = await this.getBrandConfiguration(
|
|
97
|
+
delivery.delivery.orderId,
|
|
98
|
+
);
|
|
99
|
+
if (
|
|
100
|
+
!brandConfiguration ||
|
|
101
|
+
!isLifecycleEmailEnabled(brandConfiguration, payload.kind)
|
|
102
|
+
) {
|
|
103
|
+
throw new OrderEmailWorkerError("EMAIL_KIND_NOT_ENABLED");
|
|
104
|
+
}
|
|
105
|
+
email =
|
|
106
|
+
payload.kind === "order-cancelled"
|
|
107
|
+
? renderOrderLifecycleEmail({
|
|
108
|
+
kind: payload.kind,
|
|
109
|
+
snapshot: payload.snapshot,
|
|
110
|
+
brandConfiguration,
|
|
111
|
+
})
|
|
112
|
+
: renderOrderLifecycleEmail({
|
|
113
|
+
kind: payload.kind,
|
|
114
|
+
snapshot: payload.snapshot,
|
|
115
|
+
brandConfiguration,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
80
118
|
await this.getEmailTransport().deliver({
|
|
81
119
|
deliveryId: delivery.delivery.id,
|
|
82
120
|
email,
|
|
@@ -146,9 +184,23 @@ export class JarShopOrderEmailWorker implements OnApplicationBootstrap {
|
|
|
146
184
|
});
|
|
147
185
|
}
|
|
148
186
|
|
|
149
|
-
private
|
|
187
|
+
private renderSnapshotOrderConfirmation(
|
|
188
|
+
snapshot: OrderConfirmationEmailSnapshot,
|
|
189
|
+
brandConfiguration: JarShopEmailBrandConfiguration | undefined,
|
|
190
|
+
): OrderConfirmationEmail {
|
|
191
|
+
return renderOrderConfirmationEmail({
|
|
192
|
+
...snapshot,
|
|
193
|
+
...(brandConfiguration ? { brandConfiguration } : {}),
|
|
194
|
+
confirmationUrl: createOrderConfirmationUrl(
|
|
195
|
+
toOrderEmailLanguageCode(snapshot.languageCode),
|
|
196
|
+
snapshot.confirmationToken,
|
|
197
|
+
),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async renderLegacyOrderConfirmation(
|
|
150
202
|
delivery: JarShopOrderEmailDelivery,
|
|
151
|
-
|
|
203
|
+
payload: LegacyOrderConfirmationOutboxPayload,
|
|
152
204
|
): Promise<OrderConfirmationEmail> {
|
|
153
205
|
const sourceOrder = await this.connection.rawConnection
|
|
154
206
|
.getRepository(Order)
|
|
@@ -177,6 +229,9 @@ export class JarShopOrderEmailWorker implements OnApplicationBootstrap {
|
|
|
177
229
|
throw new OrderEmailWorkerError("ORDER_RECIPIENT_MISSING");
|
|
178
230
|
}
|
|
179
231
|
|
|
232
|
+
const brandConfiguration = await this.getBrandConfiguration(
|
|
233
|
+
delivery.orderId,
|
|
234
|
+
);
|
|
180
235
|
return renderOrderConfirmationEmail({
|
|
181
236
|
recipient: order.customer.emailAddress,
|
|
182
237
|
languageCode,
|
|
@@ -193,16 +248,39 @@ export class JarShopOrderEmailWorker implements OnApplicationBootstrap {
|
|
|
193
248
|
.join(", "),
|
|
194
249
|
confirmationUrl: createOrderConfirmationUrl(
|
|
195
250
|
languageCode,
|
|
196
|
-
confirmationToken,
|
|
251
|
+
payload.confirmationToken,
|
|
197
252
|
),
|
|
198
253
|
lines: order.lines.map((line) => ({
|
|
199
254
|
name: line.productVariant.name,
|
|
200
255
|
quantity: line.quantity,
|
|
201
256
|
totalWithTax: Math.round(line.proratedLinePriceWithTax),
|
|
202
257
|
})),
|
|
258
|
+
...(brandConfiguration ? { brandConfiguration } : {}),
|
|
203
259
|
});
|
|
204
260
|
}
|
|
205
261
|
|
|
262
|
+
private async getBrandConfiguration(
|
|
263
|
+
orderId: number,
|
|
264
|
+
): Promise<JarShopEmailBrandConfiguration | undefined> {
|
|
265
|
+
const sourceOrder = await this.connection.rawConnection
|
|
266
|
+
.getRepository(Order)
|
|
267
|
+
.findOne({ where: { id: orderId }, relations: ["channels"] });
|
|
268
|
+
if (!sourceOrder) throw new OrderEmailWorkerError("ORDER_NOT_FOUND");
|
|
269
|
+
const configurations = await this.connection.rawConnection
|
|
270
|
+
.getRepository(JarShopEmailBrandConfiguration)
|
|
271
|
+
.find({
|
|
272
|
+
where: {
|
|
273
|
+
channelId: In(
|
|
274
|
+
sourceOrder.channels.map((channel) => Number(channel.id)),
|
|
275
|
+
),
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
if (configurations.length > 1) {
|
|
279
|
+
throw new OrderEmailWorkerError("EMAIL_BRAND_CHANNEL_AMBIGUOUS");
|
|
280
|
+
}
|
|
281
|
+
return configurations[0];
|
|
282
|
+
}
|
|
283
|
+
|
|
206
284
|
private async markSent(
|
|
207
285
|
deliveryId: number,
|
|
208
286
|
leaseToken: string,
|
|
@@ -258,7 +336,7 @@ export class JarShopOrderEmailWorker implements OnApplicationBootstrap {
|
|
|
258
336
|
|
|
259
337
|
export const jarShopOrderEmailWorkerTask = new ScheduledTask({
|
|
260
338
|
id: "jarshop-order-email-outbox",
|
|
261
|
-
description: "Deliver one due JarShop order
|
|
339
|
+
description: "Deliver one due JarShop order lifecycle email",
|
|
262
340
|
schedule: (cron) => cron.every(1).minutes(),
|
|
263
341
|
timeout: 30_000,
|
|
264
342
|
execute: async ({ injector }) => {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { FulfillmentLifecycleEmailKind } from "./order-email-outbox.js";
|
|
2
|
+
|
|
3
|
+
type FulfillmentCoverage = {
|
|
4
|
+
orderLineId: string | number;
|
|
5
|
+
quantity: number;
|
|
6
|
+
state: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function validateFulfillmentTransition(input: {
|
|
10
|
+
toState: string;
|
|
11
|
+
shippingMethodCodes: readonly string[];
|
|
12
|
+
paymentStates: readonly string[];
|
|
13
|
+
}): string | undefined {
|
|
14
|
+
const pickupOrder =
|
|
15
|
+
input.shippingMethodCodes.length > 0 &&
|
|
16
|
+
input.shippingMethodCodes.every((code) => code === "pickup");
|
|
17
|
+
const pickupState =
|
|
18
|
+
input.toState === "ReadyForPickup" || input.toState === "PickedUp";
|
|
19
|
+
|
|
20
|
+
if (pickupState && !pickupOrder) {
|
|
21
|
+
return "Pickup states are available only for pickup orders";
|
|
22
|
+
}
|
|
23
|
+
if (
|
|
24
|
+
pickupOrder &&
|
|
25
|
+
(input.toState === "Shipped" || input.toState === "Delivered")
|
|
26
|
+
) {
|
|
27
|
+
return "Pickup orders must use ReadyForPickup and PickedUp";
|
|
28
|
+
}
|
|
29
|
+
if (
|
|
30
|
+
input.toState === "PickedUp" &&
|
|
31
|
+
(input.paymentStates.length === 0 ||
|
|
32
|
+
input.paymentStates.some((state) => state !== "Settled"))
|
|
33
|
+
) {
|
|
34
|
+
return "Pickup cannot be completed before payment is settled";
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getCourierOrderAlignmentState(
|
|
40
|
+
orderLines: ReadonlyArray<{ orderLineId: string | number; quantity: number }>,
|
|
41
|
+
fulfillmentLines: readonly FulfillmentCoverage[],
|
|
42
|
+
):
|
|
43
|
+
| "PartiallyShipped"
|
|
44
|
+
| "Shipped"
|
|
45
|
+
| "PartiallyDelivered"
|
|
46
|
+
| "Delivered"
|
|
47
|
+
| undefined {
|
|
48
|
+
const delivered = coverageByOrderLine(
|
|
49
|
+
fulfillmentLines.filter((line) => line.state === "Delivered"),
|
|
50
|
+
);
|
|
51
|
+
const shipped = coverageByOrderLine(
|
|
52
|
+
fulfillmentLines.filter(
|
|
53
|
+
(line) => line.state === "Shipped" || line.state === "Delivered",
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
const allDelivered = hasCompleteCoverage(orderLines, delivered);
|
|
57
|
+
const anyDelivered = hasAnyCoverage(delivered);
|
|
58
|
+
const allShipped = hasCompleteCoverage(orderLines, shipped);
|
|
59
|
+
const anyShipped = hasAnyCoverage(shipped);
|
|
60
|
+
|
|
61
|
+
if (allDelivered) return "Delivered";
|
|
62
|
+
if (anyDelivered) return "PartiallyDelivered";
|
|
63
|
+
if (allShipped) return "Shipped";
|
|
64
|
+
if (anyShipped) return "PartiallyShipped";
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveShippingMethodCodeForOrderLines(
|
|
69
|
+
shippingLines: ReadonlyArray<{
|
|
70
|
+
code: string;
|
|
71
|
+
orderLineIds: readonly (string | number)[];
|
|
72
|
+
}>,
|
|
73
|
+
fulfillmentOrderLineIds: readonly (string | number)[],
|
|
74
|
+
): string {
|
|
75
|
+
const fulfillmentLineIds = new Set(fulfillmentOrderLineIds.map(String));
|
|
76
|
+
const matchingCodes = [
|
|
77
|
+
...new Set(
|
|
78
|
+
shippingLines
|
|
79
|
+
.filter((shippingLine) =>
|
|
80
|
+
shippingLine.orderLineIds.some((orderLineId) =>
|
|
81
|
+
fulfillmentLineIds.has(String(orderLineId)),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
.map((shippingLine) => shippingLine.code),
|
|
85
|
+
),
|
|
86
|
+
];
|
|
87
|
+
if (matchingCodes.length === 1) return matchingCodes[0];
|
|
88
|
+
if (matchingCodes.length === 0 && shippingLines.length === 1) {
|
|
89
|
+
return shippingLines[0].code;
|
|
90
|
+
}
|
|
91
|
+
throw new Error("fulfillment shipping method could not be resolved");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function toFulfillmentLifecycleEmailKind(
|
|
95
|
+
shippingMethodCode: string,
|
|
96
|
+
state: string,
|
|
97
|
+
): FulfillmentLifecycleEmailKind | undefined {
|
|
98
|
+
if (shippingMethodCode === "pickup") {
|
|
99
|
+
if (state === "ReadyForPickup") return "pickup-ready";
|
|
100
|
+
if (state === "PickedUp") return "pickup-picked-up";
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
if (state === "Shipped") return "shipment-shipped";
|
|
104
|
+
if (state === "Delivered") return "shipment-delivered";
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function coverageByOrderLine(lines: readonly FulfillmentCoverage[]) {
|
|
109
|
+
const coverage = new Map<string, number>();
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const orderLineId = String(line.orderLineId);
|
|
112
|
+
coverage.set(orderLineId, (coverage.get(orderLineId) ?? 0) + line.quantity);
|
|
113
|
+
}
|
|
114
|
+
return coverage;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hasCompleteCoverage(
|
|
118
|
+
orderLines: ReadonlyArray<{ orderLineId: string | number; quantity: number }>,
|
|
119
|
+
coverage: ReadonlyMap<string, number>,
|
|
120
|
+
): boolean {
|
|
121
|
+
return (
|
|
122
|
+
orderLines.length > 0 &&
|
|
123
|
+
orderLines.every(
|
|
124
|
+
(line) => (coverage.get(String(line.orderLineId)) ?? 0) >= line.quantity,
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hasAnyCoverage(coverage: ReadonlyMap<string, number>): boolean {
|
|
130
|
+
return [...coverage.values()].some((quantity) => quantity > 0);
|
|
131
|
+
}
|