diff --git a/package-lock.json b/package-lock.json index 24e1ce1e..1b371ae7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "veritable-ui", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "veritable-ui", - "version": "0.3.1", + "version": "0.3.2", "license": "Apache-2.0", "dependencies": { "@digicatapult/tsoa-oauth-express": "^0.1.0", @@ -17,6 +17,7 @@ "body-parser": "^1.20.2", "compression": "^1.7.4", "cookie-parser": "^1.4.6", + "dotenv": "^16.4.5", "envalid": "^8.0.0", "express": "^4.19.2", "htmx.org": "^1.9.12", @@ -2046,6 +2047,17 @@ "node": ">=0.3.1" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "license": "MIT" diff --git a/package.json b/package.json index bac93337..f31090ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "veritable-ui", - "version": "0.3.1", + "version": "0.3.2", "description": "UI for Veritable", "main": "src/index.ts", "type": "commonjs", @@ -36,6 +36,7 @@ "body-parser": "^1.20.2", "compression": "^1.7.4", "cookie-parser": "^1.4.6", + "dotenv": "^16.4.5", "envalid": "^8.0.0", "express": "^4.19.2", "htmx.org": "^1.9.12", diff --git a/src/controllers/connection/newConnection.ts b/src/controllers/connection/newConnection.ts new file mode 100644 index 00000000..00729992 --- /dev/null +++ b/src/controllers/connection/newConnection.ts @@ -0,0 +1,84 @@ +import { Get, Produces, Query, Route, Security, SuccessResponse } from 'tsoa' +import { inject, injectable, singleton } from 'tsyringe' + +import { Logger, type ILogger } from '../../logger.js' +import CompantHouseEntity from '../../models/companyHouseEntity.js' +import NewConnectionTemplates from '../../views/newConnection.js' +import { HTML, HTMLController } from '../HTMLController.js' + +@singleton() +@injectable() +@Security('oauth2') +@Route('/connection/new') +@Produces('text/html') +export class NewConnectionController extends HTMLController { + constructor( + private companyHouseEntity: CompantHouseEntity, + private newConnection: NewConnectionTemplates, + @inject(Logger) private logger: ILogger + ) { + super() + this.logger = logger.child({ controller: '/connection/new' }) + } + + /** + * + * @returns The new connections form page + */ + @SuccessResponse(200) + @Get('/') + public async newConnectionForm(): Promise { + this.logger.debug('new connection page requested') + return this.html( + this.newConnection.formPage({ + targetBox: await this.newConnection.companyEmptyTextBox({ + errorMessage: 'Please type in company number to populate information', + }), + }) + ) + } + + /** + * Returns a company from a validated company number + */ + @SuccessResponse(200) + @Get('/verify-company') + public async verifyCompanyForm(@Query() companyNumber: string): Promise { + this.logger.debug('connections page requested') + const regex = + /^(((AC|CE|CS|FC|FE|GE|GS|IC|LP|NC|NF|NI|NL|NO|NP|OC|OE|PC|R0|RC|SA|SC|SE|SF|SG|SI|SL|SO|SR|SZ|ZC|\d{2})\d{6})|((IP|SP|RS)[A-Z\d]{6})|(SL\d{5}[\dA]))$/ + if (!regex.test(`${companyNumber}`)) { + return this.html( + this.newConnection.companyNumberInput({ + targetBox: await this.newConnection.companyEmptyTextBox({ errorMessage: 'Company number format incorrect' }), + }) + ) + } + let company + try { + company = await this.companyHouseEntity.getCompanyProfileByCompanyNumber(companyNumber) + } catch (err) { + return this.html( + this.newConnection.companyNumberInput({ + targetBox: await this.newConnection.companyEmptyTextBox({ errorMessage: 'Company number does not exist' }), + }) + ) + } + + return this.html( + this.newConnection.companyNumberInput({ + targetBox: await this.newConnection.companyFilledTextBox({ company }), + }) + ) + } + + /** + * submits the company number for + */ + @SuccessResponse(200) + @Get('/submit') + public async submitCompanyNumber(): Promise { + // do some regex if there is a match on the whole regex return true else return false + return this.html(this.newConnection.companyEmptyTextBox({ errorMessage: 'Company does not exist' })) + } +} diff --git a/src/env.ts b/src/env.ts index f9a072d8..a7d9ad01 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,6 +1,13 @@ +import dotenv from 'dotenv' import * as envalid from 'envalid' import { singleton } from 'tsyringe' +if (process.env.NODE_ENV === 'test') { + dotenv.config({ path: 'test/test.env' }) +} else { + dotenv.config() +} + const strArrayValidator = envalid.makeValidator((input) => { const arr = input .split(',') @@ -29,6 +36,8 @@ const envConfig = { IDP_OIDC_CONFIG_URL: envalid.url({ devDefault: 'http://localhost:3080/realms/veritable/.well-known/openid-configuration', }), + COMPANY_HOUSE_API_URL: envalid.str({ default: 'https://api.company-information.service.gov.uk' }), + COMPANY_PROFILE_API_KEY: envalid.str(), } export type ENV_CONFIG = typeof envConfig diff --git a/src/models/__tests__/companyHouseEntity.test.ts b/src/models/__tests__/companyHouseEntity.test.ts new file mode 100644 index 00000000..8c6193ce --- /dev/null +++ b/src/models/__tests__/companyHouseEntity.test.ts @@ -0,0 +1,54 @@ +import { describe, it } from 'mocha' +import { Env } from '../../env' +import CompanyHouseEntity from '../companyHouseEntity' +import { + invalidCompanyNumber, + noCompanyNumber, + successResponse, + validCompanyNumber, +} from './fixtures/companyHouseFixtures' +import { withCompanyHouseMock } from './helpers/mockCompanyHouse' + +describe('companyHouseEntity', () => { + let expect: Chai.ExpectStatic + before(async () => { + expect = (await import('chai')).expect + }) + + withCompanyHouseMock() + + describe('getCompanyProfileByCompanyNumber', () => { + it('should give back a json in format of companyProfileSchema', async () => { + const environment = new Env() + const companyHouseObject = new CompanyHouseEntity(environment) + const response = await companyHouseObject.getCompanyProfileByCompanyNumber(validCompanyNumber) + expect(response).deep.equal(successResponse) + }) + it('should give back a empty json', async () => { + const environment = new Env() + const companyHouseObject = new CompanyHouseEntity(environment) + let errorMessage: unknown + try { + await companyHouseObject.getCompanyProfileByCompanyNumber(noCompanyNumber) + } catch (err) { + errorMessage = err + } + + expect(errorMessage).instanceOf(Error) + expect((errorMessage as Error).message).equals(`Error calling CompanyHouse API`) + }) + it('should throw an error saying Error calling CompanyHouse API', async () => { + const environment = new Env() + const companyHouseObject = new CompanyHouseEntity(environment) + let errorMessage: unknown + try { + await companyHouseObject.getCompanyProfileByCompanyNumber(invalidCompanyNumber) + } catch (err) { + errorMessage = err + } + + expect(errorMessage).instanceOf(Error) + expect((errorMessage as Error).message).equals(`Error calling CompanyHouse API`) + }) + }) +}) diff --git a/src/models/__tests__/fixtures/companyHouseFixtures.ts b/src/models/__tests__/fixtures/companyHouseFixtures.ts new file mode 100644 index 00000000..0ed60155 --- /dev/null +++ b/src/models/__tests__/fixtures/companyHouseFixtures.ts @@ -0,0 +1,16 @@ +export const validCompanyNumber = '10592650' +export const invalidCompanyNumber = '105926502' +export const noCompanyNumber = '' + +export const successResponse = { + registered_office_address: { + address_line_1: '51 Mornington Crescent', + locality: 'Hounslow', + postal_code: 'TW5 9ST', + country: 'United Kingdom', + }, + company_status: 'dissolved', + registered_office_is_in_dispute: false, + company_name: 'SMH IOT SOLUTIONS LTD', + company_number: '10592650', +} diff --git a/src/models/__tests__/helpers/mockCompanyHouse.ts b/src/models/__tests__/helpers/mockCompanyHouse.ts new file mode 100644 index 00000000..ca1477aa --- /dev/null +++ b/src/models/__tests__/helpers/mockCompanyHouse.ts @@ -0,0 +1,53 @@ +import { container } from 'tsyringe' +import { Dispatcher, MockAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici' +import { Env } from '../../../env' +import { + invalidCompanyNumber, + noCompanyNumber, + successResponse, + validCompanyNumber, +} from '../fixtures/companyHouseFixtures' + +const env = container.resolve(Env) + +export function withCompanyHouseMock() { + let originalDispatcher: Dispatcher + let agent: MockAgent + beforeEach(function () { + originalDispatcher = getGlobalDispatcher() + agent = new MockAgent() + setGlobalDispatcher(agent) + + const client = agent.get(env.get('COMPANY_HOUSE_API_URL')) + client + .intercept({ + path: `/company/${validCompanyNumber}`, + method: 'GET', + }) + .reply(200, successResponse) + + client + .intercept({ + path: `/company/${noCompanyNumber}`, + method: 'GET', + }) + .reply(404, {}) + + client + .intercept({ + path: `/company/${invalidCompanyNumber}`, + method: 'GET', + }) + .reply(404, { + errors: [ + { + type: 'ch:service', + error: 'company-profile-not-found', + }, + ], + }) + }) + afterEach(function () { + setGlobalDispatcher(originalDispatcher) + }) +} diff --git a/src/models/companyHouseEntity.ts b/src/models/companyHouseEntity.ts new file mode 100644 index 00000000..bb541292 --- /dev/null +++ b/src/models/companyHouseEntity.ts @@ -0,0 +1,74 @@ +import { injectable, singleton } from 'tsyringe' +import { z } from 'zod' +import { Env } from '../env' +import { InternalError } from '../errors' + +const companyProfileSchema = z.object({ + company_number: z.string(), + // .regex( + // new RegExp( + // /^((AC|ZC|FC|GE|LP|OC|SE|SA|SZ|SF|GS|SL|SO|SC|ES|NA|NZ|NF|GN|NL|NC|R0|NI|EN|\d{2}|SG|FE)\d{5}(\d|C|R))|((RS|SO)\d{3}(\d{3}|\d{2}[WSRCZF]|\d(FI|RS|SA|IP|US|EN|AS)|CUS))|((NI|SL)\d{5}[\dA])|(OC(([\dP]{5}[CWERTB])|([\dP]{4}(OC|CU))))$/ + // ) + // ), + company_name: z.string(), + registered_office_address: z.object({ + address_line_1: z.string().optional(), + address_line_2: z.string().optional(), + care_of: z.string().optional(), + country: z.string().optional(), + locality: z.string().optional(), + po_box: z.string().optional(), + postal_code: z.string().optional(), + premises: z.string().optional(), + region: z.string().optional(), + }), + registered_office_is_in_dispute: z.boolean(), + company_status: z.union([ + z.literal('active'), + z.literal('dissolved'), + z.literal('liquidation'), + z.literal('receivership'), + z.literal('converted-closed'), + z.literal('voluntary-arrangement'), + z.literal('insolvency-proceedings'), + z.literal('administration'), + z.literal('open'), + z.literal('closed'), + z.literal('registered'), + z.literal('removed'), + ]), +}) +export type CompanyProfile = z.infer + +@singleton() +@injectable() +export default class CompanyHouseEntity { + constructor(private env: Env) {} + + private async makeCompanyProfileRequest(route: string): Promise { + const url = new URL(route) + + const response = await fetch(url.toString(), { + method: 'GET', + headers: new Headers({ + Authorization: this.env.get('COMPANY_PROFILE_API_KEY'), + }), + }) + if (!response.ok) { + throw new InternalError(`Error calling CompanyHouse API`) + } + + return response.json() + } + + /* + This function will return a companyProfile object + */ + async getCompanyProfileByCompanyNumber(companyNumber: string): Promise { + const endpoint = `${this.env.get('COMPANY_HOUSE_API_URL')}/company/${encodeURIComponent(companyNumber)}` + + const companyProfile = await this.makeCompanyProfileRequest(endpoint) + + return companyProfileSchema.parse(companyProfile) + } +} diff --git a/src/views/__tests__/connection.test.ts.snap b/src/views/__tests__/connection.test.ts.snap index f6363864..c7b0a9c7 100644 --- a/src/views/__tests__/connection.test.ts.snap +++ b/src/views/__tests__/connection.test.ts.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ConnectionTemplates listPage should escape html in name 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
<div>I own you</div>
Verified - Established Connection
some action
"`; +exports[`ConnectionTemplates listPage should escape html in name 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
<div>I own you</div>
Verified - Established Connection
some action
"`; -exports[`ConnectionTemplates listPage should render multiple with each status 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
Company B
'Pending Your Verification'
some action
Company C
Unverified
some action
Company D
Verified - Established Connection
some action
Company E
Pending Your Verification
some action
Company F
Pending Their Verification
some action
"`; +exports[`ConnectionTemplates listPage should render multiple with each status 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
Company B
'Pending Your Verification'
some action
Company C
Unverified
some action
Company D
Verified - Established Connection
some action
Company E
Pending Your Verification
some action
Company F
Pending Their Verification
some action
"`; -exports[`ConnectionTemplates listPage should render with no connections 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
"`; +exports[`ConnectionTemplates listPage should render with no connections 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
"`; -exports[`ConnectionTemplates listPage should render with single connection 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
"`; +exports[`ConnectionTemplates listPage should render with single connection 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
"`; diff --git a/src/views/common.tsx b/src/views/common.tsx index 7c99ba84..cffdd46a 100644 --- a/src/views/common.tsx +++ b/src/views/common.tsx @@ -88,9 +88,9 @@ export const Page = (props: Html.PropsWithChildren): JSX.Element => ( {''} - - - + + + {Html.escapeHtml(props.title)} diff --git a/src/views/newConnection.tsx b/src/views/newConnection.tsx new file mode 100644 index 00000000..66a44ab3 --- /dev/null +++ b/src/views/newConnection.tsx @@ -0,0 +1,89 @@ +import Html from '@kitajs/html' +import { singleton } from 'tsyringe' +import { CompanyProfile } from '../models/companyHouseEntity' +import { Page } from './common' + +@singleton() +export default class newConnectionTemplates { + constructor() {} + + public formPage = ({ targetBox }: { targetBox: JSX.Element }) => { + return ( + + + + ) + } + + public companyNumberInput = ({ targetBox }: { targetBox: JSX.Element }): JSX.Element => { + return ( + <> +
+ + + + +
{targetBox}
+
+ + ) + } + + public companyFilledTextBox = ({ company }: { company: CompanyProfile }): JSX.Element => { + return ( + <> +

{Html.escapeHtml(company.company_name)}

+

{Html.escapeHtml(company.registered_office_address.address_line_1)}

+ {company?.registered_office_address?.address_line_2 && ( +

{Html.escapeHtml(company.registered_office_address.address_line_2)}

+ )} + {company?.registered_office_address?.address_line_2 && ( +

{Html.escapeHtml(company.registered_office_address.address_line_2)}

+ )} + {company?.registered_office_address?.care_of && ( +

{Html.escapeHtml(company.registered_office_address.care_of)}

+ )} + {company?.registered_office_address?.locality && ( +

{Html.escapeHtml(company.registered_office_address.locality)}

+ )} + {company?.registered_office_address?.po_box && ( +

{Html.escapeHtml(company.registered_office_address.po_box)}

+ )} + {company?.registered_office_address?.postal_code && ( +

{Html.escapeHtml(company.registered_office_address.postal_code)}

+ )} + {company?.registered_office_address?.country && ( +

{Html.escapeHtml(company.registered_office_address.country)}

+ )} + {company?.registered_office_address?.premises && ( +

{Html.escapeHtml(company.registered_office_address.premises)}

+ )} + {company?.registered_office_address?.region && ( +

{Html.escapeHtml(company.registered_office_address.region)}

+ )} +

{Html.escapeHtml(company.company_status)}

+ + ) + } + + public companyEmptyTextBox = ({ errorMessage }: { errorMessage: string }): JSX.Element => { + return ( + <> +

{Html.escapeHtml(errorMessage)}

+ + ) + } +} diff --git a/test/test.env b/test/test.env new file mode 100644 index 00000000..fe39cb16 --- /dev/null +++ b/test/test.env @@ -0,0 +1 @@ +COMPANY_PROFILE_API_KEY=API_KEY \ No newline at end of file