@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
@@ -27,23 +27,34 @@ import {
27
27
  canExchangeConfirmationToken,
28
28
  canReadConfirmationCredential,
29
29
  createConfirmationToken,
30
+ createConfirmationTokenPair,
30
31
  hashConfirmationToken,
31
32
  readCookieValue,
32
33
  } from "./confirmation.js";
33
34
  import { JarShopLegalConfiguration } from "./legal.entity.js";
34
35
  import { JarShopOrderEmailDelivery } from "./order-email-delivery.entity.js";
36
+ import { JarShopEmailBrandConfiguration } from "./order-email-configuration.entity.js";
37
+ import {
38
+ normalizeOrderEmailBrandConfiguration,
39
+ toOrderEmailBrandConfiguration,
40
+ type OrderEmailBrandConfigurationInput,
41
+ } from "./order-email-configuration.js";
42
+ import { JarShopOrderEmailLifecycleOutbox } from "./order-email-lifecycle.js";
35
43
  import {
36
44
  isOrderAvailableInChannel,
37
45
  toOrderEmailDeliveryStatus,
38
46
  } from "./order-email-delivery-status.js";
39
47
  import { createPendingOrderEmailDelivery } from "./order-email-outbox.js";
48
+ import { toOrderEmailLanguageCode } from "./order-email-delivery-core.js";
40
49
  import {
41
50
  JarShopOrderEmailWorker,
42
51
  registerJarShopOrderEmailWorkerTask,
43
52
  } from "./order-email-worker.js";
44
53
  import { JarShopOrderConsent } from "./order-consent.entity.js";
54
+ import { registerJarShopOrderLifecycle } from "./order-lifecycle-process.js";
45
55
  import {
46
56
  compareCheckoutSnapshot,
57
+ resolveLocalizedShippingMethodName,
47
58
  validateShippingSelection,
48
59
  type CheckoutCartSnapshot,
49
60
  type ShippingAddressInput,
@@ -161,12 +172,47 @@ const adminSchema = parse(`
161
172
  sentAt: DateTime
162
173
  lastErrorCode: String
163
174
  }
175
+ type JarShopEmailShippingMethodConfiguration {
176
+ code: String!
177
+ pickupDetails: String!
178
+ trackingUrlTemplate: String!
179
+ }
180
+ input JarShopEmailShippingMethodConfigurationInput {
181
+ code: String!
182
+ pickupDetails: String!
183
+ trackingUrlTemplate: String!
184
+ }
185
+ type JarShopEmailBrandConfiguration {
186
+ brandName: String!
187
+ logoUrl: String
188
+ primaryColor: String!
189
+ websiteUrl: String!
190
+ supportEmail: String!
191
+ companyName: String!
192
+ companyAddress: String!
193
+ enabledEvents: [String!]!
194
+ shippingMethods: [JarShopEmailShippingMethodConfiguration!]!
195
+ }
196
+ input JarShopEmailBrandConfigurationInput {
197
+ brandName: String!
198
+ logoUrl: String
199
+ primaryColor: String!
200
+ websiteUrl: String!
201
+ supportEmail: String!
202
+ companyName: String!
203
+ companyAddress: String!
204
+ enabledEvents: [String!]!
205
+ shippingMethods: [JarShopEmailShippingMethodConfigurationInput!]!
206
+ }
164
207
  extend type Query {
165
208
  jarShopLegalConfiguration(languageCode: String!): JarShopLegalConfiguration
166
209
  jarShopOrderEmailDeliveryStatus(orderId: ID!): JarShopOrderEmailDeliveryStatus
210
+ jarShopOrderEmailDeliveries(orderId: ID!): [JarShopOrderEmailDeliveryStatus!]!
211
+ jarShopEmailBrandConfiguration: JarShopEmailBrandConfiguration
167
212
  }
168
213
  extend type Mutation {
169
214
  updateJarShopLegalConfiguration(input: JarShopLegalConfigurationInput!): JarShopLegalConfiguration!
215
+ updateJarShopEmailBrandConfiguration(input: JarShopEmailBrandConfigurationInput!): JarShopEmailBrandConfiguration!
170
216
  }
171
217
  `);
172
218
 
@@ -613,30 +659,74 @@ class JarShopCheckoutResolver {
613
659
  acceptedAt: new Date(),
614
660
  }),
615
661
  );
616
- const confirmation = createConfirmationToken();
662
+ const confirmations = createConfirmationTokenPair();
617
663
  const tokenRepository = this.connection.getRepository(
618
664
  ctx,
619
665
  JarShopConfirmationToken,
620
666
  );
621
- await tokenRepository.save(
667
+ await tokenRepository.save([
622
668
  tokenRepository.create({
623
- tokenHash: confirmation.tokenHash,
669
+ tokenHash: confirmations.browser.tokenHash,
624
670
  orderId: Number(paymentResult.id),
625
- expiresAt: confirmation.expiresAt,
671
+ expiresAt: confirmations.browser.expiresAt,
626
672
  consumedAt: null,
627
673
  }),
628
- );
674
+ tokenRepository.create({
675
+ tokenHash: confirmations.email.tokenHash,
676
+ orderId: Number(paymentResult.id),
677
+ expiresAt: confirmations.email.expiresAt,
678
+ consumedAt: null,
679
+ }),
680
+ ]);
629
681
  const emailDeliveryRepository = this.connection.getRepository(
630
682
  ctx,
631
683
  JarShopOrderEmailDelivery,
632
684
  );
685
+ const orderEmailSnapshotSource = await this.orderService.findOne(
686
+ ctx,
687
+ paymentResult.id,
688
+ [
689
+ "customer",
690
+ "lines.productVariant",
691
+ "shippingLines.shippingMethod.translations",
692
+ "payments",
693
+ ],
694
+ );
695
+ if (!orderEmailSnapshotSource?.customer?.emailAddress) {
696
+ throw new Error("order confirmation snapshot is incomplete");
697
+ }
698
+ const orderEmailLanguageCode = toOrderEmailLanguageCode(input.languageCode);
633
699
  await emailDeliveryRepository.save(
634
700
  emailDeliveryRepository.create(
635
701
  createPendingOrderEmailDelivery({
636
702
  orderId: Number(paymentResult.id),
637
703
  checkoutAttemptId: attempt.attemptId,
638
- languageCode: input.languageCode,
639
- confirmationToken: confirmation.token,
704
+ snapshot: {
705
+ recipient: orderEmailSnapshotSource.customer.emailAddress,
706
+ languageCode: orderEmailLanguageCode,
707
+ orderCode: orderEmailSnapshotSource.code,
708
+ currencyCode: orderEmailSnapshotSource.currencyCode,
709
+ totalWithTax: orderEmailSnapshotSource.totalWithTax,
710
+ shippingWithTax: orderEmailSnapshotSource.shippingWithTax,
711
+ paymentMethod: orderEmailSnapshotSource.payments
712
+ .map((payment) => payment.method)
713
+ .join(", "),
714
+ shippingMethod: orderEmailSnapshotSource.shippingLines
715
+ .map((line) =>
716
+ resolveLocalizedShippingMethodName(
717
+ line.shippingMethod,
718
+ orderEmailLanguageCode,
719
+ ),
720
+ )
721
+ .filter(Boolean)
722
+ .join(", "),
723
+ confirmationToken: confirmations.email.token,
724
+ lines: orderEmailSnapshotSource.lines.map((line) => ({
725
+ name: line.productVariant.name,
726
+ quantity: line.quantity,
727
+ totalWithTax: Math.round(line.proratedLinePriceWithTax),
728
+ })),
729
+ },
640
730
  }),
641
731
  ),
642
732
  );
@@ -650,13 +740,13 @@ class JarShopCheckoutResolver {
650
740
  placedOrder,
651
741
  activeOrder.totalWithTax,
652
742
  activeOrder.currencyCode,
653
- confirmation.token,
743
+ confirmations.browser.token,
654
744
  )
655
745
  : success(
656
746
  paymentResult,
657
747
  activeOrder.totalWithTax,
658
748
  activeOrder.currencyCode,
659
- confirmation.token,
749
+ confirmations.browser.token,
660
750
  );
661
751
  }
662
752
 
@@ -721,10 +811,66 @@ class JarShopLegalAdminResolver {
721
811
 
722
812
  const delivery = await this.connection
723
813
  .getRepository(ctx, JarShopOrderEmailDelivery)
724
- .findOne({ where: { orderId: parsedOrderId } });
814
+ .findOne({
815
+ where: { orderId: parsedOrderId, kind: "order-confirmation" },
816
+ });
725
817
  return delivery ? toOrderEmailDeliveryStatus(delivery) : null;
726
818
  }
727
819
 
820
+ @Query()
821
+ @Allow(Permission.ReadOrder)
822
+ async jarShopOrderEmailDeliveries(
823
+ @Ctx() ctx: RequestContext,
824
+ @Args("orderId") orderId: string,
825
+ ) {
826
+ const parsedOrderId = parsePositiveOrderId(orderId);
827
+ if (parsedOrderId === undefined) return [];
828
+ const order = await this.connection
829
+ .getRepository(ctx, Order)
830
+ .findOne({ where: { id: parsedOrderId }, relations: ["channels"] });
831
+ if (
832
+ !order ||
833
+ !isOrderAvailableInChannel(
834
+ order.channels.map((channel) => channel.id),
835
+ ctx.channelId,
836
+ )
837
+ ) {
838
+ return [];
839
+ }
840
+ const deliveries = await this.connection
841
+ .getRepository(ctx, JarShopOrderEmailDelivery)
842
+ .find({ where: { orderId: parsedOrderId }, order: { createdAt: "ASC" } });
843
+ return deliveries.map(toOrderEmailDeliveryStatus);
844
+ }
845
+
846
+ @Query()
847
+ @Allow(Permission.UpdateSettings)
848
+ async jarShopEmailBrandConfiguration(@Ctx() ctx: RequestContext) {
849
+ const configuration = await this.connection
850
+ .getRepository(ctx, JarShopEmailBrandConfiguration)
851
+ .findOne({ where: { channelId: Number(ctx.channelId) } });
852
+ return configuration ? toOrderEmailBrandConfiguration(configuration) : null;
853
+ }
854
+
855
+ @Mutation()
856
+ @Allow(Permission.UpdateSettings)
857
+ async updateJarShopEmailBrandConfiguration(
858
+ @Ctx() ctx: RequestContext,
859
+ @Args("input") input: OrderEmailBrandConfigurationInput,
860
+ ) {
861
+ const normalized = normalizeOrderEmailBrandConfiguration(input);
862
+ const repository = this.connection.getRepository(
863
+ ctx,
864
+ JarShopEmailBrandConfiguration,
865
+ );
866
+ let configuration = await repository.findOne({
867
+ where: { channelId: Number(ctx.channelId) },
868
+ });
869
+ configuration ??= repository.create({ channelId: Number(ctx.channelId) });
870
+ Object.assign(configuration, normalized);
871
+ return toOrderEmailBrandConfiguration(await repository.save(configuration));
872
+ }
873
+
728
874
  @Mutation()
729
875
  @Allow(Permission.UpdateSettings)
730
876
  async updateJarShopLegalConfiguration(
@@ -849,13 +995,15 @@ function toOrderConfirmation(order: Order) {
849
995
  @VendurePlugin({
850
996
  dashboard: "./dashboard/index.tsx",
851
997
  imports: [PluginCommonModule],
852
- providers: [JarShopOrderEmailWorker],
853
- configuration: registerJarShopOrderEmailWorkerTask,
998
+ providers: [JarShopOrderEmailLifecycleOutbox, JarShopOrderEmailWorker],
999
+ configuration: (config) =>
1000
+ registerJarShopOrderEmailWorkerTask(registerJarShopOrderLifecycle(config)),
854
1001
  entities: [
855
1002
  CheckoutAttempt,
856
1003
  JarShopConfirmationToken,
857
1004
  JarShopLegalConfiguration,
858
1005
  JarShopOrderEmailDelivery,
1006
+ JarShopEmailBrandConfiguration,
859
1007
  JarShopOrderConsent,
860
1008
  ],
861
1009
  shopApiExtensions: {
@@ -0,0 +1,61 @@
1
+ import {
2
+ Column,
3
+ CreateDateColumn,
4
+ Entity,
5
+ PrimaryGeneratedColumn,
6
+ Unique,
7
+ UpdateDateColumn,
8
+ } from "typeorm";
9
+ import type { FulfillmentLifecycleEmailKind } from "./order-email-outbox.js";
10
+
11
+ export type ConfigurableOrderEmailKind =
12
+ FulfillmentLifecycleEmailKind | "order-cancelled";
13
+
14
+ export type OrderEmailShippingMethodConfiguration = {
15
+ code: string;
16
+ pickupDetails: string;
17
+ trackingUrlTemplate: string;
18
+ };
19
+
20
+ @Entity({ name: "jarshop_email_brand_configuration" })
21
+ @Unique("UQ_jarshop_email_brand_configuration_channel", ["channelId"])
22
+ export class JarShopEmailBrandConfiguration {
23
+ @PrimaryGeneratedColumn()
24
+ id!: number;
25
+
26
+ @Column({ type: "int" })
27
+ channelId!: number;
28
+
29
+ @Column({ type: "varchar", length: 120 })
30
+ brandName!: string;
31
+
32
+ @Column({ type: "varchar", length: 2_048, nullable: true })
33
+ logoUrl!: string | null;
34
+
35
+ @Column({ type: "varchar", length: 7 })
36
+ primaryColor!: string;
37
+
38
+ @Column({ type: "varchar", length: 2_048 })
39
+ websiteUrl!: string;
40
+
41
+ @Column({ type: "varchar", length: 320 })
42
+ supportEmail!: string;
43
+
44
+ @Column({ type: "varchar", length: 160 })
45
+ companyName!: string;
46
+
47
+ @Column({ type: "text" })
48
+ companyAddress!: string;
49
+
50
+ @Column({ type: "jsonb", default: () => "'[]'::jsonb" })
51
+ enabledEvents!: ConfigurableOrderEmailKind[];
52
+
53
+ @Column({ type: "jsonb", default: () => "'[]'::jsonb" })
54
+ shippingMethods!: OrderEmailShippingMethodConfiguration[];
55
+
56
+ @CreateDateColumn()
57
+ createdAt!: Date;
58
+
59
+ @UpdateDateColumn()
60
+ updatedAt!: Date;
61
+ }
@@ -0,0 +1,190 @@
1
+ import type {
2
+ ConfigurableOrderEmailKind,
3
+ JarShopEmailBrandConfiguration,
4
+ OrderEmailShippingMethodConfiguration,
5
+ } from "./order-email-configuration.entity.js";
6
+
7
+ const configurableOrderEmailKinds = [
8
+ "pickup-ready",
9
+ "pickup-picked-up",
10
+ "shipment-shipped",
11
+ "shipment-delivered",
12
+ "order-cancelled",
13
+ ] as const satisfies readonly ConfigurableOrderEmailKind[];
14
+
15
+ export type OrderEmailBrandConfigurationInput = {
16
+ brandName: string;
17
+ logoUrl?: string | null;
18
+ primaryColor: string;
19
+ websiteUrl: string;
20
+ supportEmail: string;
21
+ companyName: string;
22
+ companyAddress: string;
23
+ enabledEvents: string[];
24
+ shippingMethods: OrderEmailShippingMethodConfiguration[];
25
+ };
26
+
27
+ export function validateOrderEmailBrandConfiguration(
28
+ input: OrderEmailBrandConfigurationInput,
29
+ ): void {
30
+ requireText(input.brandName, "brandName", 120);
31
+ requireText(input.companyName, "companyName", 160);
32
+ requireText(input.companyAddress, "companyAddress", 1_000);
33
+ requireEmail(input.supportEmail);
34
+ requireSafeUrl(input.websiteUrl, "websiteUrl");
35
+ if (input.logoUrl) requireSafeUrl(input.logoUrl, "logoUrl");
36
+ if (!/^#[0-9a-f]{6}$/iu.test(input.primaryColor)) {
37
+ throw new Error("primaryColor must use #RRGGBB");
38
+ }
39
+
40
+ const enabledEvents = new Set(input.enabledEvents);
41
+ if (
42
+ enabledEvents.size !== input.enabledEvents.length ||
43
+ [...enabledEvents].some(
44
+ (kind) => !configurableOrderEmailKinds.includes(kind as never),
45
+ )
46
+ ) {
47
+ throw new Error("enabledEvents contains an unsupported or duplicate kind");
48
+ }
49
+
50
+ const methodCodes = new Set<string>();
51
+ for (const method of input.shippingMethods) {
52
+ requireText(method.code, "shippingMethods.code", 64);
53
+ if (methodCodes.has(method.code)) {
54
+ throw new Error("shippingMethods contains a duplicate code");
55
+ }
56
+ methodCodes.add(method.code);
57
+ requireOptionalText(
58
+ method.pickupDetails,
59
+ "shippingMethods.pickupDetails",
60
+ 1_000,
61
+ );
62
+ if (method.trackingUrlTemplate) {
63
+ validateTrackingUrlTemplate(method.trackingUrlTemplate);
64
+ }
65
+ }
66
+
67
+ if (
68
+ (enabledEvents.has("pickup-ready") ||
69
+ enabledEvents.has("pickup-picked-up")) &&
70
+ !input.shippingMethods.some(
71
+ (method) => method.code === "pickup" && method.pickupDetails.trim(),
72
+ )
73
+ ) {
74
+ throw new Error("pickup lifecycle email requires pickup method details");
75
+ }
76
+ }
77
+
78
+ export function normalizeOrderEmailBrandConfiguration(
79
+ input: OrderEmailBrandConfigurationInput,
80
+ ): OrderEmailBrandConfigurationInput {
81
+ validateOrderEmailBrandConfiguration(input);
82
+ return {
83
+ brandName: input.brandName.trim(),
84
+ logoUrl: input.logoUrl?.trim() || null,
85
+ primaryColor: input.primaryColor.toLowerCase(),
86
+ websiteUrl: input.websiteUrl.trim(),
87
+ supportEmail: input.supportEmail.trim().toLowerCase(),
88
+ companyName: input.companyName.trim(),
89
+ companyAddress: input.companyAddress.trim(),
90
+ enabledEvents: [...input.enabledEvents] as ConfigurableOrderEmailKind[],
91
+ shippingMethods: input.shippingMethods.map((method) => ({
92
+ code: method.code.trim(),
93
+ pickupDetails: method.pickupDetails.trim(),
94
+ trackingUrlTemplate: method.trackingUrlTemplate.trim(),
95
+ })),
96
+ };
97
+ }
98
+
99
+ export function isLifecycleEmailEnabled(
100
+ configuration: JarShopEmailBrandConfiguration | null | undefined,
101
+ kind: ConfigurableOrderEmailKind,
102
+ ): configuration is JarShopEmailBrandConfiguration {
103
+ return configuration?.enabledEvents.includes(kind) ?? false;
104
+ }
105
+
106
+ export function resolveShippingEmailConfiguration(
107
+ configuration: JarShopEmailBrandConfiguration,
108
+ code: string,
109
+ ): OrderEmailShippingMethodConfiguration | undefined {
110
+ return configuration.shippingMethods.find((method) => method.code === code);
111
+ }
112
+
113
+ export function createTrackingUrl(
114
+ template: string,
115
+ trackingCode: string,
116
+ ): string | undefined {
117
+ if (!template || !trackingCode) return undefined;
118
+ validateTrackingUrlTemplate(template);
119
+ return template.replace(
120
+ "{trackingCode}",
121
+ encodeURIComponent(trackingCode.trim()),
122
+ );
123
+ }
124
+
125
+ export function validateTrackingUrlTemplate(template: string): void {
126
+ if (
127
+ template.split("{trackingCode}").length !== 2 ||
128
+ /[\r\n]/u.test(template)
129
+ ) {
130
+ throw new Error(
131
+ "trackingUrlTemplate must contain one {trackingCode} placeholder",
132
+ );
133
+ }
134
+ requireSafeUrl(
135
+ template.replace("{trackingCode}", "TRACKING"),
136
+ "trackingUrlTemplate",
137
+ );
138
+ }
139
+
140
+ export function toOrderEmailBrandConfiguration(
141
+ configuration: JarShopEmailBrandConfiguration,
142
+ ) {
143
+ return {
144
+ brandName: configuration.brandName,
145
+ logoUrl: configuration.logoUrl,
146
+ primaryColor: configuration.primaryColor,
147
+ websiteUrl: configuration.websiteUrl,
148
+ supportEmail: configuration.supportEmail,
149
+ companyName: configuration.companyName,
150
+ companyAddress: configuration.companyAddress,
151
+ enabledEvents: configuration.enabledEvents,
152
+ shippingMethods: configuration.shippingMethods,
153
+ };
154
+ }
155
+
156
+ function requireSafeUrl(value: string, field: string): void {
157
+ requireText(value, field, 2_048);
158
+ const url = new URL(value);
159
+ if (
160
+ url.protocol !== "https:" ||
161
+ url.username ||
162
+ url.password ||
163
+ url.origin === "null"
164
+ ) {
165
+ throw new Error(`${field} must be an absolute credential-free HTTPS URL`);
166
+ }
167
+ }
168
+
169
+ function requireEmail(value: string): void {
170
+ requireText(value, "supportEmail", 320);
171
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value)) {
172
+ throw new Error("supportEmail must be a valid email address");
173
+ }
174
+ }
175
+
176
+ function requireOptionalText(value: string, field: string, maximum: number) {
177
+ if (!value) return;
178
+ requireText(value, field, maximum);
179
+ }
180
+
181
+ function requireText(value: string, field: string, maximum: number): void {
182
+ const normalized = value.trim();
183
+ if (
184
+ !normalized ||
185
+ normalized.length > maximum ||
186
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(normalized)
187
+ ) {
188
+ throw new Error(`${field} is invalid`);
189
+ }
190
+ }