Skip to content

Commit

Permalink
feat: application export now emailed (#3661)
Browse files Browse the repository at this point in the history
  • Loading branch information
YazeedLoonat authored Oct 6, 2023
1 parent a164473 commit 211716d
Show file tree
Hide file tree
Showing 10 changed files with 175 additions and 28 deletions.
18 changes: 4 additions & 14 deletions backend/core/src/applications/applications.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
Controller,
Delete,
Get,
Header,
Param,
ParseUUIDPipe,
Post,
Expand All @@ -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"
Expand All @@ -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")
Expand All @@ -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" })
Expand All @@ -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<string> {
const applications = await this.applicationsService.rawListWithFlagged(queryParams)
return this.applicationCsvExporter.exportFromObject(
applications,
queryParams.timeZone,
queryParams.includeDemographics
)
): Promise<StatusDto> {
return await this.applicationsService.sendExport(queryParams)
}

@Post()
Expand Down
27 changes: 26 additions & 1 deletion backend/core/src/applications/services/applications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Application>,
@InjectRepository(Listing) private readonly listingsRepository: Repository<Listing>
) {}
Expand Down Expand Up @@ -251,6 +255,25 @@ export class ApplicationsService {
return await this.repository.softRemove({ id: applicationId })
}

async sendExport(queryParams: ApplicationsCsvListQueryParams): Promise<StatusDto> {
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
Expand Down Expand Up @@ -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)

Expand Down
46 changes: 44 additions & 2 deletions backend/core/src/email/email.service.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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<MailDataRequired> = {
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
Expand Down Expand Up @@ -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",
}
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from "typeorm"

export class csvExportTranslations1696353656895 implements MigrationInterface {
name = "csvExportTranslations1696353656895"

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
// no down migration
}
}
30 changes: 30 additions & 0 deletions backend/core/src/shared/views/csv-export.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{{#> layout_default }}
<h1>
{{ t "csvExport.title" appOptions }}
</h1>

<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td align="left">
<p>
{{t "csvExport.hello" appOptions}}
</p>
<p>
{{t "csvExport.body" appOptions}}
</p>
</td>
</tr>
<tr>
<td align="left">
<p>
{{t "footer.thankYou" }},
</p>
<p>
{{t "header.logoTitle" }}
</p>
</td>
</tr>
</tbody>
</table>
{{/layout_default }}
5 changes: 2 additions & 3 deletions backend/core/test/applications/applications.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion backend/core/types/src/backend-swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ export class ApplicationsService {
includeDemographics?: boolean
} = {} as any,
options: IRequestOptions = {}
): Promise<string> {
): Promise<Status> {
return new Promise((resolve, reject) => {
let url = basePath + "/applications/csv"

Expand Down
1 change: 1 addition & 0 deletions sites/partners/page_content/locale_overrides/general.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 33 additions & 5 deletions sites/partners/src/lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,9 @@ const ApplicationsList = () => {
<Head>
<title>{t("nav.siteTitlePartners")}</title>
</Head>
{csvExportSuccess && <SiteAlert type="success" timeout={5000} dismissable sticky={true} />}
{csvExportSuccess && <SiteAlert type="success" dismissable sticky={true} />}
{csvExportError && (
<SiteAlert
timeout={5000}
dismissable
sticky={true}
alertMessage={{ message: t("account.settings.alerts.genericError"), type: "alert" }}
Expand Down

0 comments on commit 211716d

Please sign in to comment.