diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index 6359675828..7062a3bb9b 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -34,6 +34,7 @@ import { simplifiedDCMap, } from './seed-helpers/map-layer-factory'; import { ValidationMethod } from '../src/enums/multiselect-questions/validation-method-enum'; +import { randomNoun } from './seed-helpers/word-generator'; export const stagingSeed = async ( prismaClient: PrismaClient, @@ -45,7 +46,7 @@ export const stagingSeed = async ( }); // add another jurisdiction const additionalJurisdiction = await prismaClient.jurisdictions.create({ - data: jurisdictionFactory(), + data: jurisdictionFactory(randomNoun()), }); // create admin user await prismaClient.userAccounts.create({ @@ -69,7 +70,7 @@ export const stagingSeed = async ( }); await prismaClient.userAccounts.create({ data: await userFactory({ - roles: { isJurisdictionalAdmin: true }, + roles: { isAdmin: true }, email: 'unverified@example.com', confirmedAt: new Date(), jurisdictionIds: [jurisdiction.id], @@ -78,7 +79,7 @@ export const stagingSeed = async ( }); await prismaClient.userAccounts.create({ data: await userFactory({ - roles: { isJurisdictionalAdmin: true }, + roles: { isAdmin: true }, email: 'mfauser@bloom.com', confirmedAt: new Date(), jurisdictionIds: [jurisdiction.id], @@ -882,9 +883,25 @@ export const stagingSeed = async ( applications: value.applications, afsLastRunSetInPast: true, }); - await prismaClient.listings.create({ + const savedListing = await prismaClient.listings.create({ data: listing, }); + if (index === 0) { + await prismaClient.userAccounts.create({ + data: await userFactory({ + roles: { + isAdmin: false, + isPartner: true, + isJurisdictionalAdmin: false, + }, + email: 'partner-user@example.com', + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id, additionalJurisdiction.id], + acceptedTerms: true, + listings: [savedListing.id], + }), + }); + } }, ); }; diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts index d3b613ceab..8e5269cbc4 100644 --- a/api/src/controllers/user.controller.ts +++ b/api/src/controllers/user.controller.ts @@ -4,12 +4,15 @@ import { Controller, Delete, Get, + Header, Param, ParseUUIDPipe, Post, Put, Query, Request, + Res, + StreamableFile, UseGuards, UseInterceptors, UsePipes, @@ -28,7 +31,7 @@ import { IdDTO } from '../dtos/shared/id.dto'; import { mapTo } from '../utilities/mapTo'; import { PaginatedUserDto } from '../dtos/users/paginated-user.dto'; import { UserQueryParams } from '../dtos/users/user-query-param.dto'; -import { Request as ExpressRequest } from 'express'; +import { Request as ExpressRequest, Response } from 'express'; import { UserUpdate } from '../dtos/users/user-update.dto'; import { SuccessDTO } from '../dtos/shared/success.dto'; import { UserCreate } from '../dtos/users/user-create.dto'; @@ -44,6 +47,7 @@ import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { UserFilterParams } from '../dtos/users/user-filter-params.dto'; +import { UserCsvExporterService } from '../services/user-csv-export.service'; @Controller('user') @ApiTags('user') @@ -51,7 +55,10 @@ import { UserFilterParams } from '../dtos/users/user-filter-params.dto'; @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(IdDTO, EmailAndAppUrl) export class UserController { - constructor(private readonly userService: UserService) {} + constructor( + private readonly userService: UserService, + private readonly userCSVExportService: UserCsvExporterService, + ) {} @Get() @UseGuards(JwtAuthGuard, UserProfilePermissionGuard) @@ -85,14 +92,17 @@ export class UserController { @Get('/csv') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @UseInterceptors(ClassSerializerInterceptor) - @ApiOkResponse({ type: SuccessDTO }) @ApiOperation({ summary: 'List users in CSV', operationId: 'listAsCsv', }) + @Header('Content-Type', 'text/csv') @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) - async listAsCsv(@Request() req: ExpressRequest): Promise { - return await this.userService.export(mapTo(User, req['user'])); + async listAsCsv( + @Request() req: ExpressRequest, + @Res({ passthrough: true }) res: Response, + ): Promise { + return await this.userCSVExportService.exportFile(req, res); } @Get(`:id`) diff --git a/api/src/modules/user.module.ts b/api/src/modules/user.module.ts index 4598078cda..ebff0e482a 100644 --- a/api/src/modules/user.module.ts +++ b/api/src/modules/user.module.ts @@ -1,15 +1,16 @@ -import { Module } from '@nestjs/common'; +import { Logger, Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { UserController } from '../controllers/user.controller'; import { UserService } from '../services/user.service'; import { PrismaModule } from './prisma.module'; import { EmailModule } from './email.module'; import { PermissionModule } from './permission.module'; +import { UserCsvExporterService } from '../services/user-csv-export.service'; @Module({ imports: [PrismaModule, EmailModule, PermissionModule], controllers: [UserController], - providers: [UserService, ConfigService], + providers: [Logger, UserService, ConfigService, UserCsvExporterService], exports: [UserService], }) export class UserModule {} diff --git a/api/src/services/listing-csv-export.service.ts b/api/src/services/listing-csv-export.service.ts index bcc75bb1d9..c72d4d5581 100644 --- a/api/src/services/listing-csv-export.service.ts +++ b/api/src/services/listing-csv-export.service.ts @@ -117,11 +117,9 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { where: whereClause, }); - await this.createCsv( - listingFilePath, - queryParams, - listings as unknown as Listing[], - ); + await this.createCsv(listingFilePath, queryParams, { + listings: listings as unknown as Listing[], + }); const listingCsv = createReadStream(listingFilePath); await this.createUnitCsv(unitFilePath, listings as unknown as Listing[]); @@ -153,7 +151,7 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { async createCsv( filename: string, queryParams: QueryParams, - listings: Listing[], + optionParams: { listings: Listing[] }, ): Promise { const csvHeaders = await this.getCsvHeaders(); @@ -175,7 +173,7 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { ); // now loop over listings and write them to file - listings.forEach((listing) => { + optionParams.listings.forEach((listing) => { let row = ''; csvHeaders.forEach((header, index) => { let value = header.path.split('.').reduce((acc, curr) => { diff --git a/api/src/services/user-csv-export.service.ts b/api/src/services/user-csv-export.service.ts new file mode 100644 index 0000000000..2bc88b3d3a --- /dev/null +++ b/api/src/services/user-csv-export.service.ts @@ -0,0 +1,210 @@ +import { ForbiddenException, Injectable, StreamableFile } from '@nestjs/common'; +import dayjs from 'dayjs'; +import { Request as ExpressRequest, Response } from 'express'; +import fs, { createReadStream } from 'fs'; +import { join } from 'path'; +import { + CsvExporterServiceInterface, + CsvHeader, +} from '../types/CsvExportInterface'; +import { PrismaService } from './prisma.service'; +import { User } from '../dtos/users/user.dto'; +import { UserRole } from '../dtos/users/user-role.dto'; +import { mapTo } from '../utilities/mapTo'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { buildWhereClause } from '../utilities/build-user-where'; + +@Injectable() +export class UserCsvExporterService implements CsvExporterServiceInterface { + constructor(private prisma: PrismaService) {} + /** + * + * @param queryParams + * @param req + * @returns a promise containing a streamable file + */ + async exportFile( + req: ExpressRequest, + res: Response, + queryParams?: QueryParams, + ): Promise { + const user = mapTo(User, req['user']); + await this.authorizeCSVExport(mapTo(User, req['user'])); + const filename = join( + process.cwd(), + `src/temp/users-${user.id}-${new Date().getTime()}.csv`, + ); + await this.createCsv(filename, queryParams, { user: user }); + const file = createReadStream(filename); + return new StreamableFile(file); + } + + /** + * + * @param filename + * @param queryParams + * @returns a promise with SuccessDTO + */ + async createCsv( + filename: string, + queryParams: QueryParams, + optionalParams: { user: User }, + ): Promise { + const where = buildWhereClause( + { filter: [{ isPortalUser: true }] }, + optionalParams.user, + ); + const users = await this.prisma.userAccounts.findMany({ + where: where, + include: { + userRoles: true, + listings: true, + }, + }); + const csvHeaders = await this.getCsvHeaders(); + return new Promise((resolve, reject) => { + // create stream + const writableStream = fs.createWriteStream(`${filename}`); + writableStream + .on('error', (err) => { + console.log('csv writestream error'); + console.log(err); + reject(err); + }) + .on('close', () => { + resolve(); + }) + .on('open', () => { + writableStream.write( + csvHeaders + .map((header) => `"${header.label.replace(/"/g, `""`)}"`) + .join(',') + '\n', + ); + + // now loop over users and write them to file + users.forEach((user) => { + let row = ''; + csvHeaders.forEach((header, index) => { + let value = header.path.split('.').reduce((acc, curr) => { + // handles working with arrays + if (!isNaN(Number(curr))) { + const index = Number(curr); + return acc[index]; + } + + if (acc === null || acc === undefined) { + return ''; + } + return acc[curr]; + }, user); + value = value === undefined ? '' : value === null ? '' : value; + + if (header.format) { + value = header.format(value, user); + } + + row += value ? `"${value.toString().replace(/"/g, `""`)}"` : ''; + if (index < csvHeaders.length - 1) { + row += ','; + } + }); + + try { + writableStream.write(row + '\n'); + } catch (e) { + console.log('writeStream write error = ', e); + writableStream.once('drain', () => { + writableStream.write(row + '\n'); + }); + } + }); + + writableStream.end(); + }); + }); + } + + async getCsvHeaders(): Promise { + const headers: CsvHeader[] = [ + { + path: 'firstName', + label: 'First Name', + }, + { + path: 'lastName', + label: 'Last Name', + }, + { + path: 'email', + label: 'Email', + }, + { + path: 'userRoles', + label: 'Role', + format: (val: UserRole): string => { + const roles: string[] = []; + if (val?.isAdmin) { + roles.push('Administrator'); + } + if (val?.isPartner) { + roles.push('Partner'); + } + if (val?.isJurisdictionalAdmin) { + roles.push('Jurisdictional Admin'); + } + return roles.join(', '); + }, + }, + { + path: 'createdAt', + label: 'Date Created', + format: (val: string): string => { + return dayjs(val).format('MM-DD-YYYY'); + }, + }, + { + path: 'confirmedAt', + label: 'Status', + format: (val: string): string => (val ? 'Confirmed' : 'Unconfirmed'), + }, + { + path: 'listings', + label: 'Listing Names', + format: (val: IdDTO[]): string => { + return val?.length + ? val?.map((listing) => listing.name).join(', ') + : ''; + }, + }, + { + path: 'listings', + label: 'Listing Ids', + format: (val: IdDTO[]): string => { + return val?.length + ? val?.map((listing) => listing.id).join(', ') + : ''; + }, + }, + { + path: 'lastLoginAt', + label: 'Last Logged In', + format: (val: string): string => { + return dayjs(val).format('MM-DD-YYYY'); + }, + }, + ]; + + return headers; + } + + async authorizeCSVExport(user?: User): Promise { + if ( + user && + (user.userRoles?.isAdmin || user.userRoles?.isJurisdictionalAdmin) + ) { + return; + } else { + throw new ForbiddenException(); + } + } +} diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index 25ae88001f..f6f2719282 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -32,9 +32,9 @@ import { IdDTO } from '../dtos/shared/id.dto'; import { UserInvite } from '../dtos/users/user-invite.dto'; import { UserCreate } from '../dtos/users/user-create.dto'; import { EmailService } from './email.service'; -import { buildFromIdIndex } from '../utilities/csv-builder'; import { PermissionService } from './permission.service'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { buildWhereClause } from '../utilities/build-user-where'; /* this is the service for users @@ -70,7 +70,7 @@ export class UserService { This means we don't need to account for a user with only the partner role when it comes to accessing this function */ async list(params: UserQueryParams, user: User): Promise { - const whereClause = this.buildWhereClause(params, user); + const whereClause = buildWhereClause(params, user); const count = await this.prisma.userAccounts.count({ where: whereClause, }); @@ -105,157 +105,6 @@ export class UserService { }; } - /* - this helps build the where clause for the list() - */ - buildWhereClause( - params: UserQueryParams, - user: User, - ): Prisma.UserAccountsWhereInput { - const filters: Prisma.UserAccountsWhereInput[] = []; - - if (params.search) { - filters.push({ - OR: [ - { - firstName: { - contains: params.search, - mode: Prisma.QueryMode.insensitive, - }, - }, - { - lastName: { - contains: params.search, - mode: Prisma.QueryMode.insensitive, - }, - }, - { - email: { - contains: params.search, - mode: Prisma.QueryMode.insensitive, - }, - }, - { - listings: { - some: { - name: { - contains: params.search, - mode: Prisma.QueryMode.insensitive, - }, - }, - }, - }, - ], - }); - } - - if (!params.filter?.length) { - return { - AND: filters, - }; - } - - params.filter.forEach((filter) => { - if (filter['isPortalUser']) { - if (user?.userRoles?.isAdmin) { - filters.push({ - OR: [ - { - userRoles: { - isPartner: true, - }, - }, - { - userRoles: { - isAdmin: true, - }, - }, - { - userRoles: { - isJurisdictionalAdmin: true, - }, - }, - ], - }); - } else if (user?.userRoles?.isJurisdictionalAdmin) { - filters.push({ - OR: [ - { - userRoles: { - isPartner: true, - }, - }, - { - userRoles: { - isJurisdictionalAdmin: true, - }, - }, - ], - }); - filters.push({ - jurisdictions: { - some: { - id: { - in: user?.jurisdictions?.map((juris) => juris.id), - }, - }, - }, - }); - } - } else if ('isPortalUser' in filter) { - filters.push({ - AND: [ - { - OR: [ - { - userRoles: { - isPartner: null, - }, - }, - { - userRoles: { - isPartner: false, - }, - }, - ], - }, - { - OR: [ - { - userRoles: { - isJurisdictionalAdmin: null, - }, - }, - { - userRoles: { - isJurisdictionalAdmin: false, - }, - }, - ], - }, - { - OR: [ - { - userRoles: { - isAdmin: null, - }, - }, - { - userRoles: { - isAdmin: false, - }, - }, - ], - }, - ], - }); - } - }); - return { - AND: filters, - }; - } - /* this will return 1 user or error */ @@ -902,73 +751,6 @@ export class UserService { return rawUser; } - /* - gets and formats user data to be handed to the csv builder helper - this data will be emailed to the requesting user - */ - async export(requestingUser: User): Promise { - const users = await this.list( - { - page: 1, - limit: 'all', - filter: [ - { - isPortalUser: true, - }, - ], - }, - requestingUser, - ); - - const parsedUsers = users.items.reduce((accum, user) => { - const roles: string[] = []; - if (user.userRoles?.isAdmin) { - roles.push('Administrator'); - } - if (user.userRoles?.isPartner) { - roles.push('Partner'); - } - if (user.userRoles?.isJurisdictionalAdmin) { - roles.push('Jurisdictional Admin'); - } - - const listingNames: string[] = []; - const listingIds: string[] = []; - - user.listings?.forEach((listing) => { - listingNames.push(listing.name); - listingIds.push(listing.id); - }); - - accum[user.id] = { - 'First Name': user.firstName, - 'Last Name': user.lastName, - Email: user.email, - Role: roles.join(', '), - 'Date Created': dayjs(user.createdAt).format('MM-DD-YYYY HH:mmZ[Z]'), - Status: user.confirmedAt ? 'Confirmed' : 'Unconfirmed', - 'Listing Names': listingNames.join(', '), - 'Listing Ids': listingIds.join(', '), - 'Last Logged In': dayjs(user.lastLoginAt).format( - 'MM-DD-YYYY HH:mmZ[Z]', - ), - }; - return accum; - }, {}); - - const csvData = buildFromIdIndex(parsedUsers); - await this.emailService.sendCSV( - requestingUser.jurisdictions, - requestingUser, - csvData, - 'User Export', - 'an export of all users', - ); - return { - success: true, - }; - } - async authorizeAction( requestingUser: User, targetUser: User, diff --git a/api/src/types/CsvExportInterface.ts b/api/src/types/CsvExportInterface.ts index 7bff4267ab..4167e235a0 100644 --- a/api/src/types/CsvExportInterface.ts +++ b/api/src/types/CsvExportInterface.ts @@ -3,6 +3,7 @@ import { Request, Response } from 'express'; import { ApplicationCsvQueryParams } from '../dtos/applications/application-csv-query-params.dto'; import { ListingCsvQueryParams } from '../dtos/listings/listing-csv-query-params.dto'; import Listing from '../dtos/listings/listing.dto'; +import { User } from '../dtos/users/user.dto'; export type CsvHeader = { path: string; @@ -25,7 +26,7 @@ export interface CsvExporterServiceInterface { >( filename: string, queryParams?: QueryParams, - listings?: Listing[], + optionalParams?: { listings?: Listing[]; user?: User }, ): Promise; getCsvHeaders(...args: OneOrMoreArgs): Promise; authorizeCSVExport(user: unknown, id?: string): Promise; diff --git a/api/src/utilities/build-user-where.ts b/api/src/utilities/build-user-where.ts new file mode 100644 index 0000000000..4a58f234e1 --- /dev/null +++ b/api/src/utilities/build-user-where.ts @@ -0,0 +1,154 @@ +import { Prisma } from '@prisma/client'; +import { UserQueryParams } from '../dtos/users/user-query-param.dto'; +import { User } from '../dtos/users/user.dto'; + +/* + this helps build the where clause for the list() + */ +export const buildWhereClause = ( + params: UserQueryParams, + user: User, +): Prisma.UserAccountsWhereInput => { + const filters: Prisma.UserAccountsWhereInput[] = []; + + if (params.search) { + filters.push({ + OR: [ + { + firstName: { + contains: params.search, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + lastName: { + contains: params.search, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + email: { + contains: params.search, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + listings: { + some: { + name: { + contains: params.search, + mode: Prisma.QueryMode.insensitive, + }, + }, + }, + }, + ], + }); + } + + if (!params.filter?.length) { + return { + AND: filters, + }; + } + + params.filter.forEach((filter) => { + if (filter['isPortalUser']) { + if (user?.userRoles?.isAdmin) { + filters.push({ + OR: [ + { + userRoles: { + isPartner: true, + }, + }, + { + userRoles: { + isAdmin: true, + }, + }, + { + userRoles: { + isJurisdictionalAdmin: true, + }, + }, + ], + }); + } else if (user?.userRoles?.isJurisdictionalAdmin) { + filters.push({ + OR: [ + { + userRoles: { + isPartner: true, + }, + }, + { + userRoles: { + isJurisdictionalAdmin: true, + }, + }, + ], + }); + filters.push({ + jurisdictions: { + some: { + id: { + in: user?.jurisdictions?.map((juris) => juris.id), + }, + }, + }, + }); + } + } else if ('isPortalUser' in filter) { + filters.push({ + AND: [ + { + OR: [ + { + userRoles: { + isPartner: null, + }, + }, + { + userRoles: { + isPartner: false, + }, + }, + ], + }, + { + OR: [ + { + userRoles: { + isJurisdictionalAdmin: null, + }, + }, + { + userRoles: { + isJurisdictionalAdmin: false, + }, + }, + ], + }, + { + OR: [ + { + userRoles: { + isAdmin: null, + }, + }, + { + userRoles: { + isAdmin: false, + }, + }, + ], + }, + ], + }); + } + }); + return { + AND: filters, + }; +}; diff --git a/api/test/integration/user.e2e-spec.ts b/api/test/integration/user.e2e-spec.ts index 385fe553ec..874e024c28 100644 --- a/api/test/integration/user.e2e-spec.ts +++ b/api/test/integration/user.e2e-spec.ts @@ -604,34 +604,4 @@ describe('User Controller Tests', () => { ]); expect(res.body.email).toEqual('partneruser@email.com'); }); - - it('should send a csv export', async () => { - const storedUser = await prisma.userAccounts.create({ - data: await userFactory({ - roles: { isAdmin: true }, - mfaEnabled: false, - confirmedAt: new Date(), - }), - }); - - const res = await request(app.getHttpServer()) - .get('/user/csv') - .set('Cookie', cookies) - .expect(200); - - expect(res.body.success).toEqual(true); - expect(testEmailService.sendCSV).toHaveBeenCalledWith( - [], - expect.anything(), - expect.anything(), - 'User Export', - 'an export of all users', - ); - expect(testEmailService.sendCSV.mock.calls[0][2]).toContain( - `\"First Name\",\"Last Name\",\"Email\",\"Role\",\"Date Created\",\"Status\",\"Listing Names\",\"Listing Ids\",\"Last Logged In\"`, - ); - expect(testEmailService.sendCSV.mock.calls[0][2]).toContain( - `\"${storedUser.firstName}\",\"${storedUser.lastName}\",\"${storedUser.email}\",\"Administrator\"`, - ); - }); }); diff --git a/api/test/unit/services/user-csv-export.service.spec.ts b/api/test/unit/services/user-csv-export.service.spec.ts new file mode 100644 index 0000000000..449f9518bd --- /dev/null +++ b/api/test/unit/services/user-csv-export.service.spec.ts @@ -0,0 +1,248 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LanguagesEnum } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { Request as ExpressRequest, Response } from 'express'; +import { UserCsvExporterService } from '../../../src/services/user-csv-export.service'; +import { PrismaService } from '../../../src/services/prisma.service'; +import { User } from '../../../src/dtos/users/user.dto'; +import { PassThrough } from 'stream'; +import { UserRole } from '../../../src/dtos/users/user-role.dto'; + +describe('Testing user csv export service', () => { + let service: UserCsvExporterService; + let prisma: PrismaService; + + const mockUser = ( + position: number, + date: Date, + userRoles: UserRole, + jurisdictionId: string, + ) => { + return { + id: randomUUID(), + createdAt: date, + updatedAt: date, + passwordUpdatedAt: date, + passwordValidForDays: 180, + confirmedAt: date, + email: `exampleemail_${position}@test.com`, + firstName: `first name ${position}`, + middleName: `middle name ${position}`, + lastName: `last name ${position}`, + dob: date, + listings: [], + userRoles: userRoles, + language: LanguagesEnum.en, + jurisdictions: [ + { + id: jurisdictionId, + }, + ], + mfaEnabled: false, + lastLoginAt: date, + failedLoginAttemptsCount: 0, + phoneNumberVerified: true, + agreedToTermsOfService: true, + hitConfirmationURL: date, + activeAccessToken: randomUUID(), + activeRefreshToken: randomUUID(), + }; + }; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UserCsvExporterService, PrismaService], + }).compile(); + + service = module.get(UserCsvExporterService); + prisma = module.get(PrismaService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('exportFile', () => { + const jurisdiction1 = randomUUID(); + const jurisdiction2 = randomUUID(); + const requestingUser = { + firstName: 'requesting fName', + lastName: 'requesting lName', + email: 'requestingUser@email.com', + jurisdictions: [{ id: 'juris id' }], + userRoles: { + isAdmin: true, + isJurisdictionalAdmin: false, + isPartner: false, + }, + } as unknown as User; + + it('should export file for admin', async () => { + prisma.userAccounts.findMany = jest + .fn() + .mockResolvedValue([ + mockUser( + 0, + new Date(1707846198724), + { isAdmin: true }, + jurisdiction1, + ), + mockUser( + 1, + new Date(1707842826559), + { isPartner: true }, + jurisdiction1, + ), + mockUser( + 2, + new Date(1707842826559), + { isPartner: true }, + jurisdiction2, + ), + mockUser( + 3, + new Date(1707846198724), + { isJurisdictionalAdmin: true }, + jurisdiction1, + ), + ]); + const exportResponse = await service.exportFile( + { + user: { + ...requestingUser, + jurisdictions: [{ id: jurisdiction1 }, { id: jurisdiction2 }], + }, + } as unknown as ExpressRequest, + {} as unknown as Response, + ); + + expect(prisma.userAccounts.findMany).toBeCalledWith({ + include: { + listings: true, + userRoles: true, + }, + where: { + AND: [ + { + OR: [ + { + userRoles: { + isPartner: true, + }, + }, + { + userRoles: { + isAdmin: true, + }, + }, + { + userRoles: { + isJurisdictionalAdmin: true, + }, + }, + ], + }, + ], + }, + }); + + const headerRow = + '"First Name","Last Name","Email","Role","Date Created","Status","Listing Names","Listing Ids","Last Logged In"'; + const firstUser = + '"first name 0","last name 0","exampleemail_0@test.com","Administrator","02-13-2024","Confirmed",,,"02-13-2024"'; + + const mockedStream = new PassThrough(); + exportResponse.getStream().pipe(mockedStream); + const readable = await new Promise((resolve) => { + mockedStream.on('data', async (d) => { + const value = Buffer.from(d).toString(); + mockedStream.end(); + mockedStream.destroy(); + resolve(value); + }); + }); + + expect(readable).toContain(headerRow); + expect(readable).toContain(firstUser); + }); + it('should export file for jurisdictionAdmin', async () => { + prisma.userAccounts.findMany = jest + .fn() + .mockResolvedValue([ + mockUser( + 1, + new Date(1707842826559), + { isPartner: true }, + jurisdiction1, + ), + mockUser( + 2, + new Date(1707842826559), + { isPartner: true }, + jurisdiction2, + ), + mockUser( + 3, + new Date(1707846198724), + { isJurisdictionalAdmin: true }, + jurisdiction1, + ), + ]); + const exportResponse = await service.exportFile( + { + user: { + ...requestingUser, + jurisdictions: [{ id: jurisdiction1 }], + userRoles: { isJurisdictionalAdmin: true }, + }, + } as unknown as ExpressRequest, + {} as unknown as Response, + ); + + expect(prisma.userAccounts.findMany).toBeCalledWith({ + include: { + listings: true, + userRoles: true, + }, + where: { + AND: [ + { + OR: [ + { + userRoles: { + isPartner: true, + }, + }, + { + userRoles: { + isJurisdictionalAdmin: true, + }, + }, + ], + }, + { jurisdictions: { some: { id: { in: [jurisdiction1] } } } }, + ], + }, + }); + + const headerRow = + '"First Name","Last Name","Email","Role","Date Created","Status","Listing Names","Listing Ids","Last Logged In"'; + const firstUser = + '"first name 1","last name 1","exampleemail_1@test.com","Partner","02-13-2024","Confirmed",,,"02-13-2024"'; + + const mockedStream = new PassThrough(); + exportResponse.getStream().pipe(mockedStream); + const readable = await new Promise((resolve) => { + mockedStream.on('data', async (d) => { + const value = Buffer.from(d).toString(); + mockedStream.end(); + mockedStream.destroy(); + resolve(value); + }); + }); + + expect(readable).toContain(headerRow); + expect(readable).toContain(firstUser); + }); + }); +}); diff --git a/api/test/unit/services/user.service.spec.ts b/api/test/unit/services/user.service.spec.ts index 8189b19a5b..1f09919a40 100644 --- a/api/test/unit/services/user.service.spec.ts +++ b/api/test/unit/services/user.service.spec.ts @@ -1660,90 +1660,4 @@ describe('Testing user service', () => { expect(canOrThrowMock).not.toHaveBeenCalled(); }); }); - - describe('export', () => { - it('should send csv export', async () => { - emailService.sendCSV = jest.fn(); - const spiedList = jest.spyOn(service, 'list'); - 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 formattedDate = dayjs(date).format('MM-DD-YYYY HH:mmZ[Z]'); - prisma.userAccounts.findMany = jest.fn().mockResolvedValue([ - { - id: 'user id 1', - firstName: 'user 1 fName', - lastName: 'user 1 lName', - email: 'user1@email.com', - createdAt: date, - confirmedAt: date, - lastLoginAt: date, - userRoles: { - isAdmin: true, - }, - }, - { - id: 'user id 2', - firstName: 'user 2 fName', - lastName: 'user 2 lName', - email: 'user2@email.com', - createdAt: date, - confirmedAt: date, - lastLoginAt: date, - userRoles: { - isPartner: true, - }, - listings: [ - { - id: 'listing id 1', - name: 'listing 1', - }, - ], - }, - { - id: 'user id 3', - firstName: 'user 3 fName', - lastName: 'user 3 lName', - email: 'user3@email.com', - createdAt: date, - confirmedAt: date, - lastLoginAt: date, - userRoles: { - isJurisdictionalAdmin: true, - }, - }, - ]); - await service.export(requestingUser); - - expect(spiedList).toHaveBeenCalledWith( - { - page: 1, - limit: 'all', - filter: [ - { - isPortalUser: true, - }, - ], - }, - requestingUser, - ); - - const headerRow = `\"First Name\",\"Last Name\",\"Email\",\"Role\",\"Date Created\",\"Status\",\"Listing Names\",\"Listing Ids\",\"Last Logged In\"`; - const row1 = `\"user 1 fName\",\"user 1 lName\",\"user1@email.com\",\"Administrator\",\"${formattedDate}\",\"Confirmed\",\"\",\"\",\"${formattedDate}\"`; - const row2 = `\"user 2 fName\",\"user 2 lName\",\"user2@email.com\",\"Partner\",\"${formattedDate}\",\"Confirmed\",\"listing 1\",\"listing id 1\",\"${formattedDate}\"`; - const row3 = `\"user 3 fName\",\"user 3 lName\",\"user3@email.com\",\"Jurisdictional Admin\",\"${formattedDate}\",\"Confirmed\",\"\",\"\",\"${formattedDate}\"`; - - expect(emailService.sendCSV).toHaveBeenCalledWith( - [{ id: 'juris id' }], - requestingUser, - `${headerRow}\n${row1}\n${row2}\n${row3}\n`, - 'User Export', - 'an export of all users', - ); - }); - }); }); diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 9a4c506019..d4f5708d95 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1604,7 +1604,7 @@ export class UserService { /** * List users in CSV */ - listAsCsv(options: IRequestOptions = {}): Promise { + listAsCsv(options: IRequestOptions = {}): Promise { return new Promise((resolve, reject) => { let url = basePath + "/user/csv" diff --git a/sites/partners/cypress/e2e/default/06-admin-user-mangement.spec.ts b/sites/partners/cypress/e2e/default/06-admin-user-mangement.spec.ts index fa6661e149..10d7cf253e 100644 --- a/sites/partners/cypress/e2e/default/06-admin-user-mangement.spec.ts +++ b/sites/partners/cypress/e2e/default/06-admin-user-mangement.spec.ts @@ -21,15 +21,22 @@ describe("Admin User Mangement Tests", () => { }) it("as admin user, should be able to download export", () => { - cy.intercept("GET", "api/adapter/user/csv", { - success: true, - }) + const convertToString = (value: number) => { + return value < 10 ? `0${value}` : `${value}` + } cy.visit("/") cy.getByTestId("Users-1").click() cy.getByID("export-users").click() - cy.getByTestId("alert-box").contains( - "An email containing the exported file has been sent to admin@example.com" - ) + const now = new Date() + const dateString = `${now.getFullYear()}-${convertToString( + now.getMonth() + 1 + )}-${convertToString(now.getDate())}` + const csvName = `users-${dateString}_${convertToString(now.getHours())}_${convertToString( + now.getMinutes() + )}.csv` + const downloadFolder = Cypress.config("downloadsFolder") + const completeZipPath = `${downloadFolder}/${csvName}` + cy.readFile(completeZipPath) }) it("as admin user, should be able to create new admin", () => { diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index 58998a0868..40e041e703 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -518,40 +518,12 @@ export const useApplicationsExport = (listingId: string, includeDemographics: bo } export const useUsersExport = () => { - const { userService, profile } = useContext(AuthContext) - - const [csvExportLoading, setCsvExportLoading] = useState(false) - const [csvExportError, setCsvExportError] = useState(false) - const [csvExportSuccess, setCsvExportSuccess] = useState(false) - - const onExport = useCallback(async () => { - setCsvExportError(false) - setCsvExportSuccess(false) - setCsvExportLoading(true) - - try { - await userService.listAsCsv() - setCsvExportSuccess(true) - setSiteAlertMessage( - t("t.emailingExportSuccess", { - email: profile?.email, - }), - "success" - ) - } catch (err) { - console.log(err) - setCsvExportError(true) - } - - setCsvExportLoading(false) - }, [userService, profile?.email]) + const { userService } = useContext(AuthContext) - return { - onExport, - csvExportLoading, - csvExportError, - csvExportSuccess, - } + return useCsvExport( + () => userService.listAsCsv(), + `users-${createDateStringFromNow("YYYY-MM-DD_HH:mm")}.csv` + ) } const useCsvExport = (endpoint: () => Promise, fileName: string) => { diff --git a/sites/public/src/components/shared/FormSummaryDetails.tsx b/sites/public/src/components/shared/FormSummaryDetails.tsx index 0d96bdb970..5d296449a9 100644 --- a/sites/public/src/components/shared/FormSummaryDetails.tsx +++ b/sites/public/src/components/shared/FormSummaryDetails.tsx @@ -115,7 +115,7 @@ const FormSummaryDetails = ({ question: ApplicationMultiselectQuestion, option: ApplicationMultiselectQuestionOption ) => { - const initialMultiselectQuestion = listing.listingMultiselectQuestions.find( + const initialMultiselectQuestion = listing?.listingMultiselectQuestions.find( (elem) => cleanMultiselectString(elem.multiselectQuestions.text) === cleanMultiselectString(question.key) diff --git a/sites/public/src/pages/account/edit.tsx b/sites/public/src/pages/account/edit.tsx index 2132780793..a2831a469c 100644 --- a/sites/public/src/pages/account/edit.tsx +++ b/sites/public/src/pages/account/edit.tsx @@ -243,6 +243,7 @@ const Edit = () => { error={errors?.dateOfBirth} watch={watch} validateAge18={true} + required={true} errorMessage={t("errors.dateOfBirthErrorAge")} defaultDOB={{ birthDay: profile ? dayjs(new Date(profile.dob)).utc().format("DD") : null,