Skip to content

Commit

Permalink
fix: move user export to direct download (#3882)
Browse files Browse the repository at this point in the history
  • Loading branch information
ludtkemorgan authored Feb 20, 2024
1 parent cf398c0 commit 74a7446
Show file tree
Hide file tree
Showing 16 changed files with 681 additions and 396 deletions.
25 changes: 21 additions & 4 deletions api/prisma/seed-staging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -69,7 +70,7 @@ export const stagingSeed = async (
});
await prismaClient.userAccounts.create({
data: await userFactory({
roles: { isJurisdictionalAdmin: true },
roles: { isAdmin: true },
email: '[email protected]',
confirmedAt: new Date(),
jurisdictionIds: [jurisdiction.id],
Expand All @@ -78,7 +79,7 @@ export const stagingSeed = async (
});
await prismaClient.userAccounts.create({
data: await userFactory({
roles: { isJurisdictionalAdmin: true },
roles: { isAdmin: true },
email: '[email protected]',
confirmedAt: new Date(),
jurisdictionIds: [jurisdiction.id],
Expand Down Expand Up @@ -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: '[email protected]',
confirmedAt: new Date(),
jurisdictionIds: [jurisdiction.id, additionalJurisdiction.id],
acceptedTerms: true,
listings: [savedListing.id],
}),
});
}
},
);
};
20 changes: 15 additions & 5 deletions api/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import {
Controller,
Delete,
Get,
Header,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
Request,
Res,
StreamableFile,
UseGuards,
UseInterceptors,
UsePipes,
Expand All @@ -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';
Expand All @@ -44,14 +47,18 @@ 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')
@PermissionTypeDecorator('user')
@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)
Expand Down Expand Up @@ -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<SuccessDTO> {
return await this.userService.export(mapTo(User, req['user']));
async listAsCsv(
@Request() req: ExpressRequest,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
return await this.userCSVExportService.exportFile(req, res);
}

@Get(`:id`)
Expand Down
5 changes: 3 additions & 2 deletions api/src/modules/user.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
12 changes: 5 additions & 7 deletions api/src/services/listing-csv-export.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]);
Expand Down Expand Up @@ -153,7 +151,7 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface {
async createCsv<QueryParams extends ListingCsvQueryParams>(
filename: string,
queryParams: QueryParams,
listings: Listing[],
optionParams: { listings: Listing[] },
): Promise<void> {
const csvHeaders = await this.getCsvHeaders();

Expand All @@ -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) => {
Expand Down
210 changes: 210 additions & 0 deletions api/src/services/user-csv-export.service.ts
Original file line number Diff line number Diff line change
@@ -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<QueryParams>(
req: ExpressRequest,
res: Response,
queryParams?: QueryParams,
): Promise<StreamableFile> {
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<QueryParams>(
filename: string,
queryParams: QueryParams,
optionalParams: { user: User },
): Promise<void> {
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<CsvHeader[]> {
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<void> {
if (
user &&
(user.userRoles?.isAdmin || user.userRoles?.isJurisdictionalAdmin)
) {
return;
} else {
throw new ForbiddenException();
}
}
}
Loading

0 comments on commit 74a7446

Please sign in to comment.