diff --git a/migrations/20241216104418_refactor-housing-reference.js b/migrations/20241216104418_refactor-housing-reference.js new file mode 100644 index 00000000..55b8ab59 --- /dev/null +++ b/migrations/20241216104418_refactor-housing-reference.js @@ -0,0 +1,66 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.transaction(async (trx) => { + await trx.raw(` + ALTER TABLE application_profile_housing_reference + ALTER COLUMN phone nvarchar(36) NULL; + `) + + await trx.raw(` + ALTER TABLE application_profile_housing_reference + ADD + comment nvarchar(max), + reasonRejected nvarchar(36), + lastAdminUpdatedAt datetimeoffset, + lastAdminUpdatedBy nvarchar(36), + lastApplicantUpdatedAt datetimeoffset; + `) + + await trx.raw(` + ALTER TABLE application_profile_housing_reference + DROP COLUMN reviewStatusReason, reviewedAt; + `) + + await trx.raw(` + ALTER TABLE application_profile + ALTER COLUMN housingType nvarchar(36) NOT NULL; + `) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.transaction(async (trx) => { + await trx.raw(` + ALTER TABLE application_profile_housing_reference + ADD reviewStatusReason nvarchar(max), + reviewedAt datetimeoffset; + `) + + await trx.raw(` + ALTER TABLE application_profile_housing_reference + DROP COLUMN + comment, + reasonRejected, + lastAdminUpdatedAt, + lastApplicantUpdatedAt, + lastAdminUpdatedBy; + `) + + await trx.raw(` + ALTER TABLE application_profile_housing_reference + ALTER COLUMN phone nvarchar(36) NOT NULL; + `) + + await trx.raw(` + ALTER TABLE application_profile + ALTER COLUMN housingType nvarchar(36) NULL; + `) + }) +} diff --git a/package-lock.json b/package-lock.json index 5bc06525..e655d22a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "koa-pino-logger": "^4.0.0", "koa2-swagger-ui": "^5.10.0", "mssql": "^11.0.1", - "onecore-types": "^2.5.0", + "onecore-types": "^3.0.0", "onecore-utilities": "^1.1.0", "personnummer": "^3.2.1", "pino": "^9.1.0", @@ -7560,9 +7560,9 @@ } }, "node_modules/onecore-types": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/onecore-types/-/onecore-types-2.5.0.tgz", - "integrity": "sha512-DSv5wIn9DDtUanMOevoyDkDtlPUq5VbO57wASHdKMN32fCnfmVU/pEbzKX/ctsD+A5HQeMH0DiVQp8zhUH0mAQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/onecore-types/-/onecore-types-3.0.0.tgz", + "integrity": "sha512-VoETYXcmwo9JDDQDXebfjYFO8k/c2+YPUnazP1QypZJsySFC1YlpITBD41YHpKeJo9O8unDfpLLZQYFAlKzYpQ==", "dependencies": { "release-please": "^16.8.0" }, diff --git a/package.json b/package.json index 90e56601..b955fcec 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "koa-pino-logger": "^4.0.0", "koa2-swagger-ui": "^5.10.0", "mssql": "^11.0.1", - "onecore-types": "^2.5.0", + "onecore-types": "^3.0.0", "onecore-utilities": "^1.1.0", "personnummer": "^3.2.1", "pino": "^9.1.0", diff --git a/src/services/lease-service/adapters/application-profile-adapter.ts b/src/services/lease-service/adapters/application-profile-adapter.ts index cd6b4982..4622dc63 100644 --- a/src/services/lease-service/adapters/application-profile-adapter.ts +++ b/src/services/lease-service/adapters/application-profile-adapter.ts @@ -1,46 +1,89 @@ import { Knex } from 'knex' import { RequestError } from 'tedious' import { logger } from 'onecore-utilities' -import { ApplicationProfile } from 'onecore-types' +import { z } from 'zod' import { AdapterResult } from './types' +import { + ApplicationProfileHousingReferenceSchema, + ApplicationProfileSchema, +} from './db-schemas' -type CreateParams = { - contactCode: string - numAdults: number - numChildren: number - expiresAt: Date | null - housingType?: string - housingTypeDescription?: string - landlord?: string -} +type ApplicationProfile = z.infer + +const _CreateParamsSchema = ApplicationProfileSchema.pick({ + numChildren: true, + numAdults: true, + expiresAt: true, + housingType: true, + housingTypeDescription: true, + landlord: true, +}).extend({ + housingReference: ApplicationProfileHousingReferenceSchema.pick({ + expiresAt: true, + phone: true, + email: true, + reviewStatus: true, + comment: true, + reasonRejected: true, + lastAdminUpdatedAt: true, + lastApplicantUpdatedAt: true, + }), +}) + +type CreateParams = z.infer export async function create( db: Knex, + contactCode: string, params: CreateParams ): Promise< AdapterResult > { try { - const [profile] = await db - .insert({ - contactCode: params.contactCode, - numChildren: params.numChildren, - numAdults: params.numAdults, - expiresAt: params.expiresAt, - housingType: params.housingType, - housingTypeDescription: params.housingTypeDescription, - landlord: params.landlord, + const result = await db.transaction(async (trx) => { + const [profile] = await trx + .insert({ + contactCode: contactCode, + numChildren: params.numChildren, + numAdults: params.numAdults, + expiresAt: params.expiresAt, + housingType: params.housingType, + housingTypeDescription: params.housingTypeDescription, + landlord: params.landlord, + }) + .into('application_profile') + .returning('*') + + const [reference] = await trx + .insert({ + applicationProfileId: profile.id, + phone: params.housingReference.phone, + email: params.housingReference.email, + reviewStatus: params.housingReference.reviewStatus, + comment: params.housingReference.comment, + reasonRejected: params.housingReference.reasonRejected, + lastAdminUpdatedAt: params.housingReference.lastAdminUpdatedAt, + lastAdminUpdatedBy: 'not-implemented', + lastApplicantUpdatedAt: + params.housingReference.lastApplicantUpdatedAt, + expiresAt: params.housingReference.expiresAt, + }) + .into('application_profile_housing_reference') + .returning('*') + + return ApplicationProfileSchema.parse({ + ...profile, + housingReference: reference, }) - .into('application_profile') - .returning('*') + }) - return { ok: true, data: profile } + return { ok: true, data: result } } catch (err) { if (err instanceof RequestError) { if (err.message.includes('UQ_contactCode')) { logger.info( - { contactCode: params.contactCode }, + { contactCode }, 'applicationProfileAdapter.create - can not insert duplicate application profile' ) return { ok: false, err: 'conflict-contact-code' } @@ -62,14 +105,15 @@ export async function getByContactCode( SELECT ap.*, ( - SELECT apht.* - FROM application_profile_housing_reference apht - WHERE apht.applicationProfileId = ap.id + SELECT apht2.* + FROM application_profile_housing_reference apht2 + WHERE apht2.applicationProfileId = ap.id FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, INCLUDE_NULL_VALUES ) AS housingReference FROM application_profile ap + INNER JOIN application_profile_housing_reference apht ON ap.id = apht.applicationProfileId WHERE ap.contactCode = ? - `, + `, [contactCode] ) @@ -77,28 +121,12 @@ export async function getByContactCode( return { ok: false, err: 'not-found' } } - const housingReference = row.housingReference - ? JSON.parse(row.housingReference) - : undefined - return { ok: true, - data: { + data: ApplicationProfileSchema.parse({ ...row, - housingReference: housingReference - ? { - ...housingReference, - expiresAt: new Date(housingReference.expiresAt), - createdAt: new Date(housingReference.createdAt), - reviewedAt: housingReference.reviewedAt - ? new Date(housingReference.reviewedAt) - : null, - } - : undefined, - housingType: row.housingType || undefined, - housingTypeDescription: row.housingTypeDescription || undefined, - landlord: row.landlord || undefined, - }, + housingReference: JSON.parse(row.housingReference), + }), } } catch (err) { logger.error(err, 'applicationProfileAdapter.getByContactCode') @@ -106,14 +134,7 @@ export async function getByContactCode( } } -type UpdateParams = { - numChildren: number - numAdults: number - expiresAt: Date | null - housingType?: string - housingTypeDescription?: string - landlord?: string -} +type UpdateParams = z.infer export async function update( db: Knex, @@ -121,23 +142,50 @@ export async function update( params: UpdateParams ): Promise> { try { - const [profile] = await db('application_profile') - .update({ - numChildren: params.numChildren, - numAdults: params.numAdults, - expiresAt: params.expiresAt, - housingType: params.housingType, - housingTypeDescription: params.housingTypeDescription, - landlord: params.landlord, + const result = await db.transaction(async (trx) => { + const [profile] = await db('application_profile') + .update({ + numChildren: params.numChildren, + numAdults: params.numAdults, + expiresAt: params.expiresAt, + housingType: params.housingType, + housingTypeDescription: params.housingTypeDescription, + landlord: params.landlord, + }) + .where('contactCode', contactCode) + .returning('*') + + if (!profile) { + return 'no-update' + } + + const [reference] = await trx('application_profile_housing_reference') + .update({ + phone: params.housingReference.phone, + email: params.housingReference.email, + reviewStatus: params.housingReference.reviewStatus, + comment: params.housingReference.comment, + reasonRejected: params.housingReference.reasonRejected, + lastAdminUpdatedAt: params.housingReference.lastAdminUpdatedAt, + lastAdminUpdatedBy: 'not-implemented', + lastApplicantUpdatedAt: + params.housingReference.lastApplicantUpdatedAt, + expiresAt: params.housingReference.expiresAt, + }) + .where({ applicationProfileId: profile.id }) + .returning('*') + + return ApplicationProfileSchema.parse({ + ...profile, + housingReference: reference, }) - .where('contactCode', contactCode) - .returning('*') + }) - if (!profile) { + if (result === 'no-update') { return { ok: false, err: 'no-update' } } - return { ok: true, data: profile } + return { ok: true, data: result } } catch (err) { logger.error(err, 'applicationProfileAdapter.update') return { ok: false, err: 'unknown' } diff --git a/src/services/lease-service/adapters/application-profile-housing-reference-adapter.ts b/src/services/lease-service/adapters/application-profile-housing-reference-adapter.ts deleted file mode 100644 index 46e8531c..00000000 --- a/src/services/lease-service/adapters/application-profile-housing-reference-adapter.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Knex } from 'knex' -import { logger } from 'onecore-utilities' -import { ApplicationProfileHousingReference } from 'onecore-types' - -import { AdapterResult } from './types' -import { RequestError } from 'tedious' - -type CreateParams = Omit - -export async function create( - db: Knex, - params: CreateParams -): Promise< - AdapterResult< - ApplicationProfileHousingReference, - 'conflict-application-profile-id' | 'unknown' - > -> { - try { - const [row] = await db - .insert({ - applicationProfileId: params.applicationProfileId, - phone: params.phone, - email: params.email, - reviewStatus: params.reviewStatus, - reviewStatusReason: params.reviewStatusReason, - reviewedAt: params.reviewedAt, - expiresAt: params.expiresAt, - }) - .into('application_profile_housing_reference') - .returning('*') - - return { ok: true, data: row } - } catch (err) { - if (err instanceof RequestError) { - if (err.message.includes('UQ_applicationProfileId')) { - logger.info( - { applicationProfileId: params.applicationProfileId }, - 'ApplicationProfileHousingReferenceAdapter.create - can not insert duplicate application profile id' - ) - return { ok: false, err: 'conflict-application-profile-id' } - } - } - - logger.error(err, 'applicationProfileAdapter.create') - return { ok: false, err: 'unknown' } - } -} - -export async function findByApplicationProfileId( - db: Knex, - applicationProfileId: number -): Promise< - AdapterResult -> { - try { - const [row] = await db - .select('*') - .from('application_profile_housing_reference') - .where('applicationProfileId', applicationProfileId) - .returning('*') - - if (!row) { - return { ok: false, err: 'not-found' } - } - - return { ok: true, data: row } - } catch (err) { - logger.error( - err, - 'ApplicationProfileHousingReferenceAdapter.findByApplicationProfileId' - ) - return { ok: false, err: 'unknown' } - } -} - -type UpdateParams = Omit< - ApplicationProfileHousingReference, - 'id' | 'createdAt' | 'applicationProfileId' -> - -export async function update( - db: Knex, - applicationProfileId: number, - params: UpdateParams -): Promise< - AdapterResult -> { - try { - const [row] = await db('application_profile_housing_reference') - .update({ - phone: params.phone, - email: params.email, - reviewStatus: params.reviewStatus, - reviewStatusReason: params.reviewStatusReason, - reviewedAt: params.reviewedAt, - expiresAt: params.expiresAt, - }) - .where('applicationProfileId', applicationProfileId) - .returning('*') - - if (!row) { - return { ok: false, err: 'no-update' } - } - - return { ok: true, data: row } - } catch (err) { - logger.error(err, 'applicationProfileHousingReferenceAdapter.update') - return { ok: false, err: 'unknown' } - } -} diff --git a/src/services/lease-service/adapters/db-schemas.ts b/src/services/lease-service/adapters/db-schemas.ts new file mode 100644 index 00000000..2fd81c2b --- /dev/null +++ b/src/services/lease-service/adapters/db-schemas.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' + +export const HousingReferenceReviewStatusSchema = z.enum([ + 'APPROVED', + 'REJECTED', + 'CONTACTED_UNREACHABLE', + 'PENDING', + 'REFERENCE_NOT_REQUIRED', +]) + +export const HousingReferenceReasonRejectedSchema = z.enum([ + 'DISTURBANCE', + 'LATE_RENT_PAYMENT', + 'DEBT_TO_LANDLORD', + 'MISMANAGEMENT', +]) + +export const ApplicationProfileHousingReferenceSchema = z.object({ + id: z.number(), + applicationProfileId: z.number(), + phone: z.string().nullable(), // TODO: Should parse as phone number + email: z.string().nullable(), // TODO: Should parse as email + reviewStatus: HousingReferenceReviewStatusSchema, + comment: z.string().nullable(), + reasonRejected: HousingReferenceReasonRejectedSchema.nullable(), + lastAdminUpdatedAt: z.coerce.date().nullable(), + lastAdminUpdatedBy: z.string().nullable(), + lastApplicantUpdatedAt: z.coerce.date().nullable(), + + expiresAt: z.coerce.date(), + createdAt: z.coerce.date(), +}) + +export const ApplicationProfileHousingTypeSchema = z.enum([ + 'LIVES_WITH_FAMILY', // Bor med familj + 'LODGER', // Inneboende + 'RENTAL', // Hyresrätt + 'SUB_RENTAL', // Andrahandskontrakt + 'OWNS_HOUSE', // Äger hus + 'OWNS_FLAT', // Äger lägenhet + 'OWNS_ROW_HOUSE', // Äger radhus + 'OTHER', // Övrigt, +]) + +export const ApplicationProfileSchema = z.object({ + id: z.number(), + contactCode: z.string(), + numAdults: z.number(), + numChildren: z.number(), + housingType: ApplicationProfileHousingTypeSchema, + housingTypeDescription: z.string().nullable(), + landlord: z.string().nullable(), + housingReference: ApplicationProfileHousingReferenceSchema, + expiresAt: z.coerce.date().nullable(), + createdAt: z.coerce.date(), +}) diff --git a/src/services/lease-service/routes/contacts.ts b/src/services/lease-service/routes/contacts.ts index fc1bfd18..83916318 100644 --- a/src/services/lease-service/routes/contacts.ts +++ b/src/services/lease-service/routes/contacts.ts @@ -583,7 +583,7 @@ export const routes = (router: KoaRouter) => { return } - const [profile, operation] = result.data + const [operation, profile] = result.data ctx.status = operation === 'created' ? 201 : 200 ctx.body = { content: profile satisfies CreateOrUpdateApplicationProfileResponseData, diff --git a/src/services/lease-service/tests/adapters/application-profile-adapter.test.ts b/src/services/lease-service/tests/adapters/application-profile-adapter.test.ts index 44eae35a..cfc6dcf2 100644 --- a/src/services/lease-service/tests/adapters/application-profile-adapter.test.ts +++ b/src/services/lease-service/tests/adapters/application-profile-adapter.test.ts @@ -1,20 +1,21 @@ import assert from 'node:assert' import * as applicationProfileAdapter from '../../adapters/application-profile-adapter' +import * as factory from '../factories' import { withContext } from '../testUtils' describe('application-profile-adapter', () => { describe(applicationProfileAdapter.create, () => { it('creates application profile', () => withContext(async (ctx) => { - const profile = await applicationProfileAdapter.create(ctx.db, { - contactCode: '1234', + const profile = await applicationProfileAdapter.create(ctx.db, '1234', { expiresAt: new Date(), numAdults: 1, numChildren: 1, - housingType: 'foo', + housingType: 'RENTAL', housingTypeDescription: 'bar', landlord: 'baz', + housingReference: factory.applicationProfileHousingReference.build(), }) assert(profile.ok) @@ -29,21 +30,32 @@ describe('application-profile-adapter', () => { it('fails if existing profile for contact code already exists', () => withContext(async (ctx) => { - const profile = await applicationProfileAdapter.create(ctx.db, { - contactCode: '1234', + const profile = await applicationProfileAdapter.create(ctx.db, '1234', { expiresAt: new Date(), numAdults: 1, numChildren: 1, + housingType: 'RENTAL', + housingTypeDescription: 'bar', + landlord: 'baz', + housingReference: factory.applicationProfileHousingReference.build(), }) assert(profile.ok) - const duplicate = await applicationProfileAdapter.create(ctx.db, { - contactCode: '1234', - expiresAt: new Date(), - numAdults: 1, - numChildren: 1, - }) + const duplicate = await applicationProfileAdapter.create( + ctx.db, + '1234', + { + expiresAt: new Date(), + numAdults: 1, + numChildren: 1, + housingType: 'RENTAL', + housingTypeDescription: 'bar', + landlord: 'baz', + housingReference: + factory.applicationProfileHousingReference.build(), + } + ) expect(duplicate).toMatchObject({ ok: false, @@ -64,11 +76,14 @@ describe('application-profile-adapter', () => { it('gets application profile', () => withContext(async (ctx) => { - await applicationProfileAdapter.create(ctx.db, { - contactCode: '1234', + await applicationProfileAdapter.create(ctx.db, '1234', { expiresAt: new Date(), numAdults: 1, numChildren: 1, + housingType: 'RENTAL', + housingTypeDescription: 'bar', + landlord: 'baz', + housingReference: factory.applicationProfileHousingReference.build(), }) const result = await applicationProfileAdapter.getByContactCode( @@ -100,6 +115,11 @@ describe('application-profile-adapter', () => { expiresAt: new Date(), numAdults: 1, numChildren: 1, + housingType: 'RENTAL', + housingTypeDescription: 'bar', + landlord: 'baz', + housingReference: + factory.applicationProfileHousingReference.build(), } ) @@ -108,11 +128,14 @@ describe('application-profile-adapter', () => { it('updates application profile', () => withContext(async (ctx) => { - const profile = await applicationProfileAdapter.create(ctx.db, { - contactCode: '1234', - expiresAt: null, + const profile = await applicationProfileAdapter.create(ctx.db, '1234', { + expiresAt: new Date(), numAdults: 1, numChildren: 1, + housingType: 'RENTAL', + housingTypeDescription: 'bar', + landlord: 'baz', + housingReference: factory.applicationProfileHousingReference.build(), }) assert(profile.ok) @@ -123,6 +146,11 @@ describe('application-profile-adapter', () => { expiresAt: new Date(), numAdults: 2, numChildren: 2, + housingType: 'RENTAL', + housingTypeDescription: 'bar', + landlord: 'baz', + housingReference: + factory.applicationProfileHousingReference.build(), } ) diff --git a/src/services/lease-service/tests/adapters/application-profile-housing-reference-adapter.test.ts b/src/services/lease-service/tests/adapters/application-profile-housing-reference-adapter.test.ts deleted file mode 100644 index 1010f4b3..00000000 --- a/src/services/lease-service/tests/adapters/application-profile-housing-reference-adapter.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import assert from 'node:assert' -import { Knex } from 'knex' - -import * as adapter from '../../adapters/application-profile-housing-reference-adapter' -import * as applicationProfileAdapter from '../../adapters/application-profile-adapter' -import { withContext } from '../testUtils' - -async function createApplicationProfile(db: Knex) { - const profile = await applicationProfileAdapter.create(db, { - contactCode: '1234', - expiresAt: new Date(), - numAdults: 1, - numChildren: 1, - }) - - assert(profile.ok) - return profile.data -} - -describe('application-profile-housing-reference-adapter', () => { - describe(adapter.create, () => { - it('inserts application profile housing reference', () => - withContext(async (ctx) => { - const applicationProfile = await createApplicationProfile(ctx.db) - const reference = await adapter.create(ctx.db, { - applicationProfileId: applicationProfile.id, - email: null, - phone: '01234', - reviewStatus: 'foo', - reviewStatusReason: null, - reviewedAt: null, - expiresAt: new Date(), - }) - - assert(reference.ok) - - const inserted = await adapter.findByApplicationProfileId( - ctx.db, - applicationProfile.id - ) - - assert(inserted.ok) - - expect(inserted.data).toEqual({ - id: expect.any(Number), - applicationProfileId: applicationProfile.id, - email: null, - phone: '01234', - reviewStatus: 'foo', - reviewStatusReason: null, - reviewedAt: null, - expiresAt: expect.any(Date), - createdAt: expect.any(Date), - }) - })) - - it('rejects duplicate application profile id', () => - withContext(async (ctx) => { - const applicationProfile = await createApplicationProfile(ctx.db) - const reference = await adapter.create(ctx.db, { - applicationProfileId: applicationProfile.id, - email: null, - phone: '01234', - reviewStatus: 'foo', - reviewStatusReason: null, - reviewedAt: null, - expiresAt: new Date(), - }) - - const duplicateReference = await adapter.create(ctx.db, { - applicationProfileId: applicationProfile.id, - email: null, - phone: '01234', - reviewStatus: 'foo', - reviewStatusReason: null, - reviewedAt: null, - expiresAt: new Date(), - }) - - assert(reference.ok) - expect(duplicateReference).toEqual({ - ok: false, - err: 'conflict-application-profile-id', - }) - })) - }) - - describe(adapter.update, () => { - it('returns err if no update', () => - withContext(async (ctx) => { - const result = await adapter.update(ctx.db, 1, { - email: null, - phone: '01234', - reviewStatus: 'foo', - reviewStatusReason: null, - reviewedAt: null, - expiresAt: new Date(), - }) - - expect(result).toMatchObject({ ok: false, err: 'no-update' }) - })) - - it('updates application profile', () => - withContext(async (ctx) => { - const profile = await createApplicationProfile(ctx.db) - - await adapter.create(ctx.db, { - applicationProfileId: profile.id, - email: null, - phone: '01234', - reviewStatus: 'foo', - reviewStatusReason: null, - reviewedAt: null, - expiresAt: new Date(), - }) - - await adapter.update(ctx.db, profile.id, { - email: null, - phone: '01234', - reviewStatus: 'bar', - reviewStatusReason: null, - reviewedAt: new Date(), - expiresAt: new Date(), - }) - - const updated = await adapter.findByApplicationProfileId( - ctx.db, - profile.id - ) - - assert(updated.ok) - expect(updated.data).toMatchObject({ - applicationProfileId: profile.id, - reviewStatus: 'bar', - reviewedAt: expect.any(Date), - }) - })) - }) -}) diff --git a/src/services/lease-service/tests/factories/application-profile.ts b/src/services/lease-service/tests/factories/application-profile.ts index 3dd80b04..f1f02c21 100644 --- a/src/services/lease-service/tests/factories/application-profile.ts +++ b/src/services/lease-service/tests/factories/application-profile.ts @@ -10,7 +10,7 @@ export const ApplicationProfileFactory = Factory.define( contactCode: '12345', numAdults: 1, numChildren: 1, - housingType: 'foo', + housingType: 'RENTAL', landlord: 'baz', housingTypeDescription: 'qux', createdAt: new Date(), @@ -24,11 +24,14 @@ export const ApplicationProfileHousingReferenceFactory = id: sequence, applicationProfileId: 1, email: 'email', - name: 'name', phone: 'phone', - reviewStatus: 'status', - reviewedAt: new Date(), + reviewStatus: 'PENDING', + comment: 'comment', + lastAdminUpdatedAt: null, + lastAdminUpdatedBy: 'foo', + lastApplicantUpdatedAt: new Date(), + reasonRejected: null, + expiresAt: new Date(), - reviewStatusReason: 'reason', createdAt: new Date(), })) diff --git a/src/services/lease-service/tests/routes/contacts.test.ts b/src/services/lease-service/tests/routes/contacts.test.ts index 25b8194e..1829fb13 100644 --- a/src/services/lease-service/tests/routes/contacts.test.ts +++ b/src/services/lease-service/tests/routes/contacts.test.ts @@ -9,6 +9,7 @@ import * as tenantLeaseAdapter from '../../adapters/xpand/tenant-lease-adapter' import * as xPandSoapAdapter from '../../adapters/xpand/xpand-soap-adapter' import * as applicationProfileAdapter from '../../adapters/application-profile-adapter' import * as applicationProfileService from '../../update-or-create-application-profile' +import * as factories from '../../tests/factories' const app = new Koa() const router = new KoaRouter() @@ -18,6 +19,7 @@ app.use(router.routes()) jest.mock('axios') +beforeEach(jest.resetAllMocks) describe('GET /contacts/search', () => { it('responds with 400 if query param is missing', async () => { const res = await request(app.callback()).get('/contacts/search') @@ -163,14 +165,7 @@ describe('GET /contacts/:contactCode/application-profile', () => { .spyOn(applicationProfileAdapter, 'getByContactCode') .mockResolvedValueOnce({ ok: true, - data: { - contactCode: '1234', - createdAt: new Date(), - expiresAt: null, - id: 1, - numAdults: 0, - numChildren: 0, - }, + data: factories.applicationProfile.build(), }) const res = await request(app.callback()).get( @@ -198,26 +193,20 @@ describe('POST /contacts/:contactCode/application-profile', () => { .spyOn(applicationProfileService, 'updateOrCreateApplicationProfile') .mockResolvedValueOnce({ ok: true, - data: [ - { - contactCode: '1234', - createdAt: new Date(), - expiresAt: null, - id: 1, - numAdults: 0, - numChildren: 0, - housingType: undefined, - housingTypeDescription: undefined, - landlord: undefined, - housingReference: undefined, - }, - 'updated', - ], + data: ['updated', factories.applicationProfile.build()], }) const res = await request(app.callback()) .post('/contacts/1234/application-profile') - .send({ expiresAt: null, numAdults: 0, numChildren: 0 }) + .send({ + expiresAt: null, + numAdults: 0, + numChildren: 0, + housingType: 'RENTAL', + housingTypeDescription: null, + landlord: null, + housingReference: factories.applicationProfileHousingReference.build(), + }) expect(res.status).toBe(200) expect(() => @@ -232,26 +221,20 @@ describe('POST /contacts/:contactCode/application-profile', () => { .spyOn(applicationProfileService, 'updateOrCreateApplicationProfile') .mockResolvedValueOnce({ ok: true, - data: [ - { - contactCode: '1234', - createdAt: new Date(), - expiresAt: null, - id: 1, - numAdults: 0, - numChildren: 0, - housingType: undefined, - housingTypeDescription: undefined, - landlord: undefined, - housingReference: undefined, - }, - 'created', - ], + data: ['created', factories.applicationProfile.build()], }) const res = await request(app.callback()) .post('/contacts/1234/application-profile') - .send({ expiresAt: null, numAdults: 0, numChildren: 0 }) + .send({ + expiresAt: null, + numAdults: 0, + numChildren: 0, + housingType: 'RENTAL', + housingTypeDescription: null, + landlord: null, + housingReference: factories.applicationProfileHousingReference.build(), + }) expect(res.status).toBe(201) expect(() => diff --git a/src/services/lease-service/tests/update-or-create-application-profile.test.ts b/src/services/lease-service/tests/update-or-create-application-profile.test.ts index 64329a45..3d1d1fd9 100644 --- a/src/services/lease-service/tests/update-or-create-application-profile.test.ts +++ b/src/services/lease-service/tests/update-or-create-application-profile.test.ts @@ -1,28 +1,36 @@ import assert from 'node:assert' -import { ApplicationProfile } from 'onecore-types' +import { updateOrCreateApplicationProfile } from '../update-or-create-application-profile' import * as applicationProfileAdapter from '../adapters/application-profile-adapter' -import * as housingReferenceAdapter from '../adapters/application-profile-housing-reference-adapter' import * as factory from './factories' -import { updateOrCreateApplicationProfile } from '../update-or-create-application-profile' import { withContext } from './testUtils' describe(updateOrCreateApplicationProfile.name, () => { - describe('when no profile exists', () => { - it('creates application profile', () => - withContext(async (ctx) => { - const res = await updateOrCreateApplicationProfile(ctx.db, '1234', { + describe('when no profile exists ', () => { + it('creates application profile and housing reference', () => + withContext(async ({ db }) => { + const res = await updateOrCreateApplicationProfile(db, '1234', { expiresAt: new Date(), numAdults: 1, numChildren: 1, - housingType: 'foo', + housingType: 'RENTAL', landlord: 'baz', housingTypeDescription: 'qux', + housingReference: { + comment: null, + email: null, + expiresAt: new Date(), + lastAdminUpdatedAt: null, + lastApplicantUpdatedAt: new Date(), + phone: null, + reasonRejected: null, + reviewStatus: 'PENDING', + }, }) expect(res).toMatchObject({ ok: true }) const inserted = await applicationProfileAdapter.getByContactCode( - ctx.db, + db, '1234' ) assert(inserted.ok) @@ -31,182 +39,35 @@ describe(updateOrCreateApplicationProfile.name, () => { data: expect.objectContaining({ contactCode: '1234' }), }) })) - - it('creates application profile and housing reference', () => - withContext(async (ctx) => { - const res = await updateOrCreateApplicationProfile(ctx.db, '1234', { - expiresAt: new Date(), - numAdults: 1, - numChildren: 1, - housingType: 'foo', - landlord: 'baz', - housingTypeDescription: 'qux', - housingReference: factory.applicationProfileHousingReference.build(), - }) - - expect(res).toMatchObject({ ok: true }) - const insertedProfile = - await applicationProfileAdapter.getByContactCode(ctx.db, '1234') - assert(insertedProfile.ok) - expect(insertedProfile).toMatchObject({ - ok: true, - data: expect.objectContaining>({ - contactCode: '1234', - housingReference: expect.objectContaining({ - id: expect.any(Number), - }), - }), - }) - })) - - it('if create reference fails, profile is not created', () => - withContext(async (ctx) => { - jest - .spyOn(housingReferenceAdapter, 'create') - .mockResolvedValueOnce({ ok: false, err: 'unknown' }) - - const res = await updateOrCreateApplicationProfile(ctx.db, '1234', { - expiresAt: new Date(), - numAdults: 1, - numChildren: 1, - housingType: 'foo', - landlord: 'baz', - housingTypeDescription: 'qux', - housingReference: factory.applicationProfileHousingReference.build(), - }) - - expect(res).toMatchObject({ - ok: false, - err: 'create-reference', - }) - - const insertedProfile = - await applicationProfileAdapter.getByContactCode(ctx.db, '1234') - - expect(insertedProfile).toMatchObject({ ok: false, err: 'not-found' }) - })) }) - describe('when profile exists', () => { - it('updates application profile', () => - withContext(async (ctx) => { - const existingProfile = await applicationProfileAdapter.create( - ctx.db, - factory.applicationProfile.build({ - contactCode: '1234', - numAdults: 1, - }) - ) - assert(existingProfile.ok) - - const res = await updateOrCreateApplicationProfile( - ctx.db, - existingProfile.data.contactCode, - { - expiresAt: new Date(), - numAdults: 2, - numChildren: 2, - housingType: 'bar', - landlord: 'quux', - housingTypeDescription: 'corge', - } - ) - - expect(res).toMatchObject({ ok: true }) - const updated = await applicationProfileAdapter.getByContactCode( - ctx.db, - '1234' - ) - assert(updated.ok) - expect(updated).toMatchObject({ - ok: true, - data: expect.objectContaining({ - contactCode: '1234', - numAdults: 2, - }), - }) - })) - + describe('when profile exists ', () => { it('updates application profile and housing reference', () => - withContext(async (ctx) => { + withContext(async ({ db }) => { const existingProfile = await applicationProfileAdapter.create( - ctx.db, + db, + '1234', factory.applicationProfile.build({ contactCode: '1234', numAdults: 1, + housingReference: { email: 'foo' }, }) ) assert(existingProfile.ok) - const existingReference = await housingReferenceAdapter.create( - ctx.db, - factory.applicationProfileHousingReference.build({ - applicationProfileId: existingProfile.data.id, - email: 'foo', - }) - ) - - assert(existingReference.ok) - const res = await updateOrCreateApplicationProfile( - ctx.db, + db, existingProfile.data.contactCode, { expiresAt: new Date(), numAdults: 2, numChildren: 2, - housingType: 'bar', - landlord: 'quux', - housingTypeDescription: 'corge', - housingReference: { ...existingReference.data, email: 'bar' }, - } - ) - assert(res.ok) - - expect(res).toMatchObject({ ok: true }) - const updated = await applicationProfileAdapter.getByContactCode( - ctx.db, - '1234' - ) - assert(updated.ok) - expect(updated).toMatchObject({ - ok: true, - data: expect.objectContaining({ - contactCode: '1234', - numAdults: 2, - housingReference: expect.objectContaining({ - applicationProfileId: existingProfile.data.id, - email: 'bar', - }), - }), - }) - })) - - it('updates application profile and creates housing reference', () => - withContext(async (ctx) => { - const existingProfile = await applicationProfileAdapter.create( - ctx.db, - factory.applicationProfile.build({ - contactCode: '1234', - numAdults: 1, - }) - ) - assert(existingProfile.ok) - - const res = await updateOrCreateApplicationProfile( - ctx.db, - existingProfile.data.contactCode, - { - expiresAt: new Date(), - numAdults: 2, - numChildren: 2, - housingType: 'bar', + housingType: 'RENTAL', landlord: 'quux', housingTypeDescription: 'corge', housingReference: { - ...factory.applicationProfileHousingReference.build({ - email: 'foo', - }), + ...existingProfile.data.housingReference, + email: 'bar', }, } ) @@ -214,7 +75,7 @@ describe(updateOrCreateApplicationProfile.name, () => { expect(res).toMatchObject({ ok: true }) const updated = await applicationProfileAdapter.getByContactCode( - ctx.db, + db, '1234' ) assert(updated.ok) @@ -225,65 +86,7 @@ describe(updateOrCreateApplicationProfile.name, () => { numAdults: 2, housingReference: expect.objectContaining({ applicationProfileId: existingProfile.data.id, - email: 'foo', - }), - }), - }) - })) - - it('if update reference fails, profile is not updated', () => - withContext(async (ctx) => { - const existingProfile = await applicationProfileAdapter.create( - ctx.db, - factory.applicationProfile.build({ - contactCode: '1234', - numAdults: 1, - }) - ) - assert(existingProfile.ok) - - const existingReference = await housingReferenceAdapter.create( - ctx.db, - factory.applicationProfileHousingReference.build({ - applicationProfileId: existingProfile.data.id, - email: 'foo', - }) - ) - - assert(existingReference.ok) - - jest - .spyOn(housingReferenceAdapter, 'update') - .mockResolvedValueOnce({ ok: false, err: 'no-update' }) - - const res = await updateOrCreateApplicationProfile( - ctx.db, - existingProfile.data.contactCode, - { - expiresAt: new Date(), - numAdults: 2, - numChildren: 2, - housingType: 'bar', - landlord: 'quux', - housingTypeDescription: 'corge', - housingReference: { ...existingReference.data, email: 'bar' }, - } - ) - - expect(res).toMatchObject({ ok: false, err: 'create-reference' }) - const updated = await applicationProfileAdapter.getByContactCode( - ctx.db, - '1234' - ) - assert(updated.ok) - expect(updated).toMatchObject({ - ok: true, - data: expect.objectContaining({ - contactCode: '1234', - numAdults: 1, - housingReference: expect.objectContaining({ - applicationProfileId: existingProfile.data.id, - email: 'foo', + email: 'bar', }), }), }) diff --git a/src/services/lease-service/update-or-create-application-profile.ts b/src/services/lease-service/update-or-create-application-profile.ts index eee1bb8f..f448b63d 100644 --- a/src/services/lease-service/update-or-create-application-profile.ts +++ b/src/services/lease-service/update-or-create-application-profile.ts @@ -1,14 +1,9 @@ -import { - ApplicationProfile, - ApplicationProfileHousingReference, - leasing, -} from 'onecore-types' +import { ApplicationProfile, leasing } from 'onecore-types' import { Knex } from 'knex' import { z } from 'zod' import { AdapterResult } from './adapters/types' import * as applicationProfileAdapter from './adapters/application-profile-adapter' -import * as applicationProfileHousingReferenceAdapter from './adapters/application-profile-housing-reference-adapter' type Params = z.infer< typeof leasing.CreateOrUpdateApplicationProfileRequestParamsSchema @@ -19,111 +14,26 @@ export async function updateOrCreateApplicationProfile( contactCode: string, params: Params ): Promise< - AdapterResult< - [ApplicationProfile, 'created' | 'updated'], - | 'update-profile' - | 'update-reference' - | 'create-profile' - | 'create-reference' - | 'unknown' - > + AdapterResult<['created' | 'updated', ApplicationProfile], 'unknown'> > { - const trx = await db.transaction() + const update = await applicationProfileAdapter.update(db, contactCode, params) - const profileResult = await updateOrCreateProfile(trx, contactCode, params) - if (!profileResult.ok) { - await trx.rollback() - return { ok: false, err: profileResult.err } - } - - const [profile, operation] = profileResult.data - if (!params.housingReference) { - await trx.commit() - return { ok: true, data: profileResult.data } - } - - const housingReferenceResult = await updateOrCreateReference(trx, { - ...params.housingReference, - applicationProfileId: profile.id, - }) - - if (!housingReferenceResult.ok) { - await trx.rollback() - return { ok: false, err: housingReferenceResult.err } - } - - const [housingReference] = housingReferenceResult.data - - await trx.commit() - return { ok: true, data: [{ ...profile, housingReference }, operation] } -} - -async function updateOrCreateProfile( - trx: Knex, - contactCode: string, - params: Params -): Promise< - AdapterResult< - [ApplicationProfile, 'created' | 'updated'], - 'update-profile' | 'create-profile' - > -> { - const updateProfile = await applicationProfileAdapter.update( - trx, - contactCode, - params - ) - - if (!updateProfile.ok) { - if (updateProfile.err !== 'no-update') { - return { ok: false, err: 'update-profile' } + if (!update.ok) { + if (update.err !== 'no-update') { + return { ok: false, err: 'unknown' } } - - const insertProfile = await applicationProfileAdapter.create(trx, { + const profile = await applicationProfileAdapter.create( + db, contactCode, - ...params, - }) - - if (!insertProfile.ok) { - return { ok: false, err: 'create-profile' } - } - - return { ok: true, data: [insertProfile.data, 'created'] } - } - - return { ok: true, data: [updateProfile.data, 'updated'] } -} - -async function updateOrCreateReference( - trx: Knex, - params: Params['housingReference'] & { applicationProfileId: number } -): Promise< - AdapterResult< - [ApplicationProfileHousingReference, 'created' | 'updated'], - 'update-reference' | 'create-reference' - > -> { - const updateReference = - await applicationProfileHousingReferenceAdapter.update( - trx, - params.applicationProfileId, params ) - if (!updateReference.ok) { - if (updateReference.err !== 'no-update') { - return { ok: false, err: 'update-reference' } - } - - const insertReference = - await applicationProfileHousingReferenceAdapter.create(trx, params) - - if (!insertReference.ok) { - return { ok: false, err: 'create-reference' } + if (!profile.ok) { + return { ok: false, err: 'unknown' } } - return { ok: true, data: [insertReference.data, 'created'] } + return { ok: true, data: ['created', profile.data] } } - return { ok: true, data: [updateReference.data, 'updated'] } + return { ok: true, data: ['updated', update.data] } }