diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index eca86fb12b..f50dd0c17c 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -47,6 +47,7 @@ import { permissionActions } from '../enums/permissions/permission-actions-enum' import { PermissionAction } from '../decorators/permission-action.decorator'; import { ApplicationCsvExporterService } from '../services/application-csv-export.service'; import { ApplicationCsvQueryParams } from '../dtos/applications/application-csv-query-params.dto'; +import { MostRecentApplicationQueryParams } from '../dtos/applications/most-recent-application-query-params.dto'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; @Controller('applications') @@ -73,8 +74,23 @@ export class ApplicationController { operationId: 'list', }) @ApiOkResponse({ type: PaginatedApplicationDto }) - async list(@Query() queryParams: ApplicationQueryParams) { - return await this.applicationService.list(queryParams); + async list( + @Request() req: ExpressRequest, + @Query() queryParams: ApplicationQueryParams, + ) { + return await this.applicationService.list(queryParams, req); + } + + @Get(`mostRecentlyCreated`) + @ApiOperation({ + summary: 'Get the most recent application submitted by the user', + operationId: 'mostRecentlyCreated', + }) + @ApiOkResponse({ type: Application }) + async mostRecentlyCreated( + @Query() queryParams: MostRecentApplicationQueryParams, + ): Promise { + return await this.applicationService.mostRecentlyCreated(queryParams); } @Get(`csv`) diff --git a/api/src/dtos/applications/most-recent-application-query-params.dto.ts b/api/src/dtos/applications/most-recent-application-query-params.dto.ts new file mode 100644 index 0000000000..0875e6475e --- /dev/null +++ b/api/src/dtos/applications/most-recent-application-query-params.dto.ts @@ -0,0 +1,13 @@ +import { Expose } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +export class MostRecentApplicationQueryParams { + @Expose() + @ApiProperty({ + type: String, + example: 'userId', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + userId: string; +} diff --git a/api/src/services/application.service.ts b/api/src/services/application.service.ts index 15a3641061..59342979ff 100644 --- a/api/src/services/application.service.ts +++ b/api/src/services/application.service.ts @@ -2,8 +2,10 @@ import { BadRequestException, Injectable, NotFoundException, + ForbiddenException, } from '@nestjs/common'; import crypto from 'crypto'; +import { Request as ExpressRequest } from 'express'; import { Prisma, YesNoEnum } from '@prisma/client'; import { PrismaService } from './prisma.service'; import { Application } from '../dtos/applications/application.dto'; @@ -24,6 +26,7 @@ import Listing from '../dtos/listings/listing.dto'; import { User } from '../dtos/users/user.dto'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; import { GeocodingService } from './geocoding.service'; +import { MostRecentApplicationQueryParams } from '../dtos/applications/most-recent-application-query-params.dto'; export const view: Partial< Record @@ -83,7 +86,14 @@ export class ApplicationService { this set can either be paginated or not depending on the params it will return both the set of applications, and some meta information to help with pagination */ - async list(params: ApplicationQueryParams): Promise { + async list( + params: ApplicationQueryParams, + req: ExpressRequest, + ): Promise { + const user = mapTo(User, req['user']); + if (!user) { + throw new ForbiddenException(); + } const whereClause = this.buildWhereClause(params); const count = await this.prisma.applications.count({ @@ -120,6 +130,29 @@ export class ApplicationService { }; } + /* + this will the most recent application the user has submitted + */ + async mostRecentlyCreated( + params: MostRecentApplicationQueryParams, + ): Promise { + const rawApplication = await this.prisma.applications.findFirst({ + select: { + id: true, + }, + orderBy: { createdAt: 'desc' }, + where: { + userId: params.userId, + }, + }); + + if (!rawApplication) { + return null; + } + + return await this.findOne(rawApplication.id); + } + /* this builds the where clause for list() */ diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index 3b4da43de1..efce9728eb 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -334,6 +334,8 @@ export class AuthService { passwordHash: await passwordToHash(dto.password), passwordUpdatedAt: new Date(), resetToken: null, + confirmedAt: user.confirmedAt || new Date(), + confirmationToken: null, }, where: { id: user.id, diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts index 14dd270446..c8d20bb426 100644 --- a/api/src/services/listing.service.ts +++ b/api/src/services/listing.service.ts @@ -81,7 +81,6 @@ views.base = { include: { unitTypes: true, unitAmiChartOverrides: true, - amiChart: true, }, }, }; @@ -628,7 +627,11 @@ export class ListingService implements OnModuleInit { }); } } - return JSON.stringify(listing); + // add additional jurisdiction fields for external purpose + const jurisdiction = await this.prisma.jurisdictions.findFirst({ + where: { id: listing.jurisdictions.id }, + }); + return JSON.stringify({ ...listing, jurisdiction: jurisdiction }); } /* diff --git a/api/src/utilities/unit-utilities.ts b/api/src/utilities/unit-utilities.ts index c596b42739..3c71cc7852 100644 --- a/api/src/utilities/unit-utilities.ts +++ b/api/src/utilities/unit-utilities.ts @@ -136,7 +136,7 @@ export const generateHmiData = ( ? [ ...new Set( units - .filter((unit) => amiChartMap[unit.amiChart.id]) + .filter((unit) => unit.amiChart && amiChartMap[unit.amiChart.id]) .map((unit) => { let amiChart = amiChartMap[unit.amiChart.id]; if (unit.unitAmiChartOverrides) { diff --git a/api/test/integration/application.e2e-spec.ts b/api/test/integration/application.e2e-spec.ts index 28f2af96fb..facec388dd 100644 --- a/api/test/integration/application.e2e-spec.ts +++ b/api/test/integration/application.e2e-spec.ts @@ -129,6 +129,7 @@ describe('Application Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/applications?${query}`) + .set('Cookie', cookies) .expect(200); expect(res.body.items.length).toBe(0); }); @@ -174,6 +175,7 @@ describe('Application Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/applications?${query}`) + .set('Cookie', cookies) .expect(200); expect(res.body.items.length).toBeGreaterThanOrEqual(2); @@ -211,6 +213,7 @@ describe('Application Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/applications`) + .set('Cookie', cookies) .expect(200); expect(res.body.items.length).toBeGreaterThanOrEqual(2); @@ -652,6 +655,7 @@ describe('Application Controller Tests', () => { let geocodingOptions = savedPreferences[0].options[0]; // This catches the edge case where the geocoding hasn't completed yet if (geocodingOptions.extraData.length === 1) { + // I'm unsure why removing this console log makes this test fail. This should be looked into console.log(''); savedApplication = await prisma.applications.findMany({ where: { diff --git a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts index 0706b09855..8b0380cd20 100644 --- a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts @@ -185,11 +185,11 @@ describe('Testing Permissioning of endpoints as logged out user', () => { }); }); - it('should succeed for list endpoint', async () => { + it('should be forbidden for list endpoint', async () => { await request(app.getHttpServer()) .get(`/applications?`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should succeed for retrieve endpoint', async () => { diff --git a/api/test/jest-with-coverage.config.js b/api/test/jest-with-coverage.config.js index f5d06db2d2..0bbb69aa58 100644 --- a/api/test/jest-with-coverage.config.js +++ b/api/test/jest-with-coverage.config.js @@ -19,12 +19,13 @@ module.exports = { './src/controllers/**', './src/modules/**', './src/passports/**', + './src/utilities/custom-exception-filter.ts', ], coverageThreshold: { global: { branches: 75, - functions: 90, - lines: 90, + functions: 85, + lines: 85, }, }, workerIdleMemoryLimit: '70%', diff --git a/api/test/unit/services/app.service.spec.ts b/api/test/unit/services/app.service.spec.ts index bea848f872..769f698986 100644 --- a/api/test/unit/services/app.service.spec.ts +++ b/api/test/unit/services/app.service.spec.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'; import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; +import { randomUUID } from 'crypto'; import { AppService } from '../../../src/services/app.service'; import { PrismaService } from '../../../src/services/prisma.service'; diff --git a/api/test/unit/services/application.service.spec.ts b/api/test/unit/services/application.service.spec.ts index 0b8484088e..f0b92c2564 100644 --- a/api/test/unit/services/application.service.spec.ts +++ b/api/test/unit/services/application.service.spec.ts @@ -9,6 +9,7 @@ import { } from '@prisma/client'; import { randomUUID } from 'crypto'; import dayjs from 'dayjs'; +import { Request as ExpressRequest } from 'express'; import { PrismaService } from '../../../src/services/prisma.service'; import { ApplicationService } from '../../../src/services/application.service'; import { ApplicationQueryParams } from '../../../src/dtos/applications/application-query-params.dto'; @@ -268,6 +269,12 @@ describe('Testing application service', () => { }); it('should get applications from list() when applications are available', async () => { + const requestingUser = { + firstName: 'requesting fName', + lastName: 'requesting lName', + email: 'requestingUser@email.com', + jurisdictions: [{ id: 'juris id' }], + } as unknown as User; const date = new Date(); const mockedValue = mockApplicationSet(3, date); prisma.applications.findMany = jest.fn().mockResolvedValue(mockedValue); @@ -284,7 +291,11 @@ describe('Testing application service', () => { page: 1, }; - expect(await service.list(params)).toEqual({ + expect( + await service.list(params, { + user: requestingUser, + } as unknown as ExpressRequest), + ).toEqual({ items: mockedValue.map((mock) => ({ ...mock, flagged: true })), meta: { currentPage: 1, @@ -1588,4 +1599,57 @@ describe('Testing application service', () => { expect(canOrThrowMock).not.toHaveBeenCalled(); }); + + it('should get most recent application for a user', async () => { + const date = new Date(); + const mockedValue = mockApplication(3, date); + prisma.applications.findUnique = jest.fn().mockResolvedValue(mockedValue); + prisma.applications.findFirst = jest + .fn() + .mockResolvedValue({ id: mockedValue.id }); + + expect(await service.mostRecentlyCreated({ userId: 'example Id' })).toEqual( + mockedValue, + ); + expect(prisma.applications.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + }, + orderBy: { createdAt: 'desc' }, + where: { + userId: 'example Id', + }, + }); + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + where: { + id: mockedValue.id, + }, + include: { + userAccounts: true, + applicant: { + include: { + applicantAddress: true, + applicantWorkAddress: true, + }, + }, + applicationsMailingAddress: true, + applicationsAlternateAddress: true, + alternateContact: { + include: { + address: true, + }, + }, + accessibility: true, + demographics: true, + householdMember: { + include: { + householdMemberAddress: true, + householdMemberWorkAddress: true, + }, + }, + listings: true, + preferredUnitTypes: true, + }, + }); + }); }); diff --git a/api/test/unit/services/auth.service.spec.ts b/api/test/unit/services/auth.service.spec.ts index 1a96c5104e..198d04a062 100644 --- a/api/test/unit/services/auth.service.spec.ts +++ b/api/test/unit/services/auth.service.spec.ts @@ -648,6 +648,8 @@ describe('Testing auth service', () => { passwordHash: expect.anything(), passwordUpdatedAt: expect.anything(), resetToken: null, + confirmedAt: expect.anything(), + confirmationToken: null, }, where: { id, diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index 377a18ecc1..13de89f842 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -589,7 +589,6 @@ describe('Testing listing service', () => { include: { unitTypes: true, unitAmiChartOverrides: true, - amiChart: true, }, }, }, @@ -679,7 +678,6 @@ describe('Testing listing service', () => { include: { unitTypes: true, unitAmiChartOverrides: true, - amiChart: true, }, }, }, @@ -824,7 +822,6 @@ describe('Testing listing service', () => { include: { unitTypes: true, unitAmiChartOverrides: true, - amiChart: true, }, }, }, @@ -1122,7 +1119,6 @@ describe('Testing listing service', () => { include: { unitTypes: true, unitAmiChartOverrides: true, - amiChart: true, }, }, }, @@ -1516,7 +1512,6 @@ describe('Testing listing service', () => { include: { unitTypes: true, unitAmiChartOverrides: true, - amiChart: true, }, }, }, diff --git a/backend/core/src/auth/services/user.service.ts b/backend/core/src/auth/services/user.service.ts index 21127eec37..aff2e1afb9 100644 --- a/backend/core/src/auth/services/user.service.ts +++ b/backend/core/src/auth/services/user.service.ts @@ -384,7 +384,17 @@ export class UserService { return await this.userRepository.save(newUser) } + containsInvalidCharacters(value: string): boolean { + return value.includes(".") || value.includes("http") + } + public async createPublicUser(dto: UserCreateDto, sendWelcomeEmail = false) { + if ( + this.containsInvalidCharacters(dto.firstName) || + this.containsInvalidCharacters(dto.lastName) + ) { + throw new HttpException("Forbidden", HttpStatus.FORBIDDEN) + } const newUser = await this._createUser({ ...dto, passwordHash: await this.passwordService.passwordToHash(dto.password), diff --git a/shared-helpers/src/auth/catchNetworkError.ts b/shared-helpers/src/auth/catchNetworkError.ts index 25b9fd7bed..e4c9cd64d6 100644 --- a/shared-helpers/src/auth/catchNetworkError.ts +++ b/shared-helpers/src/auth/catchNetworkError.ts @@ -27,7 +27,7 @@ export type NetworkErrorDetermineError = ( export type NetworkErrorReset = () => void export enum NetworkErrorMessage { - PasswordOutdated = "passwordOutdated", + PasswordOutdated = "but password is no longer valid", MfaUnauthorized = "mfaUnauthorized", } @@ -38,7 +38,7 @@ export const useCatchNetworkError = () => { const [networkError, setNetworkError] = useState(null) const check401Error = (message: string, error: AxiosError) => { - if (message === NetworkErrorMessage.PasswordOutdated) { + if (message.includes(NetworkErrorMessage.PasswordOutdated)) { setNetworkError({ title: t("authentication.signIn.passwordOutdated"), description: `${t( diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index aed910f06a..ef7766c73f 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1364,6 +1364,27 @@ export class ApplicationsService { axios(configs, resolve, reject) }) } + /** + * Get the most recent application submitted by the user + */ + mostRecentlyCreated( + params: { + /** */ + userId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applications/mostRecentlyCreated" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { userId: params["userId"] } + + /** 适配ios13,get请求不允许带body */ + + axios(configs, resolve, reject) + }) + } /** * Get applications as csv */ diff --git a/sites/public/src/lib/hooks.ts b/sites/public/src/lib/hooks.ts index b83be94a2b..19df20ec8f 100644 --- a/sites/public/src/lib/hooks.ts +++ b/sites/public/src/lib/hooks.ts @@ -63,10 +63,12 @@ export async function fetchBaseListingData({ additionalFilters, orderBy, orderDir, + limit, }: { additionalFilters?: ListingFilterParams[] orderBy?: ListingOrderByKeys[] orderDir?: OrderByEnum[] + limit?: string }) { let listings = [] try { @@ -93,7 +95,7 @@ export async function fetchBaseListingData({ orderDir?: OrderByEnum[] } = { view: "base", - limit: "all", + limit: limit || "all", filter, } if (orderBy) { @@ -140,6 +142,7 @@ export async function fetchClosedListings() { ], orderBy: [ListingOrderByKeys.mostRecentlyClosed], orderDir: [OrderByEnum.desc], + limit: "10", }) } diff --git a/sites/public/src/pages/applications/start/autofill.tsx b/sites/public/src/pages/applications/start/autofill.tsx index 21793e0be2..5066a8d6c8 100644 --- a/sites/public/src/pages/applications/start/autofill.tsx +++ b/sites/public/src/pages/applications/start/autofill.tsx @@ -73,15 +73,12 @@ export default () => { if (!previousApplication && initialStateLoaded) { if (profile) { void applicationsService - .list({ + .mostRecentlyCreated({ userId: profile.id, - orderBy: ApplicationOrderByKeys.createdAt, - order: OrderByEnum.desc, - limit: 1, }) .then((res) => { - if (res && res?.items?.length) { - setPreviousApplication(new AutofillCleaner(res.items[0]).clean()) + if (res) { + setPreviousApplication(new AutofillCleaner(res).clean()) } else { onSubmit() }