diff --git a/CreateNotificationActivity/handler.ts b/CreateNotificationActivity/handler.ts index 81319f88..4a3b6a11 100644 --- a/CreateNotificationActivity/handler.ts +++ b/CreateNotificationActivity/handler.ts @@ -30,10 +30,7 @@ import { } from "io-functions-commons/dist/src/models/sender_service"; import { ulidGenerator } from "io-functions-commons/dist/src/utils/strings"; -import { - getStoreMessageContentActivityHandler, - ISuccessfulStoreMessageContentActivityResult -} from "../StoreMessageContentActivity/handler"; +import { ISuccessfulStoreMessageContentActivityResult } from "../StoreMessageContentActivity/handler"; /** * Attempt to resolve an email address from @@ -95,7 +92,7 @@ type ICreateNotificationActivityResult = | ICreateNotificationActivityNoneResult; /** - * Returns a function for handling emailNotificationActivity + * Returns a function for handling createNotificationActivity */ export const getCreateNotificationActivityHandler = ( lSenderServiceModel: SenderServiceModel, diff --git a/CreateNotificationActivity/index.ts b/CreateNotificationActivity/index.ts index 971802e5..91ed2cf2 100644 --- a/CreateNotificationActivity/index.ts +++ b/CreateNotificationActivity/index.ts @@ -9,7 +9,7 @@ * function app in Kudu */ -import { AzureFunction, Context } from "@azure/functions"; +import { AzureFunction } from "@azure/functions"; import { DocumentClient as DocumentDBClient } from "documentdb"; import { HttpsUrl } from "io-functions-commons/dist/generated/definitions/HttpsUrl"; diff --git a/CreatedMessageOrchestrator/index.ts b/CreatedMessageOrchestrator/index.ts index edb1b5ae..33ba0d7c 100644 --- a/CreatedMessageOrchestrator/index.ts +++ b/CreatedMessageOrchestrator/index.ts @@ -15,18 +15,32 @@ import { IFunctionContext } from "durable-functions/lib/src/classes"; import { ReadableReporter } from "italia-ts-commons/lib/reporters"; import { PromiseType } from "italia-ts-commons/lib/types"; +import { NotificationChannelEnum } from "io-functions-commons/dist/generated/definitions/NotificationChannel"; +import { NotificationChannelStatusValueEnum } from "io-functions-commons/dist/generated/definitions/NotificationChannelStatusValue"; import { CreatedMessageEvent } from "io-functions-commons/dist/src/models/created_message_event"; -import { getStoreMessageContentActivityHandler } from "../StoreMessageContentActivity/handler"; import { getCreateNotificationActivityHandler } from "../CreateNotificationActivity/handler"; +import { getEmailNotificationActivityHandler } from "../EmailNotificationActivity/handler"; +import { NotificationStatusUpdaterActivityInput } from "../NotificationStatusUpdaterActivity/handler"; +import { getStoreMessageContentActivityHandler } from "../StoreMessageContentActivity/handler"; +import { HandlerInputType } from "./utils"; + +/** + * Durable Functions Orchestrator that handles CreatedMessage events + * + * Note that this handler may be executed multiple times for a single job. + * See https://docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-checkpointing-and-replay + * + */ function* handler(context: IFunctionContext): IterableIterator { - // decode input CreatedMessageEvent const input = context.df.getInput(); + + // decode input CreatedMessageEvent const errorOrCreatedMessageEvent = CreatedMessageEvent.decode(input); if (errorOrCreatedMessageEvent.isLeft()) { context.log.error( - `Invalid CreatedMessageEvent received by orchestrator|ORCHESTRATOR_ID=${ + `CreatedMessageOrchestrator|Invalid CreatedMessageEvent received|ORCHESTRATOR_ID=${ context.df.instanceId }|ERRORS=${ReadableReporter.report(errorOrCreatedMessageEvent).join( " / " @@ -39,32 +53,42 @@ function* handler(context: IFunctionContext): IterableIterator { const createdMessageEvent = errorOrCreatedMessageEvent.value; const newMessageWithContent = createdMessageEvent.message; - context.log.verbose( - `CreatedMessageOrchestrator|CreatedMessageEvent received|ORCHESTRATOR_ID=${context.df.instanceId}|MESSAGE_ID=${newMessageWithContent.id}|RECIPIENT=${newMessageWithContent.fiscalCode}` - ); + if (!context.df.isReplaying) { + context.log.verbose( + `CreatedMessageOrchestrator|CreatedMessageEvent received|ORCHESTRATOR_ID=${context.df.instanceId}|MESSAGE_ID=${newMessageWithContent.id}|RECIPIENT=${newMessageWithContent.fiscalCode}` + ); + } // TODO: customize + backoff // see https://docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-error-handling#javascript-functions-2x-only-1 const retryOptions = new df.RetryOptions(5000, 10); try { + // first we store the content of the message in the database const storeMessageContentActivityResult: PromiseType< ReturnType> > = yield context.df.callActivityWithRetry( "StoreMessageContentActivity", retryOptions, - createdMessageEvent + // The cast is here for making TypeScript check that we're indeed passing + // the right parameters + // tslint:disable-next-line: no-useless-cast + createdMessageEvent as HandlerInputType< + ReturnType + > ); - context.log.verbose( - `CreatedMessageOrchestrator|StoreMessageContentActivity completed|ORCHESTRATOR_ID=${ - context.df.instanceId - }|MESSAGE_ID=${newMessageWithContent.id}|RESULT=${ - storeMessageContentActivityResult.kind === "SUCCESS" - ? "SUCCESS" - : "FAILURE/" + storeMessageContentActivityResult.reason - }` - ); + if (!context.df.isReplaying) { + context.log.verbose( + `CreatedMessageOrchestrator|StoreMessageContentActivity completed|ORCHESTRATOR_ID=${ + context.df.instanceId + }|MESSAGE_ID=${newMessageWithContent.id}|RESULT=${ + storeMessageContentActivityResult.kind === "SUCCESS" + ? "SUCCESS" + : "FAILURE/" + storeMessageContentActivityResult.reason + }` + ); + } if (storeMessageContentActivityResult.kind !== "SUCCESS") { // StoreMessageContentActivity failed permanently, we can't proceed with @@ -72,6 +96,8 @@ function* handler(context: IFunctionContext): IterableIterator { return []; } + // then we create a NotificationActivity in the database that will store + // the status of the notification on each channel const createNotificationActivityResult: PromiseType< ReturnType> > = yield context.df.callActivityWithRetry( @@ -80,18 +106,78 @@ function* handler(context: IFunctionContext): IterableIterator { { createdMessageEvent, storeMessageContentActivityResult - } + } as HandlerInputType< + ReturnType + > ); - context.log.verbose( - `createNotificationActivityResult: ${JSON.stringify( - createNotificationActivityResult - )}` - ); + if (createNotificationActivityResult.kind === "none") { + // no channel configured, no notifications need to be delivered + context.log.verbose( + `CreatedMessageOrchestrator|No notifications will be delivered|MESSAGE_ID=${newMessageWithContent.id}` + ); + return []; + } + + // TODO: run all notifications in parallel + + if (createNotificationActivityResult.hasEmail) { + // send the email notification + try { + const emailNotificationActivityResult: PromiseType< + ReturnType> + > = yield context.df.callActivityWithRetry( + "EmailNotificationActivity", + retryOptions, + { + emailNotificationEventJson: + createNotificationActivityResult.notificationEvent + } as HandlerInputType< + ReturnType + > + ); + + if (!context.df.isReplaying) { + context.log.verbose( + `CreatedMessageOrchestrator|EmailNotificationActivity result: ${JSON.stringify( + emailNotificationActivityResult + )}` + ); + } + + // update the notification status + const emailNotificationStatusUpdaterActivityInput = NotificationStatusUpdaterActivityInput.encode( + { + channel: NotificationChannelEnum.EMAIL, + messageId: createdMessageEvent.message.id, + notificationId: + createNotificationActivityResult.notificationEvent.notificationId, + status: NotificationChannelStatusValueEnum.SENT + } + ); + try { + yield context.df.callActivityWithRetry( + "NotificationStatusUpdaterActivity", + retryOptions, + emailNotificationStatusUpdaterActivityInput + ); + } catch (e) { + // too many failures + context.log.error( + `CreatedMessageOrchestrator|NotificationStatusUpdaterActivity failed too many times|MESSAGE_ID=${createdMessageEvent.message.id}|CHANNEL=email|ERROR=${e}` + ); + } + } catch (e) { + // too many failures + context.log.error( + `CreatedMessageOrchestrator|EmailNotificationActivity failed too many times|MESSAGE_ID=${createdMessageEvent.message.id}|ERROR=${e}` + ); + } + } } catch (e) { // too many retries context.log.error( - `Fatal error, StoreMessageContentActivity or createNotificationActivity exceeded the max retries|MESSAGE_ID=${createdMessageEvent.message.id}` + `CreatedMessageOrchestrator|Fatal error, StoreMessageContentActivity or CreateNotificationActivity exceeded the max retries|MESSAGE_ID=${createdMessageEvent.message.id}|ERROR=${e}` ); } diff --git a/CreatedMessageOrchestrator/utils.ts b/CreatedMessageOrchestrator/utils.ts new file mode 100644 index 00000000..b0e5774a --- /dev/null +++ b/CreatedMessageOrchestrator/utils.ts @@ -0,0 +1,9 @@ +import { Function2 } from "fp-ts/lib/function"; + +/** + * Extracts the input type of an activity handler + */ +// tslint:disable-next-line: no-any +export type HandlerInputType = T extends Function2 + ? A + : never; diff --git a/EmailNotificationActivity/function.json b/EmailNotificationActivity/function.json new file mode 100644 index 00000000..3d9a09c0 --- /dev/null +++ b/EmailNotificationActivity/function.json @@ -0,0 +1,10 @@ +{ + "bindings": [ + { + "name": "name", + "type": "activityTrigger", + "direction": "in" + } + ], + "scriptFile": "../dist/EmailNotificationActivity/index.js" +} \ No newline at end of file diff --git a/EmailNotificationActivity/handler.ts b/EmailNotificationActivity/handler.ts new file mode 100644 index 00000000..6ca5572a --- /dev/null +++ b/EmailNotificationActivity/handler.ts @@ -0,0 +1,220 @@ +import { Context } from "@azure/functions"; + +import { isLeft } from "fp-ts/lib/Either"; +import { isNone } from "fp-ts/lib/Option"; + +import * as HtmlToText from "html-to-text"; +import * as NodeMailer from "nodemailer"; + +import { + readableReport, + ReadableReporter +} from "italia-ts-commons/lib/reporters"; +import { NonEmptyString } from "italia-ts-commons/lib/strings"; + +import { + EmailNotification, + NotificationModel +} from "io-functions-commons/dist/src/models/notification"; +import { NotificationEvent } from "io-functions-commons/dist/src/models/notification_event"; +import { + TelemetryClient, + wrapCustomTelemetryClient +} from "io-functions-commons/dist/src/utils/application_insights"; +import { diffInMilliseconds } from "io-functions-commons/dist/src/utils/application_insights"; + +import { generateDocumentHtml, sendMail } from "./utils"; + +export interface INotificationDefaults { + readonly HTML_TO_TEXT_OPTIONS: HtmlToTextOptions; + readonly MAIL_FROM: NonEmptyString; +} + +type IEmailNotificationActivityResult = + | { kind: "SUCCESS"; result: "OK" | "EXPIRED" } + | { kind: "FAILURE"; reason: "WRONG_FORMAT" }; + +// Whether we're in a production environment +const isProduction = process.env.NODE_ENV === "production"; + +const getCustomTelemetryClient = wrapCustomTelemetryClient( + isProduction, + new TelemetryClient() +); + +/** + * Returns a function for handling EmailNotificationActivity + */ +export const getEmailNotificationActivityHandler = ( + lMailerTransporter: NodeMailer.Transporter, + lNotificationModel: NotificationModel, + notificationDefaultParams: INotificationDefaults +) => async ( + context: Context, + input: { + emailNotificationEventJson: unknown; + } +): Promise => { + const { emailNotificationEventJson } = input; + + const decodedEmailNotification = NotificationEvent.decode( + emailNotificationEventJson + ); + + if (decodedEmailNotification.isLeft()) { + context.log.error( + `EmailNotificationActivity|Cannot decode EmailNotification|ERROR=${ReadableReporter.report( + decodedEmailNotification + ).join(" / ")}` + ); + return { kind: "FAILURE", reason: "WRONG_FORMAT" }; + } + + const emailNotificationEvent = decodedEmailNotification.value; + + const { + message, + content, + notificationId, + senderMetadata + } = emailNotificationEvent; + + const serviceId = message.senderServiceId; + + const eventName = "handler.notification.email"; + + const appInsightsClient = getCustomTelemetryClient( + { + operationId: emailNotificationEvent.notificationId, + operationParentId: emailNotificationEvent.message.id, + serviceId: NonEmptyString.is(serviceId) ? serviceId : undefined + }, + { + messageId: emailNotificationEvent.message.id, + notificationId: emailNotificationEvent.notificationId + } + ); + + // If the message is expired we will not send any notification + // FIXME: shouldn't TTL be optional? + if ( + Date.now() - message.createdAt.getTime() > + message.timeToLiveSeconds * 1000 + ) { + // if the message is expired no more processing is necessary + context.log.verbose( + `EmailNotificationActivity|Message expired|MESSAGE_ID=${message.id}` + ); + return { kind: "SUCCESS", result: "EXPIRED" }; + } + + // fetch the notification + const errorOrMaybeNotification = await lNotificationModel.find( + notificationId, + message.id + ); + if (isLeft(errorOrMaybeNotification)) { + const error = errorOrMaybeNotification.value; + // we got an error while fetching the notification + throw new Error( + `EmailNotificationActivity|Error while fetching the notification|NOTIFICATION_ID=${notificationId}|MESSAGE_ID=${message.id}|ERROR=${error.code}` + ); + } + const maybeEmailNotification = errorOrMaybeNotification.value; + if (isNone(maybeEmailNotification)) { + // it may happen that the object is not yet visible to this function due to latency + // as the notification object is retrieved from database and we may be hitting a + // replica that is not yet in sync + throw new Error( + `EmailNotificationActivity|Notification not found|NOTIFICATION_ID=${notificationId}|MESSAGE_ID=${message.id}` + ); + } + const errorOrEmailNotification = EmailNotification.decode( + maybeEmailNotification.value + ); + if (isLeft(errorOrEmailNotification)) { + // The notification object is not compatible with this code + const error = readableReport(errorOrEmailNotification.value); + context.log.error( + `EmailNotificationActivity|Wrong format for email notification|NOTIFICATION_ID=${notificationId}|MESSAGE_ID=${message.id}|ERROR=${error}` + ); + return { kind: "FAILURE", reason: "WRONG_FORMAT" }; + } + const emailNotification = errorOrEmailNotification.value.channels.EMAIL; + const documentHtml = await generateDocumentHtml( + content.subject, + content.markdown, + senderMetadata + ); + // converts the HTML to pure text to generate the text version of the message + const bodyText = HtmlToText.fromString( + documentHtml, + notificationDefaultParams.HTML_TO_TEXT_OPTIONS + ); + const startSendMailCallTime = process.hrtime(); + // trigger email delivery + // see https://nodemailer.com/message/ + const sendResult = await sendMail(lMailerTransporter, { + from: notificationDefaultParams.MAIL_FROM, + headers: { + "X-Italia-Messages-MessageId": message.id, + "X-Italia-Messages-NotificationId": notificationId + }, + html: documentHtml, + messageId: message.id, + subject: content.subject, + text: bodyText, + to: emailNotification.toAddress + // priority: "high", // TODO: set based on kind of notification + // disableFileAccess: true, + // disableUrlAccess: true, + }); + const sendMailCallDurationMs = diffInMilliseconds(startSendMailCallTime); + const eventContent = { + dependencyTypeName: "HTTP", + duration: sendMailCallDurationMs, + name: "notification.email.delivery", + properties: { + addressSource: emailNotification.addressSource, + transport: lMailerTransporter.transporter.name + } + }; + + if (isLeft(sendResult)) { + const error = sendResult.value; + // track the event of failed delivery + appInsightsClient.trackDependency({ + ...eventContent, + data: error.message, + resultCode: error.name, + success: false + }); + context.log.error( + `EmailNotificationActivity|Error while sending email|NOTIFICATION_ID=${notificationId}|MESSAGE_ID=${message.id}|ERROR=${error.message}` + ); + throw new Error("Error while sending email"); + } + + // track the event of successful delivery + appInsightsClient.trackDependency({ + ...eventContent, + data: "OK", + resultCode: 200, + success: true + }); + + appInsightsClient.trackEvent({ + measurements: { + elapsed: Date.now() - emailNotificationEvent.message.createdAt.getTime() + }, + name: eventName, + properties: { + success: "true" + } + }); + + // TODO: handling bounces and delivery updates + // see https://nodemailer.com/usage/#sending-mail + // see #150597597 + return { kind: "SUCCESS", result: "OK" }; +}; diff --git a/EmailNotificationActivity/index.ts b/EmailNotificationActivity/index.ts new file mode 100644 index 00000000..9d179f63 --- /dev/null +++ b/EmailNotificationActivity/index.ts @@ -0,0 +1,97 @@ +/* + * This function is not intended to be invoked directly. Instead it will be + * triggered by an orchestrator function. + * + * Before running this sample, please: + * - create a Durable orchestration function + * - create a Durable HTTP starter function + * - run 'npm install durable-functions' from the wwwroot folder of your + * function app in Kudu + */ + +import { AzureFunction } from "@azure/functions"; + +import { DocumentClient as DocumentDBClient } from "documentdb"; + +import * as NodeMailer from "nodemailer"; + +import { + NOTIFICATION_COLLECTION_NAME, + NotificationModel +} from "io-functions-commons/dist/src/models/notification"; +import { + SENDER_SERVICE_COLLECTION_NAME, + SenderServiceModel +} from "io-functions-commons/dist/src/models/sender_service"; +import * as documentDbUtils from "io-functions-commons/dist/src/utils/documentdb"; +import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; +import { MailUpTransport } from "io-functions-commons/dist/src/utils/mailup"; + +import { getEmailNotificationActivityHandler } from "./handler"; + +// Setup DocumentDB +const cosmosDbUri = getRequiredStringEnv("CUSTOMCONNSTR_COSMOSDB_URI"); +const cosmosDbKey = getRequiredStringEnv("CUSTOMCONNSTR_COSMOSDB_KEY"); +const cosmosDbName = getRequiredStringEnv("COSMOSDB_NAME"); + +const documentDbDatabaseUrl = documentDbUtils.getDatabaseUri(cosmosDbName); + +// We create the db client, services and models here +// as if any error occurs during the construction of these objects +// that would be unrecoverable anyway and we neither may trig a retry +const documentClient = new DocumentDBClient(cosmosDbUri, { + masterKey: cosmosDbKey +}); + +const notificationsCollectionUrl = documentDbUtils.getCollectionUri( + documentDbDatabaseUrl, + NOTIFICATION_COLLECTION_NAME +); +const notificationModel = new NotificationModel( + documentClient, + notificationsCollectionUrl +); + +const senderServicesCollectionUrl = documentDbUtils.getCollectionUri( + documentDbDatabaseUrl, + SENDER_SERVICE_COLLECTION_NAME +); + +// +// setup NodeMailer +// +const mailupUsername = getRequiredStringEnv("MAILUP_USERNAME"); +const mailupSecret = getRequiredStringEnv("MAILUP_SECRET"); + +// +// options used when converting an HTML message to pure text +// see https://www.npmjs.com/package/html-to-text#options +// + +const HTML_TO_TEXT_OPTIONS: HtmlToTextOptions = { + ignoreImage: true, // ignore all document images + tables: true +}; + +// default sender for email +const MAIL_FROM = getRequiredStringEnv("MAIL_FROM_DEFAULT"); + +const mailerTransporter = NodeMailer.createTransport( + MailUpTransport({ + creds: { + Secret: mailupSecret, + Username: mailupUsername + } + }) +); + +const activityFunction: AzureFunction = getEmailNotificationActivityHandler( + mailerTransporter, + notificationModel, + { + HTML_TO_TEXT_OPTIONS, + MAIL_FROM + } +); + +export default activityFunction; diff --git a/EmailNotificationActivity/templates/html/.gitkeep b/EmailNotificationActivity/templates/html/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/EmailNotificationActivity/templates/html/default.ts b/EmailNotificationActivity/templates/html/default.ts new file mode 100644 index 00000000..1ac0292a --- /dev/null +++ b/EmailNotificationActivity/templates/html/default.ts @@ -0,0 +1,51 @@ +// DO NOT EDIT THIS FILE +// this file was auto generated from 'default.mjml' by gulp generate:templates +// tslint:disable-next-line:parameters-max-number +export default function( + title: string, + headlineText: string, + senderOrganizationName: string, + senderServiceName: string, + organizationFiscalCode: string, + titleText: string, + contentHtml: string, + footerHtml: string +): string { + return ` +${title}
${senderOrganizationName}
${senderServiceName}
${titleText}
${contentHtml}
`; +} diff --git a/EmailNotificationActivity/utils.ts b/EmailNotificationActivity/utils.ts new file mode 100644 index 00000000..e6f79732 --- /dev/null +++ b/EmailNotificationActivity/utils.ts @@ -0,0 +1,60 @@ +import { Either, left, right } from "fp-ts/lib/Either"; +import * as NodeMailer from "nodemailer"; + +import { MessageBodyMarkdown } from "io-functions-commons/dist/generated/definitions/MessageBodyMarkdown"; +import { MessageSubject } from "io-functions-commons/dist/generated/definitions/MessageSubject"; +import { CreatedMessageEventSenderMetadata } from "io-functions-commons/dist/src/models/created_message_sender_metadata"; +import { markdownToHtml } from "io-functions-commons/dist/src/utils/markdown"; + +// TODO: import generation script from digital-citizenship-functions +import defaultEmailTemplate from "./templates/html/default"; + +/** + * Generates the HTML for the email from the Markdown content and the subject + */ +export async function generateDocumentHtml( + subject: MessageSubject, + markdown: MessageBodyMarkdown, + senderMetadata: CreatedMessageEventSenderMetadata +): Promise { + // converts the markdown body to HTML + const bodyHtml = (await markdownToHtml.process(markdown)).toString(); + + // compose the service name from the department name and the service name + const senderServiceName = `${senderMetadata.departmentName}
${senderMetadata.serviceName}`; + + // strip leading zeroes + const organizationFiscalCode = senderMetadata.organizationFiscalCode.replace( + /^0+/, + "" + ); + + // wrap the generated HTML into an email template + return defaultEmailTemplate( + subject, // title + "", // TODO: headline + senderMetadata.organizationName, // organization name + senderServiceName, // service name + organizationFiscalCode, + subject, + bodyHtml, + "" // TODO: footer + ); +} + +/** + * Promise wrapper around Transporter#sendMail + */ +export async function sendMail( + transporter: NodeMailer.Transporter, + options: NodeMailer.SendMailOptions +): Promise> { + return new Promise>(resolve => { + transporter.sendMail(options, (err, res) => { + const result: Either = err + ? left(err) + : right(res); + resolve(result); + }); + }); +} diff --git a/NotificationStatusUpdaterActivity/function.json b/NotificationStatusUpdaterActivity/function.json new file mode 100644 index 00000000..ee1a03cd --- /dev/null +++ b/NotificationStatusUpdaterActivity/function.json @@ -0,0 +1,10 @@ +{ + "bindings": [ + { + "name": "name", + "type": "activityTrigger", + "direction": "in" + } + ], + "scriptFile": "../dist/NotificationStatusUpdaterActivity/index.js" +} \ No newline at end of file diff --git a/NotificationStatusUpdaterActivity/handler.ts b/NotificationStatusUpdaterActivity/handler.ts new file mode 100644 index 00000000..60169a33 --- /dev/null +++ b/NotificationStatusUpdaterActivity/handler.ts @@ -0,0 +1,71 @@ +import * as t from "io-ts"; + +import { Context } from "@azure/functions"; + +import { NonEmptyString } from "italia-ts-commons/lib/strings"; + +import { NotificationChannel } from "io-functions-commons/dist/generated/definitions/NotificationChannel"; +import { NotificationChannelStatusValue } from "io-functions-commons/dist/generated/definitions/NotificationChannelStatusValue"; +import { + getNotificationStatusUpdater, + NotificationStatusModel +} from "io-functions-commons/dist/src/models/notification_status"; +import { ReadableReporter } from "italia-ts-commons/lib/reporters"; + +type INotificationStatusUpdaterResult = + | { + kind: "SUCCESS"; + } + | { kind: "FAILURE" }; + +export const NotificationStatusUpdaterActivityInput = t.interface({ + channel: NotificationChannel, + messageId: NonEmptyString, + notificationId: NonEmptyString, + status: NotificationChannelStatusValue +}); + +/** + * Returns a function for handling EmailNotificationActivity + */ +export const getNotificationStatusUpdaterActivityHandler = ( + lNotificationStatusModel: NotificationStatusModel +) => async ( + context: Context, + input: unknown +): Promise => { + const decodedInput = NotificationStatusUpdaterActivityInput.decode(input); + + if (decodedInput.isLeft()) { + context.log.error( + `NotificationStatusUpdaterActivity|Cannot decode input|ERROR=${ReadableReporter.report( + decodedInput + ).join(" / ")}` + ); + return { kind: "FAILURE" }; + } + + const { channel, notificationId, messageId, status } = decodedInput.value; + const notificationStatusUpdater = getNotificationStatusUpdater( + lNotificationStatusModel, + channel, + messageId, + notificationId + ); + const errorOrUpdatedNotificationStatus = await notificationStatusUpdater( + status + ); + + if (errorOrUpdatedNotificationStatus.isLeft()) { + context.log.warn( + `NotificationStatusUpdaterActivity|Error while updating|MESSAGE_ID=${messageId}|NOTIFICATION_ID=${notificationId}|CHANNEL=${channel}|STATUS=${status}|ERROR=${errorOrUpdatedNotificationStatus.value}` + ); + throw new Error(errorOrUpdatedNotificationStatus.value.message); + } + + context.log.verbose( + `NotificationStatusUpdaterActivity|Notification status updated|MESSAGE_ID=${messageId}|NOTIFICATION_ID=${notificationId}|CHANNEL=${channel}|STATUS=${status}` + ); + + return { kind: "SUCCESS" }; +}; diff --git a/NotificationStatusUpdaterActivity/index.ts b/NotificationStatusUpdaterActivity/index.ts new file mode 100644 index 00000000..c6417183 --- /dev/null +++ b/NotificationStatusUpdaterActivity/index.ts @@ -0,0 +1,55 @@ +/* + * This function is not intended to be invoked directly. Instead it will be + * triggered by an orchestrator function. + * + * Before running this sample, please: + * - create a Durable orchestration function + * - create a Durable HTTP starter function + * - run 'npm install durable-functions' from the wwwroot folder of your + * function app in Kudu + */ + +import { AzureFunction, Context } from "@azure/functions"; + +import { DocumentClient as DocumentDBClient } from "documentdb"; + +import * as documentDbUtils from "io-functions-commons/dist/src/utils/documentdb"; + +import { + NOTIFICATION_STATUS_COLLECTION_NAME, + NotificationStatusModel +} from "io-functions-commons/dist/src/models/notification_status"; +import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; + +import { getNotificationStatusUpdaterActivityHandler } from "./handler"; + +// Setup DocumentDB + +const cosmosDbUri = getRequiredStringEnv("CUSTOMCONNSTR_COSMOSDB_URI"); +const cosmosDbKey = getRequiredStringEnv("CUSTOMCONNSTR_COSMOSDB_KEY"); +const cosmosDbName = getRequiredStringEnv("COSMOSDB_NAME"); + +const documentDbDatabaseUrl = documentDbUtils.getDatabaseUri(cosmosDbName); + +// We create the db client, services and models here +// as if any error occurs during the construction of these objects +// that would be unrecoverable anyway and we neither may trig a retry +const documentClient = new DocumentDBClient(cosmosDbUri, { + masterKey: cosmosDbKey +}); + +const notificationStatusCollectionUrl = documentDbUtils.getCollectionUri( + documentDbDatabaseUrl, + NOTIFICATION_STATUS_COLLECTION_NAME +); + +const notificationStatusModel = new NotificationStatusModel( + documentClient, + notificationStatusCollectionUrl +); + +const activityFunction: AzureFunction = getNotificationStatusUpdaterActivityHandler( + notificationStatusModel +); + +export default activityFunction; diff --git a/package.json b/package.json index d1fa0783..ca7bf6e0 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ "@types/cors": "^2.8.4", "@types/documentdb": "^1.10.5", "@types/express": "^4.16.0", + "@types/nodemailer": "^4.6.2", + "@types/html-to-text": "^1.4.31", "azurite": "^3.1.2-preview", "italia-tslint-rules": "^1.1.3", "npm-run-all": "^4.1.5", "prettier": "^1.18.2", "tslint": "^5.17.0", - "typescript": "^3.3.3" + "typescript": "^3.3.3", + "nodemailer": "^4.6.7" }, "dependencies": { "cors": "^2.8.4", @@ -33,6 +36,7 @@ "io-functions-express": "^0.1.0", "io-ts": "1.8.5", "italia-ts-commons": "^5.1.5", - "winston": "^3.2.1" + "winston": "^3.2.1", + "html-to-text": "^4.0.0" } -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index cfa5fe1d..5ab9e9ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -83,6 +83,11 @@ "@types/express-serve-static-core" "*" "@types/serve-static" "*" +"@types/html-to-text@^1.4.31": + version "1.4.31" + resolved "https://registry.yarnpkg.com/@types/html-to-text/-/html-to-text-1.4.31.tgz#3b73aa59f127551cc0ce615346caa730e7622f9f" + integrity sha512-9vTFw6vYZNnjPOep9WRXs7cw0vg04pAZgcX9bqx70q1BNT7y9sOJovpbiNIcSNyHF/6LscLvGhtb5Og1T0UEvA== + "@types/lodash@^4.14.119": version "4.14.134" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.134.tgz#9032b440122db3a2a56200e91191996161dde5b9" @@ -98,6 +103,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.0.8.tgz#551466be11b2adc3f3d47156758f610bd9f6b1d8" integrity sha512-b8bbUOTwzIY3V5vDTY1fIJ+ePKDUBqt2hC2woVGotdQQhG/2Sh62HOKHrT7ab+VerXAcPyAiTEipPu/FsreUtg== +"@types/nodemailer@^4.6.2": + version "4.6.8" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-4.6.8.tgz#c14356e799fe1d4ee566126f901bc6031cc7b1b5" + integrity sha512-IX1P3bxDP1VIdZf6/kIWYNmSejkYm9MOyMEtoDFi4DVzKjJ3kY4GhOcOAKs6lZRjqVVmF9UjPOZXuQczlpZThw== + dependencies: + "@types/node" "*" + "@types/range-parser@*": version "1.2.3" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" @@ -724,6 +736,34 @@ documentdb@^1.12.2, documentdb@^1.15.3: tunnel "0.0.5" underscore "1.8.3" +dom-serializer@0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" + integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA== + dependencies: + domelementtype "^1.3.0" + entities "^1.1.1" + +domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + +domutils@^1.5.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + dont-sniff-mimetype@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/dont-sniff-mimetype/-/dont-sniff-mimetype-1.0.0.tgz#5932890dc9f4e2f19e5eb02a20026e5e5efc8f58" @@ -778,6 +818,11 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +entities@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + env-variable@0.0.x: version "0.0.5" resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88" @@ -1102,6 +1147,11 @@ hast-util-whitespace@^1.0.0: resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-1.0.3.tgz#6d161b307bd0693b5ec000c7c7e8b5445109ee34" integrity sha512-AlkYiLTTwPOyxZ8axq2/bCwRUPjIPBfrHkXuCR92B38b3lSdU22R5F/Z4DL6a2kxWpekWq1w6Nj48tWat6GeRA== +he@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + helmet-crossdomain@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/helmet-crossdomain/-/helmet-crossdomain-0.3.0.tgz#707e2df930f13ad61f76ed08e1bb51ab2b2e85fa" @@ -1160,11 +1210,33 @@ hsts@2.2.0: dependencies: depd "2.0.0" +html-to-text@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-4.0.0.tgz#c1f4e100d74e9feab5b152d7b6b3be3c1c6412b0" + integrity sha512-QQl5EEd97h6+3crtgBhkEAO6sQnZyDff8DAeJzoSkOc1Dqe1UvTUZER0B+KjBe6fPZqq549l2VUhtracus3ndA== + dependencies: + he "^1.0.0" + htmlparser2 "^3.9.2" + lodash "^4.17.4" + optimist "^0.6.1" + html-void-elements@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-1.0.4.tgz#95e8bb5ecd6b88766569c2645f2b5f1591db9ba5" integrity sha512-yMk3naGPLrfvUV9TdDbuYXngh/TpHbA6TrOw3HL9kS8yhwx7i309BReNg7CbAJXGE+UMJ6je5OqJ7lC63o6YuQ== +htmlparser2@^3.9.2: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + http-errors@1.7.2, http-errors@~1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" @@ -1514,7 +1586,7 @@ load-json-file@^4.0.0: pify "^3.0.0" strip-bom "^3.0.0" -lodash@^4.17.11: +lodash@^4.17.11, lodash@^4.17.4: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== @@ -1626,6 +1698,11 @@ minimist@0.0.8: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= +minimist@~0.0.1: + version "0.0.10" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" + integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8= + mkdirp@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" @@ -1751,6 +1828,14 @@ one-time@0.0.4: resolved "https://registry.yarnpkg.com/one-time/-/one-time-0.0.4.tgz#f8cdf77884826fe4dff93e3a9cc37b1e4480742e" integrity sha1-+M33eISCb+Tf+T46nMN7HkSAdC4= +optimist@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" + integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY= + dependencies: + minimist "~0.0.1" + wordwrap "~0.0.2" + parse-entities@^1.1.0: version "1.2.2" resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50" @@ -2673,6 +2758,11 @@ winston@^3.1.0, winston@^3.2.1: triple-beam "^1.3.0" winston-transport "^4.3.0" +wordwrap@~0.0.2: + version "0.0.3" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" + integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc= + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"