Skip to content

Commit

Permalink
[#174710415] Send confirmation email to user after data delete (#86)
Browse files Browse the repository at this point in the history
  • Loading branch information
balanza authored Sep 22, 2020
1 parent 618aff4 commit 9b363ab
Show file tree
Hide file tree
Showing 13 changed files with 898 additions and 3 deletions.
97 changes: 97 additions & 0 deletions GetProfileActivity/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/* tslint:disable: no-any no-identical-functions */

import { right } from "fp-ts/lib/Either";

import { context as contextMock } from "../../__mocks__/durable-functions";
import { aFiscalCode, aRetrievedProfile } from "../../__mocks__/mocks";

import {
ActivityInput,
ActivityResultInvalidInputFailure,
ActivityResultNotFoundFailure,
ActivityResultQueryFailure,
ActivityResultSuccess,
createGetProfileActivityHandler
} from "../handler";

import { none, some } from "fp-ts/lib/Option";
import { fromEither, fromLeft } from "fp-ts/lib/TaskEither";
import { toCosmosErrorResponse } from "io-functions-commons/dist/src/utils/cosmosdb_model";
import { ProfileModel } from "io-functions-commons/dist/src/models/profile";

describe("GetProfileActivityHandler", () => {
it("should handle a result", async () => {
const mockModel = ({
findLastVersionByModelId: jest.fn(() =>
fromEither(right(some(aRetrievedProfile)))
)
} as any) as ProfileModel;

const handler = createGetProfileActivityHandler(mockModel);
const input: ActivityInput = {
fiscalCode: aFiscalCode
};
const result = await handler(contextMock, input);

expect(ActivityResultSuccess.decode(result).isRight()).toBe(true);
});

it("should handle a record not found failure", async () => {
const mockModel = ({
findLastVersionByModelId: jest.fn(() => fromEither(right(none)))
} as any) as ProfileModel;

const handler = createGetProfileActivityHandler(mockModel);
const input: ActivityInput = {
fiscalCode: aFiscalCode
};
const result = await handler(contextMock, input);

expect(ActivityResultNotFoundFailure.decode(result).isRight()).toBe(true);
});

it("should handle a query error", async () => {
const mockModel = ({
findLastVersionByModelId: jest.fn(() =>
fromLeft(toCosmosErrorResponse({ kind: "COSMOS_ERROR_RESPONSE" }))
)
} as any) as ProfileModel;

const handler = createGetProfileActivityHandler(mockModel);
const input: ActivityInput = {
fiscalCode: aFiscalCode
};
const result = await handler(contextMock, input);

expect(ActivityResultQueryFailure.decode(result).isRight()).toBe(true);
});

it("should handle a rejection", async () => {
const mockModel = ({
findLastVersionByModelId: jest.fn(() => fromEither(right(none)))
} as any) as ProfileModel;

const handler = createGetProfileActivityHandler(mockModel);
const input: ActivityInput = {
fiscalCode: aFiscalCode
};
const result = await handler(contextMock, input);

expect(ActivityResultNotFoundFailure.decode(result).isRight()).toBe(true);
});

it("should handle an invalid input", async () => {
const mockModel = ({} as any) as ProfileModel;

const handler = createGetProfileActivityHandler(mockModel);

// @ts-ignore to force bad behavior
const result = await handler(contextMock, {
invalid: "input"
});

expect(ActivityResultInvalidInputFailure.decode(result).isRight()).toBe(
true
);
});
});
10 changes: 10 additions & 0 deletions GetProfileActivity/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"bindings": [
{
"name": "name",
"type": "activityTrigger",
"direction": "in"
}
],
"scriptFile": "../dist/GetProfileActivity/index.js"
}
156 changes: 156 additions & 0 deletions GetProfileActivity/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Get a profile record
*/

import * as t from "io-ts";

import { fromEither, fromLeft, taskEither } from "fp-ts/lib/TaskEither";

import { Context } from "@azure/functions";

import {
ProfileModel,
RetrievedProfile
} from "io-functions-commons/dist/src/models/profile";
import { readableReport } from "italia-ts-commons/lib/reporters";
import { FiscalCode } from "italia-ts-commons/lib/strings";
import { getMessageFromCosmosErrors } from "../utils/conversions";

// Activity input
export const ActivityInput = t.interface({
fiscalCode: FiscalCode
});
export type ActivityInput = t.TypeOf<typeof ActivityInput>;

// Activity result
export const ActivityResultSuccess = t.interface({
kind: t.literal("SUCCESS"),
value: RetrievedProfile
});
export type ActivityResultSuccess = t.TypeOf<typeof ActivityResultSuccess>;

// Activity failed because of invalid input
export const ActivityResultInvalidInputFailure = t.interface({
kind: t.literal("INVALID_INPUT_FAILURE"),
reason: t.string
});
export type ActivityResultInvalidInputFailure = t.TypeOf<
typeof ActivityResultInvalidInputFailure
>;

// Activity failed because of invalid input
export const ActivityResultNotFoundFailure = t.interface({
kind: t.literal("NOT_FOUND_FAILURE")
});
export type ActivityResultNotFoundFailure = t.TypeOf<
typeof ActivityResultNotFoundFailure
>;

// Activity failed because of an error on a query
export const ActivityResultQueryFailure = t.intersection([
t.interface({
kind: t.literal("QUERY_FAILURE"),
reason: t.string
}),
t.partial({ query: t.string })
]);
export type ActivityResultQueryFailure = t.TypeOf<
typeof ActivityResultQueryFailure
>;

export const ActivityResultFailure = t.taggedUnion("kind", [
ActivityResultQueryFailure,
ActivityResultInvalidInputFailure,
ActivityResultNotFoundFailure
]);
export type ActivityResultFailure = t.TypeOf<typeof ActivityResultFailure>;

export const ActivityResult = t.taggedUnion("kind", [
ActivityResultSuccess,
ActivityResultFailure
]);

export type ActivityResult = t.TypeOf<typeof ActivityResult>;

const logPrefix = `GetUserDataProcessingActivity`;

function assertNever(_: never): void {
throw new Error("should not have executed this");
}

/**
* Logs depending on failure type
* @param context the Azure functions context
* @param failure the failure to log
*/
const logFailure = (context: Context) => (
failure: ActivityResultFailure
): void => {
switch (failure.kind) {
case "INVALID_INPUT_FAILURE":
context.log.error(
`${logPrefix}|Error decoding input|ERROR=${failure.reason}`
);
break;
case "QUERY_FAILURE":
context.log.error(
`${logPrefix}|Error ${failure.query} query error |ERROR=${failure.reason}`
);
break;
case "NOT_FOUND_FAILURE":
// it might not be a failure
context.log.warn(`${logPrefix}|Error Profile not found`);
break;
default:
assertNever(failure);
}
};

export const createGetProfileActivityHandler = (profileModel: ProfileModel) => (
context: Context,
input: unknown
) => {
// the actual handler
return fromEither(ActivityInput.decode(input))
.mapLeft<ActivityResultFailure>((reason: t.Errors) =>
ActivityResultInvalidInputFailure.encode({
kind: "INVALID_INPUT_FAILURE",
reason: readableReport(reason)
})
)
.chain(({ fiscalCode }) =>
profileModel
.findLastVersionByModelId([fiscalCode])
.foldTaskEither<ActivityResultFailure, RetrievedProfile>(
error =>
fromLeft(
ActivityResultQueryFailure.encode({
kind: "QUERY_FAILURE",
query: "findLastVersionByModelId",
reason: `${error.kind}, ${getMessageFromCosmosErrors(error)}`
})
),
maybeRecord =>
maybeRecord.fold(
fromLeft(
ActivityResultNotFoundFailure.encode({
kind: "NOT_FOUND_FAILURE"
})
),
_ => taskEither.of(_)
)
)
)
.map(record =>
ActivityResultSuccess.encode({
kind: "SUCCESS",
value: record
})
)
.mapLeft(failure => {
logFailure(context)(failure);
return failure;
})
.run()
.then(e => e.value);
};
20 changes: 20 additions & 0 deletions GetProfileActivity/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env";

import { cosmosdbClient } from "../utils/cosmosdb";

import {
PROFILE_COLLECTION_NAME,
ProfileModel
} from "io-functions-commons/dist/src/models/profile";
import { createGetProfileActivityHandler } from "./handler";

const cosmosDbName = getRequiredStringEnv("COSMOSDB_NAME");
const database = cosmosdbClient.database(cosmosDbName);

const profileModel = new ProfileModel(
database.container(PROFILE_COLLECTION_NAME)
);

const activityFunctionHandler = createGetProfileActivityHandler(profileModel);

export default activityFunctionHandler;
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ they may be customized as needed.
| USER_DATA_BACKUP_CONTAINER_NAME | Name of the storage container in which user data is backuped before being permanently deleted | string |
| USER_DATA_DELETE_DELAY_DAYS | How many days to wait when a user asks for cancellation before effectively delete her data | number |
| UserDataBackupStorageConnection | Storage connection string for GDPR user data storage | string |
| MAIL_FROM | Address from which email are sent | string |
| SENDGRID_API_KEY | If provided, SendGrid will be used | string |
| MAILUP_USERNAME | If using MailUp, the username | string |
| MAILUP_SECRET | If using MailUp, the secret | string |
| MAILHOG_HOSTNAME | Required on development, the host name of the MailHog SMTP server | string |



#### Feature flags
Expand Down
10 changes: 10 additions & 0 deletions SendUserDataDeleteEmailActivity/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"bindings": [
{
"name": "name",
"type": "activityTrigger",
"direction": "in"
}
],
"scriptFile": "../dist/SendUserDataDeleteEmailActivity/index.js"
}
Loading

0 comments on commit 9b363ab

Please sign in to comment.