@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
@@ -33,18 +33,28 @@ import {
33
33
  } from "./confirmation.js";
34
34
  import { JarShopLegalConfiguration } from "./legal.entity.js";
35
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";
36
43
  import {
37
44
  isOrderAvailableInChannel,
38
45
  toOrderEmailDeliveryStatus,
39
46
  } from "./order-email-delivery-status.js";
40
47
  import { createPendingOrderEmailDelivery } from "./order-email-outbox.js";
48
+ import { toOrderEmailLanguageCode } from "./order-email-delivery-core.js";
41
49
  import {
42
50
  JarShopOrderEmailWorker,
43
51
  registerJarShopOrderEmailWorkerTask,
44
52
  } from "./order-email-worker.js";
45
53
  import { JarShopOrderConsent } from "./order-consent.entity.js";
54
+ import { registerJarShopOrderLifecycle } from "./order-lifecycle-process.js";
46
55
  import {
47
56
  compareCheckoutSnapshot,
57
+ resolveLocalizedShippingMethodName,
48
58
  validateShippingSelection,
49
59
  type CheckoutCartSnapshot,
50
60
  type ShippingAddressInput,
@@ -162,12 +172,47 @@ const adminSchema = parse(`
162
172
  sentAt: DateTime
163
173
  lastErrorCode: String
164
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
+ }
165
207
  extend type Query {
166
208
  jarShopLegalConfiguration(languageCode: String!): JarShopLegalConfiguration
167
209
  jarShopOrderEmailDeliveryStatus(orderId: ID!): JarShopOrderEmailDeliveryStatus
210
+ jarShopOrderEmailDeliveries(orderId: ID!): [JarShopOrderEmailDeliveryStatus!]!
211
+ jarShopEmailBrandConfiguration: JarShopEmailBrandConfiguration
168
212
  }
169
213
  extend type Mutation {
170
214
  updateJarShopLegalConfiguration(input: JarShopLegalConfigurationInput!): JarShopLegalConfiguration!
215
+ updateJarShopEmailBrandConfiguration(input: JarShopEmailBrandConfigurationInput!): JarShopEmailBrandConfiguration!
171
216
  }
172
217
  `);
173
218
 
@@ -637,13 +682,51 @@ class JarShopCheckoutResolver {
637
682
  ctx,
638
683
  JarShopOrderEmailDelivery,
639
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);
640
699
  await emailDeliveryRepository.save(
641
700
  emailDeliveryRepository.create(
642
701
  createPendingOrderEmailDelivery({
643
702
  orderId: Number(paymentResult.id),
644
703
  checkoutAttemptId: attempt.attemptId,
645
- languageCode: input.languageCode,
646
- confirmationToken: confirmations.email.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
+ },
647
730
  }),
648
731
  ),
649
732
  );
@@ -728,10 +811,66 @@ class JarShopLegalAdminResolver {
728
811
 
729
812
  const delivery = await this.connection
730
813
  .getRepository(ctx, JarShopOrderEmailDelivery)
731
- .findOne({ where: { orderId: parsedOrderId } });
814
+ .findOne({
815
+ where: { orderId: parsedOrderId, kind: "order-confirmation" },
816
+ });
732
817
  return delivery ? toOrderEmailDeliveryStatus(delivery) : null;
733
818
  }
734
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
+
735
874
  @Mutation()
736
875
  @Allow(Permission.UpdateSettings)
737
876
  async updateJarShopLegalConfiguration(
@@ -856,13 +995,15 @@ function toOrderConfirmation(order: Order) {
856
995
  @VendurePlugin({
857
996
  dashboard: "./dashboard/index.tsx",
858
997
  imports: [PluginCommonModule],
859
- providers: [JarShopOrderEmailWorker],
860
- configuration: registerJarShopOrderEmailWorkerTask,
998
+ providers: [JarShopOrderEmailLifecycleOutbox, JarShopOrderEmailWorker],
999
+ configuration: (config) =>
1000
+ registerJarShopOrderEmailWorkerTask(registerJarShopOrderLifecycle(config)),
861
1001
  entities: [
862
1002
  CheckoutAttempt,
863
1003
  JarShopConfirmationToken,
864
1004
  JarShopLegalConfiguration,
865
1005
  JarShopOrderEmailDelivery,
1006
+ JarShopEmailBrandConfiguration,
866
1007
  JarShopOrderConsent,
867
1008
  ],
868
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: [] })
51
+ enabledEvents!: ConfigurableOrderEmailKind[];
52
+
53
+ @Column({ type: "jsonb", default: [] })
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
+ }