@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
@@ -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 OrderEmailOutboxPayload = {
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
- languageCode: string;
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
- { confirmationToken: input.confirmationToken },
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
- "v1",
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
- const payload = JSON.parse(decoded) as Partial<OrderEmailOutboxPayload>;
88
- if (
89
- typeof payload.confirmationToken !== "string" ||
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
- const email = await this.renderOrderConfirmation(
77
- delivery.delivery,
78
- payload.confirmationToken,
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 async renderOrderConfirmation(
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
- confirmationToken: string,
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-confirmation email",
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
+ }