Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create CHES email api logging functionality #216

Merged
merged 4 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ module.exports = {
clearMocks: true,
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverage: true
collectCoverage: true,
setupFilesAfterEnv: ['./tests/__mocks__/prismaMock.ts']
};
2,944 changes: 1,381 additions & 1,563 deletions app/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"eslint-config-recommended": "^4.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"jest-mock-extended": "^4.0.0-beta1",
"prettier": "^3.4.2",
"prisma": "^6.0.1",
"rimraf": "^6.0.1",
Expand Down
47 changes: 47 additions & 0 deletions app/src/db/migrations/20241216000000_017-ches-logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* eslint-disable max-len */
import stamps from '../stamps';

import type { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
return Promise.resolve().then(() =>
// Create public schema tables
knex.schema
.createTable('email_log', (table) => {
table.uuid('email_log_id').primary();
table.integer('http_status').defaultTo(null);
table.uuid('msg_id').defaultTo(null);
table.text('to').defaultTo(null);
table.uuid('tx_id').defaultTo(null);
stamps(knex, table);
})

// Create before update triggers
.then(() =>
knex.schema.raw(`CREATE TRIGGER before_update_email_log_trigger
BEFORE UPDATE ON email_log
FOR EACH ROW EXECUTE PROCEDURE public.set_updated_at();`)
)

// Create audit triggers
.then(() =>
knex.schema.raw(`CREATE TRIGGER audit_email_log_trigger
AFTER UPDATE OR DELETE ON email_log
FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`)
)
);
}

export async function down(knex: Knex): Promise<void> {
return (
Promise.resolve()
// Drop audit triggers
.then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_email_log_trigger ON email_log'))

// Drop public schema table triggers
.then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_email_log_trigger ON email_log'))

// Drop public schema tables
.then(() => knex.schema.dropTableIfExists('email_log'))
);
}
39 changes: 39 additions & 0 deletions app/src/db/models/email_log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Prisma } from '@prisma/client';

import type { EmailLog } from '../../types';

// Define types
const _emailLog = Prisma.validator<Prisma.email_logDefaultArgs>()({});
const _emailLogWithGraph = Prisma.validator<Prisma.email_logDefaultArgs>()({});

type PrismaRelationEmailLog = Prisma.email_logGetPayload<typeof _emailLog>;
type PrismaGraphEmailLog = Prisma.email_logGetPayload<typeof _emailLogWithGraph>;

export default {
toPrismaModel(input: EmailLog): PrismaRelationEmailLog {
return {
email_log_id: input.emailId as string,
http_status: input.httpStatus,
msg_id: input.msgId ?? null,
to: input.to ?? null,
tx_id: input.txId ?? null,
created_at: input.createdAt ? new Date(input.createdAt) : null,
created_by: input.createdBy as string,
updated_at: input.updatedAt ? new Date(input.updatedAt) : null,
updated_by: input.updatedBy as string
};
},

fromPrismaModel(input: PrismaGraphEmailLog): EmailLog {
return {
emailId: input.email_log_id,
httpStatus: Number(input.http_status),
msgId: input.msg_id || '',
to: input.to || '',
createdAt: input.created_at?.toISOString() ?? null,
createdBy: input.created_by,
updatedAt: input.updated_at?.toISOString() ?? null,
updatedBy: input.updated_by
};
}
};
1 change: 1 addition & 0 deletions app/src/db/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { default as access_request } from './access_request';
export { default as contact } from './contact';
export { default as document } from './document';
export { default as draft } from './draft';
export { default as email_log } from './email_log';
export { default as enquiry } from './enquiry';
export { default as identity_provider } from './identity_provider';
export { default as note } from './note';
Expand Down
14 changes: 14 additions & 0 deletions app/src/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,20 @@ model draft_code {
@@schema("public")
}

model email_log {
email_log_id String @id @db.Uuid
http_status Int?
msg_id String? @db.Uuid
to String?
tx_id String? @db.Uuid
created_by String? @default("00000000-0000-0000-0000-000000000000")
created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_by String?
updated_at DateTime? @db.Timestamptz(6)

@@schema("public")
}

view group_role_policy_vw {
row_number BigInt @unique
group_id Int?
Expand Down
41 changes: 41 additions & 0 deletions app/src/services/email.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import axios from 'axios';
import config from 'config';
import { v4 as uuidv4 } from 'uuid';

import prisma from '../db/dataConnection';

import type { AxiosInstance } from 'axios';
import type { Email } from '../types';

type Message = {
msgId: string;
to: Array<string>;
};

type EmailData = {
messages: Array<Message>;
txId: string;
};
/**
* @function getToken
* Gets Auth token using CHES client credentials
Expand Down Expand Up @@ -55,6 +67,9 @@ const service = {
* @returns Axios response status and data
*/
email: async (emailData: Email) => {
// Generate list of unique emails to be sent
const uniqueEmails = Array.from(new Set([...emailData.to, ...(emailData?.cc || []), ...(emailData?.bcc || [])]));

try {
const { data, status } = await chesAxios().post('/email', emailData, {
headers: {
Expand All @@ -63,14 +78,18 @@ const service = {
maxContentLength: Infinity,
maxBodyLength: Infinity
});

service.logEmail(data, uniqueEmails, status);
return { data, status };
} catch (e: unknown) {
if (axios.isAxiosError(e)) {
service.logEmail(null, uniqueEmails, e.response ? e.response.status : 500);
return {
data: e.response?.data.errors[0].message,
status: e.response ? e.response.status : 500
};
} else {
service.logEmail(null, uniqueEmails, 500);
return {
data: 'Email error',
status: 500
Expand All @@ -79,6 +98,28 @@ const service = {
}
},

/**
* @function logEmail
* Logs CHES email api calls
* @param {EmailData | null} data Object containing CHES response, or null on error
* @param {Array<string>} recipients Array of email strings
* @param {status} status Http status of CHES response
* @returns null
*/
logEmail: async (data: EmailData | null, recipients: Array<string>, status: number) => {
return await prisma.$transaction(async (trx) => {
return await trx.email_log.createMany({
data: recipients.map((recipient) => ({
email_log_id: uuidv4(),
msg_id: data?.messages?.[0].msgId,
to: recipient,
tx_id: data?.txId,
http_status: status
}))
});
});
},

/**
* @function health
* Checks CHES service health
Expand Down
9 changes: 9 additions & 0 deletions app/src/types/EmailLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IStamps } from '../interfaces/IStamps';

export type EmailLog = {
emailId: string; // Primary Key
httpStatus: number;
msgId?: string;
to?: string;
txId?: string;
} & Partial<IStamps>;
1 change: 1 addition & 0 deletions app/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type { CurrentContext } from './CurrentContext';
export type { Document } from './Document';
export type { Draft } from './Draft';
export type { Email } from './Email';
export type { EmailLog } from './EmailLog';
export type { Enquiry } from './Enquiry';
export type { EnquiryIntake } from './EnquiryIntake';
export type { EnquirySearchParameters } from './EnquirySearchParameters';
Expand Down
15 changes: 15 additions & 0 deletions app/tests/__mocks__/prismaMock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';

import prisma from '../../src/db/dataConnection';

jest.mock('../../src/db/dataConnection', () => ({
__esModule: true,
default: mockDeep<PrismaClient>()
}));

beforeEach(() => {
mockReset(prismaMock);
});

export const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>;
78 changes: 78 additions & 0 deletions app/tests/unit/services/email.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { emailService } from '../../../src/services';

import { prismaMock } from '../../__mocks__/prismaMock';

type Message = {
msgId: string;
to: Array<string>;
};

type EmailData = {
messages: Array<Message>;
txId: string;
};

const chesResponse: EmailData = {
messages: [{ msgId: '9c50c187-4f89-463b-afea-ededc889dd31', to: [] }],
txId: '508a1f8f-b5a1-4d37-a8c9-f7d7c0a86c00'
};

const recipientsDefault: Array<string> = ['test1@test.com', 'test2@test.com', 'test3@test.com', 'test4@test.com'];

describe('logEmail tests', () => {
beforeEach(() => {
prismaMock.$transaction.mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(callback: any) => callback(prismaMock)
);
});

it('should call $transaction and email_log.createMany once each', async () => {
prismaMock.email_log.createMany.mockResolvedValueOnce({ count: recipientsDefault.length });
await emailService.logEmail(chesResponse, recipientsDefault, 201);

expect(prismaMock.$transaction).toHaveBeenCalledTimes(1);
expect(prismaMock.email_log.createMany).toHaveBeenCalledTimes(1);
});

it('should call the correct number of recipients', async () => {
prismaMock.email_log.createMany.mockResolvedValueOnce({ count: recipientsDefault.length });
const res = await emailService.logEmail(chesResponse, recipientsDefault, 201);
expect(res).toEqual({ count: recipientsDefault.length });
});

it('should call createMany with the correct parameters', async () => {
const recipients: Array<string> = ['test1@test.com', 'test2@test.com', 'test3@test.com', 'test3@test.com'];

prismaMock.email_log.createMany.mockResolvedValueOnce({ count: recipients.length });
await emailService.logEmail(chesResponse, recipients, 201);

expect(prismaMock.email_log.createMany).toHaveBeenCalledWith({
data: recipients.map((x) => ({
email_log_id: expect.any(String),
msg_id: chesResponse.messages?.[0].msgId,
to: x,
tx_id: chesResponse.txId,
http_status: 201
}))
});
});

it('should call createMany with the right http status code', async () => {
const recipients: Array<string> = ['test1@test.com'];
const statusCode: number = 451;

prismaMock.email_log.createMany.mockResolvedValueOnce({ count: recipients.length });
await emailService.logEmail(chesResponse, recipients, statusCode);

expect(prismaMock.email_log.createMany).toHaveBeenCalledWith({
data: recipients.map((x) => ({
email_log_id: expect.any(String),
msg_id: chesResponse.messages?.[0].msgId,
to: x,
tx_id: chesResponse.txId,
http_status: statusCode
}))
});
});
});
Loading