From 2c74e99ee839c58748513970869670438aa66c91 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Mon, 24 Jun 2024 18:39:56 +0200 Subject: [PATCH 01/12] fix: Draft API now returns the correct types --- .talismanrc | 4 + frontend/src/components/Draft/SendButton.tsx | 15 +- frontend/src/models/Draft.tsx | 8 +- frontend/src/views/Campaign/CampaignDraft.tsx | 45 +++--- packages/models/src/DraftDTO.ts | 8 +- packages/models/src/SenderDTO.ts | 8 +- packages/models/src/test/fixtures.ts | 14 +- packages/utils/src/s3.ts | 22 +-- queue/src/workers/generate-mails.ts | 20 +-- .../controllers/campaignController.test.ts | 146 +++++++++++------- server/src/controllers/campaignController.ts | 26 +++- server/src/controllers/draftController.ts | 143 ++++------------- server/src/controllers/fileRepository.ts | 27 ++++ .../controllers/test/draftController.test.ts | 95 ++++++------ server/src/models/SenderApi.ts | 9 +- server/src/repositories/draftRepository.ts | 30 ++-- server/src/repositories/senderRepository.ts | 20 ++- server/src/test/testFixtures.ts | 146 +++++++++--------- server/src/types/express-jwt/index.d.ts | 3 +- shared/src/models/SenderDTO.ts | 8 +- 20 files changed, 387 insertions(+), 410 deletions(-) create mode 100644 server/src/controllers/fileRepository.ts diff --git a/.talismanrc b/.talismanrc index 3331fd689..5fae6e143 100644 --- a/.talismanrc +++ b/.talismanrc @@ -56,6 +56,8 @@ fileignoreconfig: checksum: 45473f39f4b07b3a33b6138a7e6fc3268690b59439d9f539eb1ce2c1abd430dc - filename: packages/schemas/src/password.ts checksum: 716d8d646fd8a00af8d4225e15190810f9e8ab082fbb14e7430be109fa676611 +- filename: packages/utils/src/s3.ts + checksum: 3270d7c99620a1dd53f1e1a90c24ba12b2b0b090969c498fbe739776f832029a - filename: queue/.env.example checksum: df71affe33700cb0a36ee0b4824ef60e7d8e5a08f3d4d16ca53c31e9085b45ae - filename: queue/README.md @@ -80,6 +82,8 @@ fileignoreconfig: checksum: b20a3159d29f2a0964c58d2befc040ef4f553ece130067316e651730c6c97d5b - filename: server/src/controllers/fileController.ts checksum: 72d31c3e49e4dbee18222e61e0a5bb48da755cefa2a8d7fa2d9f1a153290279e +- filename: server/src/controllers/fileRepository.ts + checksum: 4387143e5ef9cfb0a7168f63e361fcad10dfef5309e933d9b37c0ce89099d35a - filename: server/src/controllers/housingExportController.ts checksum: 41b6d72c503240edea923accce36af8548aa859d3494560eec003bac2c5ab4d5 - filename: server/src/index.ts diff --git a/frontend/src/components/Draft/SendButton.tsx b/frontend/src/components/Draft/SendButton.tsx index ff313bd30..17d713c63 100644 --- a/frontend/src/components/Draft/SendButton.tsx +++ b/frontend/src/components/Draft/SendButton.tsx @@ -1,19 +1,15 @@ import { Alert } from '@codegouvfr/react-dsfr/Alert'; import ConfirmationModal from '../modals/ConfirmationModal/ConfirmationModal'; -import { useUpdateCampaignMutation } from '../../services/campaign.service'; -import { Campaign } from '../../models/Campaign'; import { useForm } from '../../hooks/useForm'; interface Props { className?: string; - campaign: Campaign; form: ReturnType; + onSend(): void; } function SendButton(props: Readonly) { - const [updateCampaign] = useUpdateCampaignMutation(); - function open(openModal: () => void): void { props.form.validate(() => { openModal(); @@ -21,12 +17,7 @@ function SendButton(props: Readonly) { } function submit(): void { - props.form.validate(() => { - updateCampaign({ - ...props.campaign, - status: 'sending', - }); - }); + props.onSend(); } return ( @@ -35,7 +26,7 @@ function SendButton(props: Readonly) { openingButtonProps={{ children: 'Débuter l’envoi', iconId: 'fr-icon-send-plane-fill', - priority: 'primary', + priority: 'primary' }} size="large" title="Envoi de la campagne" diff --git a/frontend/src/models/Draft.tsx b/frontend/src/models/Draft.tsx index aa8012b1d..3f309ae1d 100644 --- a/frontend/src/models/Draft.tsx +++ b/frontend/src/models/Draft.tsx @@ -2,7 +2,7 @@ import { DraftCreationPayloadDTO, DraftDTO, DraftPreviewPayloadDTO, - DraftUpdatePayloadDTO, + DraftUpdatePayloadDTO } from '@zerologementvacant/models'; import { SenderPayload } from './Sender'; import { DeepNonNullable } from 'ts-essentials'; @@ -10,8 +10,10 @@ import { DeepNonNullable } from 'ts-essentials'; export interface Draft extends DraftDTO {} export interface DraftCreationPayload - extends DeepNonNullable { - sender: DeepNonNullable; + extends DeepNonNullable>, + Pick { + sender: DeepNonNullable> & + Pick; } export type DraftUpdatePayload = DeepNonNullable; diff --git a/frontend/src/views/Campaign/CampaignDraft.tsx b/frontend/src/views/Campaign/CampaignDraft.tsx index 888991240..70268cc2f 100644 --- a/frontend/src/views/Campaign/CampaignDraft.tsx +++ b/frontend/src/views/Campaign/CampaignDraft.tsx @@ -12,7 +12,7 @@ import { Campaign } from '../../models/Campaign'; import { Col, Container, Row } from '../../components/_dsfr'; import { useCreateDraftMutation, - useUpdateDraftMutation, + useUpdateDraftMutation } from '../../services/draft.service'; import UnsavedChanges from '../../components/UnsavedChanges/UnsavedChanges'; import PreviewButton from '../../components/Draft/PreviewButton'; @@ -25,7 +25,7 @@ import SendButton from '../../components/Draft/SendButton'; import SaveButton from '../../components/SaveButton/SaveButton'; import DraftMailInfo, { Written, - writtenSchema, + writtenSchema } from '../../components/Draft/DraftMailInfo'; import { DraftCreationPayload } from '../../models/Draft'; import DraftSenderLogo from '../../components/Draft/DraftSenderLogo'; @@ -33,6 +33,7 @@ import DraftSignature from '../../components/Draft/DraftSignature'; import CampaignRecipients from '../../components/Campaign/CampaignRecipients'; import CampaignCreatedFromGroup from '../../components/Campaign/CampaignCreatedFromGroup'; import { FileUploadDTO } from '@zerologementvacant/models'; +import { useUpdateCampaignMutation } from '../../services/campaign.service'; const schema = yup .object({ @@ -42,7 +43,7 @@ const schema = yup body: yup .string() .required('Veuillez renseigner le contenu de votre courrier'), - sender: senderSchema, + sender: senderSchema }) // Must do like that because the useForm hook has a validation bug // where it creates an infinite render loop if passed a `written` object @@ -73,10 +74,10 @@ function CampaignDraft(props: Readonly) { signatoryFirstName: '', signatoryLastName: '', signatoryRole: '', - signatoryFile: '', + signatoryFile: null }, writtenAt: '', - writtenFrom: '', + writtenFrom: '' }); useEffect(() => { @@ -97,10 +98,10 @@ function CampaignDraft(props: Readonly) { signatoryFirstName: draft.sender?.signatoryFirstName ?? '', signatoryLastName: draft.sender?.signatoryLastName ?? '', signatoryRole: draft.sender?.signatoryRole ?? '', - signatoryFile: draft.sender?.signatoryFile ?? '', + signatoryFile: draft.sender?.signatoryFile }, writtenAt: draft.writtenAt ?? '', - writtenFrom: draft.writtenFrom ?? '', + writtenFrom: draft.writtenFrom ?? '' }); } }, [draft, props.campaign.id]); @@ -110,22 +111,22 @@ function CampaignDraft(props: Readonly) { body: values.body, sender: values.sender, writtenAt: values.writtenAt, - writtenFrom: values.writtenFrom, + writtenFrom: values.writtenFrom }); const hasChanges = form.isDirty && !fp.equals(draft, values); const [createDraft, createDraftMutation] = useCreateDraftMutation(); - function create(): void { + async function create(): Promise { if (!draft) { - createDraft({ ...values, campaign: props.campaign.id }); + await createDraft({ ...values, campaign: props.campaign.id }).unwrap(); } } const [updateDraft, updateDraftMutation] = useUpdateDraftMutation(); - function update(): void { + async function update(): Promise { if (draft) { - updateDraft({ ...values, id: draft.id }); + await updateDraft({ ...values, id: draft.id }).unwrap(); } } @@ -134,6 +135,12 @@ function CampaignDraft(props: Readonly) { ? [update, updateDraftMutation] : [create, createDraftMutation]; + const [updateCampaign] = useUpdateCampaignMutation(); + async function send(): Promise { + await save(); + await updateCampaign({ ...props.campaign, status: 'sending' }); + } + function setBody(body: Body): void { setValues({ ...values, ...body }); } @@ -154,7 +161,7 @@ function CampaignDraft(props: Readonly) { setValues({ ...values, writtenAt: written.at, - writtenFrom: written.from, + writtenFrom: written.from }); } @@ -186,13 +193,13 @@ function CampaignDraft(props: Readonly) { disabled={!exists} draft={draft} /> - + ) { isLoading={mutation.isLoading} isSuccess={mutation.isSuccess} message={{ - success: 'Votre campagne a été sauvegardée avec succès', + success: 'Votre campagne a été sauvegardée avec succès' }} onSave={save} /> @@ -256,12 +263,12 @@ function CampaignDraft(props: Readonly) { - ), + ) }, { label: 'Destinataires', - content: , - }, + content: + } ]} /> diff --git a/packages/models/src/DraftDTO.ts b/packages/models/src/DraftDTO.ts index ebf818ea8..875d15fc3 100644 --- a/packages/models/src/DraftDTO.ts +++ b/packages/models/src/DraftDTO.ts @@ -7,7 +7,7 @@ export interface DraftDTO { id: string; subject: string | null; body: string | null; - logo: string[] | null; + logo: FileUploadDTO[] | null; sender: SenderDTO; writtenAt: string | null; writtenFrom: string | null; @@ -18,20 +18,18 @@ export interface DraftDTO { export interface DraftCreationPayloadDTO extends Pick< DraftDTO, - 'subject' | 'body' | 'writtenAt' | 'writtenFrom' + 'subject' | 'body' | 'logo' | 'writtenAt' | 'writtenFrom' > { campaign: string; sender: SenderPayloadDTO | null; - logo: FileUploadDTO[] } export interface DraftUpdatePayloadDTO extends Pick< DraftDTO, - 'id' | 'subject' | 'body' | 'writtenAt' | 'writtenFrom' + 'id' | 'subject' | 'body' | 'logo' | 'writtenAt' | 'writtenFrom' > { sender: SenderPayloadDTO; - logo: FileUploadDTO[] } export interface DraftPreviewPayloadDTO { diff --git a/packages/models/src/SenderDTO.ts b/packages/models/src/SenderDTO.ts index 3f3410a18..6426b37bf 100644 --- a/packages/models/src/SenderDTO.ts +++ b/packages/models/src/SenderDTO.ts @@ -1,4 +1,4 @@ -import { FileUploadDTO } from "./FileUploadDTO"; +import { FileUploadDTO } from './FileUploadDTO'; export interface SenderDTO { id: string; @@ -12,12 +12,12 @@ export interface SenderDTO { signatoryLastName: string | null; signatoryFirstName: string | null; signatoryRole: string | null; - signatoryFile: string | null; + signatoryFile: FileUploadDTO | null; createdAt: string; updatedAt: string; } -export type SenderPayloadDTO = Omit, 'signatoryFile'> & { signatoryFile: FileUploadDTO | null }; +>; diff --git a/packages/models/src/test/fixtures.ts b/packages/models/src/test/fixtures.ts index 6388e55fd..c1e1cf63b 100644 --- a/packages/models/src/test/fixtures.ts +++ b/packages/models/src/test/fixtures.ts @@ -14,6 +14,7 @@ import { OCCUPANCY_VALUES } from '../Occupancy'; import { HOUSING_KIND_VALUES } from '../HousingKind'; import { DatafoncierHousing } from '../DatafoncierHousing'; import { HOUSING_STATUS_VALUES } from '../HousingStatus'; +import { FileUploadDTO } from '../FileUploadDTO'; export function genAddressDTO( refId: string, @@ -186,14 +187,15 @@ export function genDatafoncierHousingDTO( }; } -export function genDraftDTO(sender: SenderDTO): DraftDTO { +export function genDraftDTO( + sender: SenderDTO, + logo?: FileUploadDTO[] +): DraftDTO { return { id: faker.string.uuid(), subject: faker.lorem.sentence(), body: faker.lorem.paragraphs(), - logo: faker.helpers.multiple(() => faker.image.url(), { - count: { min: 1, max: 2 } - }), + logo: logo ?? null, createdAt: new Date().toJSON(), updatedAt: new Date().toJSON(), sender, @@ -285,7 +287,7 @@ export function genOwnerDTO(): OwnerDTO { }; } -export function genSenderDTO(): SenderDTO { +export function genSenderDTO(signature?: FileUploadDTO): SenderDTO { const firstName = faker.person.firstName(); const lastName = faker.person.lastName(); return { @@ -297,7 +299,7 @@ export function genSenderDTO(): SenderDTO { address: faker.location.streetAddress({ useFullAddress: true }), email: faker.internet.email({ firstName, lastName }), phone: faker.helpers.fromRegExp(/0[1-9][0-9]{8}/), - signatoryFile: faker.image.urlPicsumPhotos(), + signatoryFile: signature ?? null, signatoryRole: faker.person.jobTitle(), signatoryFirstName: faker.person.firstName(), signatoryLastName: faker.person.lastName(), diff --git a/packages/utils/src/s3.ts b/packages/utils/src/s3.ts index 9ff939c41..120aea67f 100644 --- a/packages/utils/src/s3.ts +++ b/packages/utils/src/s3.ts @@ -1,4 +1,8 @@ -import { GetObjectCommand, GetObjectCommandOutput, S3Client } from '@aws-sdk/client-s3'; +import { + GetObjectCommand, + GetObjectCommandOutput, + S3Client +} from '@aws-sdk/client-s3'; export interface S3Options { endpoint: string; @@ -14,8 +18,8 @@ export function createS3(opts: S3Options): S3Client { forcePathStyle: true, credentials: { accessKeyId: opts.accessKeyId, - secretAccessKey: opts.secretAccessKey, - }, + secretAccessKey: opts.secretAccessKey + } }); } @@ -26,27 +30,23 @@ interface ToBase64Options { export async function getBase64Content( logo: string, - opts: ToBase64Options, + opts: ToBase64Options ): Promise { - const { response, content } = await getContent(logo, opts); return toBase64(content, response.ContentType); } -export function toBase64( - content: string, - type?: string, -): string { +export function toBase64(content: string, type?: string): string { return `data:${type};charset=utf-8;base64, ${content}`; } export async function getContent( logo: string, opts: ToBase64Options -): Promise<{ response: GetObjectCommandOutput; content: string; }> { +): Promise<{ response: GetObjectCommandOutput; content: string }> { const command = new GetObjectCommand({ Bucket: opts.bucket, - Key: logo, + Key: logo }); const response = await opts.s3.send(command); if (!response.Body) { diff --git a/queue/src/workers/generate-mails.ts b/queue/src/workers/generate-mails.ts index c7b770a8e..5531981a6 100644 --- a/queue/src/workers/generate-mails.ts +++ b/queue/src/workers/generate-mails.ts @@ -10,7 +10,7 @@ import { Readable } from 'node:stream'; import { createSDK } from '@zerologementvacant/api-sdk'; import { DRAFT_TEMPLATE_FILE, DraftData, pdf } from '@zerologementvacant/draft'; import { getAddress, replaceVariables } from '@zerologementvacant/models'; -import { createS3, slugify, getBase64Content } from '@zerologementvacant/utils'; +import { createS3, slugify } from '@zerologementvacant/utils'; import { Jobs } from '../jobs'; import config from '../config'; import { createLogger } from '../logger'; @@ -92,19 +92,7 @@ export default function createWorker() { const html: string[] = []; - // Download logos - const logos = await async.map( - draft.logo ?? [], - async (logo: string) => - getBase64Content(logo, { s3, bucket: config.s3.bucket }), - ); - const signature = draft.sender.signatoryFile - ? await getBase64Content(draft.sender.signatoryFile, { - s3, - bucket: config.s3.bucket - }) - : null; - + logger.debug('Génération du PDF...'); await async.forEach(housings, async (housing) => { const owners = await api.owner.findByHousing(housing.id); const address = getAddress(owners[0]); @@ -112,7 +100,7 @@ export default function createWorker() { html.push( await pdf.compile(DRAFT_TEMPLATE_FILE, { subject: draft.subject ?? '', - logo: logos, + logo: draft.logo?.map((logo) => logo.content) ?? null, watermark: false, body: draft.body ? replaceVariables(draft.body, { @@ -130,7 +118,7 @@ export default function createWorker() { signatoryLastName: draft.sender.signatoryLastName, signatoryFirstName: draft.sender.signatoryFirstName, signatoryRole: draft.sender.signatoryRole, - signatoryFile: signature + signatoryFile: draft.sender.signatoryFile?.content ?? null }, writtenAt: draft.writtenAt, writtenFrom: draft.writtenFrom, diff --git a/server/src/controllers/campaignController.test.ts b/server/src/controllers/campaignController.test.ts index aeef84bbb..008b31556 100644 --- a/server/src/controllers/campaignController.test.ts +++ b/server/src/controllers/campaignController.test.ts @@ -7,11 +7,11 @@ import { v4 as uuidv4 } from 'uuid'; import { isDefined, wait } from '@zerologementvacant/utils'; import { CampaignHousingDBO, - CampaignsHousing, + CampaignsHousing } from '~/repositories/campaignHousingRepository'; import { Campaigns, - formatCampaignApi, + formatCampaignApi } from '~/repositories/campaignRepository'; import { tokenProvider } from '~/test/testUtils'; import { Campaign1 } from '~/infra/database/seeds/test/20240405012855_campaigns'; @@ -20,40 +20,46 @@ import { CampaignApi } from '~/models/CampaignApi'; import { HousingStatusApi } from '~/models/HousingStatusApi'; import { formatHousingRecordApi, - Housing, + Housing } from '~/repositories/housingRepository'; import { createServer } from '~/infra/server'; import { formatGroupApi, formatGroupHousingApi, Groups, - GroupsHousing, + GroupsHousing } from '~/repositories/groupRepository'; import { genCampaignApi, + genDraftApi, genEstablishmentApi, genGroupApi, genHousingApi, + genSenderApi, genUserApi, - oneOf, + oneOf } from '~/test/testFixtures'; import { formatOwnerApi, Owners } from '~/repositories/ownerRepository'; import { formatOwnerHousingApi, - HousingOwners, + HousingOwners } from '~/repositories/housingOwnerRepository'; import { CampaignCreationPayloadDTO, CampaignDTO, - CampaignUpdatePayloadDTO, + CampaignUpdatePayloadDTO } from '@zerologementvacant/models'; import { Establishments, - formatEstablishmentApi, + formatEstablishmentApi } from '~/repositories/establishmentRepository'; import { formatUserApi, Users } from '~/repositories/userRepository'; import { HousingApi } from '~/models/HousingApi'; import { GroupApi } from '~/models/GroupApi'; +import { DraftApi } from '~/models/DraftApi'; +import { Drafts, formatDraftApi } from '~/repositories/draftRepository'; +import { CampaignsDrafts } from '~/repositories/campaignDraftRepository'; +import { formatSenderApi, Senders } from '~/repositories/senderRepository'; describe('Campaign API', () => { const { app } = createServer(); @@ -104,7 +110,7 @@ describe('Campaign API', () => { expect(status).toBe(constants.HTTP_STATUS_OK); expect(body).toMatchObject({ id: campaign.id, - filters: expect.objectContaining(campaign.filters), + filters: expect.objectContaining(campaign.filters) }); }); }); @@ -113,7 +119,7 @@ describe('Campaign API', () => { const testRoute = '/api/campaigns'; const campaigns: CampaignApi[] = Array.from({ length: 3 }).map(() => - genCampaignApi(establishment.id, user.id), + genCampaignApi(establishment.id, user.id) ); beforeAll(async () => { @@ -135,15 +141,15 @@ describe('Campaign API', () => { expect(body).toIncludeAllPartialMembers( campaigns.map((campaign) => { return { - id: campaign.id, + id: campaign.id }; - }), + }) ); }); it('should filter by group', async () => { const groups = Array.from({ length: 2 }).map(() => - genGroupApi(user, establishment), + genGroupApi(user, establishment) ); await Groups().insert(groups.map(formatGroupApi)); const campaigns = groups.map((group) => { @@ -162,9 +168,9 @@ describe('Campaign API', () => { campaigns.map((campaign) => { return { id: campaign.id, - groupId: campaign.groupId, + groupId: campaign.groupId }; - }), + }) ); }); }); @@ -181,7 +187,7 @@ describe('Campaign API', () => { it('should create a new campaign', async () => { const title = randomstring.generate(); const houses: HousingApi[] = Array.from({ length: 2 }).map(() => - genHousingApi(oneOf(establishment.geoCodes)), + genHousingApi(oneOf(establishment.geoCodes)) ); await Housing().insert(houses.map(formatHousingRecordApi)); const payload: CampaignCreationPayloadDTO = { @@ -189,8 +195,8 @@ describe('Campaign API', () => { housing: { filters: {}, all: false, - ids: houses.map((housing) => housing.id), - }, + ids: houses.map((housing) => housing.id) + } }; const { body, status } = await request(app) @@ -205,24 +211,24 @@ describe('Campaign API', () => { status: 'draft', filters: { ...payload.housing.filters, - establishmentIds: [establishment.id], + establishmentIds: [establishment.id] }, - createdAt: expect.any(String), + createdAt: expect.any(String) }); const actualCampaign = await Campaigns() .where({ title, - establishment_id: establishment.id, + establishment_id: establishment.id }) .first(); expect(actualCampaign).toMatchObject({ - title, + title }); const actualCampaignHouses = await CampaignsHousing().whereIn( 'housing_id', - houses.map((housing) => housing.id), + houses.map((housing) => housing.id) ); expect(actualCampaignHouses).toBeArrayOfSize(houses.length); expect(actualCampaignHouses).toSatisfyAll((actual) => { @@ -239,7 +245,7 @@ describe('Campaign API', () => { const groupHousing = [ genHousingApi(geoCode), genHousingApi(geoCode), - genHousingApi(geoCode), + genHousingApi(geoCode) ]; const owners = groupHousing .map((housing) => housing.owner) @@ -257,10 +263,10 @@ describe('Campaign API', () => { const { status } = await request(app) .post(testRoute(uuidv4())) .send({ - title: 'Campagne prioritaire', + title: 'Campagne prioritaire' }) .set({ - 'Content-Type': 'application/json', + 'Content-Type': 'application/json' }) .use(tokenProvider(user)); @@ -270,17 +276,17 @@ describe('Campaign API', () => { it('should throw if the group has been archived', async () => { const group: GroupApi = { ...genGroupApi(user, establishment), - archivedAt: new Date(), + archivedAt: new Date() }; await Groups().insert(formatGroupApi(group)); const { status } = await request(app) .post(testRoute(group.id)) .send({ - title: 'Campagne prioritaire', + title: 'Campagne prioritaire' }) .set({ - 'Content-Type': 'application/json', + 'Content-Type': 'application/json' }) .use(tokenProvider(user)); @@ -292,7 +298,7 @@ describe('Campaign API', () => { .post(testRoute(group.id)) .send({ title: 'Logements prioritaires', - groupId: group.id, + groupId: group.id }) .use(tokenProvider(user)); @@ -304,10 +310,10 @@ describe('Campaign API', () => { status: 'draft', establishmentId: establishment.id, filters: { - groupIds: [group.id], + groupIds: [group.id] }, createdAt: expect.toBeDateString(), - userId: user.id, + userId: user.id }); }); @@ -315,17 +321,17 @@ describe('Campaign API', () => { const { body, status } = await request(app) .post(testRoute(group.id)) .send({ - title: 'Logements prioritaires', + title: 'Logements prioritaires' }) .use(tokenProvider(user)); expect(status).toBe(constants.HTTP_STATUS_CREATED); const campaignHousing = await CampaignsHousing().where( 'campaign_id', - body.id, + body.id ); expect(campaignHousing).toIncludeAllPartialMembers( - groupHousing.map((housing) => ({ housing_id: housing.id })), + groupHousing.map((housing) => ({ housing_id: housing.id })) ); }); @@ -333,7 +339,7 @@ describe('Campaign API', () => { const { status } = await request(app) .post(testRoute(group.id)) .send({ - title: 'Logements prioritaires', + title: 'Logements prioritaires' }) .use(tokenProvider(user)); @@ -343,7 +349,7 @@ describe('Campaign API', () => { const housingIds = groupHousing.map((housing) => housing.id); const housingEvents = await HousingEvents().whereIn( 'housing_id', - housingIds, + housingIds ); expect(housingEvents.length).toBeGreaterThanOrEqual(groupHousing.length); }); @@ -353,14 +359,23 @@ describe('Campaign API', () => { const testRoute = (id: string) => `/api/campaigns/${id}`; const payload: CampaignUpdatePayloadDTO = { title: 'New title', - status: 'sending', + status: 'sending' }; let campaign: CampaignApi; + let draft: DraftApi; beforeEach(async () => { campaign = genCampaignApi(establishment.id, user.id); await Campaigns().insert(formatCampaignApi(campaign)); + const sender = genSenderApi(establishment); + await Senders().insert(formatSenderApi(sender)); + draft = genDraftApi(establishment, sender); + await Drafts().insert(formatDraftApi(draft)); + await CampaignsDrafts().insert({ + campaign_id: campaign.id, + draft_id: draft.id + }); }); it('should be forbidden for a non-authenticated user', async () => { @@ -406,7 +421,7 @@ describe('Campaign API', () => { it('should update the campaign title', async () => { const payload: CampaignUpdatePayloadDTO = { status: campaign.status, - title: 'New title', + title: 'New title' }; const { body, status } = await request(app) @@ -418,14 +433,14 @@ describe('Campaign API', () => { expect(body).toMatchObject({ id: campaign.id, status: campaign.status, - title: payload.title, + title: payload.title }); const actual = await Campaigns().where({ id: campaign.id }).first(); expect(actual).toMatchObject({ id: campaign.id, status: campaign.status, - title: payload.title, + title: payload.title }); }); @@ -435,7 +450,7 @@ describe('Campaign API', () => { it('should set the status from "draft" to "sending"', async () => { const payload: CampaignUpdatePayloadDTO = { title: campaign.title, - status: 'sending', + status: 'sending' }; const { body, status } = await request(app) @@ -448,21 +463,36 @@ describe('Campaign API', () => { id: campaign.id, title: campaign.title, status: 'sending', - validatedAt: expect.any(String), + validatedAt: expect.any(String) }); }); + + it('should fail if the draft is missing', async () => { + const campaignWithoutDraft = genCampaignApi(establishment.id, user.id); + const payload: CampaignUpdatePayloadDTO = { + title: campaignWithoutDraft.title, + status: 'sending' + }; + + const { status } = await request(app) + .put(testRoute(campaignWithoutDraft.id)) + .send(payload) + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_NOT_FOUND); + }); }); describe('Send the campaign', () => { it('should set the status from "sending" to "in-progress"', async () => { campaign = { ...campaign, status: 'sending' }; await Campaigns().where({ id: campaign.id }).update({ - status: campaign.status, + status: campaign.status }); const payload: CampaignUpdatePayloadDTO = { title: campaign.title, status: 'in-progress', - sentAt: faker.date.recent().toJSON(), + sentAt: faker.date.recent().toJSON() }; const { body, status } = await request(app) @@ -476,7 +506,7 @@ describe('Campaign API', () => { title: campaign.title, status: payload.status, sentAt: payload.sentAt, - confirmedAt: expect.any(String), + confirmedAt: expect.any(String) }); }); @@ -487,11 +517,11 @@ describe('Campaign API', () => { it('should set the status from "in-progress" to "archived"', async () => { campaign = { ...campaign, status: 'in-progress' }; await Campaigns().where({ id: campaign.id }).update({ - status: campaign.status, + status: campaign.status }); const payload: CampaignUpdatePayloadDTO = { title: campaign.title, - status: 'archived', + status: 'archived' }; const { body, status } = await request(app) @@ -504,7 +534,7 @@ describe('Campaign API', () => { id: campaign.id, title: campaign.title, status: payload.status, - archivedAt: expect.any(String), + archivedAt: expect.any(String) }); }); @@ -545,20 +575,20 @@ describe('Campaign API', () => { const actualCampaign = await Campaigns().where({ establishment_id: establishment.id, - id: campaign.id, + id: campaign.id }); expect(actualCampaign).toStrictEqual([]); }); it('should delete linked events and campaign housing', async () => { const houses: HousingApi[] = Array.from({ length: 2 }).map(() => - genHousingApi(oneOf(establishment.geoCodes)), + genHousingApi(oneOf(establishment.geoCodes)) ); await Housing().insert(houses.map(formatHousingRecordApi)); const campaignHouses: CampaignHousingDBO[] = houses.map((housing) => ({ campaign_id: campaign.id, housing_geo_code: housing.geoCode, - housing_id: housing.id, + housing_id: housing.id })); await CampaignsHousing().insert(campaignHouses); @@ -567,12 +597,12 @@ describe('Campaign API', () => { .use(tokenProvider(user)); const actualCampaignHouses = await CampaignsHousing().where({ - campaign_id: campaign.id, + campaign_id: campaign.id }); expect(actualCampaignHouses).toBeArrayOfSize(0); const actualCampaignEvents = await CampaignEvents().where({ - campaign_id: campaign.id, + campaign_id: campaign.id }); expect(actualCampaignEvents).toBeArrayOfSize(0); }); @@ -580,13 +610,13 @@ describe('Campaign API', () => { it('should set status never contacted for waiting housing without anymore campaigns', async () => { const housing: HousingApi = { ...genHousingApi(oneOf(establishment.geoCodes)), - status: HousingStatusApi.Waiting, + status: HousingStatusApi.Waiting }; await Housing().insert(formatHousingRecordApi(housing)); await CampaignsHousing().insert({ campaign_id: campaign.id, housing_geo_code: housing.geoCode, - housing_id: housing.id, + housing_id: housing.id }); await request(app) @@ -596,14 +626,14 @@ describe('Campaign API', () => { const actualHousing = await Housing() .where({ geo_code: housing.geoCode, - id: housing.id, + id: housing.id }) .first(); expect(actualHousing).toMatchObject({ geo_code: housing.geoCode, id: housing.id, status: HousingStatusApi.NeverContacted, - sub_status: null, + sub_status: null }); }); }); diff --git a/server/src/controllers/campaignController.ts b/server/src/controllers/campaignController.ts index cc301117c..9bf48f82e 100644 --- a/server/src/controllers/campaignController.ts +++ b/server/src/controllers/campaignController.ts @@ -4,7 +4,7 @@ import campaignHousingRepository from '~/repositories/campaignHousingRepository' import { CampaignApi, CampaignSortableApi, - toCampaignDTO, + toCampaignDTO } from '~/models/CampaignApi'; import housingRepository from '~/repositories/housingRepository'; import eventRepository from '~/repositories/eventRepository'; @@ -39,6 +39,8 @@ import { } from '@zerologementvacant/models'; import CampaignStatusError from '~/errors/campaignStatusError'; import CampaignFileMissingError from '~/errors/CampaignFileMissingError'; +import draftRepository from '~/repositories/draftRepository'; +import DraftMissingError from '~/errors/draftMissingError'; const getCampaignValidators = [param('id').notEmpty().isUUID()]; @@ -215,7 +217,7 @@ async function createCampaignFromGroup(request: Request, response: Response) { createdAt: new Date().toJSON(), groupId, userId: auth.userId, - establishmentId: auth.establishmentId, + establishmentId: auth.establishmentId }; await campaignRepository.save(campaign); @@ -271,14 +273,26 @@ async function update(request: Request, response: Response) { const { auth, params } = request as AuthenticatedRequest; const body = request.body as CampaignUpdatePayloadDTO; - const campaign = await campaignRepository.findOne({ - id: params.id, - establishmentId: auth.establishmentId - }); + const [campaign, drafts] = await Promise.all([ + campaignRepository.findOne({ + id: params.id, + establishmentId: auth.establishmentId + }), + draftRepository.find({ + filters: { + campaign: params.id, + establishment: auth.establishmentId + } + }) + ]); if (!campaign) { throw new CampaignMissingError(params.id); } + if (drafts.length === 0) { + throw new DraftMissingError(params.id); + } + if ( campaign.status !== body.status && nextStatus(campaign.status) !== body.status diff --git a/server/src/controllers/draftController.ts b/server/src/controllers/draftController.ts index 33c270294..ee50a1bc3 100644 --- a/server/src/controllers/draftController.ts +++ b/server/src/controllers/draftController.ts @@ -1,4 +1,3 @@ -import async from 'async'; import { Request, Response } from 'express'; import { AuthenticatedRequest } from 'express-jwt'; import { body, ValidationChain } from 'express-validator'; @@ -12,13 +11,10 @@ import { DraftDTO, DraftPreviewPayloadDTO, DraftUpdatePayloadDTO, - FileUploadDTO, - SenderDTO, getAddress, HOUSING_KIND_VALUES, replaceVariables } from '@zerologementvacant/models'; -import { createS3, toBase64, getContent, getBase64Content } from '@zerologementvacant/utils'; import { DraftApi, toDraftDTO } from '~/models/DraftApi'; import draftRepository, { DraftFilters } from '~/repositories/draftRepository'; import campaignDraftRepository from '~/repositories/campaignDraftRepository'; @@ -29,7 +25,6 @@ import { isUUIDParam } from '~/utils/validators'; import { logger } from '~/infra/logger'; import { SenderApi } from '~/models/SenderApi'; import senderRepository from '~/repositories/senderRepository'; -import config from '~/infra/config'; export interface DraftParams extends Record { id: string; @@ -39,102 +34,26 @@ interface DraftQuery { campaign?: string; } -async function list(request: Request, response: Response) { - const { auth, query } = request as AuthenticatedRequest; +async function list( + request: Request, + response: Response +) { + const { auth, query } = request as AuthenticatedRequest< + never, + DraftDTO[], + never, + DraftQuery + >; const filters: DraftFilters = { ...(fp.pick(['campaign'], query) as DraftQuery), establishment: auth.establishmentId }; - const drafts: DraftApi[] = await draftRepository.find({ filters }); - // Download logos from S3 - const s3 = createS3({ - endpoint: config.s3.endpoint, - region: config.s3.region, - accessKeyId: config.s3.accessKeyId, - secretAccessKey: config.s3.secretAccessKey, - }); - - interface EnrichedSenderDTO extends Omit { - signatoryFile: FileUploadDTO | null; - } - - interface EnrichedDraftDTO extends Omit { - logo: FileUploadDTO[] | null; - sender: EnrichedSenderDTO; - } - - function toEnrichedDraftDTO(draft: DraftDTO): EnrichedDraftDTO { - return { - id: draft.id, - subject: draft.subject, - body: draft.body, - logo: null, - sender: { - id: draft.sender.id, - name: draft.sender.name, - service: draft.sender.service, - firstName: draft.sender.firstName, - lastName: draft.sender.lastName, - address: draft.sender.address, - email: draft.sender.email, - phone: draft.sender.phone, - signatoryLastName: draft.sender.signatoryLastName, - signatoryFirstName: draft.sender.signatoryFirstName, - signatoryRole: draft.sender.signatoryRole, - signatoryFile: null, - createdAt: draft.sender.createdAt, - updatedAt: draft.sender.updatedAt, - }, - writtenAt: draft.writtenAt, - writtenFrom: draft.writtenFrom, - createdAt: draft.createdAt, - updatedAt: draft.createdAt, - }; - } - - const originalDrafts = await drafts.map(toDraftDTO); - const enrichedDrafts = await Promise.all(originalDrafts.map(async (draft) => { - const enrichedDraft = toEnrichedDraftDTO(draft); - const logos = draft.logo ? await Promise.all(draft.logo.map(async (logo) => { - try { - const { response, content } = await getContent(logo, { s3, bucket: config.s3.bucket }); - return { - id: logo, - type: response.ContentType, - url: logo, - content: toBase64(content, response.ContentType), - } as FileUploadDTO; - } catch(e) { - logger.error(`Failed to get content from S3 bucket: ${config.s3.bucket}, logo: ${logo}`); - return null; - } - })) : null; - enrichedDraft.logo = logos?.filter(logo => logo !== null) as FileUploadDTO[]; - - if(draft.sender.signatoryFile !== null) { - try { - const { response, content } = await getContent(draft.sender.signatoryFile, { s3, bucket: config.s3.bucket }); - const signatoryFile = { - id: draft.sender.signatoryFile, - type: response.ContentType, - url: draft.sender.signatoryFile, - content: toBase64(content, response.ContentType), - } as FileUploadDTO; - enrichedDraft.sender.signatoryFile = signatoryFile; - } catch(e) { - logger.error(`Failed to get content from S3 bucket: ${config.s3.bucket}, logo: ${draft.sender.signatoryFile}`); - } - } - - return enrichedDraft; - })) as unknown as EnrichedDraftDTO; - - response.status(constants.HTTP_STATUS_OK).json(enrichedDrafts); + response.status(constants.HTTP_STATUS_OK).json(drafts.map(toDraftDTO)); } const partialDraftValidators: ValidationChain[] = [ @@ -158,7 +77,7 @@ const senderValidators: ValidationChain[] = [ 'phone', 'signatoryLastName', 'signatoryFirstName', - 'signatoryRole', + 'signatoryRole' ].map((prop) => body(`sender.${prop}`) .optional({ nullable: true }) @@ -168,9 +87,16 @@ const senderValidators: ValidationChain[] = [ ) ]; -async function create(request: Request, response: Response) { - const { auth } = request as AuthenticatedRequest; - const body = request.body as DraftCreationPayloadDTO; +async function create( + request: Request, + response: Response +) { + const { auth, body } = request as AuthenticatedRequest< + never, + DraftDTO, + DraftCreationPayloadDTO, + never + >; const campaign = await campaignRepository.findOne({ id: body.campaign, @@ -201,7 +127,7 @@ async function create(request: Request, response: Response) { id: uuidv4(), subject: body.subject, body: body.body, - logo: body.logo ? body.logo.map((logo: FileUploadDTO) => logo.id) as string[] : null, + logo: body.logo, sender, senderId: sender.id, writtenAt: body.writtenAt, @@ -261,26 +187,9 @@ async function preview( throw new DraftMissingError(params.id); } - // Download logos from S3 - const s3 = createS3({ - endpoint: config.s3.endpoint, - region: config.s3.region, - accessKeyId: config.s3.accessKeyId, - secretAccessKey: config.s3.secretAccessKey - }); - const logos = await async.map(draft.logo ?? [], async (logo: string) => - getBase64Content(logo, { s3, bucket: config.s3.bucket }) - ); - - const signature = draft.sender.signatoryFile - ? await getBase64Content(draft.sender.signatoryFile.id, { - s3, - bucket: config.s3.bucket - }) - : null; const html = await pdf.compile(DRAFT_TEMPLATE_FILE, { subject: draft.subject, - logo: logos, + logo: draft.logo?.map((logo) => logo.content) ?? null, watermark: true, body: draft.body ? replaceVariables(draft.body, { @@ -298,7 +207,7 @@ async function preview( signatoryLastName: draft.sender.signatoryLastName, signatoryFirstName: draft.sender.signatoryFirstName, signatoryRole: draft.sender.signatoryRole, - signatoryFile: signature + signatoryFile: draft.sender.signatoryFile?.content ?? null }, writtenAt: draft.writtenAt, writtenFrom: draft.writtenFrom, @@ -382,7 +291,7 @@ async function update(request: Request, response: Response) { ...draft, subject: body.subject, body: body.body, - logo: body.logo?.map((logo: FileUploadDTO) => logo.id) as string[], + logo: body.logo, sender, senderId: sender.id, writtenAt: body.writtenAt, diff --git a/server/src/controllers/fileRepository.ts b/server/src/controllers/fileRepository.ts new file mode 100644 index 000000000..fb74b6ddd --- /dev/null +++ b/server/src/controllers/fileRepository.ts @@ -0,0 +1,27 @@ +import { FileUploadDTO } from '@zerologementvacant/models'; +import { createS3, getContent, toBase64 } from '@zerologementvacant/utils'; +import config from '~/infra/config'; +import { createLogger } from '~/infra/logger'; + +const logger = createLogger('fileRepository'); +const s3 = createS3({ + endpoint: config.s3.endpoint, + region: config.s3.region, + accessKeyId: config.s3.accessKeyId, + secretAccessKey: config.s3.secretAccessKey +}); + +export async function download(logo: string): Promise { + logger.debug('Downloading logo from S3...'); + const { content, response } = await getContent(logo, { + s3, + bucket: config.s3.bucket + }); + + return { + id: logo, + content: toBase64(content, response.ContentType), + url: logo, + type: response.ContentType ?? 'base64' + }; +} diff --git a/server/src/controllers/test/draftController.test.ts b/server/src/controllers/test/draftController.test.ts index a063b38c9..c4d85d709 100644 --- a/server/src/controllers/test/draftController.test.ts +++ b/server/src/controllers/test/draftController.test.ts @@ -9,30 +9,29 @@ import { genDraftApi, genEstablishmentApi, genSenderApi, - genUserApi, + genUserApi } from '../../test/testFixtures'; import { tokenProvider } from '../../test/testUtils'; import { CampaignApi } from '../../models/CampaignApi'; import { DraftRecordDBO, Drafts, - formatDraftApi, + formatDraftApi } from '../../repositories/draftRepository'; import { Campaigns, - formatCampaignApi, + formatCampaignApi } from '../../repositories/campaignRepository'; import { CampaignsDrafts } from '../../repositories/campaignDraftRepository'; import { DraftCreationPayloadDTO, DraftDTO, DraftUpdatePayloadDTO, - FileUploadDTO, - SenderPayloadDTO, + SenderPayloadDTO } from '@zerologementvacant/models'; import { Establishments, - formatEstablishmentApi, + formatEstablishmentApi } from '../../repositories/establishmentRepository'; import { formatUserApi, Users } from '../../repositories/userRepository'; import { SenderApi } from '../../models/SenderApi'; @@ -40,7 +39,7 @@ import fp from 'lodash/fp'; import { formatSenderApi, SenderDBO, - Senders, + Senders } from '../../repositories/senderRepository'; describe('Draft API', () => { @@ -53,7 +52,7 @@ describe('Draft API', () => { beforeAll(async () => { await Establishments().insert( - [establishment, anotherEstablishment].map(formatEstablishmentApi), + [establishment, anotherEstablishment].map(formatEstablishmentApi) ); await Users().insert([user, anotherUser].map(formatUserApi)); }); @@ -65,8 +64,8 @@ describe('Draft API', () => { const drafts: DraftApi[] = [ ...Array.from({ length: 4 }, () => genDraftApi(establishment, sender)), ...Array.from({ length: 2 }, () => - genDraftApi(anotherEstablishment, sender), - ), + genDraftApi(anotherEstablishment, sender) + ) ]; beforeAll(async () => { @@ -91,7 +90,7 @@ describe('Draft API', () => { .where('establishment_id', establishment.id) .whereIn( 'id', - body.map((draft: DraftDTO) => draft.id), + body.map((draft: DraftDTO) => draft.id) ); expect(body).toHaveLength(actual.length); }); @@ -102,7 +101,7 @@ describe('Draft API', () => { await Campaigns().insert(formatCampaignApi(campaign)); await CampaignsDrafts().insert({ campaign_id: campaign.id, - draft_id: firstDraft.id, + draft_id: firstDraft.id }); const { body, status } = await request(app) @@ -145,9 +144,9 @@ describe('Draft API', () => { 'signatoryFile', 'signatoryRole', 'signatoryFirstName', - 'signatoryLastName', + 'signatoryLastName' ], - sender, + sender ); draft = genDraftApi(establishment, sender); await Campaigns().insert(formatCampaignApi(campaign)); @@ -155,7 +154,7 @@ describe('Draft API', () => { it('should fail if the payload has a wrong format', async () => { async function fail( - payload: Partial, + payload: Partial ): Promise { const { status } = await request(app) .post(testRoute) @@ -174,13 +173,12 @@ describe('Draft API', () => { const payload: DraftCreationPayloadDTO = { subject: draft.subject, body: draft.body, - logo: draft.logo?.map(logo => { - return { id: faker.string.uuid(), type:'image/jpeg', url: logo, content: '' }; - }) as FileUploadDTO[], + // TODO: test with logo + logo: [], campaign: missingCampaign.id, sender: senderPayload, writtenAt: draft.writtenAt, - writtenFrom: draft.writtenFrom, + writtenFrom: draft.writtenFrom }; const { status } = await request(app) @@ -195,13 +193,11 @@ describe('Draft API', () => { const payload: DraftCreationPayloadDTO = { subject: draft.subject, body: draft.body, - logo: draft.logo?.map(logo => { - return { id: faker.string.uuid(), type:'image/jpeg', url: logo, content: '' }; - }) as FileUploadDTO[], + logo: [], campaign: campaign.id, sender: senderPayload, writtenAt: draft.writtenAt, - writtenFrom: draft.writtenFrom, + writtenFrom: draft.writtenFrom }; const { body, status } = await request(app) @@ -221,15 +217,15 @@ describe('Draft API', () => { address: payload.sender?.address ?? null, email: payload.sender?.email ?? null, phone: payload.sender?.phone ?? null, - signatoryFile: payload.sender?.signatoryFile ? payload.sender.signatoryFile.id : null, + signatoryFile: payload.sender?.signatoryFile ?? null, signatoryFirstName: payload.sender?.signatoryFirstName ?? null, signatoryLastName: payload.sender?.signatoryLastName ?? null, signatoryRole: payload.sender?.signatoryRole ?? null, createdAt: expect.any(String), - updatedAt: expect.any(String), + updatedAt: expect.any(String) }, writtenAt: payload.writtenAt, - writtenFrom: payload.writtenFrom, + writtenFrom: payload.writtenFrom }); const actual = await Drafts().where({ id: body.id }).first(); @@ -237,13 +233,13 @@ describe('Draft API', () => { id: body.id, subject: payload.subject, body: payload.body, - logo: payload.logo.map(logo => logo.id), + logo: payload.logo?.map((logo) => logo.id) ?? null, sender_id: expect.any(String), written_at: payload.writtenAt, written_from: payload.writtenFrom, created_at: expect.any(Date), updated_at: expect.any(Date), - establishment_id: establishment.id, + establishment_id: establishment.id }); }); @@ -251,13 +247,11 @@ describe('Draft API', () => { const payload: DraftCreationPayloadDTO = { subject: draft.subject, body: draft.body, - logo: draft.logo?.map(logo => { - return { id: faker.string.uuid(), type:'image/jpeg', url: logo, content: '' }; - }) as FileUploadDTO[], + logo: [], campaign: campaign.id, sender: senderPayload, writtenAt: draft.writtenAt, - writtenFrom: draft.writtenFrom, + writtenFrom: draft.writtenFrom }; const { status } = await request(app) @@ -288,10 +282,17 @@ describe('Draft API', () => { id: draft.id, subject: faker.lorem.sentence(), body: faker.lorem.paragraph(), - logo: [ { id: faker.string.uuid(), type:'image/jpeg', url: 'https://example.com/logo.png', content: '' }], + logo: [ + { + id: faker.string.uuid(), + type: 'image/jpeg', + url: 'https://example.com/logo.png', + content: '' + } + ], sender: fp.omit(['id', 'createdAt', 'updatedAt'], sender), writtenAt: faker.date.recent().toISOString().substring(0, 10), - writtenFrom: faker.location.city(), + writtenFrom: faker.location.city() }; await Senders().insert(formatSenderApi(sender)); await Drafts().insert(formatDraftApi(draft)); @@ -332,10 +333,10 @@ describe('Draft API', () => { } await fail('bad-format', { - body: '', + body: '' }); await fail(faker.string.uuid(), { - body: undefined, + body: undefined }); }); @@ -351,7 +352,7 @@ describe('Draft API', () => { id: draft.id, subject: payload.subject, body: payload.body, - logo: payload.logo.map(logo => logo.id), + logo: payload.logo, sender: { id: expect.any(String), name: sender.name, @@ -361,17 +362,17 @@ describe('Draft API', () => { address: sender.address, email: sender.email, phone: sender.phone, - signatoryFile: sender.signatoryFile?.id ?? null, + signatoryFile: sender.signatoryFile ?? null, signatoryFirstName: sender.signatoryFirstName, signatoryLastName: sender.signatoryLastName, signatoryRole: sender.signatoryRole, createdAt: expect.any(String), - updatedAt: expect.any(String), + updatedAt: expect.any(String) }, writtenAt: payload.writtenAt, writtenFrom: payload.writtenFrom, createdAt: expect.any(String), - updatedAt: expect.any(String), + updatedAt: expect.any(String) }); const actual = await Drafts().where('id', draft.id).first(); @@ -379,13 +380,13 @@ describe('Draft API', () => { id: draft.id, subject: payload.subject, body: payload.body, - logo: payload.logo.map(logo => logo.id), + logo: payload.logo?.map((logo) => logo.id) ?? null, written_at: payload.writtenAt, written_from: payload.writtenFrom, created_at: expect.any(Date), updated_at: expect.any(Date), establishment_id: draft.establishmentId, - sender_id: body.sender.id, + sender_id: body.sender.id }); }); @@ -394,8 +395,8 @@ describe('Draft API', () => { ...payload, sender: { ...payload.sender, - name: 'Another name', - }, + name: 'Another name' + } }; const { status } = await request(app) @@ -408,7 +409,7 @@ describe('Draft API', () => { const actualSender = await Senders() .where({ id: draft.sender.id, - establishment_id: sender.establishmentId, + establishment_id: sender.establishmentId }) .first(); expect(actualSender).toStrictEqual({ @@ -426,13 +427,13 @@ describe('Draft API', () => { signatory_last_name: sender.signatoryLastName, created_at: expect.any(Date), updated_at: expect.any(Date), - establishment_id: sender.establishmentId, + establishment_id: sender.establishmentId }); const actualDraft = await Drafts() .where({ id: draft.id, - establishment_id: draft.establishmentId, + establishment_id: draft.establishmentId }) .first(); expect(actualDraft).toHaveProperty('sender_id', actualSender?.id); diff --git a/server/src/models/SenderApi.ts b/server/src/models/SenderApi.ts index 4127f03fb..91df1a2e0 100644 --- a/server/src/models/SenderApi.ts +++ b/server/src/models/SenderApi.ts @@ -1,7 +1,6 @@ -import { FileUploadDTO, SenderDTO } from '@zerologementvacant/models'; +import { SenderDTO } from '@zerologementvacant/models'; -export interface SenderApi extends Omit { - signatoryFile: FileUploadDTO | null; +export interface SenderApi extends SenderDTO { establishmentId: string; } @@ -18,8 +17,8 @@ export function toSenderDTO(sender: SenderApi): SenderDTO { signatoryLastName: sender.signatoryLastName, signatoryFirstName: sender.signatoryFirstName, signatoryRole: sender.signatoryRole, - signatoryFile: sender.signatoryFile ? sender.signatoryFile.id : null, + signatoryFile: sender.signatoryFile, createdAt: sender.createdAt, - updatedAt: sender.updatedAt, + updatedAt: sender.updatedAt }; } diff --git a/server/src/repositories/draftRepository.ts b/server/src/repositories/draftRepository.ts index a29416f6b..21de5957c 100644 --- a/server/src/repositories/draftRepository.ts +++ b/server/src/repositories/draftRepository.ts @@ -7,8 +7,10 @@ import { campaignsDraftsTable } from '~/repositories/campaignDraftRepository'; import { parseSenderApi, SenderDBO, - sendersTable, + sendersTable } from '~/repositories/senderRepository'; +import { download } from '~/controllers/fileRepository'; +import async from 'async'; export const draftsTable = 'drafts'; export const Drafts = (transaction: Knex = db) => @@ -31,7 +33,7 @@ async function find(opts?: FindOptions): Promise { .modify(filterQuery(opts?.filters)) .orderBy('created_at', 'desc'); logger.debug('Found drafts', drafts); - return drafts.map(parseDraftApi); + return async.map(drafts, parseDraftApi); } type FindOneOptions = Pick; @@ -42,8 +44,8 @@ async function findOne(opts: FindOneOptions): Promise { .where( filterQuery({ id: opts.id, - establishment: opts.establishmentId, - }), + establishment: opts.establishmentId + }) ) .first(); if (!draft) { @@ -65,7 +67,7 @@ async function save(draft: DraftApi): Promise { 'written_at', 'written_from', 'updated_at', - 'sender_id', + 'sender_id' ]); } @@ -75,7 +77,7 @@ function listQuery(query: Knex.QueryBuilder): void { .leftJoin( sendersTable, `${draftsTable}.sender_id`, - `${sendersTable}.id`, + `${sendersTable}.id` ) .select(db.raw(`to_json(${sendersTable}.*) AS sender`)); } @@ -95,7 +97,7 @@ function filterQuery(filters?: DraftFilters) { .join( campaignsDraftsTable, `${campaignsDraftsTable}.draft_id`, - `${draftsTable}.id`, + `${draftsTable}.id` ) .where(`${campaignsDraftsTable}.campaign_id`, filters.campaign); } @@ -123,31 +125,31 @@ export const formatDraftApi = (draft: DraftApi): DraftRecordDBO => ({ id: draft.id, subject: draft.subject, body: draft.body, - logo: draft.logo, + logo: draft.logo?.map((logo) => logo.id) ?? null, written_at: draft.writtenAt, written_from: draft.writtenFrom, establishment_id: draft.establishmentId, sender_id: draft.senderId, created_at: new Date(draft.createdAt), - updated_at: new Date(draft.updatedAt), + updated_at: new Date(draft.updatedAt) }); -export const parseDraftApi = (draft: DraftDBO): DraftApi => ({ +export const parseDraftApi = async (draft: DraftDBO): Promise => ({ id: draft.id, subject: draft.subject, body: draft.body, - logo: draft.logo, + logo: await async.map(draft.logo ?? [], download), writtenAt: draft.written_at, writtenFrom: draft.written_from, establishmentId: draft.establishment_id, senderId: draft.sender_id, - sender: parseSenderApi(draft.sender), + sender: await parseSenderApi(draft.sender), createdAt: draft.created_at.toJSON(), - updatedAt: draft.updated_at.toJSON(), + updatedAt: draft.updated_at.toJSON() }); export default { find, findOne, - save, + save }; diff --git a/server/src/repositories/senderRepository.ts b/server/src/repositories/senderRepository.ts index 99d39f326..df1182cb0 100644 --- a/server/src/repositories/senderRepository.ts +++ b/server/src/repositories/senderRepository.ts @@ -1,7 +1,7 @@ -import { FileUploadDTO } from '@zerologementvacant/models'; import db, { where } from '~/infra/database'; import { logger } from '~/infra/logger'; import { SenderApi } from '~/models/SenderApi'; +import { download } from '~/controllers/fileRepository'; export const sendersTable = 'senders'; export const Senders = (transaction = db) => @@ -15,7 +15,7 @@ async function findOne(opts: FindOneOptions): Promise { logger.debug('Finding sender...', opts); const whereOptions = where( ['id', 'name', 'establishmentId'], - { table: sendersTable }, + { table: sendersTable } ); const sender = await Senders().where(whereOptions(opts)).first(); @@ -44,7 +44,7 @@ async function save(sender: SenderApi): Promise { 'signatory_first_name', 'signatory_role', 'signatory_file', - 'updated_at', + 'updated_at' ]); logger.debug('Saved sender', sender); } @@ -82,10 +82,12 @@ export const formatSenderApi = (sender: SenderApi): SenderDBO => ({ signatory_file: sender.signatoryFile ? sender.signatoryFile.id : null, created_at: new Date(sender.createdAt), updated_at: new Date(sender.updatedAt), - establishment_id: sender.establishmentId, + establishment_id: sender.establishmentId }); -export const parseSenderApi = (sender: SenderDBO): SenderApi => ({ +export const parseSenderApi = async ( + sender: SenderDBO +): Promise => ({ id: sender.id, name: sender.name, service: sender.service, @@ -97,13 +99,15 @@ export const parseSenderApi = (sender: SenderDBO): SenderApi => ({ signatoryLastName: sender.signatory_last_name, signatoryFirstName: sender.signatory_first_name, signatoryRole: sender.signatory_role, - signatoryFile: sender.signatory_file ? { id: sender.signatory_file, type: '', url: sender.signatory_file, content: '' } as FileUploadDTO: null, + signatoryFile: sender.signatory_file + ? await download(sender.signatory_file) + : null, createdAt: new Date(sender.created_at).toJSON(), updatedAt: new Date(sender.updated_at).toJSON(), - establishmentId: sender.establishment_id, + establishmentId: sender.establishment_id }); export default { findOne, - save, + save }; diff --git a/server/src/test/testFixtures.ts b/server/src/test/testFixtures.ts index 2a8070c03..40019f360 100644 --- a/server/src/test/testFixtures.ts +++ b/server/src/test/testFixtures.ts @@ -7,14 +7,14 @@ import { CampaignIntent, EstablishmentApi, hasPriority, - INTENTS, + INTENTS } from '~/models/EstablishmentApi'; import { addHours } from 'date-fns'; import { ENERGY_CONSUMPTION_GRADES, HousingApi, OccupancyKindApi, - OwnershipKindsApi, + OwnershipKindsApi } from '~/models/HousingApi'; import { CampaignApi } from '~/models/CampaignApi'; import { GeoPerimeterApi } from '~/models/GeoPerimeterApi'; @@ -22,13 +22,13 @@ import { ProspectApi } from '~/models/ProspectApi'; import { RESET_LINK_EXPIRATION, RESET_LINK_LENGTH, - ResetLinkApi, + ResetLinkApi } from '~/models/ResetLinkApi'; import { ContactPointApi } from '~/models/ContactPointApi'; import { SIGNUP_LINK_EXPIRATION, SIGNUP_LINK_LENGTH, - SignupLinkApi, + SignupLinkApi } from '~/models/SignupLinkApi'; import { LocalityApi, TaxKindsApi } from '~/models/LocalityApi'; import { OwnerProspectApi } from '~/models/OwnerProspectApi'; @@ -38,7 +38,7 @@ import { EventApi, GroupHousingEventApi, HousingEventApi, - OwnerEventApi, + OwnerEventApi } from '~/models/EventApi'; import { AddressKinds, @@ -50,7 +50,7 @@ import { EventSections, firstDefined, HOUSING_SOURCES, - UserAccountDTO, + UserAccountDTO } from '@zerologementvacant/shared'; import { GroupApi } from '~/models/GroupApi'; import { DatafoncierOwner } from '~/scripts/shared'; @@ -60,7 +60,7 @@ import { OwnerMatchDBO } from '~/repositories/ownerMatchRepository'; import { ConflictApi, HousingOwnerConflictApi, - OwnerConflictApi, + OwnerConflictApi } from '~/models/ConflictApi'; import { logger } from '~/infra/logger'; import { BuildingApi } from '~/models/BuildingApi'; @@ -88,7 +88,7 @@ export const genGeoCode = (): string => { * @param locality */ export const genInvariant = ( - locality: string = faker.string.numeric(3), + locality: string = faker.string.numeric(3) ): string => locality + faker.string.alpha(7); export const genLocalId = (department: string, invariant: string): string => @@ -98,8 +98,8 @@ export const genNumber = (length = 10) => { return Number( randomstring.generate({ length, - charset: 'numeric', - }), + charset: 'numeric' + }) ); }; @@ -118,7 +118,7 @@ export const genLocalityApi = (geoCode = genGeoCode()): LocalityApi => { id: uuidv4(), geoCode, name: faker.location.city(), - taxKind: TaxKindsApi.None, + taxKind: TaxKindsApi.None }; }; @@ -136,7 +136,7 @@ export const genEstablishmentApi = ( campaignIntent, available: true, priority: hasPriority({ campaignIntent }) ? 'high' : 'standard', - kind: oneOf(ESTABLISHMENT_KINDS), + kind: oneOf(ESTABLISHMENT_KINDS) }; }; @@ -156,7 +156,7 @@ export const genUserApi = (establishmentId: string): UserApi => { lastAuthenticatedAt: new Date(), updatedAt: new Date(), deletedAt: undefined, - ...genUserAccountDTO, + ...genUserAccountDTO }; }; @@ -165,22 +165,22 @@ export const genUserAccountDTO: UserAccountDTO = { lastName: faker.person.lastName(), phone: faker.phone.number(), position: faker.person.jobType(), - timePerWeek: randomstring.generate(), + timePerWeek: randomstring.generate() }; export const genProspectApi = ( - establishment: EstablishmentApi, + establishment: EstablishmentApi ): ProspectApi => { return { email: genEmail(), establishment: { id: establishment.id, siren: establishment.siren, - campaignIntent: establishment.campaignIntent, + campaignIntent: establishment.campaignIntent }, hasAccount: true, hasCommitment: true, - lastAccountRequestAt: new Date(), + lastAccountRequestAt: new Date() }; }; @@ -197,7 +197,7 @@ export const genOwnerProspectApi = (geoCode?: string): OwnerProspectApi => { invariant: randomstring.generate(), callBack: true, read: false, - createdAt: new Date(), + createdAt: new Date() }; }; @@ -207,7 +207,7 @@ export const genOwnerApi = (): OwnerApi => { id, rawAddress: [ faker.location.streetAddress(), - `${faker.location.zipCode()}, ${faker.location.city()}`, + `${faker.location.zipCode()}, ${faker.location.city()}` ], // Get the start of the day to avoid time zone issues birthDate: faker.date.birthdate(), @@ -216,13 +216,13 @@ export const genOwnerApi = (): OwnerApi => { phone: faker.phone.number(), kind: randomstring.generate(), kindDetail: randomstring.generate(), - additionalAddress: randomstring.generate(), + additionalAddress: randomstring.generate() }; }; export const genAddressApi = ( refId: string, - addressKind: AddressKinds, + addressKind: AddressKinds ): AddressApi => { return { refId, @@ -233,18 +233,18 @@ export const genAddressApi = ( city: faker.location.city(), latitude: faker.address.latitude(), longitude: faker.address.longitude(), - score: Math.random(), + score: Math.random() }; }; export const genHousingOwnerApi = ( housing: HousingApi, - owner: OwnerApi, + owner: OwnerApi ): HousingOwnerApi => ({ ...owner, housingGeoCode: housing.geoCode, housingId: housing.id, - rank: genNumber(1), + rank: genNumber(1) }); export const genBuildingApi = (housingList: HousingApi[]): BuildingApi => { @@ -254,13 +254,13 @@ export const genBuildingApi = (housingList: HousingApi[]): BuildingApi => { uuidv4(), housingCount: housingList.length, vacantHousingCount: housingList.filter( - (housing) => housing.occupancy === OccupancyKindApi.Vacant, - ).length, + (housing) => housing.occupancy === OccupancyKindApi.Vacant + ).length }; }; export const genHousingApi = ( - geoCode: string = genGeoCode(), + geoCode: string = genGeoCode() ): MarkRequired => { const id = uuidv4(); const department = geoCode.substring(0, 2); @@ -272,7 +272,7 @@ export const genHousingApi = ( localId: genLocalId(department, invariant), rawAddress: [ faker.location.streetAddress(), - `${geoCode} ${faker.location.city()}`, + `${geoCode} ${faker.location.city()}` ], geoCode, localityKind: randomstring.generate(), @@ -299,14 +299,14 @@ export const genHousingApi = ( campaignIds: [], contactCount: genNumber(1), source: faker.helpers.arrayElement(HOUSING_SOURCES), - mutationDate: faker.date.past(), + mutationDate: faker.date.past() }; }; export const genCampaignApi = ( establishmentId: string, createdBy: string, - group?: GroupApi, + group?: GroupApi ): CampaignApi => { return { id: uuidv4(), @@ -315,22 +315,22 @@ export const genCampaignApi = ( status: 'draft', filters: { geoPerimetersIncluded: [randomstring.generate()], - geoPerimetersExcluded: [randomstring.generate()], + geoPerimetersExcluded: [randomstring.generate()] }, createdAt: new Date().toJSON(), userId: createdBy, - groupId: group?.id, + groupId: group?.id }; }; export const genGeoPerimeterApi = ( - establishmentId: string, + establishmentId: string ): GeoPerimeterApi => { return { id: uuidv4(), establishmentId, name: randomstring.generate(), - kind: randomstring.generate(), + kind: randomstring.generate() }; }; @@ -338,26 +338,26 @@ export const genResetLinkApi = (userId: string): ResetLinkApi => { return { id: randomstring.generate({ length: RESET_LINK_LENGTH, - charset: 'alphanumeric', + charset: 'alphanumeric' }), userId, createdAt: new Date(), expiresAt: addHours(new Date(), RESET_LINK_EXPIRATION), - usedAt: null, + usedAt: null }; }; export const genSignupLinkApi = (prospectEmail: string): SignupLinkApi => ({ id: randomstring.generate({ length: SIGNUP_LINK_LENGTH, - charset: 'alphanumeric', + charset: 'alphanumeric' }), prospectEmail, - expiresAt: addHours(new Date(), SIGNUP_LINK_EXPIRATION), + expiresAt: addHours(new Date(), SIGNUP_LINK_EXPIRATION) }); export const genContactPointApi = ( - establishmentId: string, + establishmentId: string ): ContactPointApi => { return { id: uuidv4(), @@ -366,7 +366,7 @@ export const genContactPointApi = ( opening: randomstring.generate(), address: `${faker.location.streetAddress()}, ${faker.location.zipCode()} ${faker.location.city()}`, email: genEmail(), - geoCodes: [genGeoCode()], + geoCodes: [genGeoCode()] }; }; @@ -375,11 +375,11 @@ export const genSettingsApi = (establishmentId: string): SettingsApi => { id: uuidv4(), establishmentId, contactPoints: { - public: genBoolean(), + public: genBoolean() }, inbox: { - enabled: true, - }, + enabled: true + } }; }; @@ -392,39 +392,39 @@ function genEventApi(createdBy: string): EventApi { section: oneOf(EventSections), conflict: genBoolean(), createdAt: new Date(), - createdBy, + createdBy }; } export const genOwnerEventApi = ( ownerId: string, - createdBy: string, + createdBy: string ): OwnerEventApi => { return { ...genEventApi(createdBy), old: { ...genOwnerApi(), id: ownerId }, new: { ...genOwnerApi(), id: ownerId }, - ownerId, + ownerId }; }; export const genHousingEventApi = ( housing: HousingApi, - createdBy: UserApi, + createdBy: UserApi ): HousingEventApi => { return { ...genEventApi(createdBy.id), old: housing, new: { ...genHousingApi(housing.geoCode), id: housing.id }, housingId: housing.id, - housingGeoCode: housing.geoCode, + housingGeoCode: housing.geoCode }; }; export const genGroupHousingEventApi = ( housing: HousingApi, group: GroupApi, - createdBy: UserApi, + createdBy: UserApi ): GroupHousingEventApi => { return { ...genEventApi(createdBy.id), @@ -432,13 +432,13 @@ export const genGroupHousingEventApi = ( new: group, groupId: group.id, housingId: housing.id, - housingGeoCode: housing.geoCode, + housingGeoCode: housing.geoCode }; }; export const genGroupApi = ( creator: UserApi, - establishment: EstablishmentApi, + establishment: EstablishmentApi ): GroupApi => { return { id: uuidv4(), @@ -451,12 +451,12 @@ export const genGroupApi = ( createdBy: creator, establishmentId: establishment.id, exportedAt: null, - archivedAt: null, + archivedAt: null }; }; export const genDatafoncierOwner = ( - idprocpte = randomstring.generate(11), + idprocpte = randomstring.generate(11) ): DatafoncierOwner => { const idcom = genGeoCode(); return { @@ -472,7 +472,7 @@ export const genDatafoncierOwner = ( dnupro: randomstring.generate(6), dnulp: randomstring.generate({ length: 1, - charset: 'numeric', + charset: 'numeric' }), ccocif: randomstring.generate(4), dnuper: randomstring.generate(6), @@ -497,7 +497,7 @@ export const genDatafoncierOwner = ( gtyp6: randomstring.generate(1), dlign3: [ faker.location.buildingNumber().substring(0, 4), - faker.location.street(), + faker.location.street() ] .join(' ') .substring(0, 30), @@ -536,12 +536,12 @@ export const genDatafoncierOwner = ( catpro2txt: randomstring.generate(100), catpro3: randomstring.generate(3), catpro3txt: randomstring.generate(105), - idpk: genNumber(5), + idpk: genNumber(5) }; }; export const genDatafoncierHousing = ( - geoCode = genGeoCode(), + geoCode = genGeoCode() ): DatafoncierHousing => { const department = geoCode.substring(0, 2); const localityCode = geoCode.substring(2, 5); @@ -673,26 +673,26 @@ export const genDatafoncierHousing = ( code_epci: null, lib_epci: null, ban_cp: randomstring.generate(5), - dis_ban_ff: genNumber(1), + dis_ban_ff: genNumber(1) }; }; export const genOwnerMatch = ( datafoncierOwner: DatafoncierOwner, - owner: OwnerApi, + owner: OwnerApi ): OwnerMatchDBO => ({ owner_id: owner.id, - idpersonne: datafoncierOwner.idpersonne, + idpersonne: datafoncierOwner.idpersonne }); export const genConflictApi = ( existing: T, - replacement: T, + replacement: T ): ConflictApi => ({ id: uuidv4(), createdAt: new Date(), existing, - replacement, + replacement }); export const genOwnerConflictApi = (): OwnerConflictApi => @@ -701,11 +701,11 @@ export const genOwnerConflictApi = (): OwnerConflictApi => export const genHousingOwnerConflictApi = ( housing: HousingApi, existing: HousingOwnerApi, - replacement: HousingOwnerApi, + replacement: HousingOwnerApi ): HousingOwnerConflictApi => ({ ...genConflictApi(existing, replacement), housingGeoCode: housing.geoCode, - housingId: housing.id, + housingId: housing.id }); const genNoteApi = (creator: UserApi): NoteApi => ({ @@ -713,36 +713,34 @@ const genNoteApi = (creator: UserApi): NoteApi => ({ noteKind: faker.word.noun(), content: faker.lorem.paragraph(), createdBy: creator.id, - createdAt: faker.date.past(), + createdAt: faker.date.past() }); export const genHousingNoteApi = ( creator: UserApi, - housing: HousingApi, + housing: HousingApi ): HousingNoteApi => ({ ...genNoteApi(creator), housingGeoCode: housing.geoCode, - housingId: housing.id, + housingId: housing.id }); export function genDraftApi( establishment: EstablishmentApi, - sender: SenderApi, + sender: SenderApi ): DraftApi { return { id: uuidv4(), subject: faker.lorem.sentence(), body: faker.lorem.paragraph(), - logo: faker.helpers.multiple(() => faker.image.url(), { - count: { min: 1, max: 2 }, - }), + logo: [], createdAt: new Date().toJSON(), updatedAt: new Date().toJSON(), sender, senderId: sender.id, writtenAt: faker.date.recent().toJSON().substring(0, 'yyyy-mm-dd'.length), writtenFrom: faker.location.streetAddress({ useFullAddress: true }), - establishmentId: establishment.id, + establishmentId: establishment.id }; } @@ -758,12 +756,12 @@ export function genSenderApi(establishment: EstablishmentApi): SenderApi { address: faker.location.streetAddress({ useFullAddress: true }), email: faker.internet.email({ firstName, lastName }), phone: faker.phone.number(), - signatoryFile: { id: uuidv4(), type: '', url: uuidv4(), content:'' }, + signatoryFile: null, signatoryRole: faker.person.jobTitle(), signatoryFirstName: faker.person.firstName(), signatoryLastName: faker.person.lastName(), createdAt: faker.date.past().toJSON(), updatedAt: faker.date.recent().toJSON(), - establishmentId: establishment.id, + establishmentId: establishment.id }; } diff --git a/server/src/types/express-jwt/index.d.ts b/server/src/types/express-jwt/index.d.ts index 690d0d2ea..e7425f1db 100644 --- a/server/src/types/express-jwt/index.d.ts +++ b/server/src/types/express-jwt/index.d.ts @@ -20,8 +20,9 @@ declare module 'express-jwt' { PathParams extends Record = Record, ResponseBody = any, RequestBody = any, + RequestQuery = any > = MarkRequired< - express.Request, + express.Request, 'auth' | 'establishment' | 'user' >; } diff --git a/shared/src/models/SenderDTO.ts b/shared/src/models/SenderDTO.ts index 3f3410a18..6426b37bf 100644 --- a/shared/src/models/SenderDTO.ts +++ b/shared/src/models/SenderDTO.ts @@ -1,4 +1,4 @@ -import { FileUploadDTO } from "./FileUploadDTO"; +import { FileUploadDTO } from './FileUploadDTO'; export interface SenderDTO { id: string; @@ -12,12 +12,12 @@ export interface SenderDTO { signatoryLastName: string | null; signatoryFirstName: string | null; signatoryRole: string | null; - signatoryFile: string | null; + signatoryFile: FileUploadDTO | null; createdAt: string; updatedAt: string; } -export type SenderPayloadDTO = Omit, 'signatoryFile'> & { signatoryFile: FileUploadDTO | null }; +>; From 40b5a3e22fd3683dd60c761f32ff3b0ea620879b Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Mon, 24 Jun 2024 19:00:49 +0200 Subject: [PATCH 02/12] fix(queue): fix log --- queue/src/workers/generate-mails.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/queue/src/workers/generate-mails.ts b/queue/src/workers/generate-mails.ts index 5531981a6..b3072bd82 100644 --- a/queue/src/workers/generate-mails.ts +++ b/queue/src/workers/generate-mails.ts @@ -92,7 +92,7 @@ export default function createWorker() { const html: string[] = []; - logger.debug('Génération du PDF...'); + logger.debug('Generating PDF...'); await async.forEach(housings, async (housing) => { const owners = await api.owner.findByHousing(housing.id); const address = getAddress(owners[0]); From 4fd8da2b82060fefd4a2baf7f8451d6929e2eb86 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Mon, 24 Jun 2024 19:30:27 +0200 Subject: [PATCH 03/12] fix(queue): write PDF pages by housing in series --- queue/src/workers/generate-mails.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/queue/src/workers/generate-mails.ts b/queue/src/workers/generate-mails.ts index b3072bd82..5471deb41 100644 --- a/queue/src/workers/generate-mails.ts +++ b/queue/src/workers/generate-mails.ts @@ -93,9 +93,8 @@ export default function createWorker() { const html: string[] = []; logger.debug('Generating PDF...'); - await async.forEach(housings, async (housing) => { - const owners = await api.owner.findByHousing(housing.id); - const address = getAddress(owners[0]); + await async.forEachSeries(housings, async (housing) => { + const address = getAddress(housing.owner); html.push( await pdf.compile(DRAFT_TEMPLATE_FILE, { @@ -105,7 +104,7 @@ export default function createWorker() { body: draft.body ? replaceVariables(draft.body, { housing, - owner: owners[0] + owner: housing.owner }) : '', sender: { @@ -123,7 +122,7 @@ export default function createWorker() { writtenAt: draft.writtenAt, writtenFrom: draft.writtenFrom, owner: { - fullName: owners[0].fullName, + fullName: housing.owner.fullName, address: address } }) From 146ed56713b79e58930daf4d4982c7096992cea9 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Tue, 25 Jun 2024 11:28:53 +0200 Subject: [PATCH 04/12] fix(api-sdk): fix housing API call --- packages/api-sdk/src/housing-api.ts | 10 +- queue/src/workers/generate-mails.ts | 72 ++++++------ server/src/controllers/housingController.ts | 119 ++++++++++---------- 3 files changed, 99 insertions(+), 102 deletions(-) diff --git a/packages/api-sdk/src/housing-api.ts b/packages/api-sdk/src/housing-api.ts index 93426c6ff..a744bd3a2 100644 --- a/packages/api-sdk/src/housing-api.ts +++ b/packages/api-sdk/src/housing-api.ts @@ -3,7 +3,7 @@ import { AxiosInstance } from 'axios'; import { HousingDTO, HousingFiltersDTO, - PaginationOptions, + Pagination } from '@zerologementvacant/models'; export interface HousingAPI { @@ -15,14 +15,14 @@ export function createHousingAPI(http: AxiosInstance): HousingAPI { async find(opts?: FindOptions): Promise { const response = await http.post('/housing', opts, { headers: { - 'Content-Type': 'application/json', - }, + 'Content-Type': 'application/json' + } }); return response.data.entities; - }, + } }; } -interface FindOptions extends PaginationOptions { +interface FindOptions extends Partial { filters?: HousingFiltersDTO; } diff --git a/queue/src/workers/generate-mails.ts b/queue/src/workers/generate-mails.ts index 5471deb41..149fbbb77 100644 --- a/queue/src/workers/generate-mails.ts +++ b/queue/src/workers/generate-mails.ts @@ -2,7 +2,6 @@ import { GetObjectCommand } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import archiver from 'archiver'; -import async from 'async'; import { Worker, WorkerOptions } from 'bullmq'; import { parseRedisUrl } from 'parse-redis-url-simple'; import { Readable } from 'node:stream'; @@ -54,6 +53,7 @@ export default function createWorker() { storage }); logger.info('SDK created.'); + const transformer = pdf.createTransformer({ logger }); return new Worker( 'campaign:generate', @@ -74,9 +74,7 @@ export default function createWorker() { filters: { campaignIds: [payload.campaignId] }, - pagination: { - paginate: false - } + paginate: false }), api.draft.find({ filters: { @@ -90,46 +88,42 @@ export default function createWorker() { throw new Error('Draft missing'); } - const html: string[] = []; - logger.debug('Generating PDF...'); - await async.forEachSeries(housings, async (housing) => { + const htmls = housings.map((housing) => { const address = getAddress(housing.owner); - html.push( - await pdf.compile(DRAFT_TEMPLATE_FILE, { - subject: draft.subject ?? '', - logo: draft.logo?.map((logo) => logo.content) ?? null, - watermark: false, - body: draft.body - ? replaceVariables(draft.body, { - housing, - owner: housing.owner - }) - : '', - sender: { - name: draft.sender.name, - service: draft.sender.service, - firstName: draft.sender.firstName, - lastName: draft.sender.lastName, - address: draft.sender.address, - phone: draft.sender.phone, - signatoryLastName: draft.sender.signatoryLastName, - signatoryFirstName: draft.sender.signatoryFirstName, - signatoryRole: draft.sender.signatoryRole, - signatoryFile: draft.sender.signatoryFile?.content ?? null - }, - writtenAt: draft.writtenAt, - writtenFrom: draft.writtenFrom, - owner: { - fullName: housing.owner.fullName, - address: address - } - }) - ); + return transformer.compile(DRAFT_TEMPLATE_FILE, { + subject: draft.subject ?? '', + logo: draft.logo?.map((logo) => logo.content) ?? null, + watermark: false, + body: draft.body + ? replaceVariables(draft.body, { + housing, + owner: housing.owner + }) + : '', + sender: { + name: draft.sender.name, + service: draft.sender.service, + firstName: draft.sender.firstName, + lastName: draft.sender.lastName, + address: draft.sender.address, + phone: draft.sender.phone, + signatoryLastName: draft.sender.signatoryLastName, + signatoryFirstName: draft.sender.signatoryFirstName, + signatoryRole: draft.sender.signatoryRole, + signatoryFile: draft.sender.signatoryFile?.content ?? null + }, + writtenAt: draft.writtenAt, + writtenFrom: draft.writtenFrom, + owner: { + fullName: housing.owner.fullName, + address: address + } + }); }); - const finalPDF = await pdf.fromHTML(html); + const finalPDF = await transformer.fromHTML(htmls); logger.debug('Done writing PDF'); const name = new Date() .toISOString() diff --git a/server/src/controllers/housingController.ts b/server/src/controllers/housingController.ts index e629a0588..a18b4249b 100644 --- a/server/src/controllers/housingController.ts +++ b/server/src/controllers/housingController.ts @@ -12,10 +12,10 @@ import { HousingApi, HousingRecordApi, HousingSortableApi, - OccupancyKindApi, + OccupancyKindApi } from '~/models/HousingApi'; import housingFiltersApi, { - HousingFiltersApi, + HousingFiltersApi } from '~/models/HousingFiltersApi'; import { UserRoles } from '~/models/UserApi'; import eventRepository from '~/repositories/eventRepository'; @@ -45,7 +45,7 @@ import createDatafoncierOwnersRepository from '~/repositories/datafoncierOwnersR const getValidators = oneOf([ param('id').isString().isLength({ min: 12, max: 12 }), // localId - param('id').isUUID(), // id + param('id').isUUID() // id ]); async function get(request: Request, response: Response) { const { params, establishment } = request as AuthenticatedRequest; @@ -59,7 +59,7 @@ async function get(request: Request, response: Response) { geoCode: establishment.geoCodes, id, localId, - includes: ['events', 'owner', 'perimeters', 'campaigns'], + includes: ['events', 'owner', 'perimeters', 'campaigns'] }); if (!housing) { throw new HousingMissingError(params.id); @@ -71,12 +71,12 @@ async function get(request: Request, response: Response) { const listValidators: ValidationChain[] = [ ...housingFiltersApi.validators(), ...sortApi.queryValidators, - ...paginationApi.validators, + ...paginationApi.validators ]; async function list( request: Request, - response: Response, + response: Response ) { const { auth, body, user } = request as AuthenticatedRequest; // TODO: type the whole body @@ -84,20 +84,21 @@ async function list( const role = user.role; const sort = sortApi.parse( - request.query.sort as string[] | undefined, + request.query.sort as string[] | undefined ); const filters: HousingFiltersApi = { ...body.filters, establishmentIds: - [UserRoles.Admin, UserRoles.Visitor].includes(role) && body.filters.establishmentIds?.length > 0 + [UserRoles.Admin, UserRoles.Visitor].includes(role) && + body.filters.establishmentIds?.length > 0 ? body.filters.establishmentIds - : [auth.establishmentId], + : [auth.establishmentId] }; - logger.trace('List housing', { + logger.debug('List housing', { pagination, filters, - sort, + sort }); const [housing, count] = await Promise.all([ @@ -105,11 +106,11 @@ async function list( filters, pagination, sort, - includes: ['owner', 'campaigns'], + includes: ['owner', 'campaigns'] }), // Kept for backward-compatibility // TODO: remove this - Promise.resolve({ housing: 1, owners: 1 }), + Promise.resolve({ housing: 1, owners: 1 }) ]); const offset = (pagination.page - 1) * pagination.perPage; @@ -117,7 +118,7 @@ async function list( .status(constants.HTTP_STATUS_OK) .setHeader( 'Content-Range', - `housing ${offset}-${offset + housing.length - 1}/${count.housing}`, + `housing ${offset}-${offset + housing.length - 1}/${count.housing}` ) .json({ entities: housing, @@ -125,7 +126,7 @@ async function list( filteredOwnerCount: count.owners, page: pagination.page, perPage: pagination.perPage, - totalCount: 0, + totalCount: 0 }); } @@ -138,9 +139,10 @@ async function count(request: Request, response: Response): Promise { const count = await housingRepository.count({ ...filters, establishmentIds: - [UserRoles.Admin, UserRoles.Visitor].includes(role) && filters.establishmentIds?.length + [UserRoles.Admin, UserRoles.Visitor].includes(role) && + filters.establishmentIds?.length ? filters.establishmentIds - : [establishmentId], + : [establishmentId] }); response.status(constants.HTTP_STATUS_OK).json(count); } @@ -149,7 +151,7 @@ const datafoncierHousingRepository = createDatafoncierHousingRepository(); const datafoncierOwnerRepository = createDatafoncierOwnersRepository(); const createValidators: ValidationChain[] = [ - body('localId').isString().isLength({ min: 12, max: 12 }), + body('localId').isString().isLength({ min: 12, max: 12 }) ]; async function create(request: Request, response: Response) { const { auth, body } = request as AuthenticatedRequest; @@ -157,14 +159,14 @@ async function create(request: Request, response: Response) { const existing = await housingRepository.findOne({ geoCode, - localId: body.localId, + localId: body.localId }); if (existing) { throw new HousingExistsError(body.localId); } const datafoncierHousing = await datafoncierHousingRepository.findOne({ - idlocal: body.localId, + idlocal: body.localId }); if (!datafoncierHousing) { throw new HousingMissingError(body.localId); @@ -173,8 +175,8 @@ async function create(request: Request, response: Response) { const datafoncierOwners = await datafoncierOwnerRepository.findDatafoncierOwners({ filters: { - idprocpte: datafoncierHousing.idprocpte, - }, + idprocpte: datafoncierHousing.idprocpte + } }); // Create the missing datafoncier owners if needed await async.forEach(datafoncierOwners, async (datafoncierOwner) => { @@ -188,13 +190,13 @@ async function create(request: Request, response: Response) { }); const owners = await ownerRepository.find({ filters: { - idpersonne: datafoncierOwners.map((owner) => owner.idpersonne), - }, + idpersonne: datafoncierOwners.map((owner) => owner.idpersonne) + } }); const housing: HousingRecordApi = toHousingRecordApi( { source: 'datafoncier-manual' }, - datafoncierHousing, + datafoncierHousing ); await housingRepository.save(housing); await housingOwnerRepository.saveMany(toHousingOwnersApi(housing, owners)); @@ -210,13 +212,13 @@ async function create(request: Request, response: Response) { (await housingRepository.findOne({ geoCode: housing.geoCode, id: housing.id, - includes: ['owner'], + includes: ['owner'] })) ?? undefined, housingGeoCode: housing.geoCode, housingId: housing.id, conflict: false, createdAt: new Date(), - createdBy: auth.userId, + createdBy: auth.userId }; await eventRepository.insertHousingEvent(event); @@ -237,24 +239,24 @@ const updateValidators = [ body('housingUpdate.statusUpdate') .optional() .custom((value) => - validator.isIn(String(value.status), Object.values(HousingStatusApi)), + validator.isIn(String(value.status), Object.values(HousingStatusApi)) ), body('housingUpdate.occupancyUpdate') .optional() .custom((value) => - validator.isIn(String(value.occupancy), Object.values(OccupancyKindApi)), + validator.isIn(String(value.occupancy), Object.values(OccupancyKindApi)) ) .custom( (value) => !value.occupancyIntended || validator.isIn( String(value.occupancyIntended), - Object.values(OccupancyKindApi), - ), + Object.values(OccupancyKindApi) + ) ), body('housingUpdate.note') .optional() - .custom((value) => value.content && !validator.isEmpty(value.content)), + .custom((value) => value.content && !validator.isEmpty(value.content)) ]; async function update(request: Request, response: Response) { @@ -264,7 +266,7 @@ async function update(request: Request, response: Response) { const updatedHousing = await updateHousing( housingId, housingUpdateApi, - request as AuthenticatedRequest, + request as AuthenticatedRequest ); response.status(constants.HTTP_STATUS_OK).json(updatedHousing); @@ -273,18 +275,18 @@ async function update(request: Request, response: Response) { const updateHousing = async ( housingId: string, housingUpdate: HousingUpdateBody, - authUser: Pick, + authUser: Pick ): Promise => { logger.trace('Update housing', { id: housingId, - update: housingUpdate, + update: housingUpdate }); const { establishment, user } = authUser; const housing = await housingRepository.findOne({ id: housingId, - geoCode: establishment.geoCodes, + geoCode: establishment.geoCodes }); if (!housing) { throw new HousingMissingError(housingId); @@ -295,7 +297,7 @@ const updateHousing = async ( ...(housingUpdate.occupancyUpdate ? { occupancy: housingUpdate.occupancyUpdate.occupancy, - occupancyIntended: housingUpdate.occupancyUpdate.occupancyIntended, + occupancyIntended: housingUpdate.occupancyUpdate.occupancyIntended } : {}), ...(housingUpdate.statusUpdate @@ -303,9 +305,9 @@ const updateHousing = async ( status: housingUpdate.statusUpdate.status, subStatus: housingUpdate.statusUpdate.subStatus, vacancyReasons: housingUpdate.statusUpdate.vacancyReasons, - precisions: housingUpdate.statusUpdate.precisions, + precisions: housingUpdate.statusUpdate.precisions } - : {}), + : {}) }; await housingRepository.update(updatedHousing); @@ -316,7 +318,7 @@ const updateHousing = async ( housing.id, housingUpdate, user.id, - housing.geoCode, + housing.geoCode ); return updatedHousing; @@ -326,7 +328,7 @@ const updateListValidators = [ body('allHousing').isBoolean(), body('housingIds').custom(isArrayOf(isUUID)), ...housingFiltersApi.validators(), - ...updateValidators, + ...updateValidators ]; async function updateList(request: Request, response: Response) { @@ -341,9 +343,10 @@ async function updateList(request: Request, response: Response) { const filters: HousingFiltersApi = { ...body.filters, establishmentIds: - [UserRoles.Admin, UserRoles.Visitor].includes(role) && body.filters.establishmentIds?.length > 0 + [UserRoles.Admin, UserRoles.Visitor].includes(role) && + body.filters.establishmentIds?.length > 0 ? body.filters.establishmentIds - : [auth.establishmentId], + : [auth.establishmentId] }; const housingList = await housingRepository @@ -352,21 +355,21 @@ async function updateList(request: Request, response: Response) { _.filter((housing) => allHousing ? !housingIds.includes(housing.id) - : housingIds.includes(housing.id), - ), + : housingIds.includes(housing.id) + ) ); const housingContactedWithCampaigns = housingList.filter( (housing) => housing.status !== HousingStatusApi.NeverContacted && - hasCampaigns(housing), + hasCampaigns(housing) ); if ( housingUpdateApi.statusUpdate?.status === HousingStatusApi.NeverContacted && housingContactedWithCampaigns.length > 0 ) { throw new HousingUpdateForbiddenError( - ...housingContactedWithCampaigns.map((housing) => housing.id), + ...housingContactedWithCampaigns.map((housing) => housing.id) ); } @@ -375,9 +378,9 @@ async function updateList(request: Request, response: Response) { updateHousing( housing.id, housingUpdateApi, - request as AuthenticatedRequest, - ), - ), + request as AuthenticatedRequest + ) + ) ); response.status(constants.HTTP_STATUS_OK).json(updatedHousingList); @@ -386,7 +389,7 @@ async function updateList(request: Request, response: Response) { async function createHousingUpdateEvents( housingApi: HousingApi, housingUpdate: HousingUpdateBody, - userId: string, + userId: string ): Promise { const statusUpdate = housingUpdate.statusUpdate; if ( @@ -405,12 +408,12 @@ async function createHousingUpdateEvents( old: housingApi, new: { ...housingApi, - ...housingUpdate.statusUpdate, + ...housingUpdate.statusUpdate }, createdBy: userId, createdAt: new Date(), housingId: housingApi.id, - housingGeoCode: housingApi.geoCode, + housingGeoCode: housingApi.geoCode }); } @@ -430,12 +433,12 @@ async function createHousingUpdateEvents( old: housingApi, new: { ...housingApi, - ...housingUpdate.occupancyUpdate, + ...housingUpdate.occupancyUpdate }, createdBy: userId, createdAt: new Date(), housingId: housingApi.id, - housingGeoCode: housingApi.geoCode, + housingGeoCode: housingApi.geoCode }); } } @@ -443,7 +446,7 @@ async function createHousingUpdateNote( housingId: string, housingUpdate: HousingUpdateBody, userId: string, - geoCode: string, + geoCode: string ) { if (housingUpdate.note) { await noteRepository.insertHousingNote({ @@ -452,7 +455,7 @@ async function createHousingUpdateNote( createdBy: userId, createdAt: new Date(), housingId, - housingGeoCode: geoCode, + housingGeoCode: geoCode }); } } @@ -468,7 +471,7 @@ const housingController = { updateValidators, update, updateListValidators, - updateList, + updateList }; export default housingController; From ea81499e1a2735dc6468250a6bcfc7665efbd2cf Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Tue, 25 Jun 2024 11:30:44 +0200 Subject: [PATCH 05/12] perf(draft): reduce memory allocation to generate the campaign archive --- packages/draft/package.json | 3 + packages/draft/src/pdf.ts | 101 ++++++++++-------- server/src/controllers/draftController.ts | 6 +- .../controllers/test/draftController.test.ts | 18 +++- yarn.lock | 5 +- 5 files changed, 82 insertions(+), 51 deletions(-) diff --git a/packages/draft/package.json b/packages/draft/package.json index ed93e28e6..fd0289c5b 100644 --- a/packages/draft/package.json +++ b/packages/draft/package.json @@ -10,6 +10,7 @@ }, "devDependencies": { "@tsconfig/node20": "^20.1.4", + "@types/async": "^3.2.24", "@types/jest": "^29.5.12", "@types/node": "^20.12.12", "copyfiles": "^2.4.1", @@ -22,6 +23,8 @@ }, "dependencies": { "@codegouvfr/react-dsfr": "^1.9.11", + "@zerologementvacant/utils": "workspace:^", + "async": "^3.2.5", "handlebars": "^4.7.8", "pdf-lib": "^1.17.1", "puppeteer": "^22.9.0" diff --git a/packages/draft/src/pdf.ts b/packages/draft/src/pdf.ts index 34275adcf..d1d89c874 100644 --- a/packages/draft/src/pdf.ts +++ b/packages/draft/src/pdf.ts @@ -1,8 +1,10 @@ +import async from 'async'; import { ReadableStream } from 'node:stream/web'; import * as handlebars from 'handlebars'; import path from 'node:path'; import { PDFDocument } from 'pdf-lib'; import puppeteer from 'puppeteer'; +import { Logger } from '@zerologementvacant/utils'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore global.ReadableStream = ReadableStream; @@ -11,62 +13,67 @@ handlebars.registerHelper('localdate', (date: string) => { return new Date(date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', - year: 'numeric', + year: 'numeric' }); }); -async function compile(html: string, data?: T): Promise { - const compiled = handlebars.compile(html); - return compiled(data); +interface TransformerOptions { + logger: Logger; } -async function fromHTML(htmlArray: string[]): Promise { - // Launch the browser and open a new blank page - const browser = await puppeteer.launch({ - args: ['--no-sandbox'], - }); - - const pdfDocs = []; - - for (const html of htmlArray) { - const page = await browser.newPage(); - await page.setContent(html, { - waitUntil: 'networkidle0', - }); - await page.addStyleTag({ - path: path.join( - __dirname, - '..', - 'node_modules', - '@codegouvfr', - 'react-dsfr', - 'dsfr', - 'dsfr.min.css', - ), - }); - await page.addStyleTag({ - path: path.join(__dirname, 'templates', 'draft', 'draft.css'), - }); - const pdfBuffer = await page.pdf({ format: 'A4' }); - pdfDocs.push(pdfBuffer); - await page.close(); - } - browser.close(); - - const mergedPdf = await PDFDocument.create(); +function createTransformer(opts: TransformerOptions) { + const { logger } = opts; - for (const pdfBuffer of pdfDocs) { - const pdf = await PDFDocument.load(pdfBuffer); - const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices()); - copiedPages.forEach((page: any) => mergedPdf.addPage(page)); - } + return { + compile(template: string, data?: T): string { + const compiled = handlebars.compile(template); + return compiled(data); + }, + async fromHTML(htmls: string[]): Promise { + // Launch the browser and open a new blank page + const browser = await puppeteer.launch({ + args: ['--no-sandbox'] + }); - const mergedPdfFile = await mergedPdf.save(); + let index = 1; + const document = await PDFDocument.create(); + await async.forEachSeries(htmls, async (html) => { + logger.debug(`Generating PDF page ${index} of ${htmls.length}...`); + index++; + const tab = await browser.newPage(); + await tab.setContent(html, { + waitUntil: 'networkidle0' + }); + await tab.addStyleTag({ + path: path.join( + __dirname, + '..', + 'node_modules', + '@codegouvfr', + 'react-dsfr', + 'dsfr', + 'dsfr.min.css' + ) + }); + await tab.addStyleTag({ + path: path.join(__dirname, 'templates', 'draft', 'draft.css') + }); + const buffer = await tab.pdf({ format: 'A4' }); + await tab.close(); + const chunk = await PDFDocument.load(buffer); + const pages = await document.copyPages(chunk, chunk.getPageIndices()); + pages.forEach((page) => { + document.addPage(page); + }); + }); - return Buffer.from(mergedPdfFile); + const mergedPDF = await document.save(); + logger.info('Saved generated PDF'); + return Buffer.from(mergedPDF); + } + }; } export default { - compile, - fromHTML, + createTransformer }; diff --git a/server/src/controllers/draftController.ts b/server/src/controllers/draftController.ts index ee50a1bc3..82431a975 100644 --- a/server/src/controllers/draftController.ts +++ b/server/src/controllers/draftController.ts @@ -34,6 +34,8 @@ interface DraftQuery { campaign?: string; } +const transformer = pdf.createTransformer({ logger }); + async function list( request: Request, response: Response @@ -187,7 +189,7 @@ async function preview( throw new DraftMissingError(params.id); } - const html = await pdf.compile(DRAFT_TEMPLATE_FILE, { + const html = transformer.compile(DRAFT_TEMPLATE_FILE, { subject: draft.subject, logo: draft.logo?.map((logo) => logo.content) ?? null, watermark: true, @@ -216,7 +218,7 @@ async function preview( address: getAddress(body.owner) } }); - const finalPDF = await pdf.fromHTML([html]); + const finalPDF = await transformer.fromHTML([html]); response.status(constants.HTTP_STATUS_OK).type('pdf').send(finalPDF); } const previewValidators: ValidationChain[] = [ diff --git a/server/src/controllers/test/draftController.test.ts b/server/src/controllers/test/draftController.test.ts index c4d85d709..dc13c1b5e 100644 --- a/server/src/controllers/test/draftController.test.ts +++ b/server/src/controllers/test/draftController.test.ts @@ -1,5 +1,6 @@ import { faker } from '@faker-js/faker/locale/fr'; import { constants } from 'http2'; +import path from 'node:path'; import request from 'supertest'; import { createServer } from '~/infra/server'; @@ -27,6 +28,7 @@ import { DraftCreationPayloadDTO, DraftDTO, DraftUpdatePayloadDTO, + FileUploadDTO, SenderPayloadDTO } from '@zerologementvacant/models'; import { @@ -50,11 +52,20 @@ describe('Draft API', () => { const anotherEstablishment = genEstablishmentApi(); const anotherUser = genUserApi(anotherEstablishment.id); + let logo: FileUploadDTO; + beforeAll(async () => { await Establishments().insert( [establishment, anotherEstablishment].map(formatEstablishmentApi) ); await Users().insert([user, anotherUser].map(formatUserApi)); + + const response = await request(app) + .post('/api/files') + .attach('file', path.join(__dirname, 'test.jpeg')) + .use(tokenProvider(user)) + .expect(constants.HTTP_STATUS_CREATED); + logo = response.body; }); describe('GET /drafts', () => { @@ -62,7 +73,12 @@ describe('Draft API', () => { const sender = genSenderApi(establishment); const drafts: DraftApi[] = [ - ...Array.from({ length: 4 }, () => genDraftApi(establishment, sender)), + ...Array.from({ length: 4 }, () => { + return { + ...genDraftApi(establishment, sender), + logo: [logo] + }; + }), ...Array.from({ length: 2 }, () => genDraftApi(anotherEstablishment, sender) ) diff --git a/yarn.lock b/yarn.lock index 745ec5802..b39bb8f06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8773,8 +8773,11 @@ __metadata: dependencies: "@codegouvfr/react-dsfr": "npm:^1.9.11" "@tsconfig/node20": "npm:^20.1.4" + "@types/async": "npm:^3.2.24" "@types/jest": "npm:^29.5.12" "@types/node": "npm:^20.12.12" + "@zerologementvacant/utils": "workspace:^" + async: "npm:^3.2.5" copyfiles: "npm:^2.4.1" handlebars: "npm:^4.7.8" jest: "npm:^29.7.0" @@ -9109,7 +9112,7 @@ __metadata: languageName: unknown linkType: soft -"@zerologementvacant/utils@workspace:*, @zerologementvacant/utils@workspace:packages/utils": +"@zerologementvacant/utils@workspace:*, @zerologementvacant/utils@workspace:^, @zerologementvacant/utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@zerologementvacant/utils@workspace:packages/utils" dependencies: From 5804ad47ae9282cb3cafeba266e68054ea2d9028 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Tue, 25 Jun 2024 13:51:02 +0200 Subject: [PATCH 06/12] perf(draft): cache html render function --- packages/draft/src/pdf.ts | 8 ++++++-- queue/src/workers/generate-mails.ts | 16 +++++++++++----- yarn.lock | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/draft/src/pdf.ts b/packages/draft/src/pdf.ts index d1d89c874..c773ca391 100644 --- a/packages/draft/src/pdf.ts +++ b/packages/draft/src/pdf.ts @@ -23,11 +23,15 @@ interface TransformerOptions { function createTransformer(opts: TransformerOptions) { const { logger } = opts; + const cache = new Map string>(); return { compile(template: string, data?: T): string { - const compiled = handlebars.compile(template); - return compiled(data); + const render = cache.get(template) ?? handlebars.compile(template); + if (!cache.has(template)) { + cache.set(template, render); + } + return render(data); }, async fromHTML(htmls: string[]): Promise { // Launch the browser and open a new blank page diff --git a/queue/src/workers/generate-mails.ts b/queue/src/workers/generate-mails.ts index 149fbbb77..a1e9d62d3 100644 --- a/queue/src/workers/generate-mails.ts +++ b/queue/src/workers/generate-mails.ts @@ -58,9 +58,8 @@ export default function createWorker() { return new Worker( 'campaign:generate', async (job) => { - return storage.run( - { establishment: job.data.establishmentId }, - async () => { + return storage + .run({ establishment: job.data.establishmentId }, async () => { const payload = job.data; logger.info('Generating mail for campaign', job.data); @@ -206,8 +205,15 @@ export default function createWorker() { }); return { id: campaign.id }; - } - ); + }) + .catch((error) => { + logger.error('Campaign archive generation failed', { + error: { + message: error.message + } + }); + throw error; + }); }, workerConfig ); diff --git a/yarn.lock b/yarn.lock index b39bb8f06..9e1427b81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7912,6 +7912,13 @@ __metadata: languageName: node linkType: hard +"@types/mustache@npm:^4": + version: 4.2.5 + resolution: "@types/mustache@npm:4.2.5" + checksum: 10c0/624975c39068d47407eadb89628aaff5ef60f3b7a71eef92a254310896a4e90518a01dcf71d95779ab2c986034a6ca5403d22fea237c67ff87f2e2b3fb794ea6 + languageName: node + linkType: hard + "@types/mute-stream@npm:^0.0.4": version: 0.0.4 resolution: "@types/mute-stream@npm:0.0.4" @@ -8775,6 +8782,7 @@ __metadata: "@tsconfig/node20": "npm:^20.1.4" "@types/async": "npm:^3.2.24" "@types/jest": "npm:^29.5.12" + "@types/mustache": "npm:^4" "@types/node": "npm:^20.12.12" "@zerologementvacant/utils": "workspace:^" async: "npm:^3.2.5" @@ -8782,6 +8790,7 @@ __metadata: handlebars: "npm:^4.7.8" jest: "npm:^29.7.0" jest-extended: "npm:^4.0.2" + mustache: "npm:^4.2.0" nodemon: "npm:^3.1.0" pdf-lib: "npm:^1.17.1" puppeteer: "npm:^22.9.0" @@ -18760,6 +18769,15 @@ __metadata: languageName: node linkType: hard +"mustache@npm:^4.2.0": + version: 4.2.0 + resolution: "mustache@npm:4.2.0" + bin: + mustache: bin/mustache + checksum: 10c0/1f8197e8a19e63645a786581d58c41df7853da26702dbc005193e2437c98ca49b255345c173d50c08fe4b4dbb363e53cb655ecc570791f8deb09887248dd34a2 + languageName: node + linkType: hard + "mute-stream@npm:^1.0.0": version: 1.0.0 resolution: "mute-stream@npm:1.0.0" From b2dd5997e455a7e90ab35534775fa7b57f1812df Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Tue, 25 Jun 2024 13:55:35 +0200 Subject: [PATCH 07/12] chore: update yarn.lock --- yarn.lock | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9e1427b81..b39bb8f06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7912,13 +7912,6 @@ __metadata: languageName: node linkType: hard -"@types/mustache@npm:^4": - version: 4.2.5 - resolution: "@types/mustache@npm:4.2.5" - checksum: 10c0/624975c39068d47407eadb89628aaff5ef60f3b7a71eef92a254310896a4e90518a01dcf71d95779ab2c986034a6ca5403d22fea237c67ff87f2e2b3fb794ea6 - languageName: node - linkType: hard - "@types/mute-stream@npm:^0.0.4": version: 0.0.4 resolution: "@types/mute-stream@npm:0.0.4" @@ -8782,7 +8775,6 @@ __metadata: "@tsconfig/node20": "npm:^20.1.4" "@types/async": "npm:^3.2.24" "@types/jest": "npm:^29.5.12" - "@types/mustache": "npm:^4" "@types/node": "npm:^20.12.12" "@zerologementvacant/utils": "workspace:^" async: "npm:^3.2.5" @@ -8790,7 +8782,6 @@ __metadata: handlebars: "npm:^4.7.8" jest: "npm:^29.7.0" jest-extended: "npm:^4.0.2" - mustache: "npm:^4.2.0" nodemon: "npm:^3.1.0" pdf-lib: "npm:^1.17.1" puppeteer: "npm:^22.9.0" @@ -18769,15 +18760,6 @@ __metadata: languageName: node linkType: hard -"mustache@npm:^4.2.0": - version: 4.2.0 - resolution: "mustache@npm:4.2.0" - bin: - mustache: bin/mustache - checksum: 10c0/1f8197e8a19e63645a786581d58c41df7853da26702dbc005193e2437c98ca49b255345c173d50c08fe4b4dbb363e53cb655ecc570791f8deb09887248dd34a2 - languageName: node - linkType: hard - "mute-stream@npm:^1.0.0": version: 1.0.0 resolution: "mute-stream@npm:1.0.0" From 02f61c3d32b404f5a2040eefc7ce34fca2f9199f Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Tue, 25 Jun 2024 14:10:10 +0200 Subject: [PATCH 08/12] revert(server): revert unwanted change --- .../controllers/test/draftController.test.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/server/src/controllers/test/draftController.test.ts b/server/src/controllers/test/draftController.test.ts index dc13c1b5e..c4d85d709 100644 --- a/server/src/controllers/test/draftController.test.ts +++ b/server/src/controllers/test/draftController.test.ts @@ -1,6 +1,5 @@ import { faker } from '@faker-js/faker/locale/fr'; import { constants } from 'http2'; -import path from 'node:path'; import request from 'supertest'; import { createServer } from '~/infra/server'; @@ -28,7 +27,6 @@ import { DraftCreationPayloadDTO, DraftDTO, DraftUpdatePayloadDTO, - FileUploadDTO, SenderPayloadDTO } from '@zerologementvacant/models'; import { @@ -52,20 +50,11 @@ describe('Draft API', () => { const anotherEstablishment = genEstablishmentApi(); const anotherUser = genUserApi(anotherEstablishment.id); - let logo: FileUploadDTO; - beforeAll(async () => { await Establishments().insert( [establishment, anotherEstablishment].map(formatEstablishmentApi) ); await Users().insert([user, anotherUser].map(formatUserApi)); - - const response = await request(app) - .post('/api/files') - .attach('file', path.join(__dirname, 'test.jpeg')) - .use(tokenProvider(user)) - .expect(constants.HTTP_STATUS_CREATED); - logo = response.body; }); describe('GET /drafts', () => { @@ -73,12 +62,7 @@ describe('Draft API', () => { const sender = genSenderApi(establishment); const drafts: DraftApi[] = [ - ...Array.from({ length: 4 }, () => { - return { - ...genDraftApi(establishment, sender), - logo: [logo] - }; - }), + ...Array.from({ length: 4 }, () => genDraftApi(establishment, sender)), ...Array.from({ length: 2 }, () => genDraftApi(anotherEstablishment, sender) ) From a1fab7b722301df1fd671b95ee1acf269b2b5dfb Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Wed, 26 Jun 2024 08:50:43 +0200 Subject: [PATCH 09/12] test(server): fix draft test --- server/src/controllers/test/draftController.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/server/src/controllers/test/draftController.test.ts b/server/src/controllers/test/draftController.test.ts index c4d85d709..52c0ddefc 100644 --- a/server/src/controllers/test/draftController.test.ts +++ b/server/src/controllers/test/draftController.test.ts @@ -282,14 +282,7 @@ describe('Draft API', () => { id: draft.id, subject: faker.lorem.sentence(), body: faker.lorem.paragraph(), - logo: [ - { - id: faker.string.uuid(), - type: 'image/jpeg', - url: 'https://example.com/logo.png', - content: '' - } - ], + logo: [], sender: fp.omit(['id', 'createdAt', 'updatedAt'], sender), writtenAt: faker.date.recent().toISOString().substring(0, 10), writtenFrom: faker.location.city() From 72e8aeb0fca1ea225ae8ec70a8a8f0c7a78183dc Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Wed, 26 Jun 2024 10:31:21 +0200 Subject: [PATCH 10/12] build: upsize postgres addon for review apps --- .github/workflows/review-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/review-app.yml b/.github/workflows/review-app.yml index 5091b7235..ee53b2ca0 100644 --- a/.github/workflows/review-app.yml +++ b/.github/workflows/review-app.yml @@ -23,7 +23,7 @@ jobs: # TODO: do not create it if exists - name: Create postgres addon - run: clever addon create postgresql-addon $PR_NAME-postgres --org $ORGA_ID --plan dev --region par + run: clever addon create postgresql-addon $PR_NAME-postgres --org $ORGA_ID --plan xxs_sml --region par - name: Create redis addon run: clever addon create redis-addon $PR_NAME-redis --org $ORGA_ID --plan s_mono --region par From 38d6324761e47892fed55bdac4d8158a13470234 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Wed, 26 Jun 2024 10:31:57 +0200 Subject: [PATCH 11/12] build: upsize API and queue for review apps --- .github/workflows/review-app.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/review-app.yml b/.github/workflows/review-app.yml index ee53b2ca0..a4781d8ed 100644 --- a/.github/workflows/review-app.yml +++ b/.github/workflows/review-app.yml @@ -94,7 +94,7 @@ jobs: clever deploy -a $APP_ALIAS - name: Scale down for the run - run: clever scale --flavor XS -a $APP_ALIAS + run: clever scale --flavor S -a $APP_ALIAS deploy-queue: if: github.event.action == 'opened' || github.event.action == 'reopened' @@ -154,7 +154,7 @@ jobs: clever deploy -a $APP_ALIAS - name: Scale down for the run - run: clever scale --flavor XS -a $APP_ALIAS + run: clever scale --flavor S -a $APP_ALIAS deploy-front: if: github.event.action == 'opened' || github.event.action == 'reopened' @@ -239,7 +239,7 @@ jobs: run: clever deploy --force -a $APP_ALIAS - name: Scale down for the run - run: clever scale --flavor XS -a $APP_ALIAS + run: clever scale --flavor S -a $APP_ALIAS update-front: if: github.event.action == 'synchronize' @@ -303,7 +303,7 @@ jobs: run: clever deploy --force -a $APP_ALIAS - name: Scale down for the run - run: clever scale --flavor XS -a $APP_ALIAS + run: clever scale --flavor S -a $APP_ALIAS delete-queue: runs-on: ubuntu-latest From 32328daff6a57535ed74d9dc1d6e846d87cf98d2 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Wed, 26 Jun 2024 11:04:19 +0200 Subject: [PATCH 12/12] build: upsize queue to medium as it was not sufficient --- .github/workflows/review-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/review-app.yml b/.github/workflows/review-app.yml index a4781d8ed..2a84e6eed 100644 --- a/.github/workflows/review-app.yml +++ b/.github/workflows/review-app.yml @@ -154,7 +154,7 @@ jobs: clever deploy -a $APP_ALIAS - name: Scale down for the run - run: clever scale --flavor S -a $APP_ALIAS + run: clever scale --flavor M -a $APP_ALIAS deploy-front: if: github.event.action == 'opened' || github.event.action == 'reopened' @@ -303,7 +303,7 @@ jobs: run: clever deploy --force -a $APP_ALIAS - name: Scale down for the run - run: clever scale --flavor S -a $APP_ALIAS + run: clever scale --flavor M -a $APP_ALIAS delete-queue: runs-on: ubuntu-latest