diff --git a/.env.example b/.env.example index f5cd10abfb..47b3cf616e 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,7 @@ GOVUK_NOTIFY_RESUME_EMAIL_TEMPLATE_ID=👻 GOVUK_NOTIFY_REMINDER_EMAIL_TEMPLATE_ID=👻 GOVUK_NOTIFY_EXPIRY_EMAIL_TEMPLATE_ID=👻 GOVUK_NOTIFY_SUBMISSION_EMAIL_TEMPLATE_ID=👻 +GOVUK_NOTIFY_CONFIRMATION_EMAIL_TEMPLATE_ID=👻 UNIFORM_TOKEN_URL=👻 UNIFORM_SUBMISSION_URL=👻 diff --git a/api.planx.uk/.env.test.example b/api.planx.uk/.env.test.example index 35dd3c3475..9b12f41504 100644 --- a/api.planx.uk/.env.test.example +++ b/api.planx.uk/.env.test.example @@ -37,6 +37,7 @@ GOVUK_NOTIFY_RESUME_EMAIL_TEMPLATE_ID=👻 GOVUK_NOTIFY_REMINDER_EMAIL_TEMPLATE_ID=👻 GOVUK_NOTIFY_EXPIRY_EMAIL_TEMPLATE_ID=👻 GOVUK_NOTIFY_SUBMISSION_EMAIL_TEMPLATE_ID=👻 +GOVUK_NOTIFY_CONFIRMATION_EMAIL_TEMPLATE_ID=👻 UNIFORM_TOKEN_URL=👻 UNIFORM_SUBMISSION_URL=👻 diff --git a/api.planx.uk/auth/index.ts b/api.planx.uk/auth/index.ts index 8d6b36aeef..4a8b0fc651 100644 --- a/api.planx.uk/auth/index.ts +++ b/api.planx.uk/auth/index.ts @@ -1,6 +1,5 @@ import { Request, Response, NextFunction } from "express"; import crypto from "crypto"; -import { singleSessionEmailTemplates } from "../saveAndReturn/utils"; import assert from "assert"; /** @@ -39,21 +38,18 @@ const useSendEmailAuth = ( next: NextFunction ): void => { switch (req.params.template) { + // Requires authorization - can only be triggered by Hasura scheduled events case "reminder": case "expiry": - // Requires authorization - can only be triggered by Hasura scheduled events + case "confirmation": return useHasuraAuth(req, res, next); + // Public access case "save": - // Public access return next(); default: { - // Invalid template - const validTemplates = Object.keys(singleSessionEmailTemplates); return next({ status: 400, - message: `Invalid template - must be one of [${validTemplates.join( - ", " - )}]`, + message: "Invalid template", }); } } diff --git a/api.planx.uk/saveAndReturn/sendEmail.test.ts b/api.planx.uk/saveAndReturn/sendEmail.test.ts index 50164be7f0..09ea086007 100644 --- a/api.planx.uk/saveAndReturn/sendEmail.test.ts +++ b/api.planx.uk/saveAndReturn/sendEmail.test.ts @@ -127,14 +127,16 @@ describe("Send Email endpoint", () => { .send(data) .expect(400) .then(response => { - expect(response.body).toHaveProperty("error", 'Invalid template - must be one of [save, reminder, expiry, submit]'); + expect(response.body).toHaveProperty("error", "Invalid template"); }); }); }); describe("Templates which require authorisation", () => { + const templates = ["reminder", "expiry", "confirmation"]; + it("returns 401 UNAUTHORIZED if no auth header is provided", async () => { - for (const template of ["reminder", "expiry"]) { + for (const template of templates) { const data = { payload: { sessionId: 123, email: TEST_EMAIL } }; await supertest(app) .post(`/send-email/${template}`) @@ -144,7 +146,7 @@ describe("Send Email endpoint", () => { }); it("returns 401 UNAUTHORIZED if no incorrect auth header is provided", async () => { - for (const template of ["reminder", "expiry"]) { + for (const template of templates) { const data = { payload: { sessionId: 123, email: TEST_EMAIL } }; await supertest(app) .post(`/send-email/${template}`) @@ -155,7 +157,7 @@ describe("Send Email endpoint", () => { }); it("returns 200 OK if the correct headers are used", async () => { - for (const template of ["reminder", "expiry"]) { + for (const template of templates) { const data = { payload: { sessionId: 123, email: TEST_EMAIL } }; await supertest(app) .post(`/send-email/${template}`) diff --git a/api.planx.uk/saveAndReturn/utils.ts b/api.planx.uk/saveAndReturn/utils.ts index cf86d3602a..45624e869e 100644 --- a/api.planx.uk/saveAndReturn/utils.ts +++ b/api.planx.uk/saveAndReturn/utils.ts @@ -1,25 +1,42 @@ import { format, addDays } from "date-fns"; -import { gql } from "graphql-request"; +import { gql, GraphQLClient } from "graphql-request"; import { publicGraphQLClient as publicClient, adminGraphQLClient as adminClient } from "../hasura"; import { EmailSubmissionNotifyConfig, LowCalSession, SaveAndReturnNotifyConfig, Team } from "../types"; import { notifyClient } from "./notify"; const DAYS_UNTIL_EXPIRY = 28; -const singleSessionEmailTemplates = { +/** + * Triggered by applicants when saving + * Validated using email address & sessionId + */ +const publicEmailTemplates = { save: process.env.GOVUK_NOTIFY_SAVE_RETURN_EMAIL_TEMPLATE_ID, - reminder: process.env.GOVUK_NOTIFY_REMINDER_EMAIL_TEMPLATE_ID, - expiry: process.env.GOVUK_NOTIFY_EXPIRY_EMAIL_TEMPLATE_ID, - submit: process.env.GOVUK_NOTIFY_SUBMISSION_EMAIL_TEMPLATE_ID, }; -const multipleSessionEmailTemplates = { +/** + * Triggered by applicants when resuming + * Validated using email address & inbox (magic link) + */ +const hybridEmailTemplates = { resume: process.env.GOVUK_NOTIFY_RESUME_EMAIL_TEMPLATE_ID, }; +/** + * Triggered by Hasura scheduled events + * Validated with the useHasuraAuth() middleware + */ +const privateEmailTemplates = { + reminder: process.env.GOVUK_NOTIFY_REMINDER_EMAIL_TEMPLATE_ID, + expiry: process.env.GOVUK_NOTIFY_EXPIRY_EMAIL_TEMPLATE_ID, + confirmation: process.env.GOVUK_NOTIFY_CONFIRMATION_EMAIL_TEMPLATE_ID, + submit: process.env.GOVUK_NOTIFY_SUBMISSION_EMAIL_TEMPLATE_ID, +}; + const emailTemplates = { - ...singleSessionEmailTemplates, - ...multipleSessionEmailTemplates, + ...publicEmailTemplates, + ...hybridEmailTemplates, + ...privateEmailTemplates, }; export type Template = keyof typeof emailTemplates; @@ -94,7 +111,7 @@ const calculateExpiryDate = (createdAt: string): string => { }; /** - * Sends "Save", "Remind", and "Expiry" emails to Save & Return users + * Sends "Save", "Remind", "Expiry" and "Confirmation" emails to Save & Return users */ const sendSingleApplicationEmail = async ( template: Template, @@ -104,7 +121,8 @@ const sendSingleApplicationEmail = async ( try { const { flowSlug, team, session } = await validateSingleSessionRequest( email, - sessionId + sessionId, + template, ); const config = { personalisation: getPersonalisation(session, flowSlug, team), @@ -125,7 +143,8 @@ const sendSingleApplicationEmail = async ( */ const validateSingleSessionRequest = async ( email: string, - sessionId: string + sessionId: string, + template: Template, ) => { try { const query = gql` @@ -147,10 +166,11 @@ const validateSingleSessionRequest = async ( } } `; + const client = getClientForTemplate(template); const headers = getSaveAndReturnPublicHeaders(sessionId, email); const { lowcal_sessions: [session], - } = await publicClient.request(query, null, headers); + } = await client.request(query, null, headers); if (!session) throw Error(`Unable to find session: ${sessionId}`); @@ -164,6 +184,10 @@ const validateSingleSessionRequest = async ( } }; +const getClientForTemplate = (template: Template): GraphQLClient => ( + template in privateEmailTemplates ? adminClient : publicClient +); + interface SessionDetails { hasUserSaved: boolean; address: any; @@ -349,7 +373,6 @@ export { convertSlugToName, getResumeLink, sendSingleApplicationEmail, - singleSessionEmailTemplates, markSessionAsSubmitted, DAYS_UNTIL_EXPIRY, calculateExpiryDate, diff --git a/docker-compose.yml b/docker-compose.yml index cd4deda6e8..d6e64124bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -163,6 +163,7 @@ services: GOVUK_NOTIFY_REMINDER_EMAIL_TEMPLATE_ID: ${GOVUK_NOTIFY_REMINDER_EMAIL_TEMPLATE_ID} GOVUK_NOTIFY_EXPIRY_EMAIL_TEMPLATE_ID: ${GOVUK_NOTIFY_EXPIRY_EMAIL_TEMPLATE_ID} GOVUK_NOTIFY_SUBMISSION_EMAIL_TEMPLATE_ID: ${GOVUK_NOTIFY_SUBMISSION_EMAIL_TEMPLATE_ID} + GOVUK_NOTIFY_CONFIRMATION_EMAIL_TEMPLATE_ID: ${GOVUK_NOTIFY_CONFIRMATION_EMAIL_TEMPLATE_ID} HASURA_PLANX_API_KEY: ${HASURA_PLANX_API_KEY} FILE_API_KEY: ${FILE_API_KEY} SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL} diff --git a/hasura.planx.uk/metadata/tables.yaml b/hasura.planx.uk/metadata/tables.yaml index d5dc77e53a..f7e2a447d1 100644 --- a/hasura.planx.uk/metadata/tables.yaml +++ b/hasura.planx.uk/metadata/tables.yaml @@ -224,6 +224,35 @@ _is_null: true check: {} event_triggers: + - name: email_user_submission_confirmation + definition: + enable_manual: false + update: + columns: + - submitted_at + retry_conf: + num_retries: 0 + interval_sec: 10 + timeout_sec: 60 + webhook_from_env: HASURA_PLANX_API_URL + headers: + - name: authorization + value_from_env: HASURA_PLANX_API_KEY + request_transform: + body: + action: transform + template: |- + { + "payload": { + "sessionId": {{$body.event.data.new.id}}, + "email": {{$body.event.data.new.email}} + } + } + url: '{{$base_url}}/send-email/confirmation' + method: POST + version: 2 + query_params: {} + template_engine: Kriti - name: setup_lowcal_expiry_events definition: enable_manual: false diff --git a/infrastructure/application/index.ts b/infrastructure/application/index.ts index 187a8d07ba..e7dfb34de6 100644 --- a/infrastructure/application/index.ts +++ b/infrastructure/application/index.ts @@ -394,6 +394,10 @@ export = async () => { name: "GOVUK_NOTIFY_SUBMISSION_EMAIL_TEMPLATE_ID", value: "7e77bdae-7379-4dd8-a8cc-086a0029163c", }, + { + name: "GOVUK_NOTIFY_CONFIRMATION_EMAIL_TEMPLATE_ID", + value: "8b82b606-defa-4daa-8fdb-e78b852b8ffb", + }, { name: "SLACK_WEBHOOK_URL", value: config.require("slack-webhook-url"),