diff --git a/backend/core/src/applications/applications.controller.ts b/backend/core/src/applications/applications.controller.ts index c90f8e5034..7a6f783946 100644 --- a/backend/core/src/applications/applications.controller.ts +++ b/backend/core/src/applications/applications.controller.ts @@ -3,7 +3,6 @@ import { Controller, Delete, Get, - Header, Param, ParseUUIDPipe, Post, @@ -22,7 +21,6 @@ import { ApplicationDto } from "./dto/application.dto" import { ValidationsGroupsEnum } from "../shared/types/validations-groups-enum" import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" import { applicationMultiselectQuestionApiExtraModels } from "./types/application-multiselect-question-api-extra-models" -import { ApplicationCsvExporterService } from "./services/application-csv-exporter.service" import { ApplicationsService } from "./services/applications.service" import { ActivityLogInterceptor } from "../activity-log/interceptors/activity-log.interceptor" import { PaginatedApplicationListQueryParams } from "./dto/paginated-application-list-query-params" @@ -32,6 +30,7 @@ import { PaginatedApplicationDto } from "./dto/paginated-application.dto" import { ApplicationCreateDto } from "./dto/application-create.dto" import { ApplicationUpdateDto } from "./dto/application-update.dto" import { IdDto } from "../shared/dto/id.dto" +import { StatusDto } from "../shared/dto/status.dto" @Controller("applications") @ApiTags("applications") @@ -47,10 +46,7 @@ import { IdDto } from "../shared/dto/id.dto" ) @ApiExtraModels(...applicationMultiselectQuestionApiExtraModels, ApplicationsApiExtraModel) export class ApplicationsController { - constructor( - private readonly applicationsService: ApplicationsService, - private readonly applicationCsvExporter: ApplicationCsvExporterService - ) {} + constructor(private readonly applicationsService: ApplicationsService) {} @Get() @ApiOperation({ summary: "List applications", operationId: "list" }) @@ -62,17 +58,11 @@ export class ApplicationsController { @Get(`csv`) @ApiOperation({ summary: "List applications as csv", operationId: "listAsCsv" }) - @Header("Content-Type", "text/csv") async listAsCsv( @Query(new ValidationPipe(defaultValidationPipeOptions)) queryParams: ApplicationsCsvListQueryParams - ): Promise { - const applications = await this.applicationsService.rawListWithFlagged(queryParams) - return this.applicationCsvExporter.exportFromObject( - applications, - queryParams.timeZone, - queryParams.includeDemographics - ) + ): Promise { + return await this.applicationsService.sendExport(queryParams) } @Post() diff --git a/backend/core/src/applications/services/applications.service.ts b/backend/core/src/applications/services/applications.service.ts index 3778fe7c46..11efa4653b 100644 --- a/backend/core/src/applications/services/applications.service.ts +++ b/backend/core/src/applications/services/applications.service.ts @@ -26,6 +26,9 @@ import { ApplicationCreateDto } from "../dto/application-create.dto" import { ApplicationUpdateDto } from "../dto/application-update.dto" import { ApplicationsCsvListQueryParams } from "../dto/applications-csv-list-query-params" import { Listing } from "../../listings/entities/listing.entity" +import { ApplicationCsvExporterService } from "./application-csv-exporter.service" +import { User } from "../../auth/entities/user.entity" +import { StatusDto } from "../../shared/dto/status.dto" @Injectable({ scope: Scope.REQUEST }) export class ApplicationsService { @@ -34,6 +37,7 @@ export class ApplicationsService { private readonly authzService: AuthzService, private readonly listingsService: ListingsService, private readonly emailService: EmailService, + private readonly applicationCsvExporter: ApplicationCsvExporterService, @InjectRepository(Application) private readonly repository: Repository, @InjectRepository(Listing) private readonly listingsRepository: Repository ) {} @@ -251,6 +255,25 @@ export class ApplicationsService { return await this.repository.softRemove({ id: applicationId }) } + async sendExport(queryParams: ApplicationsCsvListQueryParams): Promise { + const applications = await this.rawListWithFlagged(queryParams) + const csvString = this.applicationCsvExporter.exportFromObject( + applications, + queryParams.timeZone, + queryParams.includeDemographics + ) + const listing = await this.listingsRepository.findOne({ where: { id: queryParams.listingId } }) + await this.emailService.sendCSV( + (this.req.user as unknown) as User, + listing.name, + listing.id, + csvString + ) + return { + status: "Success", + } + } + private _getQb(params: PaginatedApplicationListQueryParams, view = "base", withSelect = true) { /** * Map used to generate proper parts @@ -415,7 +438,9 @@ export class ApplicationsService { private async authorizeCSVExport(user, listingId) { /** - * Checking authorization for each application is very expensive. By making lisitngId required, we can check if the user has update permissions for the listing, since right now if a user has that they also can run the export for that listing + * Checking authorization for each application is very expensive. + * By making listingId required, we can check if the user has update permissions for the listing, since right now if a user has that + * they also can run the export for that listing */ const jurisdictionId = await this.listingsService.getJurisdictionIdByListingId(listingId) diff --git a/backend/core/src/email/email.service.ts b/backend/core/src/email/email.service.ts index 39553fbbd0..187965572e 100644 --- a/backend/core/src/email/email.service.ts +++ b/backend/core/src/email/email.service.ts @@ -1,6 +1,7 @@ import { HttpException, Injectable, Logger, Scope } from "@nestjs/common" import { SendGridService } from "@anchan828/nest-sendgrid" import { ResponseError } from "@sendgrid/helpers/classes" +import { MailDataRequired } from "@sendgrid/helpers/classes/mail" import merge from "lodash/merge" import Handlebars from "handlebars" import path from "path" @@ -18,6 +19,13 @@ import { Language } from "../shared/types/language-enum" import { JurisdictionsService } from "../jurisdictions/services/jurisdictions.service" import { Translation } from "../translations/entities/translation.entity" import { IdName } from "../../types" +import { formatLocalDate } from "../shared/utils/format-local-date" + +type EmailAttachmentData = { + data: string + name: string + type: string +} @Injectable({ scope: Scope.REQUEST }) export class EmailService { @@ -293,15 +301,26 @@ export class EmailService { from: string, subject: string, body: string, - retry = 3 + retry = 3, + attachment?: EmailAttachmentData ) { const multipleRecipients = Array.isArray(to) - const emailParams = { + const emailParams: Partial = { to, from, subject, html: body, } + if (attachment) { + emailParams.attachments = [ + { + content: Buffer.from(attachment.data).toString("base64"), + filename: attachment.name, + type: attachment.type, + disposition: "attachment", + }, + ] + } const handleError = (error) => { if (error instanceof ResponseError) { const { response } = error @@ -415,4 +434,27 @@ export class EmailService { throw new HttpException("email failed", 500) } } + + async sendCSV(user: User, listingName: string, listingId: string, applicationData: string) { + void (await this.loadTranslations( + user.jurisdictions?.length === 1 ? user.jurisdictions[0] : null, + user.language || Language.en + )) + const jurisdiction = await this.getUserJurisdiction(user) + await this.send( + user.email, + jurisdiction.emailFromAddress, + `${listingName} applications export`, + this.template("csv-export")({ + user: user, + appOptions: { listingName, appUrl: this.configService.get("PARTNERS_PORTAL_URL") }, + }), + undefined, + { + data: applicationData, + name: `applications-${listingId}-${formatLocalDate(new Date(), "YYYY-MM-DD_HH:mm:ss")}.csv`, + type: "text/csv", + } + ) + } } diff --git a/backend/core/src/migration/1696353656895-csv-export-translations.ts b/backend/core/src/migration/1696353656895-csv-export-translations.ts new file mode 100644 index 0000000000..e8c716b18c --- /dev/null +++ b/backend/core/src/migration/1696353656895-csv-export-translations.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class csvExportTranslations1696353656895 implements MigrationInterface { + name = "csvExportTranslations1696353656895" + + public async up(queryRunner: QueryRunner): Promise { + const translations: { id: string; translations: any }[] = await queryRunner.query(` + SELECT + id, + translations + FROM translations + WHERE language = 'en' + `) + translations.forEach(async (translation) => { + let data = translation.translations + data.csvExport = { + title: "%{listingName} applications export", + body: "The attached file is an applications export for %{listingName}. If you have any questions, please reach out to your administrator.", + hello: "Hello,", + } + data = JSON.stringify(data) + await queryRunner.query(` + UPDATE translations + SET translations = '${data.replace(/'/g, "''")}' + WHERE id = '${translation.id}' + `) + }) + } + + public async down(queryRunner: QueryRunner): Promise { + // no down migration + } +} diff --git a/backend/core/src/shared/views/csv-export.hbs b/backend/core/src/shared/views/csv-export.hbs new file mode 100644 index 0000000000..af51532ba1 --- /dev/null +++ b/backend/core/src/shared/views/csv-export.hbs @@ -0,0 +1,30 @@ +{{#> layout_default }} +

+ {{ t "csvExport.title" appOptions }} +

+ + + + + + + + + + +
+

+ {{t "csvExport.hello" appOptions}} +

+

+ {{t "csvExport.body" appOptions}} +

+
+

+ {{t "footer.thankYou" }}, +

+

+ {{t "header.logoTitle" }} +

+
+{{/layout_default }} diff --git a/backend/core/test/applications/applications.e2e-spec.ts b/backend/core/test/applications/applications.e2e-spec.ts index b03a8bf1ad..22b295b7bf 100644 --- a/backend/core/test/applications/applications.e2e-spec.ts +++ b/backend/core/test/applications/applications.e2e-spec.ts @@ -47,7 +47,7 @@ describe("Applications", () => { beforeEach(async () => { /* eslint-disable @typescript-eslint/no-empty-function */ - const testEmailService = { confirmation: async () => {} } + const testEmailService = { confirmation: async () => {}, sendCSV: async () => {} } /* eslint-enable @typescript-eslint/no-empty-function */ const moduleRef = await Test.createTestingModule({ imports: [ @@ -441,8 +441,7 @@ describe("Applications", () => { .get(`/applications/csv/?listingId=${listing1Id}`) .set(...setAuthorization(adminAccessToken)) .expect(200) - expect(typeof res.text === "string") - expect(new RegExp(/Flagged/).test(res.text)).toEqual(true) + expect(res.body.status).toEqual("Success") }) it(`should allow an admin to delete user's applications`, async () => { diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index acfeabf646..fbd056ac24 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -566,7 +566,7 @@ export class ApplicationsService { includeDemographics?: boolean } = {} as any, options: IRequestOptions = {} - ): Promise { + ): Promise { return new Promise((resolve, reject) => { let url = basePath + "/applications/csv" diff --git a/sites/partners/page_content/locale_overrides/general.json b/sites/partners/page_content/locale_overrides/general.json index 85ef507cdb..687884c554 100644 --- a/sites/partners/page_content/locale_overrides/general.json +++ b/sites/partners/page_content/locale_overrides/general.json @@ -373,6 +373,7 @@ "t.descriptionTitle": "Description", "t.done": "Done", "t.draft": "Draft", + "t.emailingExportSuccess": "An email containing the exported file has been sent to %{email}", "t.end": "End", "t.endTime": "End Time", "t.enterAmount": "Enter amount", diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index 29790cdc4e..8ed34fdb54 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -498,13 +498,41 @@ export const createDateStringFromNow = (format = "YYYY-MM-DD_HH:mm:ss"): string } export const useApplicationsExport = (listingId: string, includeDemographics: boolean) => { - const { applicationsService } = useContext(AuthContext) + const { applicationsService, profile } = useContext(AuthContext) const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone.replace("/", "-") - return useCsvExport( - () => applicationsService.listAsCsv({ listingId, timeZone, includeDemographics }), - `applications-${listingId}-${createDateStringFromNow()}.csv` - ) + 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 applicationsService.listAsCsv({ listingId, timeZone, includeDemographics }) + setCsvExportSuccess(true) + setSiteAlertMessage( + t("t.emailingExportSuccess", { + email: profile?.email, + }), + "success" + ) + } catch (err) { + console.log(err) + setCsvExportError(true) + } + + setCsvExportLoading(false) + }, [applicationsService, includeDemographics, listingId, profile?.email, timeZone]) + + return { + onExport, + csvExportLoading, + csvExportError, + csvExportSuccess, + } } export const useUsersExport = () => { diff --git a/sites/partners/src/pages/listings/[id]/applications/index.tsx b/sites/partners/src/pages/listings/[id]/applications/index.tsx index 5c5beec0d5..76a49be0d7 100644 --- a/sites/partners/src/pages/listings/[id]/applications/index.tsx +++ b/sites/partners/src/pages/listings/[id]/applications/index.tsx @@ -109,10 +109,9 @@ const ApplicationsList = () => { {t("nav.siteTitlePartners")} - {csvExportSuccess && } + {csvExportSuccess && } {csvExportError && (