From 40f864e29256f358c100c837d30a3b0c7d498e9b Mon Sep 17 00:00:00 2001 From: ikusteu Date: Tue, 19 Sep 2023 23:38:28 +0200 Subject: [PATCH 1/9] Refactor email template UI: * reorganise the code in more self contained, modular (and simplified) chunks * update the UI for more comprehensive UX * update template interpolation logic to automtically add `
` after newlines (so that we don't have to use html `

` tags) * remove `

` tags from default email templates --- .../src/pages/admin_preferences/Buttons.tsx | 64 ------- .../pages/admin_preferences/EmailTemplate.tsx | 41 ----- .../pages/admin_preferences/PreviewField.tsx | 76 -------- .../pages/admin_preferences/TemplateBlock.tsx | 164 ++++++++++++++++++ .../src/pages/admin_preferences/index.tsx | 25 ++- .../views/EmailTemplateSettings.tsx | 71 +++----- packages/client/src/utils/helpers.ts | 20 --- packages/shared/src/data/templates.ts | 8 +- packages/shared/src/utils/text.ts | 2 +- packages/ui/src/Layout/Layout.tsx | 2 +- 10 files changed, 209 insertions(+), 264 deletions(-) delete mode 100644 packages/client/src/pages/admin_preferences/Buttons.tsx delete mode 100644 packages/client/src/pages/admin_preferences/EmailTemplate.tsx delete mode 100644 packages/client/src/pages/admin_preferences/PreviewField.tsx create mode 100644 packages/client/src/pages/admin_preferences/TemplateBlock.tsx diff --git a/packages/client/src/pages/admin_preferences/Buttons.tsx b/packages/client/src/pages/admin_preferences/Buttons.tsx deleted file mode 100644 index fac0f92eb..000000000 --- a/packages/client/src/pages/admin_preferences/Buttons.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { MutableRefObject } from "react"; -import { useFormikContext } from "formik"; - -import { EmailType, EmailTypeButtons } from "@eisbuk/shared"; -import { useTranslation, EmailTemplateLabel } from "@eisbuk/translations"; -import { Button, ButtonColor } from "@eisbuk/ui"; - -import { capitalizeFirst, formatTemplateString } from "@/utils/helpers"; - -declare interface ButtonsProps { - buttons: EmailTypeButtons; - emailType: EmailType; - input: MutableRefObject; -} -const Buttons: React.FC = ({ buttons, emailType, input }) => { - const { setFieldValue } = useFormikContext(); - - const { t } = useTranslation(); - - const insertString = (buttonValue: string) => { - if (input.current) { - const [start, end] = [ - input.current.selectionStart, - input.current.selectionEnd, - ]; - const field = input.current.name.split(".")[1]; - - if (start !== null && end !== null) { - /** @TODO pass translated default click here messages?? */ - - const inputValue = formatTemplateString(buttonValue); - setFieldValue( - `emailTemplates[${emailType}].[${field}]`, - [ - input.current.value.slice(0, start), - inputValue, - input.current.value.slice(end), - ].join("") - ); - - input.current.focus(); - input.current.selectionStart = input.current.selectionEnd = start; - } - } - }; - - return ( -

- {Object.values(buttons[emailType]).map((button) => ( - - ))} -
- ); -}; - -export default Buttons; diff --git a/packages/client/src/pages/admin_preferences/EmailTemplate.tsx b/packages/client/src/pages/admin_preferences/EmailTemplate.tsx deleted file mode 100644 index 4a365292f..000000000 --- a/packages/client/src/pages/admin_preferences/EmailTemplate.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { MutableRefObject } from "react"; - -import { FormField, FormFieldVariant } from "@eisbuk/ui"; -import { useTranslation, EmailTemplateLabel } from "@eisbuk/translations"; - -export interface EmailTemplateFieldProps { - label: string; - input: MutableRefObject; -} - -const EmailTemplate: React.FC = ({ - input, - label, - ...rest -}) => { - const { t } = useTranslation(); - return ( -
- (input.current = e.target)} - {...rest} - /> - (input.current = e.target)} - {...rest} - /> -
- ); -}; - -export default EmailTemplate; diff --git a/packages/client/src/pages/admin_preferences/PreviewField.tsx b/packages/client/src/pages/admin_preferences/PreviewField.tsx deleted file mode 100644 index 0150ba200..000000000 --- a/packages/client/src/pages/admin_preferences/PreviewField.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from "react"; -import { useFormikContext } from "formik"; -import { EmailTemplate, interpolateText } from "@eisbuk/shared"; -import { FormField, FormFieldVariant } from "@eisbuk/ui"; -import { useTranslation, EmailTemplateLabel } from "@eisbuk/translations"; -import { replaceHTMLTags } from "@/utils/helpers"; - -export interface PreviewFieldProps { - template: EmailTemplate; - name: string; -} -const PreviewField: React.FC = ({ name, ...props }) => { - const { - values: { emailTemplates }, - setFieldValue, - } = useFormikContext(); - - const { t } = useTranslation(); - - const preveiwDefaults = { - organizationName: "Organization Name", - name: "Saul", - surname: "Goodman", - bookingsLink: "https://ice.it/saul", - bookingsMonth: "April", - extendedBookingsDate: "06/04", - icsFile: "icsFile.ics", - }; - const subject = replaceHTMLTags(emailTemplates[name].subject); - const html = replaceHTMLTags(emailTemplates[name].html); - React.useEffect(() => { - if (emailTemplates[name].subject.trim() !== "") { - setFieldValue( - `${name}-subject-preview`, - `${interpolateText(subject, preveiwDefaults)}` - ); - } - }, [emailTemplates[name].subject, setFieldValue, name]); - - React.useEffect(() => { - if (emailTemplates[name].html.trim() !== "") { - setFieldValue( - `${name}-html-preview`, - `${interpolateText(html, preveiwDefaults)}` - ); - } - }, [emailTemplates[name].html, setFieldValue, name]); - - return ( -
- - -
- ); -}; - -export default PreviewField; diff --git a/packages/client/src/pages/admin_preferences/TemplateBlock.tsx b/packages/client/src/pages/admin_preferences/TemplateBlock.tsx new file mode 100644 index 000000000..3296e14cc --- /dev/null +++ b/packages/client/src/pages/admin_preferences/TemplateBlock.tsx @@ -0,0 +1,164 @@ +import React from "react"; +import { useFormikContext } from "formik"; + +import { useTranslation, EmailTemplateLabel } from "@eisbuk/translations"; + +import { Button, ButtonColor, FormField, FormFieldVariant } from "@eisbuk/ui"; +import { interpolateText, OrganizationData } from "@eisbuk/shared"; + +interface ButtonAttributes { + label: EmailTemplateLabel; + value: string; +} + +interface Props { + name: string; + buttons: ButtonAttributes[]; +} + +const TemplateBlock: React.FC = ({ name, buttons }) => { + const { t } = useTranslation(); + + const { + values: { emailTemplates }, + setFieldValue, + } = useFormikContext(); + + const input = React.useRef(null); + + const insertValuePlaceholder = (buttonValue: string) => () => { + if (!input.current) return; + + const selection = getInputSelection(input.current); + if (!selection) return; + + const [start, end] = selection; + + const { name, value } = input.current; + + // Format the button value - add anchor tag to links, where aplicable + const inputValue = formatValuePlaceholder(buttonValue); + + const updatedValue = stringInsert(value, start, end, inputValue); + + setFieldValue(name, updatedValue); + + input.current.focus(); + input.current.selectionStart = input.current.selectionEnd = start; + }; + + return ( +
+

+ {t(EmailTemplateLabel[name])} +

+
+
+
+
+ {buttons.map(({ value, label }) => ( + + ))} +
+ +
+ (input.current = e.target)} + /> + (input.current = e.target)} + /> +
+
+
+ +
+
+

Preview

+
+

Subject:

+ +
+ +
+

Text:

+

+

+
+
+
+
+ ); +}; + +const previewDefaults = { + organizationName: "Organization Name", + name: "Saul", + surname: "Goodman", + bookingsLink: "https://ice.it/saul", + bookingsMonth: "April", + extendedBookingsDate: "06/04", + icsFile: "icsFile.ics", +}; + +const stringInsert = ( + string: string, + start: number, + end: number, + value: string +) => [string.slice(0, start), value, string.slice(end)].join(""); + +const getInputSelection = (input: HTMLInputElement) => + input.selectionStart && input.selectionEnd + ? [input.selectionStart, input.selectionEnd] + : null; + +const applyStylingToLinks = (html: string) => + html.replace(/ `${s} style="color: blue"`); + +export const formatValuePlaceholder = (value: string) => { + switch (value) { + case "icsFile": + return 'Clicca qui per aggiungere le tue prenotazioni al tuo calendario'; + case "bookingsLink": + return 'Clicca qui per prenotare e gestire le tue lezioni'; + default: + return `{{ ${value} }}`; + } +}; + +export default TemplateBlock; diff --git a/packages/client/src/pages/admin_preferences/index.tsx b/packages/client/src/pages/admin_preferences/index.tsx index e8a3846a6..984ad15ed 100644 --- a/packages/client/src/pages/admin_preferences/index.tsx +++ b/packages/client/src/pages/admin_preferences/index.tsx @@ -130,21 +130,20 @@ const OrganizationSettings: React.FC = () => { } > -
-
- {view === Views.GeneralSettings && ( - - )} - -
- {view === Views.EmailTemplates ? ( - - ) : ( + {view === Views.GeneralSettings ? ( +
+
+ + - )} - + +
-
+ ) : ( +
+ + + )} )} diff --git a/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx b/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx index 053b06dad..77f84feed 100644 --- a/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx +++ b/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx @@ -1,61 +1,44 @@ import React from "react"; import { useSelector } from "react-redux"; -import { useTranslation, EmailTemplateLabel } from "@eisbuk/translations"; -import { FormSection } from "@eisbuk/ui"; -import { EmailTypeButtons, EmailType } from "@eisbuk/shared"; +import { EmailType } from "@eisbuk/shared"; import { getOrganizationSettings } from "@/store/selectors/app"; -import EmailTemplate from "../EmailTemplate"; -import PreviewField from "../PreviewField"; -import Buttons from "../Buttons"; +import TemplateBlock from "../TemplateBlock"; +import { EmailTemplateLabel } from "@eisbuk/translations"; const EmailTemplateSettings: React.FC = () => { - // Keep a ref of the input element - const input = React.useRef(null); - const organization = useSelector(getOrganizationSettings); - const { t } = useTranslation(); - - const buttons: EmailTypeButtons = { - [EmailType.SendBookingsLink]: { - bookingsLink: "bookingsLink", - name: "name", - surname: "surname", - }, - [EmailType.SendCalendarFile]: { - name: "name", - surname: "surname", - calendarFile: "calendarFile", - }, - [EmailType.SendExtendedBookingsDate]: { - bookingsMonth: "bookingsMonth", - extendedBookingsDate: "extendedBookingsDate", - name: "name", - surname: "surname", - }, + const buttons = { + [EmailType.SendBookingsLink]: [ + { label: EmailTemplateLabel.BookingsLink, value: "bookingsLink" }, + { label: EmailTemplateLabel.Name, value: "name" }, + { label: EmailTemplateLabel.Surname, value: "surname" }, + ], + [EmailType.SendCalendarFile]: [ + { label: EmailTemplateLabel.Name, value: "name" }, + { label: EmailTemplateLabel.Surname, value: "surname" }, + { label: EmailTemplateLabel.CalendarFile, value: "calendarFile" }, + ], + [EmailType.SendExtendedBookingsDate]: [ + { label: EmailTemplateLabel.BookingsMonth, value: "bookingsMonth" }, + { + label: EmailTemplateLabel.ExtendedBookingsDate, + value: "extendedBookingsDate", + }, + { label: EmailTemplateLabel.Name, value: "name" }, + { label: EmailTemplateLabel.Surname, value: "surname" }, + ], }; + return (
{organization.emailTemplates && - Object.entries(organization.emailTemplates).map(([name, temp]) => { - return ( - -
- - - - -
-
- ); - })} + Object.keys(organization.emailTemplates).map((name) => ( + + ))}
); }; diff --git a/packages/client/src/utils/helpers.ts b/packages/client/src/utils/helpers.ts index 372bf5f27..ccb3c9d02 100644 --- a/packages/client/src/utils/helpers.ts +++ b/packages/client/src/utils/helpers.ts @@ -131,23 +131,3 @@ export const replaceHTMLTags = (template: string) => { const regex = new RegExp(/( |<([^>]+)>)/gi); return template.replaceAll(regex, ""); }; -/** - * Format string of field value of email template by either surroduning it with {{ }} or an tag - * @param value - value that needs to be formatted to be inserted into the email template - * @returns formatted string - * */ -export const formatTemplateString = (value: string, message = "") => { - switch (value) { - case "icsFile": - return ` ${ - message || - "Clicca qui per aggiungere le tue prenotazioni al tuo calendario" - } `; - case "bookingsLink": - return ` ${ - message || "Clicca qui per prenotare e gestire le tue lezioni" - }`; - default: - return `{{ ${value} }}`; - } -}; diff --git a/packages/shared/src/data/templates.ts b/packages/shared/src/data/templates.ts index 671469314..e26ec4049 100644 --- a/packages/shared/src/data/templates.ts +++ b/packages/shared/src/data/templates.ts @@ -4,18 +4,18 @@ export const defaultEmailTemplates = { [EmailType.SendBookingsLink]: { subject: "prenotazioni lezioni di {{ organizationName }}", html: `

Ciao {{ name }},

-

Ti inviamo un link per prenotare le tue prossime lezioni con {{ organizationName }}:

+ Ti inviamo un link per prenotare le tue prossime lezioni con {{ organizationName }}: Clicca qui per prenotare e gestire le tue lezioni`, }, [EmailType.SendCalendarFile]: { subject: `Calendario prenotazioni {{ organizationName }}`, html: `

Ciao {{ name }},

Ti inviamo un file per aggiungere le tue prossime lezioni con {{ organizationName }} al tuo calendario:

- Clicca qui per aggiungere le tue prenotazioni al tuo calendario`, +

Clicca qui per aggiungere le tue prenotazioni al tuo calendario

`, }, [EmailType.SendExtendedBookingsDate]: { - subject: `

Ciao {{ name }},

`, + subject: `Ciao {{ name }},`, html: `

Ti inviamo un link per prenotare le tue prossime lezioni con {{ organizationName }}:

- Clicca qui per prenotare e gestire le tue lezioni`, +

Clicca qui per prenotare e gestire le tue lezioni

`, }, }; diff --git a/packages/shared/src/utils/text.ts b/packages/shared/src/utils/text.ts index 6bdac3a50..7f4491407 100644 --- a/packages/shared/src/utils/text.ts +++ b/packages/shared/src/utils/text.ts @@ -39,7 +39,7 @@ export const interpolateText = ( ); }); - return template.trim(); + return template.trim().replaceAll("\n", "\n
\n"); }; export const checkExpected = (input: string, expected: string) => { diff --git a/packages/ui/src/Layout/Layout.tsx b/packages/ui/src/Layout/Layout.tsx index e1d08e0dc..6e8302e9a 100644 --- a/packages/ui/src/Layout/Layout.tsx +++ b/packages/ui/src/Layout/Layout.tsx @@ -86,7 +86,7 @@ export const LayoutContent: React.FC<{
{actionButtons && (
-
{actionButtons}
+
{actionButtons}
)} From c973012394d4fce1cb1816edd41e7f581fc29f36 Mon Sep 17 00:00:00 2001 From: ikusteu Date: Fri, 22 Sep 2023 16:09:05 +0200 Subject: [PATCH 2/9] Start refactoring the email interpolation and sending code to be reusable for SMS as well: * Refactor `EmailType` and `EmailTypePayload` types to `ClientMessageType` and `ClientMessagePayload` respectively (and include `ClientMessageMethod`) * Flatten `client` in the client message payload (ex email payload) for easier type composablity --- .../src/__tests__/cloudFunctions.test.ts | 38 ++-- .../client/src/__tests__/integrations.test.ts | 16 +- .../client/src/__tests__/sendEmail.test.ts | 46 +++-- .../sendBookingsLinkDialogUtils.test.ts | 16 +- .../SendBookingsLinkDialog/utils.ts | 20 ++- .../src/pages/admin_preferences/index.tsx | 32 ++-- .../views/EmailTemplateSettings.tsx | 8 +- .../store/actions/icsCalendarOperations.ts | 25 +-- packages/functions/src/sendEmail/https.ts | 17 +- packages/functions/src/sendEmail/utils.ts | 26 +-- .../functions/src/sendEmail/validations.ts | 162 ++++++++---------- .../src/__testData__/dataTriggers.ts | 4 +- packages/shared/src/data/templates.ts | 8 +- .../src/enums/{email.ts => clientMessage.ts} | 7 +- packages/shared/src/index.ts | 4 +- packages/shared/src/types/clientMessage.ts | 55 ++++++ packages/shared/src/types/email.ts | 76 -------- 17 files changed, 286 insertions(+), 274 deletions(-) rename packages/shared/src/enums/{email.ts => clientMessage.ts} (58%) create mode 100644 packages/shared/src/types/clientMessage.ts delete mode 100644 packages/shared/src/types/email.ts diff --git a/packages/client/src/__tests__/cloudFunctions.test.ts b/packages/client/src/__tests__/cloudFunctions.test.ts index 0062de185..7178c94de 100644 --- a/packages/client/src/__tests__/cloudFunctions.test.ts +++ b/packages/client/src/__tests__/cloudFunctions.test.ts @@ -8,13 +8,14 @@ import { describe, expect } from "vitest"; import { HTTPSErrors, BookingsErrors, - ClientEmailPayload, - EmailType, + ClientMessagePayload, + ClientMessageType, sanitizeCustomer, CustomerBase, Collection, Customer, DeliveryQueue, + ClientMessageMethod, } from "@eisbuk/shared"; import { CloudFunction } from "@eisbuk/shared/ui"; @@ -53,7 +54,7 @@ describe("Cloud functions", () => { async () => { const { organization } = await setUpOrganization({ doLogin: false }); const payload = { - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, organization, displayName: "displayName", bookingsLink: "bookingsLink", @@ -81,14 +82,12 @@ describe("Cloud functions", () => { }, }); const payload = { - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, organization, bookingsLink: "bookingsLink", - customer: { - name: saul.name, - surname: saul.surname, - email: saul.email, - }, + name: saul.name, + surname: saul.surname, + email: saul.email, }; await expect( httpsCallable(functions, CloudFunction.SendEmail)(payload) @@ -135,19 +134,20 @@ describe("Cloud functions", () => { expect(Boolean(bookingsSnap.data())).toEqual(true); }); - const payload: ClientEmailPayload[EmailType.SendCalendarFile] = { - type: EmailType.SendCalendarFile, + const payload: ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendCalendarFile + > = { + type: ClientMessageType.SendCalendarFile, organization, attachments: { filename: "icsFile.ics", content: "content", }, - customer: { - name: saul.name, - surname: saul.surname, - email: saul.email || "email@gmail.com", - secretKey: saul.secretKey, - }, + name: saul.name, + surname: saul.surname, + email: saul.email || "email@gmail.com", + secretKey: saul.secretKey, }; await expect( httpsCallable(functions, CloudFunction.SendEmail)(payload) @@ -163,7 +163,7 @@ describe("Cloud functions", () => { "should reject if no value for organziation provided", async () => { const payload = { - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, displayName: "displayName", bookingsLink: "string", customer: { @@ -181,7 +181,7 @@ describe("Cloud functions", () => { testWithEmulator("should reject if no recipient provided", async () => { const { organization } = await setUpOrganization(); const payload = { - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, organization, bookingsLink: "string", customer: { diff --git a/packages/client/src/__tests__/integrations.test.ts b/packages/client/src/__tests__/integrations.test.ts index 0f70dc5a1..003707df9 100644 --- a/packages/client/src/__tests__/integrations.test.ts +++ b/packages/client/src/__tests__/integrations.test.ts @@ -2,7 +2,12 @@ import { describe, expect, beforeEach, afterAll } from "vitest"; import { httpsCallable } from "@firebase/functions"; import { createJestSMTPServer } from "jest-smtp"; -import { ClientEmailPayload, CustomerFull, EmailType } from "@eisbuk/shared"; +import { + ClientMessageMethod, + ClientMessagePayload, + ClientMessageType, + CustomerFull, +} from "@eisbuk/shared"; import { CloudFunction } from "@eisbuk/shared/ui"; import { DeliveryStatus } from "@eisbuk/firestore-process-delivery"; @@ -52,10 +57,13 @@ describe.skip("Email sending and delivery", () => { done(); }; - const payload: ClientEmailPayload[EmailType.SendBookingsLink] = { - type: EmailType.SendBookingsLink, + const payload: ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendBookingsLink + > = { + type: ClientMessageType.SendBookingsLink, organization, - customer: saul as Required, + ...(saul as Required), bookingsLink: "https://eisbuk.it/saul", }; diff --git a/packages/client/src/__tests__/sendEmail.test.ts b/packages/client/src/__tests__/sendEmail.test.ts index 3802c80ec..0285f03b8 100644 --- a/packages/client/src/__tests__/sendEmail.test.ts +++ b/packages/client/src/__tests__/sendEmail.test.ts @@ -2,11 +2,12 @@ import { describe, expect, test } from "vitest"; import { httpsCallable } from "@firebase/functions"; import { - ClientEmailPayload, - EmailType, + ClientMessagePayload, + ClientMessageType, interpolateText, - defaultEmailTemplates, + defaultEmailTemplates as emailTemplates, EmailInterpolationValues, + ClientMessageMethod, } from "@eisbuk/shared"; import { CloudFunction } from "@eisbuk/shared/ui"; @@ -17,23 +18,32 @@ import { adminDb, functions } from "@/__testSetup__/firestoreSetup"; type SendEmailTest = | { - type: EmailType.SendBookingsLink; + type: ClientMessageType.SendBookingsLink; payload: Pick< - ClientEmailPayload[EmailType.SendBookingsLink], + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendBookingsLink + >, "bookingsLink" >; } | { - type: EmailType.SendCalendarFile; + type: ClientMessageType.SendCalendarFile; payload: Pick< - ClientEmailPayload[EmailType.SendCalendarFile], + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendCalendarFile + >, "attachments" >; } | { - type: EmailType.SendExtendedBookingsDate; + type: ClientMessageType.SendExtendedBookingsDate; payload: Pick< - ClientEmailPayload[EmailType.SendExtendedBookingsDate], + ClientMessagePayload< + ClientMessageMethod, + ClientMessageType.SendExtendedBookingsDate + >, "extendedBookingsDate" | "bookingsMonth" >; }; @@ -69,7 +79,7 @@ const runSendEmailTableTests = (tests: SendEmailTest[]) => { >( functions, CloudFunction.SendEmail - )({ type, ...payload, organization, customer: saul }); + )({ type, ...payload, organization, ...saul }); expect(success).toEqual(true); @@ -85,17 +95,17 @@ const runSendEmailTableTests = (tests: SendEmailTest[]) => { }; // In case of send bookings link, add bookings link specific interpolation values - if (type === EmailType.SendBookingsLink) { + if (type === ClientMessageType.SendBookingsLink) { interpolationValues.bookingsLink = payload.bookingsLink; } // In case of send calendar file, add calendar file specific interpolation values - if (type === EmailType.SendCalendarFile) { + if (type === ClientMessageType.SendCalendarFile) { interpolationValues.calendarFile = payload.attachments.filename; } // In case of send extended bookings date, add extended bookings date specific interpolation values - if (type === EmailType.SendExtendedBookingsDate) { + if (type === ClientMessageType.SendExtendedBookingsDate) { interpolationValues.bookingsMonth = payload.bookingsMonth; interpolationValues.extendedBookingsDate = payload.extendedBookingsDate; } @@ -111,11 +121,11 @@ const runSendEmailTableTests = (tests: SendEmailTest[]) => { // We're using default templates for each email type and checking against interpolation values // provided in the test payload subject: interpolateText( - defaultEmailTemplates[type].subject, + emailTemplates[type].subject, interpolationValues ), html: interpolateText( - defaultEmailTemplates[type].html, + emailTemplates[type].html, interpolationValues ), }), @@ -128,13 +138,13 @@ const runSendEmailTableTests = (tests: SendEmailTest[]) => { describe("SendEmail", () => runSendEmailTableTests([ { - type: EmailType.SendBookingsLink, + type: ClientMessageType.SendBookingsLink, payload: { bookingsLink: "https://eisbuk.com/bookings", }, }, { - type: EmailType.SendCalendarFile, + type: ClientMessageType.SendCalendarFile, payload: { attachments: { filename: "calendar.ics", @@ -143,7 +153,7 @@ describe("SendEmail", () => }, }, { - type: EmailType.SendExtendedBookingsDate, + type: ClientMessageType.SendExtendedBookingsDate, payload: { bookingsMonth: "2021-01", extendedBookingsDate: "2021-01-31", diff --git a/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts b/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts index 43f52e5c0..a4a429841 100644 --- a/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts +++ b/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts @@ -1,7 +1,11 @@ import { describe, vi, expect, test, afterEach } from "vitest"; import { getFirestore as getClientFirestore } from "@firebase/firestore"; -import { EmailType, OrgSubCollection, SMSMessage } from "@eisbuk/shared"; +import { + ClientMessageType, + OrgSubCollection, + SMSMessage, +} from "@eisbuk/shared"; import { CloudFunction, Routes } from "@eisbuk/shared/ui"; import i18n, { NotificationMessage, Prompt } from "@eisbuk/translations"; import { updateLocalDocuments } from "@eisbuk/react-redux-firebase-firestore"; @@ -136,12 +140,10 @@ describe("Send bookings link dialog utils", () => { expect(mockSendMail).toHaveBeenCalledTimes(1); expect(mockSendMail).toHaveBeenCalledWith({ bookingsLink, - customer: { - email: saul.email, - name: saul.name, - surname: saul.surname, - }, - type: EmailType.SendBookingsLink, + email: saul.email, + name: saul.name, + surname: saul.surname, + type: ClientMessageType.SendBookingsLink, }); // check for success notification expect(mockDispatch).toHaveBeenCalledWith( diff --git a/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts b/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts index 95054d59b..3a5dad620 100644 --- a/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts +++ b/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts @@ -1,8 +1,9 @@ import { - ClientEmailPayload, + ClientMessagePayload, Customer, - EmailType, + ClientMessageType, SMSMessage, + ClientMessageMethod, } from "@eisbuk/shared"; import { CloudFunction, Routes } from "@eisbuk/shared/ui"; import i18n, { NotificationMessage, Prompt } from "@eisbuk/translations"; @@ -92,15 +93,16 @@ export const sendBookingsLink: SendBookingsLink = ${bookingsLink}`; const emailPayload: Omit< - ClientEmailPayload[EmailType.SendBookingsLink], + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendBookingsLink + >, "organization" > = { - customer: { - name, - surname, - email: email!, - }, - type: EmailType.SendBookingsLink, + name, + surname, + email: email!, + type: ClientMessageType.SendBookingsLink, bookingsLink, }; diff --git a/packages/client/src/pages/admin_preferences/index.tsx b/packages/client/src/pages/admin_preferences/index.tsx index 984ad15ed..003cc1008 100644 --- a/packages/client/src/pages/admin_preferences/index.tsx +++ b/packages/client/src/pages/admin_preferences/index.tsx @@ -3,11 +3,15 @@ import * as Yup from "yup"; import { useDispatch, useSelector } from "react-redux"; import { Formik, Form, FormikHelpers } from "formik"; -import { defaultEmailTemplates, OrganizationData } from "@eisbuk/shared"; +import { + defaultEmailTemplates as emailTemplates, + OrganizationData, +} from "@eisbuk/shared"; import i18n, { ActionButton, ValidationMessage, useTranslation, + SettingsNavigationLabel, } from "@eisbuk/translations"; import { Button, @@ -40,18 +44,18 @@ const OrganizationValidation = Yup.object().shape({ // #endregion validations const OrganizationSettings: React.FC = () => { - enum Views { - EmailTemplates = "EmailTemplatesSection", + enum View { GeneralSettings = "GeneralSettings", + EmailTemplates = "EmailTemplates", } // Get appropriate view to render const viewsLookup = { - [Views.EmailTemplates]: EmailTemplateSettings, - [Views.GeneralSettings]: GeneralSettings, + [View.GeneralSettings]: GeneralSettings, + [View.EmailTemplates]: EmailTemplateSettings, }; const [view, setView] = useState( - Views.GeneralSettings + View.GeneralSettings ); const dispatch = useDispatch(); @@ -85,16 +89,16 @@ const OrganizationSettings: React.FC = () => { setView(Views.GeneralSettings)} - active={view === Views.GeneralSettings} + label={i18n.t(SettingsNavigationLabel.GeneralSettings)} + onClick={() => setView(View.GeneralSettings)} + active={view === View.GeneralSettings} /> setView(Views.EmailTemplates)} - active={view === Views.EmailTemplates} + label={i18n.t(SettingsNavigationLabel.EmailTemplates)} + onClick={() => setView(View.EmailTemplates)} + active={view === View.EmailTemplates} /> ); @@ -130,7 +134,7 @@ const OrganizationSettings: React.FC = () => { } > - {view === Views.GeneralSettings ? ( + {view === View.GeneralSettings ? (
@@ -156,7 +160,7 @@ const emptyValues = { displayName: "", emailFrom: "", emailNameFrom: "", - emailTemplates: defaultEmailTemplates, + emailTemplates, existingSecrets: [], location: "", defaultCountryCode: "", diff --git a/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx b/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx index 77f84feed..8af310a0b 100644 --- a/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx +++ b/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useSelector } from "react-redux"; -import { EmailType } from "@eisbuk/shared"; +import { ClientMessageType } from "@eisbuk/shared"; import { getOrganizationSettings } from "@/store/selectors/app"; @@ -12,17 +12,17 @@ const EmailTemplateSettings: React.FC = () => { const organization = useSelector(getOrganizationSettings); const buttons = { - [EmailType.SendBookingsLink]: [ + [ClientMessageType.SendBookingsLink]: [ { label: EmailTemplateLabel.BookingsLink, value: "bookingsLink" }, { label: EmailTemplateLabel.Name, value: "name" }, { label: EmailTemplateLabel.Surname, value: "surname" }, ], - [EmailType.SendCalendarFile]: [ + [ClientMessageType.SendCalendarFile]: [ { label: EmailTemplateLabel.Name, value: "name" }, { label: EmailTemplateLabel.Surname, value: "surname" }, { label: EmailTemplateLabel.CalendarFile, value: "calendarFile" }, ], - [EmailType.SendExtendedBookingsDate]: [ + [ClientMessageType.SendExtendedBookingsDate]: [ { label: EmailTemplateLabel.BookingsMonth, value: "bookingsMonth" }, { label: EmailTemplateLabel.ExtendedBookingsDate, diff --git a/packages/client/src/store/actions/icsCalendarOperations.ts b/packages/client/src/store/actions/icsCalendarOperations.ts index 43ab67160..8aec7f179 100644 --- a/packages/client/src/store/actions/icsCalendarOperations.ts +++ b/packages/client/src/store/actions/icsCalendarOperations.ts @@ -5,9 +5,10 @@ import i18n, { NotificationMessage } from "@eisbuk/translations"; import { BookingSubCollection, CalendarEvents, - ClientEmailPayload, + ClientMessageMethod, + ClientMessagePayload, + ClientMessageType, Customer, - EmailType, } from "@eisbuk/shared"; import { CloudFunction } from "@eisbuk/shared/ui"; @@ -182,18 +183,22 @@ const sendICSFile = try { const handler = CloudFunction.SendEmail; const payload = { - type: EmailType.SendCalendarFile, - customer: { - name, - surname, - secretKey, - email, - }, + type: ClientMessageType.SendCalendarFile, + name, + surname, + secretKey, + email, attachments: { filename: "bookedSlots.ics", content: icsFile, }, - } as Omit; + } as Omit< + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendCalendarFile + >, + "organization" + >; await createFunctionCaller(getFunctions(), handler, payload)(); diff --git a/packages/functions/src/sendEmail/https.ts b/packages/functions/src/sendEmail/https.ts index bf0cc1ef6..9e3c52527 100644 --- a/packages/functions/src/sendEmail/https.ts +++ b/packages/functions/src/sendEmail/https.ts @@ -4,10 +4,11 @@ import admin from "firebase-admin"; import { Collection, DeliveryQueue, - ClientEmailPayload, - EmailType, + ClientMessagePayload, + ClientMessageType, OrganizationData, HTTPSErrors, + ClientMessageMethod, } from "@eisbuk/shared"; import { interpolateEmail, validateClientEmailPayload } from "./utils"; @@ -27,7 +28,7 @@ export const sendEmail = functions .region(__functionsZone__) .https.onCall( async ( - payload: ClientEmailPayload[EmailType], + payload: ClientMessagePayload, { auth }: functions.https.CallableContext ) => { const { organization } = payload; @@ -35,10 +36,10 @@ export const sendEmail = functions if ( !(await checkUser(organization, auth)) && !( - payload.type === EmailType.SendCalendarFile && + payload.type === ClientMessageType.SendCalendarFile && (await checkSecretKey({ organization: organization, - secretKey: payload.customer.secretKey, + secretKey: payload.secretKey, })) ) ) { @@ -74,8 +75,8 @@ export const sendEmail = functions const emailTemplate = emailTemplates[payload.type]; const { subject, html } = interpolateEmail(emailTemplate, { organizationName: displayName, - name: validatedPayload.customer.name, - surname: validatedPayload.customer.surname, + name: validatedPayload.name, + surname: validatedPayload.surname, bookingsLink: validatedPayload.bookingsLink, bookingsMonth: validatedPayload.bookingsMonth, extendedBookingsDate: validatedPayload.extendedBookingsDate, @@ -85,7 +86,7 @@ export const sendEmail = functions // Construct an email for process delivery const email = { from: emailFrom, - to: validatedPayload.customer.email, + to: validatedPayload.email, bcc: emailBcc || emailFrom, subject, html, diff --git a/packages/functions/src/sendEmail/utils.ts b/packages/functions/src/sendEmail/utils.ts index 44f15c837..3efe41215 100644 --- a/packages/functions/src/sendEmail/utils.ts +++ b/packages/functions/src/sendEmail/utils.ts @@ -2,12 +2,13 @@ import { JSONSchemaType } from "ajv"; import { EmailTemplate, - EmailType, - ClientEmailPayload, + ClientMessageType, + ClientMessagePayload, interpolateText, MergeUnion, HTTPSErrors, EmailInterpolationValues, + ClientMessageMethod, } from "@eisbuk/shared"; import { EisbukHttpsError, validateJSON } from "../utils"; @@ -20,11 +21,14 @@ import { /** * Validate client email payload accepts an email payload and applies the correct validation for an email type. */ -export const validateClientEmailPayload = ( - payload: ClientEmailPayload[T] +export const validateClientEmailPayload = ( + payload: ClientMessagePayload ) => { // Check that the type has been provided and is a supported email type - if (!payload.type || !Object.values(EmailType).includes(payload.type)) { + if ( + !payload.type || + !Object.values(ClientMessageType).includes(payload.type) + ) { throw new EisbukHttpsError( "invalid-argument", HTTPSErrors.EmailInvalidType @@ -32,12 +36,14 @@ export const validateClientEmailPayload = ( } type ValidationSchemaLookup = { - [key in EmailType]: JSONSchemaType; + [key in ClientMessageType]: JSONSchemaType< + ClientMessagePayload + >; }; const validationSchemaLookup: ValidationSchemaLookup = { - [EmailType.SendBookingsLink]: SendBookingsLinkEmailSchema, - [EmailType.SendCalendarFile]: SendICSEmailSchema, - [EmailType.SendExtendedBookingsDate]: SendExtendDateEmailSchema, + [ClientMessageType.SendBookingsLink]: SendBookingsLinkEmailSchema, + [ClientMessageType.SendCalendarFile]: SendICSEmailSchema, + [ClientMessageType.SendExtendedBookingsDate]: SendExtendDateEmailSchema, }; const [res, errors] = validateJSON( @@ -50,7 +56,7 @@ export const validateClientEmailPayload = ( throw new EisbukHttpsError("invalid-argument", errors.join(" ")); } - return res as MergeUnion; + return res as MergeUnion>; }; /** diff --git a/packages/functions/src/sendEmail/validations.ts b/packages/functions/src/sendEmail/validations.ts index a3e9bda29..82ffb04e2 100644 --- a/packages/functions/src/sendEmail/validations.ts +++ b/packages/functions/src/sendEmail/validations.ts @@ -3,11 +3,9 @@ import { JSONSchemaType } from "ajv"; import { EmailAttachment, EmailPayload, - ClientEmailPayload, - EmailType, - SendCalendarFileCustomer, - SendBookingsLinkCustomer, - SendExtendedBookingLinkCustomer, + ClientMessagePayload, + ClientMessageType, + ClientMessageMethod, } from "@eisbuk/shared"; import { SMTPPreferences } from "./types"; @@ -57,76 +55,7 @@ const EmailAttachmentSchema: JSONSchemaType = { }; /** - * A validation schema for customer field in bookingsLink email payload - */ -const SendBookingsLinkCustomerSchema: JSONSchemaType = - { - type: "object", - required: ["name", "surname", "email"], - properties: { - name: { - type: "string", - }, - surname: { - type: "string", - }, - email: { - type: "string", - pattern: emailPattern, - errorMessage: __invalidEmailError, - }, - }, - }; - -/** - * A validation schema for customer field in bookingsLink email payload - */ -const SendExtendedBookingLinkCustomerSchema: JSONSchemaType = - { - type: "object", - required: ["name", "surname", "email"], - properties: { - name: { - type: "string", - }, - surname: { - type: "string", - }, - email: { - type: "string", - pattern: emailPattern, - errorMessage: __invalidEmailError, - }, - }, - }; - -/** - * A validation schema for customer field in bookingsLink email payload - */ -const SendCalendarFileCustomerSchema: JSONSchemaType = - { - type: "object", - required: ["name", "surname", "secretKey", "email"], - properties: { - name: { - type: "string", - }, - surname: { - type: "string", - }, - email: { - type: "string", - pattern: emailPattern, - errorMessage: __invalidEmailError, - }, - secretKey: { - type: "string", - }, - }, - }; - -/** - * Validation schema for a fully constructed email (to be send over SMTP), + * Validation schema for a fully constructed email (to be sent over SMTP), * including `to`, `from`, `bcc` and `html` */ export const EmailPayloadSchema: JSONSchemaType = { @@ -162,10 +91,21 @@ export const EmailPayloadSchema: JSONSchemaType = { * Validation schema for a ics email payload */ export const SendICSEmailSchema: JSONSchemaType< - ClientEmailPayload[EmailType.SendCalendarFile] + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendCalendarFile + > > = { type: "object", - required: ["type", "organization", "customer", "attachments"], + required: [ + "type", + "organization", + "name", + "surname", + "email", + "secretKey", + "attachments", + ], properties: { type: { type: "string", @@ -175,7 +115,22 @@ export const SendICSEmailSchema: JSONSchemaType< type: "string", errorMessage: "Missing organization", }, - customer: SendCalendarFileCustomerSchema, + name: { + type: "string", + errorMessage: "Missing customer name", + }, + surname: { + type: "string", + errorMessage: "Missing customer surname", + }, + email: { + type: "string", + errorMessage: "Missing customer email", + }, + secretKey: { + type: "string", + errorMessage: "Missing secretKey", + }, attachments: EmailAttachmentSchema, }, }; @@ -183,15 +138,19 @@ export const SendICSEmailSchema: JSONSchemaType< /** * Validation schema for an ExtendDate email payload */ - export const SendExtendDateEmailSchema: JSONSchemaType< - ClientEmailPayload[EmailType.SendExtendedBookingsDate] + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendExtendedBookingsDate + > > = { type: "object", required: [ "type", "organization", - "customer", + "name", + "surname", + "email", "bookingsMonth", "extendedBookingsDate", ], @@ -204,6 +163,18 @@ export const SendExtendDateEmailSchema: JSONSchemaType< type: "string", errorMessage: "Missing organization", }, + name: { + type: "string", + errorMessage: "Missing customer name", + }, + surname: { + type: "string", + errorMessage: "Missing customer surname", + }, + email: { + type: "string", + errorMessage: "Missing customer email", + }, bookingsMonth: { type: "string", errorMessage: "Missing bookingsMonth", @@ -212,7 +183,6 @@ export const SendExtendDateEmailSchema: JSONSchemaType< type: "string", errorMessage: "Missing extendedBookingsDate", }, - customer: SendExtendedBookingLinkCustomerSchema, }, }; @@ -220,10 +190,20 @@ export const SendExtendDateEmailSchema: JSONSchemaType< * Validation schema for a bookingsLink email payload */ export const SendBookingsLinkEmailSchema: JSONSchemaType< - ClientEmailPayload[EmailType.SendBookingsLink] + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendBookingsLink + > > = { type: "object", - required: ["type", "organization", "customer", "bookingsLink"], + required: [ + "type", + "organization", + "name", + "surname", + "email", + "bookingsLink", + ], properties: { type: { type: "string", @@ -233,12 +213,22 @@ export const SendBookingsLinkEmailSchema: JSONSchemaType< type: "string", errorMessage: "Missing organization", }, + name: { + type: "string", + errorMessage: "Missing customer name", + }, + surname: { + type: "string", + errorMessage: "Missing customer surname", + }, + email: { + type: "string", + errorMessage: "Missing customer email", + }, bookingsLink: { type: "string", errorMessage: "Missing bookingsLink", }, - - customer: SendBookingsLinkCustomerSchema, }, }; diff --git a/packages/react-redux-firebase-firestore/src/__testData__/dataTriggers.ts b/packages/react-redux-firebase-firestore/src/__testData__/dataTriggers.ts index 25c9d3467..8c0b22336 100644 --- a/packages/react-redux-firebase-firestore/src/__testData__/dataTriggers.ts +++ b/packages/react-redux-firebase-firestore/src/__testData__/dataTriggers.ts @@ -1,7 +1,7 @@ import { CustomerAttendance, CustomerBookingEntry, - EmailType, + ClientMessageType, OrganizationData, SlotAttendnace, } from "@eisbuk/shared"; @@ -69,7 +69,7 @@ export const organization: OrganizationData = { admins: ["Gus Fring"], emailFrom: "gus@lospollos.hermanos", emailTemplates: { - [EmailType.SendBookingsLink]: { + [ClientMessageType.SendBookingsLink]: { subject: "prenotazioni lezioni di {{ displayName }}", html: `

Ciao {{ name }},

Ti inviamo un link per prenotare le tue prossime lezioni con {{ displayName }}:

diff --git a/packages/shared/src/data/templates.ts b/packages/shared/src/data/templates.ts index e26ec4049..44024ca49 100644 --- a/packages/shared/src/data/templates.ts +++ b/packages/shared/src/data/templates.ts @@ -1,19 +1,19 @@ -import { EmailType } from "../enums/email"; +import { ClientMessageType } from "../enums/clientMessage"; export const defaultEmailTemplates = { - [EmailType.SendBookingsLink]: { + [ClientMessageType.SendBookingsLink]: { subject: "prenotazioni lezioni di {{ organizationName }}", html: `

Ciao {{ name }},

Ti inviamo un link per prenotare le tue prossime lezioni con {{ organizationName }}: Clicca qui per prenotare e gestire le tue lezioni`, }, - [EmailType.SendCalendarFile]: { + [ClientMessageType.SendCalendarFile]: { subject: `Calendario prenotazioni {{ organizationName }}`, html: `

Ciao {{ name }},

Ti inviamo un file per aggiungere le tue prossime lezioni con {{ organizationName }} al tuo calendario:

Clicca qui per aggiungere le tue prenotazioni al tuo calendario

`, }, - [EmailType.SendExtendedBookingsDate]: { + [ClientMessageType.SendExtendedBookingsDate]: { subject: `Ciao {{ name }},`, html: `

Ti inviamo un link per prenotare le tue prossime lezioni con {{ organizationName }}:

Clicca qui per prenotare e gestire le tue lezioni

`, diff --git a/packages/shared/src/enums/email.ts b/packages/shared/src/enums/clientMessage.ts similarity index 58% rename from packages/shared/src/enums/email.ts rename to packages/shared/src/enums/clientMessage.ts index 03bbda0df..02fd12e88 100644 --- a/packages/shared/src/enums/email.ts +++ b/packages/shared/src/enums/clientMessage.ts @@ -1,4 +1,9 @@ -export enum EmailType { +export enum ClientMessageMethod { + Email = "email", + SMS = "sms", +} + +export enum ClientMessageType { SendBookingsLink = "send-bookings-link", SendCalendarFile = "send-calendar-file", SendExtendedBookingsDate = "send-extended-bookings-date", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ab4fbf1a5..d5bd69116 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,10 +1,10 @@ export * from "./enums/firestore"; export * from "./enums/errorMessages"; -export * from "./enums/email"; +export * from "./enums/clientMessage"; export * from "./types/firestore"; export * from "./types/cloudFunctions"; export * from "./types/misc"; -export * from "./types/email"; +export * from "./types/clientMessage"; export * from "./types/utils"; export * from "./utils"; export * from "./data/templates"; diff --git a/packages/shared/src/types/clientMessage.ts b/packages/shared/src/types/clientMessage.ts new file mode 100644 index 000000000..58c6b307a --- /dev/null +++ b/packages/shared/src/types/clientMessage.ts @@ -0,0 +1,55 @@ +import { ClientMessageMethod, ClientMessageType } from "../enums/clientMessage"; + +interface ClientData { + name: string; + surname: string; +} + +interface AdditionalClientDataLookup { + [ClientMessageMethod.Email]: { + email: string; + }; + [ClientMessageMethod.SMS]: { + phone: string; + }; +} + +interface AdditionalMessageDataLookup { + [ClientMessageType.SendBookingsLink]: { + bookingsLink: string; + }; + [ClientMessageType.SendCalendarFile]: { + secretKey: string; + attachments: { + filename: string; + content: string | Buffer; + }; + }; + [ClientMessageType.SendExtendedBookingsDate]: { + bookingsMonth: string; + extendedBookingsDate: string; + }; +} + +type ClientMessagePayloadLookup = { + [T in ClientMessageType]: { + type: T; + organization: string; + } & AdditionalMessageDataLookup[T]; +}; + +export type ClientMessagePayload< + M extends ClientMessageMethod, + C extends ClientMessageType = ClientMessageType +> = ClientData & AdditionalClientDataLookup[M] & ClientMessagePayloadLookup[C]; + +export interface EmailInterpolationValues { + [key: string]: string | undefined; + organizationName: string; + name: string; + surname: string; + bookingsLink?: string; + calendarFile?: string; + bookingsMonth?: string; + extendedBookingsDate?: string; +} diff --git a/packages/shared/src/types/email.ts b/packages/shared/src/types/email.ts deleted file mode 100644 index 8a7c57b50..000000000 --- a/packages/shared/src/types/email.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { EmailType } from "../enums/email"; - -export interface EmailTypePayload { - [EmailType.SendBookingsLink]: { - type: EmailType.SendBookingsLink; - organization: string; - bookingsLink: string; - customer: SendBookingsLinkCustomer; - }; - [EmailType.SendCalendarFile]: { - type: EmailType.SendCalendarFile; - organization: string; - customer: SendCalendarFileCustomer; - attachments: { - filename: string; - content: string | Buffer; - }; - }; - [EmailType.SendExtendedBookingsDate]: { - type: EmailType.SendExtendedBookingsDate; - organization: string; - bookingsMonth: string; - extendedBookingsDate: string; - customer: SendExtendedBookingLinkCustomer; - }; -} -export type ClientEmailPayload = { - [T in EmailType]: EmailTypePayload[T]; -}; - -export interface EmailTypeButtons { - [EmailType.SendBookingsLink]: { - bookingsLink: string; - name: string; - surname: string; - }; - [EmailType.SendCalendarFile]: { - name: string; - surname: string; - calendarFile: string; - }; - [EmailType.SendExtendedBookingsDate]: { - bookingsMonth: string; - extendedBookingsDate: string; - name: string; - surname: string; - }; -} - -export interface SendExtendedBookingLinkCustomer { - name: string; - surname: string; - email: string; -} -export interface SendBookingsLinkCustomer { - name: string; - surname: string; - email: string; -} -export interface SendCalendarFileCustomer { - name: string; - surname: string; - email: string; - secretKey: string; -} - -export interface EmailInterpolationValues { - [key: string]: string | undefined; - organizationName: string; - name: string; - surname: string; - bookingsLink?: string; - calendarFile?: string; - bookingsMonth?: string; - extendedBookingsDate?: string; -} From b6cb1192faeef168343d9015e9d1a01b3c78377f Mon Sep 17 00:00:00 2001 From: ikusteu Date: Sat, 23 Sep 2023 12:56:37 +0200 Subject: [PATCH 3/9] Refactor SMS sending logic to use templates: * Update send SMS https endpoint to use templates when constructing an SMS message for delivery * Remove unnecessary `SendBookingsLinkMethod` enumn (replaced by ` ClientMessageMethod`) --- packages/client/src/__testSetup__/node.ts | 2 + .../client/src/__tests__/dataTriggers.test.ts | 7 +- packages/client/src/enums/other.ts | 4 - .../SendBookingsLinkDialog.tsx | 6 +- .../__tests__/SendBookingsLinkDialog.test.tsx | 15 ++- .../sendBookingsLinkDialogUtils.test.ts | 33 +++--- .../SendBookingsLinkDialog/utils.ts | 47 ++++---- .../src/pages/admin_preferences/data.ts | 25 +++++ .../views/SMSTemplateSettings.tsx | 27 +++++ .../src/pages/athlete_profile/index.tsx | 15 +-- packages/functions/src/sendSMS/https.ts | 52 +++++++-- packages/functions/src/sendSMS/types.ts | 7 ++ packages/functions/src/sendSMS/utils.ts | 58 ++++++++++ packages/functions/src/sendSMS/validations.ts | 102 ++++++++++++++++++ packages/functions/src/testData.ts | 3 + packages/shared/src/data/templates.ts | 9 ++ packages/shared/src/enums/errorMessages.ts | 1 + packages/shared/src/types/firestore.ts | 7 +- 18 files changed, 346 insertions(+), 74 deletions(-) delete mode 100644 packages/client/src/enums/other.ts create mode 100644 packages/client/src/pages/admin_preferences/data.ts create mode 100644 packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx diff --git a/packages/client/src/__testSetup__/node.ts b/packages/client/src/__testSetup__/node.ts index 02dcc101a..e48f9202d 100644 --- a/packages/client/src/__testSetup__/node.ts +++ b/packages/client/src/__testSetup__/node.ts @@ -5,6 +5,7 @@ import { Collection, OrganizationData, defaultEmailTemplates as emailTemplates, + defaultSMSTemplates as smsTemplates, } from "@eisbuk/shared"; import { adminDb, auth } from "./firestoreSetup"; @@ -48,6 +49,7 @@ export const setUpOrganization: SetUpOrganization = async ({ admins: [email], emailFrom, emailTemplates, + smsTemplates, ...additionalSetup, }; diff --git a/packages/client/src/__tests__/dataTriggers.test.ts b/packages/client/src/__tests__/dataTriggers.test.ts index 2e23d4422..92cd2b9de 100644 --- a/packages/client/src/__tests__/dataTriggers.test.ts +++ b/packages/client/src/__tests__/dataTriggers.test.ts @@ -12,7 +12,8 @@ import { SlotType, OrganizationData, sanitizeCustomer, - defaultEmailTemplates, + defaultEmailTemplates as emailTemplates, + defaultSMSTemplates as smsTemplates, } from "@eisbuk/shared"; import { saul, walt } from "@eisbuk/testing/customers"; @@ -398,10 +399,10 @@ describe("Cloud functions -> Data triggers ->", () => { location: "Albuquerque", admins: ["Gus Fring"], emailFrom: "gus@lospollos.hermanos", - emailTemplates: defaultEmailTemplates, + emailTemplates, emailNameFrom: "Gus", smsFrom: "Gus", - smsTemplate: "SMS Temp here", + smsTemplates, existingSecrets: ["authToken", "exampleSecret"], emailBcc: "gus@lospollos.hermanos", }; diff --git a/packages/client/src/enums/other.ts b/packages/client/src/enums/other.ts deleted file mode 100644 index ec38a7442..000000000 --- a/packages/client/src/enums/other.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum SendBookingLinkMethod { - SMS = "sms", - Email = "email", -} diff --git a/packages/client/src/features/modal/components/SendBookingsLinkDialog/SendBookingsLinkDialog.tsx b/packages/client/src/features/modal/components/SendBookingsLinkDialog/SendBookingsLinkDialog.tsx index 0809c3e19..ec0914712 100644 --- a/packages/client/src/features/modal/components/SendBookingsLinkDialog/SendBookingsLinkDialog.tsx +++ b/packages/client/src/features/modal/components/SendBookingsLinkDialog/SendBookingsLinkDialog.tsx @@ -1,18 +1,16 @@ import React from "react"; import { useDispatch } from "react-redux"; -import { Customer } from "@eisbuk/shared"; +import { Customer, ClientMessageMethod } from "@eisbuk/shared"; import { ActionDialog } from "@eisbuk/ui"; import i18n, { ActionButton } from "@eisbuk/translations"; import { BaseModalProps } from "../../types"; -import { SendBookingLinkMethod } from "@/enums/other"; - import { getBookingsLink, getDialogPrompt, sendBookingsLink } from "./utils"; type SendBookingsLinkProps = BaseModalProps & - Customer & { method: SendBookingLinkMethod }; + Customer & { method: ClientMessageMethod }; const SendBookingsLinkDialog: React.FC = ({ onClose, diff --git a/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/SendBookingsLinkDialog.test.tsx b/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/SendBookingsLinkDialog.test.tsx index e67c78337..5961d6da9 100644 --- a/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/SendBookingsLinkDialog.test.tsx +++ b/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/SendBookingsLinkDialog.test.tsx @@ -7,8 +7,7 @@ import React from "react"; import { screen, render } from "@testing-library/react"; import i18n, { ActionButton } from "@eisbuk/translations"; - -import { SendBookingLinkMethod } from "@/enums/other"; +import { ClientMessageMethod } from "@eisbuk/shared"; import SendBookingsLinkDialog from "../SendBookingsLinkDialog"; import * as utils from "../utils"; @@ -47,7 +46,7 @@ describe("SendBookingsLinkDialog", () => { {}} {...saul} - method={SendBookingLinkMethod.Email} + method={ClientMessageMethod.Email} onClose={mockOnClose} /> ); @@ -60,7 +59,7 @@ describe("SendBookingsLinkDialog", () => { {}} {...saul} - method={SendBookingLinkMethod.Email} + method={ClientMessageMethod.Email} onClose={mockOnClose} /> ); @@ -73,7 +72,7 @@ describe("SendBookingsLinkDialog", () => { {}} {...saul} - method={SendBookingLinkMethod.Email} + method={ClientMessageMethod.Email} onClose={mockOnClose} /> ); @@ -82,7 +81,7 @@ describe("SendBookingsLinkDialog", () => { expect(mockDispatch).toHaveBeenCalledWith( mockSendBookingsLink({ ...saul, - method: SendBookingLinkMethod.Email, + method: ClientMessageMethod.Email, bookingsLink: testBookingsLink, }) ); @@ -93,7 +92,7 @@ describe("SendBookingsLinkDialog", () => { {}} {...saul} - method={SendBookingLinkMethod.SMS} + method={ClientMessageMethod.SMS} onClose={mockOnClose} /> ); @@ -102,7 +101,7 @@ describe("SendBookingsLinkDialog", () => { expect(mockDispatch).toHaveBeenCalledWith( mockSendBookingsLink({ ...saul, - method: SendBookingLinkMethod.SMS, + method: ClientMessageMethod.SMS, bookingsLink: testBookingsLink, }) ); diff --git a/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts b/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts index a4a429841..4bcbf17a9 100644 --- a/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts +++ b/packages/client/src/features/modal/components/SendBookingsLinkDialog/__tests__/sendBookingsLinkDialogUtils.test.ts @@ -4,7 +4,7 @@ import { getFirestore as getClientFirestore } from "@firebase/firestore"; import { ClientMessageType, OrgSubCollection, - SMSMessage, + ClientMessageMethod, } from "@eisbuk/shared"; import { CloudFunction, Routes } from "@eisbuk/shared/ui"; import i18n, { NotificationMessage, Prompt } from "@eisbuk/translations"; @@ -12,7 +12,6 @@ import { updateLocalDocuments } from "@eisbuk/react-redux-firebase-firestore"; import { getNewStore } from "@/store/createStore"; -import { SendBookingLinkMethod } from "@/enums/other"; import { NotifVariant } from "@/enums/store"; import { enqueueNotification } from "@/features/notifications/actions"; @@ -73,7 +72,7 @@ describe("Send bookings link dialog utils", () => { runGetDialogTableTests([ { name: "should display 'email' prompt for method = \"email\" when 'email' defined", - method: SendBookingLinkMethod.Email, + method: ClientMessageMethod.Email, email: testEmail, want: { title: i18n.t(Prompt.SendEmailTitle), @@ -83,7 +82,7 @@ describe("Send bookings link dialog utils", () => { }, { name: "should display 'sms' prompt for method = \"sms\" when 'phone' defined", - method: SendBookingLinkMethod.SMS, + method: ClientMessageMethod.SMS, phone: testPhone, want: { title: i18n.t(Prompt.SendSMSTitle), @@ -93,7 +92,7 @@ describe("Send bookings link dialog utils", () => { }, { name: "should display 'no-email' prompt and disable confirmation for method = \"email\" when 'email' undefined", - method: SendBookingLinkMethod.Email, + method: ClientMessageMethod.Email, want: { title: i18n.t(Prompt.NoEmailTitle), body: i18n.t(Prompt.NoEmailMessage), @@ -102,7 +101,7 @@ describe("Send bookings link dialog utils", () => { }, { name: "should display 'no-sms' prompt and disable confirmation for method = \"sms\" when 'phone' undefined", - method: SendBookingLinkMethod.SMS, + method: ClientMessageMethod.SMS, want: { title: i18n.t(Prompt.NoPhoneTitle), body: i18n.t(Prompt.NoPhoneMessage), @@ -132,7 +131,7 @@ describe("Send bookings link dialog utils", () => { async () => { const testThunk = sendBookingsLink({ ...saul, - method: SendBookingLinkMethod.Email, + method: ClientMessageMethod.Email, bookingsLink, }); await runThunk(testThunk, mockDispatch, getState, { getFirestore }); @@ -160,7 +159,7 @@ describe("Send bookings link dialog utils", () => { async () => { const testThunk = sendBookingsLink({ ...saul, - method: SendBookingLinkMethod.SMS, + method: ClientMessageMethod.SMS, bookingsLink, }); await runThunk(testThunk, mockDispatch, getState, { @@ -168,15 +167,13 @@ describe("Send bookings link dialog utils", () => { }); // check results expect(mockSendSMS).toHaveBeenCalledTimes(1); - const sentSMS = mockSendSMS.mock.calls[0][0] as SMSMessage; - - expect(sentSMS.to).toEqual(saul.phone); - // we're not matching the complete html of message - // but are asserting that it contains important parts - expect(sentSMS.message.includes(bookingsLink)).toBeTruthy(); - expect(sentSMS.message.includes(saul.name)).toBeTruthy(); - // the sms should be clean, without markup - expect(sentSMS.message.includes("p>")).toBeFalsy(); + expect(mockSendSMS).toHaveBeenCalledWith({ + bookingsLink, + phone: saul.phone, + name: saul.name, + surname: saul.surname, + type: ClientMessageType.SendBookingsLink, + }); // check for success notification expect(mockDispatch).toHaveBeenCalledWith( @@ -198,7 +195,7 @@ describe("Send bookings link dialog utils", () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const testThunk = sendBookingsLink({ ...saul, - method: SendBookingLinkMethod.Email, + method: ClientMessageMethod.Email, bookingsLink, }); await runThunk(testThunk, mockDispatch, getState, { getFirestore }); diff --git a/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts b/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts index 3a5dad620..1fa98e9c3 100644 --- a/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts +++ b/packages/client/src/features/modal/components/SendBookingsLinkDialog/utils.ts @@ -2,7 +2,6 @@ import { ClientMessagePayload, Customer, ClientMessageType, - SMSMessage, ClientMessageMethod, } from "@eisbuk/shared"; import { CloudFunction, Routes } from "@eisbuk/shared/ui"; @@ -12,18 +11,13 @@ import { createFunctionCaller } from "@/utils/firebase"; import { FirestoreThunk } from "@/types/store"; -import { SendBookingLinkMethod } from "@/enums/other"; import { NotifVariant } from "@/enums/store"; import { enqueueNotification } from "@/features/notifications/actions"; -import { getOrganization } from "@/lib/getters"; interface GetDialogPrompt { ( - payload: { method: SendBookingLinkMethod } & Pick< - Customer, - "email" | "phone" - > + payload: { method: ClientMessageMethod } & Pick ): { title: string; body: string; @@ -37,7 +31,7 @@ interface GetDialogPrompt { // eslint-disable-next-line consistent-return export const getDialogPrompt: GetDialogPrompt = (props) => { switch (props.method) { - case SendBookingLinkMethod.Email: + case ClientMessageMethod.Email: const { email } = props; if (!email) { return { @@ -52,7 +46,7 @@ export const getDialogPrompt: GetDialogPrompt = (props) => { disabled: false, }; - case SendBookingLinkMethod.SMS: + case ClientMessageMethod.SMS: const { phone } = props; if (!phone) { return { @@ -72,7 +66,7 @@ export const getDialogPrompt: GetDialogPrompt = (props) => { interface SendBookingsLink { ( payload: { - method: SendBookingLinkMethod; + method: ClientMessageMethod; bookingsLink: string; } & Customer ): FirestoreThunk; @@ -88,33 +82,40 @@ export const sendBookingsLink: SendBookingsLink = throw new Error(); } - const sms = `Ciao ${name}, - Ti inviamo un link per prenotare le tue prossime lezioni con ${getOrganization()}: - ${bookingsLink}`; - - const emailPayload: Omit< + const payloadBase: Omit< ClientMessagePayload< - ClientMessageMethod.Email, + ClientMessageMethod, ClientMessageType.SendBookingsLink >, - "organization" + "organization" | "email" | "phone" > = { + type: ClientMessageType.SendBookingsLink, name, surname, - email: email!, - type: ClientMessageType.SendBookingsLink, bookingsLink, }; const config = { - [SendBookingLinkMethod.Email]: { + [ClientMessageMethod.Email]: { handler: CloudFunction.SendEmail, - payload: emailPayload, + payload: { ...payloadBase, email } as Omit< + ClientMessagePayload< + ClientMessageMethod.Email, + ClientMessageType.SendBookingsLink + >, + "organization" + >, successMessage: i18n.t(NotificationMessage.EmailSent), }, - [SendBookingLinkMethod.SMS]: { + [ClientMessageMethod.SMS]: { handler: CloudFunction.SendSMS, - payload: { to: phone, message: sms } as SMSMessage, + payload: { ...payloadBase, phone } as Omit< + ClientMessagePayload< + ClientMessageMethod.SMS, + ClientMessageType.SendBookingsLink + >, + "organization" + >, successMessage: i18n.t(NotificationMessage.SMSSent), }, }; diff --git a/packages/client/src/pages/admin_preferences/data.ts b/packages/client/src/pages/admin_preferences/data.ts new file mode 100644 index 000000000..d8f0e3b87 --- /dev/null +++ b/packages/client/src/pages/admin_preferences/data.ts @@ -0,0 +1,25 @@ +import { ClientMessageType } from "@eisbuk/shared"; + +import { EmailTemplateLabel } from "@eisbuk/translations"; + +export const buttons = { + [ClientMessageType.SendBookingsLink]: [ + { label: EmailTemplateLabel.BookingsLink, value: "bookingsLink" }, + { label: EmailTemplateLabel.Name, value: "name" }, + { label: EmailTemplateLabel.Surname, value: "surname" }, + ], + [ClientMessageType.SendCalendarFile]: [ + { label: EmailTemplateLabel.Name, value: "name" }, + { label: EmailTemplateLabel.Surname, value: "surname" }, + { label: EmailTemplateLabel.CalendarFile, value: "calendarFile" }, + ], + [ClientMessageType.SendExtendedBookingsDate]: [ + { label: EmailTemplateLabel.BookingsMonth, value: "bookingsMonth" }, + { + label: EmailTemplateLabel.ExtendedBookingsDate, + value: "extendedBookingsDate", + }, + { label: EmailTemplateLabel.Name, value: "name" }, + { label: EmailTemplateLabel.Surname, value: "surname" }, + ], +}; diff --git a/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx b/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx new file mode 100644 index 000000000..1bbb26e44 --- /dev/null +++ b/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { useSelector } from "react-redux"; + +import { getOrganizationSettings } from "@/store/selectors/app"; + +import TemplateBlock from "../TemplateBlock"; + +import { buttons } from "../data"; + +const EmailTemplateSettings: React.FC = () => { + const organization = useSelector(getOrganizationSettings); + + return ( +
+ {organization.smsTemplates && + Object.keys(organization.smsTemplates).map((name) => ( + + ))} +
+ ); +}; + +export default EmailTemplateSettings; diff --git a/packages/client/src/pages/athlete_profile/index.tsx b/packages/client/src/pages/athlete_profile/index.tsx index 168a539f3..35b38fe83 100644 --- a/packages/client/src/pages/athlete_profile/index.tsx +++ b/packages/client/src/pages/athlete_profile/index.tsx @@ -8,14 +8,17 @@ import { FormButtonColor, LayoutContent, } from "@eisbuk/ui"; -import { Customer, CustomerLoose, OrgSubCollection } from "@eisbuk/shared"; +import { + ClientMessageMethod, + Customer, + CustomerLoose, + OrgSubCollection, +} from "@eisbuk/shared"; import { PrivateRoutes, Routes } from "@eisbuk/shared/ui"; import { useFirestoreSubscribe } from "@eisbuk/react-redux-firebase-firestore"; import { Phone, Calendar, Mail } from "@eisbuk/svg"; import { ActionButton, useTranslation } from "@eisbuk/translations"; -import { SendBookingLinkMethod } from "@/enums/other"; - import Layout from "@/controllers/Layout"; import { getCustomerById, getCustomersList } from "@/store/selectors/customers"; @@ -99,7 +102,7 @@ const ActionButtons: React.FC = ({ customer }) => { const hasPhone = Boolean(customer.phone); const hasEmail = Boolean(customer.email); - const sendBookingsLink = (method: SendBookingLinkMethod) => () => { + const sendBookingsLink = (method: ClientMessageMethod) => () => { openBookingsLinkDialog({ ...customer, method, @@ -114,7 +117,7 @@ const ActionButtons: React.FC = ({ customer }) => { } @@ -122,7 +125,7 @@ const ActionButtons: React.FC = ({ customer }) => { {t(ActionButton.SendBookingsEmail)} } diff --git a/packages/functions/src/sendSMS/https.ts b/packages/functions/src/sendSMS/https.ts index 2501b26c7..1f2a472da 100644 --- a/packages/functions/src/sendSMS/https.ts +++ b/packages/functions/src/sendSMS/https.ts @@ -1,13 +1,23 @@ import functions from "firebase-functions"; import admin from "firebase-admin"; -import { SendSMSPayload, Collection, DeliveryQueue } from "@eisbuk/shared"; +import { + Collection, + DeliveryQueue, + ClientMessagePayload, + ClientMessageMethod, + OrganizationData, + interpolateText, + ClientMessageType, +} from "@eisbuk/shared"; import { __functionsZone__ } from "../constants"; import { SMSStatusPayload } from "./types"; -import { checkUser, checkRequiredFields, throwUnauth } from "../utils"; +import { checkUser, throwUnauth } from "../utils"; + +import { validateSMSPayload } from "./utils"; /** * Sends SMS message using template data from organizations firestore entry and provided params @@ -15,11 +25,41 @@ import { checkUser, checkRequiredFields, throwUnauth } from "../utils"; export const sendSMS = functions .region(__functionsZone__) .https.onCall( - async ({ organization, ...payload }: SendSMSPayload, { auth }) => { + async ( + payload: ClientMessagePayload< + ClientMessageMethod.SMS, + Exclude + >, + { auth } + ) => { + const { organization } = payload; + if (!(await checkUser(organization, auth))) throwUnauth(); // check payload - checkRequiredFields(payload, ["message", "to"]); + const validatedPayload = validateSMSPayload(payload); + + // Get the template and the organization name from organizaion preferences + const orgDoc = await admin + .firestore() + .collection("organizations") + .doc(payload.organization) + .get(); + const { smsTemplates, displayName = organization } = + orgDoc.data() as OrganizationData; + + // Interpolate the email with the payload and the organization name + const smsTemplate = smsTemplates[payload.type]; + const message = interpolateText(smsTemplate, { + organizationName: displayName, + name: validatedPayload.name, + surname: validatedPayload.surname, + bookingsLink: validatedPayload.bookingsLink, + bookingsMonth: validatedPayload.bookingsMonth, + extendedBookingsDate: validatedPayload.extendedBookingsDate, + }); + + const smsPayload = { to: payload.phone, message }; // Add SMS to delivery queue, thus starting the delivery process await admin @@ -28,9 +68,9 @@ export const sendSMS = functions `${Collection.DeliveryQueues}/${organization}/${DeliveryQueue.SMSQueue}` ) .doc() - .set({ payload }); + .set({ payload: smsPayload }); - return { sms: payload, organization, success: true }; + return { sms: smsPayload, organization, success: true }; } ); diff --git a/packages/functions/src/sendSMS/types.ts b/packages/functions/src/sendSMS/types.ts index 71f2b6e30..8cb92cdbf 100644 --- a/packages/functions/src/sendSMS/types.ts +++ b/packages/functions/src/sendSMS/types.ts @@ -1,3 +1,10 @@ +import { ClientMessageType } from "@eisbuk/shared"; + +export type SMSMessageType = Exclude< + ClientMessageType, + ClientMessageType.SendCalendarFile +>; + export interface SMSRecipient { msisdn: string; } diff --git a/packages/functions/src/sendSMS/utils.ts b/packages/functions/src/sendSMS/utils.ts index cb21975c2..c63a8f2a8 100644 --- a/packages/functions/src/sendSMS/utils.ts +++ b/packages/functions/src/sendSMS/utils.ts @@ -1,7 +1,65 @@ import http from "http"; +import { JSONSchemaType } from "ajv"; + +import { + ClientMessageMethod, + ClientMessagePayload, + ClientMessageType, + HTTPSErrors, + MergeUnion, +} from "@eisbuk/shared"; + +import { SMSMessageType } from "./types"; import { __functionsZone__, __projectId__ } from "../constants"; +import { EisbukHttpsError, validateJSON } from "../utils"; +import { + SendBookingsLinkSMSSchema, + SendExtendDateSMSSchema, +} from "./validations"; + +/** + * Validate client email payload accepts an email payload and applies the correct validation for an email type. + */ +export const validateSMSPayload = ( + payload: ClientMessagePayload +) => { + // Check that the type has been provided and is a supported email type + if ( + !payload.type || + // Sending of ICS calendar over SMS is not suppoerted + !Object.values([ + ClientMessageType.SendBookingsLink, + ClientMessageType.SendExtendedBookingsDate, + ]).includes(payload.type) + ) { + throw new EisbukHttpsError("invalid-argument", HTTPSErrors.SMSInvalidType); + } + + type ValidationSchemaLookup = { + [K in SMSMessageType]: JSONSchemaType< + ClientMessagePayload + >; + }; + const validationSchemaLookup: ValidationSchemaLookup = { + [ClientMessageType.SendBookingsLink]: SendBookingsLinkSMSSchema, + [ClientMessageType.SendExtendedBookingsDate]: SendExtendDateSMSSchema, + }; + + const [res, errors] = validateJSON( + validationSchemaLookup[payload.type] as ValidationSchemaLookup[T], + payload, + "Constructing the email gave following errors (check the email payload and organization preferences):" + ); + + if (errors !== null) { + throw new EisbukHttpsError("invalid-argument", errors.join(" ")); + } + + return res as MergeUnion>; +}; + /** * A convenience method used to create SMS request options. * Used purely for code readability diff --git a/packages/functions/src/sendSMS/validations.ts b/packages/functions/src/sendSMS/validations.ts index dd37a78b8..e3eab1d5b 100644 --- a/packages/functions/src/sendSMS/validations.ts +++ b/packages/functions/src/sendSMS/validations.ts @@ -1,3 +1,8 @@ +import { + ClientMessageMethod, + ClientMessagePayload, + ClientMessageType, +} from "@eisbuk/shared"; import { JSONSchemaType } from "ajv"; import { SMSRecipient, SMSAPIPayload } from "./types"; @@ -28,3 +33,100 @@ export const SMSAPIPayloadSchema: JSONSchemaType = { callback_url: { type: "string", nullable: true }, }, }; + +/** + * Validation schema for an ExtendDate payload + */ +export const SendExtendDateSMSSchema: JSONSchemaType< + ClientMessagePayload< + ClientMessageMethod.SMS, + ClientMessageType.SendExtendedBookingsDate + > +> = { + type: "object", + required: [ + "type", + "organization", + "name", + "surname", + "phone", + "bookingsMonth", + "extendedBookingsDate", + ], + properties: { + type: { + type: "string", + errorMessage: "SMS type missing", + }, + organization: { + type: "string", + errorMessage: "Missing organization", + }, + name: { + type: "string", + errorMessage: "Missing customer name", + }, + surname: { + type: "string", + errorMessage: "Missing customer surname", + }, + phone: { + type: "string", + errorMessage: "Missing customer phone number", + }, + bookingsMonth: { + type: "string", + errorMessage: "Missing bookingsMonth", + }, + extendedBookingsDate: { + type: "string", + errorMessage: "Missing extendedBookingsDate", + }, + }, +}; + +/** + * Validation schema for a bookingsLink SMS payload + */ +export const SendBookingsLinkSMSSchema: JSONSchemaType< + ClientMessagePayload< + ClientMessageMethod.SMS, + ClientMessageType.SendBookingsLink + > +> = { + type: "object", + required: [ + "type", + "organization", + "name", + "surname", + "phone", + "bookingsLink", + ], + properties: { + type: { + type: "string", + errorMessage: "SMS type missing", + }, + organization: { + type: "string", + errorMessage: "Missing organization", + }, + name: { + type: "string", + errorMessage: "Missing customer name", + }, + surname: { + type: "string", + errorMessage: "Missing customer surname", + }, + phone: { + type: "string", + errorMessage: "Missing customer phone number", + }, + bookingsLink: { + type: "string", + errorMessage: "Missing bookingsLink", + }, + }, +}; diff --git a/packages/functions/src/testData.ts b/packages/functions/src/testData.ts index e475ad6c7..8828a885e 100644 --- a/packages/functions/src/testData.ts +++ b/packages/functions/src/testData.ts @@ -12,6 +12,7 @@ import { OrgSubCollection, CreateAuthUserPayload, defaultEmailTemplates as emailTemplates, + defaultSMSTemplates as smsTemplates, CustomerFull, } from "@eisbuk/shared"; @@ -75,6 +76,7 @@ export const createOrganization = functions displayName, admins: ["test@eisbuk.it", "+3912345678"], emailTemplates, + smsTemplates, }); } ); @@ -120,6 +122,7 @@ const createUserInAuth = async ({ admins: adminsEntry, displayName: organization, emailTemplates, + smsTemplates, }, { merge: true } ); diff --git a/packages/shared/src/data/templates.ts b/packages/shared/src/data/templates.ts index 44024ca49..8e40786a8 100644 --- a/packages/shared/src/data/templates.ts +++ b/packages/shared/src/data/templates.ts @@ -19,3 +19,12 @@ export const defaultEmailTemplates = {

Clicca qui per prenotare e gestire le tue lezioni

`, }, }; + +export const defaultSMSTemplates = { + [ClientMessageType.SendBookingsLink]: `Ciao {{ name }}, + Ti inviamo un link per prenotare le tue prossime lezioni con {{ organizationName }}: + {{ bookingsLink }}`, + + [ClientMessageType.SendExtendedBookingsDate]: `Ti inviamo un link per prenotare le tue prossime lezioni con {{ organizationName }}: + {{ bookingsLink }}`, +}; diff --git a/packages/shared/src/enums/errorMessages.ts b/packages/shared/src/enums/errorMessages.ts index 7524295ad..e64b5d545 100644 --- a/packages/shared/src/enums/errorMessages.ts +++ b/packages/shared/src/enums/errorMessages.ts @@ -5,6 +5,7 @@ export enum HTTPSErrors { TimedOut = "Function timed out", MissingParameter = "One or more required parameters are missing from the payload", EmailInvalidType = "Email type missing or invalid", + SMSInvalidType = "SMS type missing or invalid", NoOrganziation = "No argument for organization provided", NoSecrets = "No secrets document found, make sure to create a secrets document for an organziation at: '/secrets/{ organization }'", NoSMTPConfigured = "No smtp configuration found, make sure to create a secrets document for an organziation at: '/secrets/{ organization }' with your smtp configuration", diff --git a/packages/shared/src/types/firestore.ts b/packages/shared/src/types/firestore.ts index bb8a16a39..b3848e6c3 100644 --- a/packages/shared/src/types/firestore.ts +++ b/packages/shared/src/types/firestore.ts @@ -46,9 +46,11 @@ export interface OrganizationData { */ smsFrom?: string; /** - * Template for reminder SMSs + * Templates for SMS messages (bookings link, extending date, etc.) */ - smsTemplate?: string; + smsTemplates: { + [name: string]: string; + }; /** * A default country code (e.g. "IT") used to get the default dial code prefix * for phone inputs (e.g. "+39") @@ -77,6 +79,7 @@ export type EmailTemplate = { subject: string; html: string; }; + /** Organization data copied over to a new collection shared publicly */ export type PublicOrganizationData = Pick< OrganizationData, From be27c2f445496404156da7b3b62f298370de37b2 Mon Sep 17 00:00:00 2001 From: ikusteu Date: Sat, 23 Sep 2023 20:26:03 +0200 Subject: [PATCH 4/9] Extend /admin_preferences page to accommodate for sms templates: * Create a SMS Templates tab * Refactor TemplateBlock for more composability * Update translations --- .../pages/admin_preferences/TemplateBlock.tsx | 131 +++++++----------- .../src/pages/admin_preferences/data.ts | 36 +++-- .../src/pages/admin_preferences/index.tsx | 83 ++++++----- .../views/EmailTemplateSettings.tsx | 113 +++++++++++---- .../views/SMSTemplateSettings.tsx | 60 ++++++-- packages/translations/src/dict/en.json | 17 ++- packages/translations/src/dict/it.json | 17 ++- packages/translations/src/translations.ts | 35 ++--- 8 files changed, 298 insertions(+), 194 deletions(-) diff --git a/packages/client/src/pages/admin_preferences/TemplateBlock.tsx b/packages/client/src/pages/admin_preferences/TemplateBlock.tsx index 3296e14cc..6512efbe4 100644 --- a/packages/client/src/pages/admin_preferences/TemplateBlock.tsx +++ b/packages/client/src/pages/admin_preferences/TemplateBlock.tsx @@ -1,56 +1,71 @@ import React from "react"; import { useFormikContext } from "formik"; -import { useTranslation, EmailTemplateLabel } from "@eisbuk/translations"; - -import { Button, ButtonColor, FormField, FormFieldVariant } from "@eisbuk/ui"; -import { interpolateText, OrganizationData } from "@eisbuk/shared"; +import { useTranslation, MessageTemplateLabel } from "@eisbuk/translations"; +import { Button, ButtonColor } from "@eisbuk/ui"; +import { OrganizationData } from "@eisbuk/shared"; interface ButtonAttributes { - label: EmailTemplateLabel; + label: MessageTemplateLabel; value: string; } +export type TemplateFieldsInterface = React.FC<{ + name: string; + input: React.MutableRefObject; +}>; +export type PreviewFieldsInterface = React.FC<{ + name: string; +}>; + interface Props { name: string; buttons: ButtonAttributes[]; + // This is just a rich way of ensuring our type is in fact a + // subset of organization data field names + type: keyof Pick; + TemplateFields: TemplateFieldsInterface; + PreviewFields: PreviewFieldsInterface; } -const TemplateBlock: React.FC = ({ name, buttons }) => { +const TemplateBlock: React.FC = ({ + name, + buttons, + type, + TemplateFields, + PreviewFields, +}) => { const { t } = useTranslation(); - const { - values: { emailTemplates }, - setFieldValue, - } = useFormikContext(); + const { setFieldValue } = useFormikContext(); const input = React.useRef(null); const insertValuePlaceholder = (buttonValue: string) => () => { if (!input.current) return; - const selection = getInputSelection(input.current); - if (!selection) return; - - const [start, end] = selection; + const [start, end] = getInputSelection(input.current); const { name, value } = input.current; - // Format the button value - add anchor tag to links, where aplicable - const inputValue = formatValuePlaceholder(buttonValue); + const inputValue = + // Format the placeholder value, add anchor tag to links, where aplicable + formatValuePlaceholder(buttonValue, type === "emailTemplates"); const updatedValue = stringInsert(value, start, end, inputValue); setFieldValue(name, updatedValue); input.current.focus(); - input.current.selectionStart = input.current.selectionEnd = start; + // Set selection to the end of the inserted value, after the field has been focused (hence the timeout) + const setSelection = () => input.current?.setSelectionRange(start, start); + setTimeout(setSelection, 5); }; return (

- {t(EmailTemplateLabel[name])} + {t(MessageTemplateLabel[name])}

@@ -70,54 +85,17 @@ const TemplateBlock: React.FC = ({ name, buttons }) => {
- (input.current = e.target)} - /> - (input.current = e.target)} - /> +
-

Preview

-
-

Subject:

- -
- -
-

Text:

-

-

+

+ {t(MessageTemplateLabel.Preview)} +

+
@@ -125,16 +103,6 @@ const TemplateBlock: React.FC = ({ name, buttons }) => { ); }; -const previewDefaults = { - organizationName: "Organization Name", - name: "Saul", - surname: "Goodman", - bookingsLink: "https://ice.it/saul", - bookingsMonth: "April", - extendedBookingsDate: "06/04", - icsFile: "icsFile.ics", -}; - const stringInsert = ( string: string, start: number, @@ -142,20 +110,23 @@ const stringInsert = ( value: string ) => [string.slice(0, start), value, string.slice(end)].join(""); -const getInputSelection = (input: HTMLInputElement) => - input.selectionStart && input.selectionEnd - ? [input.selectionStart, input.selectionEnd] - : null; - -const applyStylingToLinks = (html: string) => - html.replace(/ `${s} style="color: blue"`); +const getInputSelection = (input: HTMLInputElement) => [ + input.selectionStart || 0, + input.selectionEnd || 0, +]; -export const formatValuePlaceholder = (value: string) => { +const formatValuePlaceholder = (value: string, wrapLinks: boolean) => { switch (value) { case "icsFile": - return 'Clicca qui per aggiungere le tue prenotazioni al tuo calendario'; + if (wrapLinks) { + return 'Clicca qui per aggiungere le tue prenotazioni al tuo calendario'; + } + // eslint-disable-next-line no-fallthrough case "bookingsLink": - return 'Clicca qui per prenotare e gestire le tue lezioni'; + if (wrapLinks) { + return 'Clicca qui per prenotare e gestire le tue lezioni'; + } + // eslint-disable-next-line no-fallthrough default: return `{{ ${value} }}`; } diff --git a/packages/client/src/pages/admin_preferences/data.ts b/packages/client/src/pages/admin_preferences/data.ts index d8f0e3b87..e9eaecc3e 100644 --- a/packages/client/src/pages/admin_preferences/data.ts +++ b/packages/client/src/pages/admin_preferences/data.ts @@ -1,25 +1,37 @@ import { ClientMessageType } from "@eisbuk/shared"; - -import { EmailTemplateLabel } from "@eisbuk/translations"; +import { MessageTemplateLabel } from "@eisbuk/translations"; export const buttons = { [ClientMessageType.SendBookingsLink]: [ - { label: EmailTemplateLabel.BookingsLink, value: "bookingsLink" }, - { label: EmailTemplateLabel.Name, value: "name" }, - { label: EmailTemplateLabel.Surname, value: "surname" }, + { label: MessageTemplateLabel.Name, value: "name" }, + { label: MessageTemplateLabel.Surname, value: "surname" }, + { label: MessageTemplateLabel.BookingsLink, value: "bookingsLink" }, + { label: MessageTemplateLabel.OrganizationName, value: "organizationName" }, ], [ClientMessageType.SendCalendarFile]: [ - { label: EmailTemplateLabel.Name, value: "name" }, - { label: EmailTemplateLabel.Surname, value: "surname" }, - { label: EmailTemplateLabel.CalendarFile, value: "calendarFile" }, + { label: MessageTemplateLabel.Name, value: "name" }, + { label: MessageTemplateLabel.Surname, value: "surname" }, + { label: MessageTemplateLabel.CalendarFile, value: "calendarFile" }, + { label: MessageTemplateLabel.OrganizationName, value: "organizationName" }, ], [ClientMessageType.SendExtendedBookingsDate]: [ - { label: EmailTemplateLabel.BookingsMonth, value: "bookingsMonth" }, + { label: MessageTemplateLabel.Name, value: "name" }, + { label: MessageTemplateLabel.Surname, value: "surname" }, + { label: MessageTemplateLabel.BookingsMonth, value: "bookingsMonth" }, { - label: EmailTemplateLabel.ExtendedBookingsDate, + label: MessageTemplateLabel.ExtendedBookingsDate, value: "extendedBookingsDate", }, - { label: EmailTemplateLabel.Name, value: "name" }, - { label: EmailTemplateLabel.Surname, value: "surname" }, + { label: MessageTemplateLabel.OrganizationName, value: "organizationName" }, ], }; + +export const previewValues = { + organizationName: "Organization Name", + name: "Saul", + surname: "Goodman", + bookingsLink: "https://ice.it/saul", + bookingsMonth: "April", + extendedBookingsDate: "06/04", + icsFile: "icsFile.ics", +}; diff --git a/packages/client/src/pages/admin_preferences/index.tsx b/packages/client/src/pages/admin_preferences/index.tsx index 003cc1008..373129571 100644 --- a/packages/client/src/pages/admin_preferences/index.tsx +++ b/packages/client/src/pages/admin_preferences/index.tsx @@ -5,6 +5,7 @@ import { Formik, Form, FormikHelpers } from "formik"; import { defaultEmailTemplates as emailTemplates, + defaultSMSTemplates as smsTemplates, OrganizationData, } from "@eisbuk/shared"; import i18n, { @@ -33,6 +34,7 @@ import { isEmpty } from "@/utils/helpers"; import EmailTemplateSettings from "./views/EmailTemplateSettings"; import GeneralSettings from "./views/GeneralSettings"; +import SMSTemplateSettings from "./views/SMSTemplateSettings"; // #region validations const OrganizationValidation = Yup.object().shape({ @@ -47,12 +49,14 @@ const OrganizationSettings: React.FC = () => { enum View { GeneralSettings = "GeneralSettings", EmailTemplates = "EmailTemplates", + SMSTemplates = "SMSTemplates", } // Get appropriate view to render const viewsLookup = { [View.GeneralSettings]: GeneralSettings, [View.EmailTemplates]: EmailTemplateSettings, + [View.SMSTemplates]: SMSTemplateSettings, }; const [view, setView] = useState( View.GeneralSettings @@ -100,6 +104,13 @@ const OrganizationSettings: React.FC = () => { onClick={() => setView(View.EmailTemplates)} active={view === View.EmailTemplates} /> + setView(View.SMSTemplates)} + active={view === View.SMSTemplates} + /> ); @@ -111,44 +122,46 @@ const OrganizationSettings: React.FC = () => { validationSchema={OrganizationValidation} > {({ isSubmitting, isValidating, handleReset }) => ( - - - -
- } - > - {view === View.GeneralSettings ? ( -
-
- + + + + +
+ } + > + {view === View.GeneralSettings ? ( +
+
- +
-
- ) : ( -
+ ) : view === View.EmailTemplates ? ( - - )} - + ) : ( +
+ +
+ )} + + )} @@ -165,7 +178,7 @@ const emptyValues = { location: "", defaultCountryCode: "", smsFrom: "", - smsTemplate: "", + smsTemplates, emailBcc: "", }; diff --git a/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx b/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx index 8af310a0b..66da9793f 100644 --- a/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx +++ b/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx @@ -1,46 +1,103 @@ import React from "react"; import { useSelector } from "react-redux"; +import { useFormikContext } from "formik"; -import { ClientMessageType } from "@eisbuk/shared"; +import { FormField, FormFieldVariant } from "@eisbuk/ui"; +import { MessageTemplateLabel, useTranslation } from "@eisbuk/translations"; +import { interpolateText, OrganizationData } from "@eisbuk/shared"; import { getOrganizationSettings } from "@/store/selectors/app"; -import TemplateBlock from "../TemplateBlock"; -import { EmailTemplateLabel } from "@eisbuk/translations"; +import TemplateBlock, { + PreviewFieldsInterface, + TemplateFieldsInterface, +} from "../TemplateBlock"; + +import { buttons, previewValues } from "../data"; const EmailTemplateSettings: React.FC = () => { const organization = useSelector(getOrganizationSettings); - const buttons = { - [ClientMessageType.SendBookingsLink]: [ - { label: EmailTemplateLabel.BookingsLink, value: "bookingsLink" }, - { label: EmailTemplateLabel.Name, value: "name" }, - { label: EmailTemplateLabel.Surname, value: "surname" }, - ], - [ClientMessageType.SendCalendarFile]: [ - { label: EmailTemplateLabel.Name, value: "name" }, - { label: EmailTemplateLabel.Surname, value: "surname" }, - { label: EmailTemplateLabel.CalendarFile, value: "calendarFile" }, - ], - [ClientMessageType.SendExtendedBookingsDate]: [ - { label: EmailTemplateLabel.BookingsMonth, value: "bookingsMonth" }, - { - label: EmailTemplateLabel.ExtendedBookingsDate, - value: "extendedBookingsDate", - }, - { label: EmailTemplateLabel.Name, value: "name" }, - { label: EmailTemplateLabel.Surname, value: "surname" }, - ], - }; - return (
{organization.emailTemplates && - Object.keys(organization.emailTemplates).map((name) => ( - - ))} + Object.keys(organization.emailTemplates) + .sort((a, b) => (a < b ? -1 : 1)) + .map((name) => ( + + ))}
); }; +const TemplateFields: TemplateFieldsInterface = ({ name, input }) => { + const { t } = useTranslation(); + + return ( + <> + (input.current = e.target)} + /> + (input.current = e.target)} + /> + + ); +}; + +const PreviewFields: PreviewFieldsInterface = ({ name }) => { + const { + values: { + emailTemplates: { [name]: template }, + }, + } = useFormikContext(); + + return ( + <> +
+

Subject:

+ +
+ +
+

Text:

+

+

+ + ); +}; + +/** A simple function used to "override" the tailwind reset styles - explicitly display links as blue text */ +const applyStylingToLinks = (html: string) => + html.replaceAll(/ `${s} style="color: blue"`); + export default EmailTemplateSettings; diff --git a/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx b/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx index 1bbb26e44..be3a2ef9a 100644 --- a/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx +++ b/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx @@ -1,11 +1,18 @@ +import { useFormikContext } from "formik"; import React from "react"; import { useSelector } from "react-redux"; +import { FormField, FormFieldVariant } from "@eisbuk/ui"; +import { interpolateText, OrganizationData } from "@eisbuk/shared"; + import { getOrganizationSettings } from "@/store/selectors/app"; -import TemplateBlock from "../TemplateBlock"; +import TemplateBlock, { + PreviewFieldsInterface, + TemplateFieldsInterface, +} from "../TemplateBlock"; -import { buttons } from "../data"; +import { buttons, previewValues } from "../data"; const EmailTemplateSettings: React.FC = () => { const organization = useSelector(getOrganizationSettings); @@ -13,15 +20,50 @@ const EmailTemplateSettings: React.FC = () => { return (
{organization.smsTemplates && - Object.keys(organization.smsTemplates).map((name) => ( - - ))} + Object.keys(organization.smsTemplates) + .sort((a, b) => (a < b ? -1 : 1)) + .map((name) => ( + + ))}
); }; +const TemplateFields: TemplateFieldsInterface = ({ input, name }) => { + return ( + (input.current = e.target)} + /> + ); +}; + +const PreviewFields: PreviewFieldsInterface = ({ name }) => { + const { + values: { + smsTemplates: { [name]: template }, + }, + } = useFormikContext(); + + return ( +

+ ); +}; + export default EmailTemplateSettings; diff --git a/packages/translations/src/dict/en.json b/packages/translations/src/dict/en.json index 826982d3a..ede0bc6f7 100644 --- a/packages/translations/src/dict/en.json +++ b/packages/translations/src/dict/en.json @@ -18,8 +18,9 @@ }, "SettingsNavigationLabel": { + "GeneralSettings": "General Settings", "EmailTemplates": "Email Templates", - "GeneralSettings": "General Settings" + "SMSTemplates": "SMS Templates" }, "Authorization": { @@ -162,21 +163,23 @@ "SmsTemplateHelpText": "This is a template for SMS sent to customers." }, - "EmailTemplateLabel": { - "SubjectPreview": "Subject Preview", - "HTMLPreview": "Body Preview", + "MessageTemplateLabel": { + "Preview": "Preview", + + "Subject": "Subject", + "Body": "Text", + "SendBookingsLink": "Send Bookings Link", "SendCalendarFile": "Send Calendar File", "SendExtendedBookingsDate": "Send Extended Bookings Date", - "OrganizationName": "Organization Name", + "BookingsLink": "Bookings Link", "Name": "Name", "Surname": "Surname", "IcsFile": "Calendar File Link", "BookingsMonth": "Bookings Month", "ExtendedBookingsDate": "Extended Bookings Date", - "Subject": "Subject", - "HTML": "Body" + "OrganizationName": "Organization Name" }, "Validations": { diff --git a/packages/translations/src/dict/it.json b/packages/translations/src/dict/it.json index dcd9d0fbb..3b5095c50 100644 --- a/packages/translations/src/dict/it.json +++ b/packages/translations/src/dict/it.json @@ -18,8 +18,9 @@ }, "SettingsNavigationLabel": { + "GeneralSettings": "Impostazioni Generali", "EmailTemplates": "Modelli di Emails", - "GeneralSettings": "Impostazioni Generali" + "SMSTemplates": "Modelli di SMSs" }, "Authorization": { @@ -163,21 +164,23 @@ "SmsTemplateHelpText": "This is a template for SMS sent to customers." }, - "EmailTemplateLabel": { - "SubjectPreview": "Anteprima Del Soggetto", - "HTMLPreview": "Anteprima Del Corpo", + "MessageTemplateLabel": { + "Preview": "Anteprima", + + "Subject": "Argomento", + "Body": "Corpo", + "SendBookingsLink": "Invia Collegamento Per Le Prenotazioni", "SendCalendarFile": "Invia File Calendario", "SendExtendedBookingsDate": "Invia Data Di Prenotazione Estesa", - "OrganizationName": "Nome Dell'Organizzazione", + "BookingsLink": "Link prenotazioni", "Name": "Nome", "Surname": "Cognome", "IcsFile": "Collegamento Al File Del Calendario", "BookingsMonth": "Mese Delle Prenotazioni", "ExtendedBookingsDate": "Data Di Prenotazione Estesa", - "Subject": "Argomento", - "HTML": "Corpo" + "OrganizationName": "Nome Dell'Organizzazione" }, "Validations": { diff --git a/packages/translations/src/translations.ts b/packages/translations/src/translations.ts index 25012f1dd..fa3512d6f 100644 --- a/packages/translations/src/translations.ts +++ b/packages/translations/src/translations.ts @@ -20,8 +20,9 @@ export enum AttendanceNavigationLabel { } export enum SettingsNavigationLabel { - EmailTemplates = "SettingsNavigationLabel.EmailTemplates", GeneralSettings = "SettingsNavigationLabel.GeneralSettings", + EmailTemplates = "SettingsNavigationLabel.EmailTemplates", + SMSTemplates = "SettingsNavigationLabel.SMSTemplates", } // #endregion navigation @@ -183,21 +184,23 @@ export enum OrganizationLabel { SmsTemplateHelpText = "OrganizationLabel.SmsTemplateHelpText", } -export enum EmailTemplateLabel { - SubjectPreview = "EmailTemplateLabel.SubjectPreview", - HTMLPreview = "EmailTemplateLabel.HTMLPreview", - "send-bookings-link" = "EmailTemplateLabel.SendBookingsLink", - "send-calendar-file" = "EmailTemplateLabel.SendCalendarFile", - "send-extended-bookings-date" = "EmailTemplateLabel.SendExtendedBookingsDate", - OrganizationName = "EmailTemplateLabel.OrganizationName", - BookingsLink = "EmailTemplateLabel.BookingsLink", - Name = "EmailTemplateLabel.Name", - Surname = "EmailTemplateLabel.Surname", - CalendarFile = "EmailTemplateLabel.IcsFile", - BookingsMonth = "EmailTemplateLabel.BookingsMonth", - ExtendedBookingsDate = "EmailTemplateLabel.ExtendedBookingsDate", - Subject = "EmailTemplateLabel.Subject", - HTML = "EmailTemplateLabel.HTML", +export enum MessageTemplateLabel { + Preview = "MessageTemplateLabel.Preview", + + Subject = "MessageTemplateLabel.Subject", + Body = "MessageTemplateLabel.Body", + + "send-bookings-link" = "MessageTemplateLabel.SendBookingsLink", + "send-calendar-file" = "MessageTemplateLabel.SendCalendarFile", + "send-extended-bookings-date" = "MessageTemplateLabel.SendExtendedBookingsDate", + + BookingsLink = "MessageTemplateLabel.BookingsLink", + Name = "MessageTemplateLabel.Name", + Surname = "MessageTemplateLabel.Surname", + CalendarFile = "MessageTemplateLabel.IcsFile", + BookingsMonth = "MessageTemplateLabel.BookingsMonth", + ExtendedBookingsDate = "MessageTemplateLabel.ExtendedBookingsDate", + OrganizationName = "MessageTemplateLabel.OrganizationName", } export enum BookingNotesForm { From 74f2c8ea1741f997a088cb4cc736f63b644ef2f7 Mon Sep 17 00:00:00 2001 From: ikusteu Date: Sun, 24 Sep 2023 00:19:56 +0200 Subject: [PATCH 5/9] Polish the preview logic for email/SMS template settings --- .../src/pages/admin_preferences/TemplateBlock.tsx | 4 +++- .../views/EmailTemplateSettings.tsx | 6 +++--- .../views/SMSTemplateSettings.tsx | 14 ++++++++++++-- packages/shared/src/data/templates.ts | 2 +- packages/shared/src/utils/text.ts | 2 +- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/client/src/pages/admin_preferences/TemplateBlock.tsx b/packages/client/src/pages/admin_preferences/TemplateBlock.tsx index 6512efbe4..0a05097c2 100644 --- a/packages/client/src/pages/admin_preferences/TemplateBlock.tsx +++ b/packages/client/src/pages/admin_preferences/TemplateBlock.tsx @@ -58,7 +58,9 @@ const TemplateBlock: React.FC = ({ input.current.focus(); // Set selection to the end of the inserted value, after the field has been focused (hence the timeout) - const setSelection = () => input.current?.setSelectionRange(start, start); + const cursorPosition = start + inputValue.length; + const setSelection = () => + input.current?.setSelectionRange(cursorPosition, cursorPosition); setTimeout(setSelection, 5); }; diff --git a/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx b/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx index 66da9793f..d3adf81ac 100644 --- a/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx +++ b/packages/client/src/pages/admin_preferences/views/EmailTemplateSettings.tsx @@ -74,7 +74,7 @@ const PreviewFields: PreviewFieldsInterface = ({ name }) => {

Subject:

{

Text:

{ }; /** A simple function used to "override" the tailwind reset styles - explicitly display links as blue text */ -const applyStylingToLinks = (html: string) => +const formatPreview = (html: string) => html.replaceAll(/ `${s} style="color: blue"`); export default EmailTemplateSettings; diff --git a/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx b/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx index be3a2ef9a..844b7ef89 100644 --- a/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx +++ b/packages/client/src/pages/admin_preferences/views/SMSTemplateSettings.tsx @@ -59,11 +59,21 @@ const PreviewFields: PreviewFieldsInterface = ({ name }) => { return (

); }; +/** + * Since SMS can't be formatted using HTML, here we're formatting the plain text + * into HTML and wrapping links with anchor tag (and painting them to blue) for preview purposes + */ +const formatPreview = (text: string) => + text.replaceAll( + /http[^ ]* /g, + (s) => `${s}` + ); + export default EmailTemplateSettings; diff --git a/packages/shared/src/data/templates.ts b/packages/shared/src/data/templates.ts index 8e40786a8..3c10001ae 100644 --- a/packages/shared/src/data/templates.ts +++ b/packages/shared/src/data/templates.ts @@ -4,7 +4,7 @@ export const defaultEmailTemplates = { [ClientMessageType.SendBookingsLink]: { subject: "prenotazioni lezioni di {{ organizationName }}", html: `

Ciao {{ name }},

- Ti inviamo un link per prenotare le tue prossime lezioni con {{ organizationName }}: +

Ti inviamo un link per prenotare le tue prossime lezioni con {{ organizationName }}:

Clicca qui per prenotare e gestire le tue lezioni`, }, [ClientMessageType.SendCalendarFile]: { diff --git a/packages/shared/src/utils/text.ts b/packages/shared/src/utils/text.ts index 7f4491407..6bdac3a50 100644 --- a/packages/shared/src/utils/text.ts +++ b/packages/shared/src/utils/text.ts @@ -39,7 +39,7 @@ export const interpolateText = ( ); }); - return template.trim().replaceAll("\n", "\n
\n"); + return template.trim(); }; export const checkExpected = (input: string, expected: string) => { From 6fda9be31a1136cb7cb494dae13aebfc04da09f8 Mon Sep 17 00:00:00 2001 From: ikusteu Date: Sun, 24 Sep 2023 11:05:00 +0200 Subject: [PATCH 6/9] * Update LayoutContent to accept a wrapping component (defaults to a component wrapping the contents in `div`) * Apply the update to admin_preferences page --- .../src/pages/admin_preferences/index.tsx | 77 +++++++++---------- packages/ui/src/Layout/Layout.tsx | 16 +++- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/packages/client/src/pages/admin_preferences/index.tsx b/packages/client/src/pages/admin_preferences/index.tsx index 373129571..3bac40eb0 100644 --- a/packages/client/src/pages/admin_preferences/index.tsx +++ b/packages/client/src/pages/admin_preferences/index.tsx @@ -122,46 +122,45 @@ const OrganizationSettings: React.FC = () => { validationSchema={OrganizationValidation} > {({ isSubmitting, isValidating, handleReset }) => ( -
- - - + + + + + } + > + {view === View.GeneralSettings ? ( +
+
+ +
- } - > - {view === View.GeneralSettings ? ( -
-
- - -
-
- ) : view === View.EmailTemplates ? ( - - ) : ( -
- -
- )} - - +
+ ) : view === View.EmailTemplates ? ( + + ) : ( +
+ +
+ )} +
)} diff --git a/packages/ui/src/Layout/Layout.tsx b/packages/ui/src/Layout/Layout.tsx index 6e8302e9a..d3d827abc 100644 --- a/packages/ui/src/Layout/Layout.tsx +++ b/packages/ui/src/Layout/Layout.tsx @@ -71,7 +71,7 @@ const Layout: React.FC = ({ - {children} +
{children}
); }; @@ -79,8 +79,16 @@ const Layout: React.FC = ({ export const LayoutContent: React.FC<{ wide?: boolean; actionButtons?: JSX.Element; -}> = ({ children, wide = false, actionButtons = null }) => ( -
+ Component?: React.FC<{ className: string }>; +}> = ({ + children, + wide = false, + actionButtons = null, + Component = ({ children, className }) => ( +
{children}
+ ), +}) => ( +
{children}
@@ -89,7 +97,7 @@ export const LayoutContent: React.FC<{
{actionButtons}
)} -
+ ); /** Get styles for top / botton row of the header */ From f1300d9f90e247f09addc22d69dfc4d22e87e35d Mon Sep 17 00:00:00 2001 From: ikusteu Date: Sun, 24 Sep 2023 11:25:01 +0200 Subject: [PATCH 7/9] Add a test for SMS sending functionality --- packages/client/src/__tests__/sendSMS.test.ts | 125 ++++++++++++++++++ packages/functions/src/sendSMS/https.ts | 15 ++- 2 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 packages/client/src/__tests__/sendSMS.test.ts diff --git a/packages/client/src/__tests__/sendSMS.test.ts b/packages/client/src/__tests__/sendSMS.test.ts new file mode 100644 index 000000000..bc9cf854e --- /dev/null +++ b/packages/client/src/__tests__/sendSMS.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "vitest"; +import { httpsCallable } from "@firebase/functions"; + +import { + ClientMessagePayload, + ClientMessageType, + interpolateText, + defaultSMSTemplates as smsTemplates, + EmailInterpolationValues, + ClientMessageMethod, + OrganizationData, +} from "@eisbuk/shared"; +import { CloudFunction } from "@eisbuk/shared/ui"; + +import { saul } from "@eisbuk/testing/customers"; + +import { setUpOrganization } from "@/__testSetup__/node"; +import { adminDb, functions } from "@/__testSetup__/firestoreSetup"; + +type SendSMSTest = + | { + type: ClientMessageType.SendBookingsLink; + payload: Pick< + ClientMessagePayload< + ClientMessageMethod.SMS, + ClientMessageType.SendBookingsLink + >, + "bookingsLink" + >; + } + | { + type: ClientMessageType.SendExtendedBookingsDate; + payload: Pick< + ClientMessagePayload< + ClientMessageMethod.SMS, + ClientMessageType.SendExtendedBookingsDate + >, + "extendedBookingsDate" | "bookingsMonth" + >; + }; + +/** + * Runs table tests from the payload passed in (for each email type) + * Unlike other table tests, the assertions are not in the payload, but rather + * handled internally, by constructing the interpolation values and checking + * against the default templates. + */ +const runSendEmailTableTests = (tests: SendSMSTest[]) => { + const setup = { + doLogin: true, + setSecrets: true, + additionalSetup: { + smsFrom: "Eisbuk", + } as OrganizationData, + }; + + tests.forEach(({ type, payload }) => { + test(`should construct an email of type ${type} and hand it over for delivery`, async () => { + const { organization } = await setUpOrganization(setup); + + const { + data: { success, deliveryDocumentPath }, + } = await httpsCallable< + unknown, + { + deliveryDocumentPath: string; + success: boolean; + } + >( + functions, + CloudFunction.SendSMS + )({ type, ...payload, organization, ...saul }); + + expect(success).toEqual(true); + + // Check the process document + const deliveryDocPath = deliveryDocumentPath; + const deliveryDoc = await adminDb.doc(deliveryDocPath).get(); + const deliveryDocData = deliveryDoc.data(); + + const interpolationValues: EmailInterpolationValues = { + name: saul.name, + surname: saul.surname, + organizationName: organization, + }; + + // In case of send bookings link, add bookings link specific interpolation values + if (type === ClientMessageType.SendBookingsLink) { + interpolationValues.bookingsLink = payload.bookingsLink; + } + + // In case of send extended bookings date, add extended bookings date specific interpolation values + if (type === ClientMessageType.SendExtendedBookingsDate) { + interpolationValues.bookingsMonth = payload.bookingsMonth; + interpolationValues.extendedBookingsDate = payload.extendedBookingsDate; + } + + expect(deliveryDocData).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ + to: saul.phone!, + message: interpolateText(smsTemplates[type], interpolationValues), + }), + }) + ); + }); + }); +}; + +describe("SendSMS", () => + runSendEmailTableTests([ + { + type: ClientMessageType.SendBookingsLink, + payload: { + bookingsLink: "https://eisbuk.com/bookings", + }, + }, + { + type: ClientMessageType.SendExtendedBookingsDate, + payload: { + bookingsMonth: "2021-01", + extendedBookingsDate: "2021-01-31", + }, + }, + ])); diff --git a/packages/functions/src/sendSMS/https.ts b/packages/functions/src/sendSMS/https.ts index 1f2a472da..3246899b5 100644 --- a/packages/functions/src/sendSMS/https.ts +++ b/packages/functions/src/sendSMS/https.ts @@ -62,15 +62,20 @@ export const sendSMS = functions const smsPayload = { to: payload.phone, message }; // Add SMS to delivery queue, thus starting the delivery process - await admin + const deliveryDoc = admin .firestore() .collection( `${Collection.DeliveryQueues}/${organization}/${DeliveryQueue.SMSQueue}` ) - .doc() - .set({ payload: smsPayload }); - - return { sms: smsPayload, organization, success: true }; + .doc(); + await deliveryDoc.set({ payload: smsPayload }); + + return { + sms: smsPayload, + organization, + success: true, + deliveryDocumentPath: deliveryDoc.path, + }; } ); From d5fc9f402ec5bba4c62682bc33877f198a30a66c Mon Sep 17 00:00:00 2001 From: ikusteu Date: Sun, 24 Sep 2023 18:31:15 +0200 Subject: [PATCH 8/9] Fix typecheck errors --- packages/client/src/pages/admin_preferences/index.tsx | 2 +- .../src/__testData__/dataTriggers.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/client/src/pages/admin_preferences/index.tsx b/packages/client/src/pages/admin_preferences/index.tsx index 3bac40eb0..3d6bc0dce 100644 --- a/packages/client/src/pages/admin_preferences/index.tsx +++ b/packages/client/src/pages/admin_preferences/index.tsx @@ -123,7 +123,7 @@ const OrganizationSettings: React.FC = () => { > {({ isSubmitting, isValidating, handleReset }) => (