diff --git a/frontend/src/components/Aside/AsideNext.tsx b/frontend/src/components/Aside/AsideNext.tsx new file mode 100644 index 000000000..49e5c266f --- /dev/null +++ b/frontend/src/components/Aside/AsideNext.tsx @@ -0,0 +1,75 @@ +import { fr } from '@codegouvfr/react-dsfr'; +import Button from '@codegouvfr/react-dsfr/Button'; +import ButtonsGroup from '@codegouvfr/react-dsfr/ButtonsGroup'; +import Drawer, { DrawerProps } from '@mui/material/Drawer'; +import Grid from '@mui/material/Unstable_Grid2'; +import { ReactNode } from 'react'; + +interface AsideProps { + drawerProps?: Omit; + header?: ReactNode; + main?: ReactNode; + footer?: ReactNode; + open: boolean; + onClose(): void; + onSave(): void; +} + +function Aside(props: AsideProps) { + return ( + + + + {props.header && {props.header}} + + + + + + + + {props.main} + + + +
+ {props.footer ?? ( + + )} +
+
+
+ ); +} + +export default Aside; diff --git a/frontend/src/components/HousingDetails/HousingDetailsCard.tsx b/frontend/src/components/HousingDetails/HousingDetailsCard.tsx index c9e93905e..436e52793 100644 --- a/frontend/src/components/HousingDetails/HousingDetailsCard.tsx +++ b/frontend/src/components/HousingDetails/HousingDetailsCard.tsx @@ -8,20 +8,17 @@ import classNames from 'classnames'; import { useState } from 'react'; import styles from './housing-details-card.module.scss'; -import { Housing, HousingUpdate } from '../../models/Housing'; +import { Housing } from '../../models/Housing'; import HousingDetailsSubCardBuilding from './HousingDetailsSubCardBuilding'; import HousingDetailsSubCardProperties from './HousingDetailsSubCardProperties'; import HousingDetailsSubCardLocation from './HousingDetailsSubCardLocation'; import EventsHistory from '../EventsHistory/EventsHistory'; import { Event } from '../../models/Event'; import HousingEditionSideMenu from '../HousingEdition/HousingEditionSideMenu'; -import { useFindNotesByHousingQuery } from '../../services/note.service'; -import { useFindEventsByHousingQuery } from '../../services/event.service'; import { Note } from '../../models/Note'; import HousingDetailsCardOccupancy from './HousingDetailsSubCardOccupancy'; import HousingDetailsCardMobilisation from './HousingDetailsSubCardMobilisation'; import { Campaign } from '../../models/Campaign'; -import { useUpdateHousingMutation } from '../../services/housing.service'; import AppLink from '../_app/AppLink/AppLink'; import { useUser } from '../../hooks/useUser'; @@ -39,32 +36,9 @@ function HousingDetailsCard({ housingCampaigns }: Props) { const { isVisitor } = useUser(); - const [updateHousing] = useUpdateHousingMutation(); - const [isHousingListEditionExpand, setIsHousingListEditionExpand] = useState(false); - const { refetch: refetchHousingEvents } = useFindEventsByHousingQuery( - housing.id - ); - const { refetch: refetchHousingNotes } = useFindNotesByHousingQuery( - housing.id - ); - - const submitHousingUpdate = async ( - housing: Housing, - housingUpdate: HousingUpdate - ) => { - await updateHousing({ - housing, - housingUpdate - }); - await refetchHousingEvents(); - await refetchHousingNotes(); - - setIsHousingListEditionExpand(false); - }; - return ( @@ -100,7 +74,6 @@ function HousingDetailsCard({ setIsHousingListEditionExpand(false)} /> @@ -111,21 +84,24 @@ function HousingDetailsCard({ <> - event.category === 'Followup' && - event.kind === 'Update' && - event.section === 'Situation' && - event.name === "Modification du statut d'occupation" && - event.old.occupancy !== event.new.occupancy - ) : - housingEvents.find( - (event) => - event.category === 'Group' && - event.kind === 'Create' && - event.section === 'Ajout d’un logement dans un groupe' && - event.name === "Ajout dans un groupe" - )} + lastOccupancyEvent={ + housing.source !== 'datafoncier-import' + ? housingEvents.find( + (event) => + event.category === 'Followup' && + event.kind === 'Update' && + event.section === 'Situation' && + event.name === "Modification du statut d'occupation" && + event.old.occupancy !== event.new.occupancy + ) + : housingEvents.find( + (event) => + event.category === 'Group' && + event.kind === 'Create' && + event.section === 'Ajout d’un logement dans un groupe' && + event.name === 'Ajout dans un groupe' + ) + } /> Occupation : - + {OccupancyKindLabels[getOccupancy(housing.occupancy)]} diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index fb1d1b295..0a6529b98 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -1,97 +1,360 @@ import Button from '@codegouvfr/react-dsfr/Button'; -import { useRef } from 'react'; +import Tabs, { TabsProps } from '@codegouvfr/react-dsfr/Tabs'; +import Tag from '@codegouvfr/react-dsfr/Tag'; +import { yupResolver } from '@hookform/resolvers/yup'; +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Unstable_Grid2'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { fromJS } from 'immutable'; +import { FormProvider, useController, useForm } from 'react-hook-form'; +import { ElementOf } from 'ts-essentials'; +import * as yup from 'yup'; -import { Container, Text } from '../_dsfr'; +import { + HOUSING_STATUS_VALUES, + HousingStatus, + Occupancy, + OCCUPANCY_VALUES, + PRECISION_MECHANISM_CATEGORY_VALUES, + PrecisionCategory +} from '@zerologementvacant/models'; import { Housing, HousingUpdate } from '../../models/Housing'; -import Aside from '../Aside/Aside'; -import HousingEditionForm from './HousingEditionForm'; -import styles from './housing-edition.module.scss'; import AppLink from '../_app/AppLink/AppLink'; -import Label from '../Label/Label'; -import Typography from '@mui/material/Typography'; +import AsideNext from '../Aside/AsideNext'; +import LabelNext from '../Label/LabelNext'; +import AppSelectNext from '../_app/AppSelect/AppSelectNext'; +import { + allOccupancyOptions, + statusOptions +} from '../../models/HousingFilters'; +import HousingStatusSelect from './HousingStatusSelect'; +import { getSubStatusOptions } from '../../models/HousingState'; +import AppTextInputNext from '../_app/AppTextInput/AppTextInputNext'; +import { useCreateNoteByHousingMutation } from '../../services/note.service'; +import { useUpdateHousingNextMutation } from '../../services/housing.service'; +import { useNotification } from '../../hooks/useNotification'; -interface Props { - housing?: Housing; +interface HousingEditionSideMenuProps { + housing: Housing | null; expand: boolean; - onSubmit: (housing: Housing, housingUpdate: HousingUpdate) => void; + onSubmit?: (housing: Housing, housingUpdate: HousingUpdate) => void; onClose: () => void; } -function HousingEditionSideMenu({ housing, expand, onSubmit, onClose }: Props) { - const statusFormRef = useRef<{ submit: () => void }>(); +const WIDTH = '700px'; + +const schema = yup.object({ + occupancy: yup + .string() + .required('Veuillez renseigner l’occupation actuelle') + .oneOf(OCCUPANCY_VALUES), + occupancyIntended: yup + .string() + .oneOf(OCCUPANCY_VALUES) + .nullable() + .optional() + .default(null), + status: yup + .number() + .required('Veuillez renseigner le statut de suivi') + .oneOf(HOUSING_STATUS_VALUES), + subStatus: yup.string().trim().nullable().optional().default(null), + note: yup.string() +}); + +type FormSchema = yup.InferType; + +function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { + const { housing, expand, onClose } = props; + const form = useForm({ + values: { + occupancy: props.housing?.occupancy ?? Occupancy.UNKNOWN, + occupancyIntended: props.housing?.occupancyIntended ?? Occupancy.UNKNOWN, + status: props.housing?.status ?? HousingStatus.NEVER_CONTACTED, + subStatus: props.housing?.subStatus ?? '', + note: '' + }, + mode: 'onSubmit', + resolver: yupResolver(schema) + }); + + const [createNote, noteCreationMutation] = useCreateNoteByHousingMutation(); + const [updateHousing, housingUpdateMutation] = useUpdateHousingNextMutation(); + + useNotification({ + toastId: 'note-creation', + isError: noteCreationMutation.isError, + isLoading: noteCreationMutation.isLoading, + isSuccess: noteCreationMutation.isSuccess, + message: { + error: 'Impossible de créer la note', + loading: 'Création de la note...', + success: 'Note créée !' + } + }); + useNotification({ + toastId: 'housing-update', + isError: housingUpdateMutation.isError, + isLoading: housingUpdateMutation.isLoading, + isSuccess: housingUpdateMutation.isSuccess, + message: { + error: 'Impossible de mettre à jour le logement', + loading: 'Mise à jour du logement...', + success: 'Logement mis à jour !' + } + }); - const submit = (housingUpdate: HousingUpdate) => { + function submit() { if (housing) { - onSubmit(housing, housingUpdate); + const { note, ...payload } = form.getValues(); + + const hasChanges = fromJS(form.formState.dirtyFields) + .filterNot((_, key) => key === 'note') + .some((value) => !!value); + if (hasChanges) { + updateHousing({ + ...housing, + // TODO: directly pass payload whenever + // Housing and HousingDTO are aligned + occupancy: payload.occupancy as Occupancy, + occupancyIntended: payload.occupancyIntended as Occupancy | null, + status: payload.status as HousingStatus, + subStatus: payload.subStatus + }); + } + + if (note) { + createNote({ + id: housing.id, + content: note + }); + } } - }; - return ( -
-
+ + Badges + + + + + + Évolutions du logement (1) + + + + + Travaux : en cours + + + + ), + label: 'Mobilisation' + }; + } + + function NoteTab(): ElementOf { + return { + content: ( + + ), + label: 'Note' + }; + } + + return ( + ({ + zIndex: theme.zIndex.appBar + 1, + '& .MuiDrawer-paper': { + px: '1.5rem', + py: '2rem', + width: WIDTH + } + }) + }} + header={ + + + {props.housing?.rawAddress.join(' - ')} + + + Identifiant fiscal national : {props.housing?.localId} + + + Voir la fiche logement dans un nouvel onglet + + + } + main={ + + + + } + open={expand} + onClose={onClose} + onSave={form.handleSubmit(submit)} + /> ); } diff --git a/frontend/src/components/HousingEdition/HousingStatusSelect.tsx b/frontend/src/components/HousingEdition/HousingStatusSelect.tsx index 06d1e3dea..614878ccc 100644 --- a/frontend/src/components/HousingEdition/HousingStatusSelect.tsx +++ b/frontend/src/components/HousingEdition/HousingStatusSelect.tsx @@ -13,6 +13,10 @@ interface Props { messageType?: string; message?: string; } + +/** + * @deprecated Will be replaced by a custom AppSelectNext + */ const HousingStatusSelect = ({ selected, options, diff --git a/frontend/src/components/HousingList/HousingList.tsx b/frontend/src/components/HousingList/HousingList.tsx index 716da7380..6b009e933 100644 --- a/frontend/src/components/HousingList/HousingList.tsx +++ b/frontend/src/components/HousingList/HousingList.tsx @@ -11,7 +11,6 @@ import { Housing, HousingSort, HousingSortable, - HousingUpdate, OccupancyKindLabels, SelectedHousing } from '../../models/Housing'; @@ -32,10 +31,7 @@ import { DefaultPagination } from '../../store/reducers/housingReducer'; import { Pagination } from '@zerologementvacant/models'; import HousingSubStatusBadge from '../HousingStatusBadge/HousingSubStatusBadge'; import HousingEditionSideMenu from '../HousingEdition/HousingEditionSideMenu'; -import { - useCountHousingQuery, - useUpdateHousingMutation -} from '../../services/housing.service'; +import { useCountHousingQuery } from '../../services/housing.service'; import { isDefined } from '../../utils/compareUtils'; import Badge from '@codegouvfr/react-dsfr/Badge'; import Button from '@codegouvfr/react-dsfr/Button'; @@ -59,14 +55,11 @@ const HousingList = ({ const header = findChild(children, SelectableListHeader); const campaignList = useCampaignList(); - const { isVisitor } = useUser(); - const [updateHousing] = useUpdateHousingMutation(); - const [pagination, setPagination] = useState(DefaultPagination); const [sort, setSort] = useState(); - const [updatingHousing, setUpdatingHousing] = useState(); + const [updatingHousing, setUpdatingHousing] = useState(null); const { housingList } = useHousingList({ filters, @@ -270,17 +263,6 @@ const HousingList = ({ columns = [selectColumn, ...columns, actionColumn]; } - const submitHousingUpdate = async ( - housing: Housing, - housingUpdate: HousingUpdate - ) => { - await updateHousing({ - housing, - housingUpdate - }); - setUpdatingHousing(undefined); - }; - return (
@@ -357,8 +339,9 @@ const HousingList = ({ setUpdatingHousing(undefined)} + onClose={() => { + setUpdatingHousing(null); + }} />
); diff --git a/frontend/src/components/_app/AppSelect/AppSelectNext.tsx b/frontend/src/components/_app/AppSelect/AppSelectNext.tsx new file mode 100644 index 000000000..0022ffdb8 --- /dev/null +++ b/frontend/src/components/_app/AppSelect/AppSelectNext.tsx @@ -0,0 +1,148 @@ +import { fr } from '@codegouvfr/react-dsfr'; +import Checkbox from '@codegouvfr/react-dsfr/Checkbox'; +import { + BaseSelectProps, + Box, + MenuItem, + Select as MuiSelect +} from '@mui/material'; +import { useId } from 'react'; +import { noop } from 'ts-essentials'; +import { match, Pattern } from 'ts-pattern'; +import { useController } from 'react-hook-form'; + +interface Option { + id?: string; + label: string; + value: Value; +} + +type AppSelectNextProps = Omit< + BaseSelectProps>, + 'multiple' | 'value' +> & { + disabled?: boolean; + name: string; + options: ReadonlyArray>; + // Keep this until upgrading to MUI v6 + multiple?: Multiple; + value?: SelectValue; +}; + +type SelectValue = Multiple extends true + ? ReadonlyArray + : Value | null; + +function AppSelectNext( + props: AppSelectNextProps +) { + const labelId = `fr-label-${useId()}`; + const selectId = `fr-select-${useId()}`; + + const multiple = props.multiple ?? false; + + // Form handling + const { field } = useController({ + name: props.name, + disabled: props.disabled + }); + + const isControlled = props.value !== undefined; + const value: SelectValue = isControlled + ? props.value + : field.value; + const onChange = isControlled ? props.onChange : field.onChange; + + return ( + + + { + return match(values) + .with(Pattern.string, (value) => { + return props.options.find((option) => option.value === value) + ?.label; + }) + .with(Pattern.array(Pattern.string), (values) => { + return match((values as string[]).length) + .with(1, () => '1 option sélectionnée') + .with( + Pattern.number.int().gte(2), + (nb) => `${nb} options sélectionnées` + ) + .otherwise(() => ''); + }) + .otherwise(() => ''); + }} + sx={{ width: '100%' }} + value={value ?? ''} + variant="standard" + onChange={onChange} + > + {props.options.map((option) => ( + + {!props.multiple ? ( + option.label + ) : ( + value === option.value + ), + value: 'vacant', + onClick: noop, + onChange: noop + } + } + ]} + orientation="vertical" + small + /> + )} + + ))} + + + ); +} + +export default AppSelectNext; diff --git a/frontend/src/mocks/handlers/housing-handlers.ts b/frontend/src/mocks/handlers/housing-handlers.ts index b8a0c80f4..dc06a1eca 100644 --- a/frontend/src/mocks/handlers/housing-handlers.ts +++ b/frontend/src/mocks/handlers/housing-handlers.ts @@ -7,6 +7,7 @@ import { HousingDTO, HousingFiltersDTO, HousingPayloadDTO, + HousingUpdatePayloadDTO, Paginated } from '@zerologementvacant/models'; import { @@ -134,6 +135,7 @@ export const housingHandlers: RequestHandler[] = [ } ), + // Get a housing by id http.get( `${config.apiEndpoint}/api/housing/:id`, ({ params }) => { @@ -183,6 +185,34 @@ export const housingHandlers: RequestHandler[] = [ ) }); } + ), + + // Update a housing + http.put( + `${config.apiEndpoint}/api/housing/:id`, + async ({ params, request }) => { + const payload = await request.json(); + const housing = data.housings.find((housing) => housing.id === params.id); + if (!housing) { + throw HttpResponse.json( + { + name: 'HousingMissingError', + message: `Housing ${params.id} missing` + }, + { status: constants.HTTP_STATUS_NOT_FOUND } + ); + } + + const updated: HousingDTO = { + ...housing, + ...payload + }; + data.housings = data.housings.map((housing) => { + return housing.id === updated.id ? updated : housing; + }); + + return HttpResponse.json(updated); + } ) ]; diff --git a/frontend/src/mocks/handlers/note-handlers.ts b/frontend/src/mocks/handlers/note-handlers.ts index a7e9e1b52..0c953cdeb 100644 --- a/frontend/src/mocks/handlers/note-handlers.ts +++ b/frontend/src/mocks/handlers/note-handlers.ts @@ -1,7 +1,8 @@ import { http, HttpResponse, RequestHandler } from 'msw'; import { constants } from 'node:http2'; -import { NoteDTO } from '@zerologementvacant/models'; +import { NoteDTO, NotePayloadDTO } from '@zerologementvacant/models'; +import { genNoteDTO, genUserDTO } from '@zerologementvacant/models/fixtures'; import config from '../../utils/config'; import data from './data'; @@ -11,7 +12,7 @@ interface PathParams { export const noteHandlers: RequestHandler[] = [ http.get( - `${config.apiEndpoint}/api/notes/housing/:id`, + `${config.apiEndpoint}/api/housing/:id/notes`, async ({ params }) => { const housing = data.housings.find((housing) => housing.id === params.id); if (!housing) { @@ -25,5 +26,32 @@ export const noteHandlers: RequestHandler[] = [ status: constants.HTTP_STATUS_OK }); } + ), + + // Create a note for a housing + http.post( + `${config.apiEndpoint}/api/housing/:id/notes`, + async ({ params, request }) => { + const housing = data.housings.find((housing) => housing.id === params.id); + if (!housing) { + throw HttpResponse.json(null, { + status: constants.HTTP_STATUS_NOT_FOUND + }); + } + + const payload = await request.json(); + const creator = genUserDTO(); + const note: NoteDTO = { + ...genNoteDTO(creator), + ...payload + }; + data.housingNotes.set( + housing.id, + (data.housingNotes.get(housing.id) ?? []).concat(note) + ); + return HttpResponse.json(note, { + status: constants.HTTP_STATUS_CREATED + }); + } ) ]; diff --git a/frontend/src/models/Housing.tsx b/frontend/src/models/Housing.tsx index 60fc5d542..7380fc7f7 100644 --- a/frontend/src/models/Housing.tsx +++ b/frontend/src/models/Housing.tsx @@ -21,7 +21,9 @@ import { Compare } from '../utils/compareUtils'; export interface Housing { id: string; + // Identifiant fiscal départemental invariant: string; + // Identifiant fiscal national localId: string; geoCode: string; cadastralReference: string; @@ -297,8 +299,8 @@ export function toHousingDTO(housing: Housing): HousingDTO { // TODO: fix this by making Housing extend HousingDTO ownershipKind: housing.ownershipKind, status: housing.status as unknown as HousingStatus, - subStatus: housing.subStatus, - precisions: housing.precisions, + subStatus: housing.subStatus ?? null, + precisions: housing.precisions ?? null, energyConsumption: housing.energyConsumption as unknown as EnergyConsumption, energyConsumptionAt: housing.energyConsumptionAt, diff --git a/frontend/src/services/housing.service.ts b/frontend/src/services/housing.service.ts index b24533e1e..f074e2511 100644 --- a/frontend/src/services/housing.service.ts +++ b/frontend/src/services/housing.service.ts @@ -6,7 +6,9 @@ import { parseISO } from 'date-fns'; import { SortOptions, toQuery } from '../models/Sort'; import { AbortOptions } from '../utils/fetchUtils'; import { + HousingDTO, HousingFiltersDTO, + HousingUpdatePayloadDTO, PaginationOptions } from '@zerologementvacant/models'; import { parseOwner } from './owner.service'; @@ -130,6 +132,22 @@ export const housingApi = zlvApi.injectEndpoints({ } ] }), + updateHousingNext: builder.mutation< + HousingDTO, + HousingUpdatePayloadDTO & Pick + >({ + query: ({ id, ...payload }) => ({ + url: `housing/${id}`, + method: 'PUT', + body: payload + }), + invalidatesTags: (result, error, payload) => [ + { type: 'Housing', id: payload.id }, + 'HousingByStatus', + 'HousingCountByStatus', + 'Event' + ] + }), updateHousingList: builder.mutation< number, { @@ -185,5 +203,6 @@ export const { useCountHousingQuery, useCreateHousingMutation, useUpdateHousingMutation, + useUpdateHousingNextMutation, useUpdateHousingListMutation } = housingApi; diff --git a/frontend/src/services/note.service.ts b/frontend/src/services/note.service.ts index eaa8ca674..57a6866f1 100644 --- a/frontend/src/services/note.service.ts +++ b/frontend/src/services/note.service.ts @@ -1,14 +1,30 @@ -import { Note } from '../models/Note'; -import { NoteDTO } from '@zerologementvacant/models'; import { parseISO } from 'date-fns'; + +import { + HousingDTO, + NoteDTO, + NotePayloadDTO +} from '@zerologementvacant/models'; +import { Note } from '../models/Note'; import { zlvApi } from './api.service'; export const noteApi = zlvApi.injectEndpoints({ endpoints: (builder) => ({ findNotesByHousing: builder.query({ - query: (housingId) => `notes/housing/${housingId}`, + query: (id) => `housing/${id}/notes`, providesTags: () => ['Note'], - transformResponse: (response: any[]) => response.map((_) => parseNote(_)) + transformResponse: (notes: ReadonlyArray) => notes.map(parseNote) + }), + createNoteByHousing: builder.mutation< + NoteDTO, + Pick & NotePayloadDTO + >({ + query: ({ id, ...payload }) => ({ + url: `housing/${id}/notes`, + method: 'POST', + body: payload + }), + invalidatesTags: () => ['Note'] }) }) }); @@ -18,4 +34,5 @@ const parseNote = (noteDTO: NoteDTO): Note => ({ createdAt: parseISO(noteDTO.createdAt) }); -export const { useFindNotesByHousingQuery } = noteApi; +export const { useFindNotesByHousingQuery, useCreateNoteByHousingMutation } = + noteApi; diff --git a/frontend/src/views/Housing/test/HousingView.test.tsx b/frontend/src/views/Housing/test/HousingView.test.tsx index 159bc0082..ebbff5f3d 100644 --- a/frontend/src/views/Housing/test/HousingView.test.tsx +++ b/frontend/src/views/Housing/test/HousingView.test.tsx @@ -87,7 +87,9 @@ describe('Housing view', () => { const vacancyStartYear = await screen .findByText(/^Dans cette situation depuis/) .then((label) => label.nextElementSibling); - expect(vacancyStartYear).toHaveTextContent(`1 an (${format(subYears(new Date(), 1), 'yyyy')})`); + expect(vacancyStartYear).toHaveTextContent( + `1 an (${format(subYears(new Date(), 1), 'yyyy')})` + ); }); }); @@ -227,4 +229,60 @@ describe('Housing view', () => { expect(link).toBeVisible(); }); }); + + describe('Update the housing', () => { + it('should update the occupancy', async () => { + renderView(housing); + + const [update] = await screen.findAllByRole('button', { + name: /Mettre à jour/ + }); + await user.click(update); + const occupancy = await screen.findByLabelText('Occupation actuelle'); + await user.click(occupancy); + const options = await screen.findByRole('listbox'); + const option = await within(options).findByRole('option', { + name: 'En location' + }); + await user.click(option); + const save = await screen.findByRole('button', { + name: 'Enregistrer' + }); + await user.click(save); + const newOccupancy = await screen.findByLabelText('Occupation'); + expect(newOccupancy).toHaveTextContent(/En location/i); + }); + + it('should create a note', async () => { + renderView(housing); + + const [update] = await screen.findAllByRole('button', { + name: /Mettre à jour/ + }); + await user.click(update); + const noteTab = await screen.findByRole('tab', { + name: 'Note' + }); + await user.click(noteTab); + const notePanel = await screen.findByRole('tabpanel', { + name: 'Note' + }); + const textbox = await within(notePanel).findByLabelText('Nouvelle note'); + await user.type(textbox, faker.lorem.paragraph()); + const save = await screen.findByRole('button', { + name: 'Enregistrer' + }); + await user.click(save); + const history = await screen.findByRole('tab', { + name: 'Historique de suivi' + }); + await user.click(history); + const panel = await screen.findByRole('tabpanel', { + name: 'Historique de suivi' + }); + screen.logTestingPlaygroundURL(); + const note = await within(panel).findByText('Note'); + expect(note).toBeVisible(); + }); + }); }); diff --git a/packages/models/package.json b/packages/models/package.json index 968afc3a0..1af1e6b65 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -23,7 +23,8 @@ "dependencies": { "@zerologementvacant/draft": "workspace:*", "@zerologementvacant/utils": "workspace:*", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "ts-essentials": "^10.0.2" }, "devDependencies": { "@faker-js/faker": "^8.4.1", diff --git a/packages/models/src/HousingDTO.ts b/packages/models/src/HousingDTO.ts index 19bedcdc7..58e93a8a5 100644 --- a/packages/models/src/HousingDTO.ts +++ b/packages/models/src/HousingDTO.ts @@ -1,8 +1,8 @@ -import { OwnerDTO } from './OwnerDTO'; -import { HousingStatus } from './HousingStatus'; import { EnergyConsumption } from './EnergyConsumption'; -import { Occupancy } from './Occupancy'; import { HousingKind } from './HousingKind'; +import { HousingStatus } from './HousingStatus'; +import { Occupancy } from './Occupancy'; +import { OwnerDTO } from './OwnerDTO'; // TODO: complete this type export interface HousingDTO { @@ -22,7 +22,7 @@ export interface HousingDTO { cadastralReference?: string; buildingYear?: number; taxed?: boolean; - vacancyReasons?: string[]; + vacancyReasons: string[] | null; /** * @deprecated See {@link dataFileYears} */ @@ -33,18 +33,28 @@ export interface HousingDTO { rentalValue?: number; ownershipKind?: string; status: HousingStatus; - subStatus?: string; - precisions?: string[]; + subStatus: string | null; + precisions: string[] | null; energyConsumption?: EnergyConsumption; energyConsumptionAt?: Date; occupancy: Occupancy; - occupancyIntended?: Occupancy; + occupancyIntended: Occupancy | null; source: HousingSource | null; owner: OwnerDTO; } export type HousingPayloadDTO = Pick; +export type HousingUpdatePayloadDTO = + // Required keys + Pick & { + // Optional, nullable keys + subStatus?: string | null; + precisions?: string[] | null; + vacancyReasons?: string[] | null; + occupancyIntended?: Occupancy | null; + }; + export interface HousingCountDTO { housing: number; owners: number; diff --git a/packages/models/src/NoteDTO.ts b/packages/models/src/NoteDTO.ts index 1387bd5a1..86da4456b 100644 --- a/packages/models/src/NoteDTO.ts +++ b/packages/models/src/NoteDTO.ts @@ -1,6 +1,12 @@ +import { UserDTO } from './UserDTO'; + export interface NoteDTO { + id: string; content: string; noteKind: string; createdBy: string; createdAt: string; + creator?: UserDTO; } + +export type NotePayloadDTO = Pick; diff --git a/packages/models/src/Precision.ts b/packages/models/src/Precision.ts index 96d153ad2..b445b3192 100644 --- a/packages/models/src/Precision.ts +++ b/packages/models/src/Precision.ts @@ -13,6 +13,22 @@ export const PRECISION_CATEGORY_VALUES = [ export type PrecisionCategory = (typeof PRECISION_CATEGORY_VALUES)[number]; +export const PRECISION_MECHANISM_CATEGORY_VALUES: ReadonlyArray = + [ + 'dispositifs-incitatifs', + 'dispositifs-coercitifs', + 'hors-dispositif-public' + ]; +export const PRECISION_BLOCKING_POINT_CATEGORY_VALUES: ReadonlyArray = + [ + 'blocage-involontaire', + 'blocage-volontaire', + 'immeuble-environnement', + 'tiers-en-cause' + ]; +export const PRECISION_EVOLUTION_CATEGORY_VALUES: ReadonlyArray = + ['travaux', 'occupation', 'mutation']; + export interface Precision { id: string; category: PrecisionCategory; diff --git a/packages/models/src/test/fixtures.ts b/packages/models/src/test/fixtures.ts index 005558762..e42f90345 100644 --- a/packages/models/src/test/fixtures.ts +++ b/packages/models/src/test/fixtures.ts @@ -23,6 +23,7 @@ import { EstablishmentDTO } from '../EstablishmentDTO'; import { ESTABLISHMENT_KIND_VALUES } from '../EstablishmentKind'; import { ESTABLISHMENT_SOURCE_VALUES } from '../EstablishmentSource'; import { OWNER_KIND_LABELS } from '../OwnerKind'; +import { NoteDTO } from '../NoteDTO'; export function genGeoCode(): string { const geoCode = faker.helpers.arrayElement([ @@ -296,9 +297,13 @@ export function genHousingDTO(owner: OwnerDTO): HousingDTO { .streetAddress({ useFullAddress: true }) .split(' '), occupancy: faker.helpers.arrayElement(OCCUPANCY_VALUES), + occupancyIntended: faker.helpers.arrayElement(OCCUPANCY_VALUES), housingKind: faker.helpers.arrayElement(HOUSING_KIND_VALUES), status: faker.helpers.arrayElement(HOUSING_STATUS_VALUES), - owner + subStatus: null, + owner, + precisions: null, + vacancyReasons: null }; } @@ -320,6 +325,17 @@ export function genLocalId(department: string, invariant: string): string { return department + invariant; } +export function genNoteDTO(creator: UserDTO): NoteDTO { + return { + id: faker.string.uuid(), + content: faker.lorem.paragraph(), + noteKind: 'Note courante', + createdBy: creator.id, + createdAt: new Date().toJSON(), + creator + }; +} + export function genOwnerDTO(): OwnerDTO { const id = faker.string.uuid(); const address = genAddressDTO(id, AddressKinds.Owner); diff --git a/packages/schemas/src/housing-update-payload.ts b/packages/schemas/src/housing-update-payload.ts new file mode 100644 index 000000000..7d872061b --- /dev/null +++ b/packages/schemas/src/housing-update-payload.ts @@ -0,0 +1,41 @@ +import { array, number, object, ObjectSchema, string } from 'yup'; + +import { + HOUSING_STATUS_VALUES, + HousingUpdatePayloadDTO, + OCCUPANCY_VALUES +} from '@zerologementvacant/models'; + +export const housingUpdatePayload: ObjectSchema = + object({ + // Required keys + status: number() + .required('Veuillez renseigner le statut de suivi') + .oneOf(HOUSING_STATUS_VALUES), + occupancy: string() + .required('Veuillez renseigner l’occupation actuelle') + .oneOf(OCCUPANCY_VALUES), + // Optional, nullable keys + subStatus: string().trim().min(1).nullable().optional().default(null), + precisions: array() + .of(string().trim().required()) + .nullable() + .optional() + .default(null) + .transform((value) => + Array.isArray(value) && value.length === 0 ? null : value + ), + vacancyReasons: array() + .of(string().trim().required()) + .nullable() + .optional() + .default(null) + .transform((value) => + Array.isArray(value) && value.length === 0 ? null : value + ), + occupancyIntended: string() + .oneOf(OCCUPANCY_VALUES) + .nullable() + .optional() + .default(null) + }); diff --git a/packages/schemas/src/id.ts b/packages/schemas/src/id.ts new file mode 100644 index 000000000..e8f95caca --- /dev/null +++ b/packages/schemas/src/id.ts @@ -0,0 +1,3 @@ +import { string } from 'yup'; + +export const id = string().uuid().required(); diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 2d72e8de8..e48f96e45 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -4,6 +4,9 @@ import { email } from './email'; import { establishmentFilters } from './establishment-filters'; import { geoCode } from './geo-code'; import { housingFilters } from './housing-filters'; +import { housingUpdatePayload } from './housing-update-payload'; +import { id } from './id'; +import { notePayload } from './note-payload'; import { password, passwordConfirmation } from './password'; import { siren } from './siren'; @@ -17,6 +20,9 @@ export const schemas = { establishmentFilters, geoCode, housingFilters, + housingUpdatePayload, + id, + notePayload, password, passwordConfirmation, siren diff --git a/packages/schemas/src/note-payload.ts b/packages/schemas/src/note-payload.ts new file mode 100644 index 000000000..d6d48d364 --- /dev/null +++ b/packages/schemas/src/note-payload.ts @@ -0,0 +1,7 @@ +import { object, ObjectSchema, string } from 'yup'; + +import { NotePayloadDTO } from '@zerologementvacant/models'; + +export const notePayload: ObjectSchema = object({ + content: string().required('Veuillez renseigner le contenu de la note') +}); diff --git a/packages/schemas/src/test/housing-update-payload.test.ts b/packages/schemas/src/test/housing-update-payload.test.ts new file mode 100644 index 000000000..f07561426 --- /dev/null +++ b/packages/schemas/src/test/housing-update-payload.test.ts @@ -0,0 +1,39 @@ +import { fc, test } from '@fast-check/jest'; + +import { + HOUSING_STATUS_VALUES, + HousingUpdatePayloadDTO, + OCCUPANCY_VALUES +} from '@zerologementvacant/models'; +import { housingUpdatePayload } from '../housing-update-payload'; + +describe('Housing update payload', () => { + test.prop({ + status: fc.constantFrom(...HOUSING_STATUS_VALUES), + occupancy: fc.constantFrom(...OCCUPANCY_VALUES), + subStatus: fc.oneof( + fc.stringMatching(/\S/), + fc.constant(null), + fc.constant(undefined) + ), + precisions: fc.oneof( + fc.array(fc.stringMatching(/\S/), { minLength: 1 }), + fc.constant(null), + fc.constant(undefined) + ), + vacancyReasons: fc.oneof( + fc.array(fc.stringMatching(/\S/), { minLength: 1 }), + fc.constant(null), + fc.constant(undefined) + ), + occupancyIntended: fc.oneof( + fc.constantFrom(...OCCUPANCY_VALUES), + fc.constant(null), + fc.constant(undefined) + ) + })('shoud validate inputs', (payload) => { + const validate = () => housingUpdatePayload.validateSync(payload); + + expect(validate).not.toThrow(); + }); +}); diff --git a/packages/schemas/src/test/id.test.ts b/packages/schemas/src/test/id.test.ts new file mode 100644 index 000000000..f09108026 --- /dev/null +++ b/packages/schemas/src/test/id.test.ts @@ -0,0 +1,13 @@ +import { fc, test } from '@fast-check/jest'; +import { id } from '../id'; + +describe('id', () => { + test.prop<[string]>([fc.uuid({ version: 4 })])( + 'should validate inputs', + (input) => { + const validate = () => id.validateSync(input); + + expect(validate).not.toThrow(); + } + ); +}); diff --git a/packages/schemas/src/test/note-payload.test.ts b/packages/schemas/src/test/note-payload.test.ts new file mode 100644 index 000000000..8a89cda68 --- /dev/null +++ b/packages/schemas/src/test/note-payload.test.ts @@ -0,0 +1,14 @@ +import { fc, test } from '@fast-check/jest'; + +import { NotePayloadDTO } from '@zerologementvacant/models'; +import { notePayload } from '../note-payload'; + +describe('Note payload', () => { + test.prop({ + content: fc.string({ minLength: 1 }) + })(`should validate inputs`, (payload) => { + const validate = () => notePayload.validateSync(payload); + + expect(validate).not.toThrow(); + }); +}); diff --git a/server/src/controllers/housingController.test.ts b/server/src/controllers/housingController.test.ts index 9f5a04df8..d1984d41a 100644 --- a/server/src/controllers/housingController.test.ts +++ b/server/src/controllers/housingController.test.ts @@ -8,6 +8,7 @@ import { tokenProvider } from '~/test/testUtils'; import { formatHousingRecordApi, Housing, + HousingRecordDBO, housingTable } from '~/repositories/housingRepository'; import { @@ -26,8 +27,9 @@ import { Owners, ownerTable } from '~/repositories/ownerRepository'; -import { HousingStatusApi } from '~/models/HousingStatusApi'; +import { HousingStatusApi, toHousingStatus } from '~/models/HousingStatusApi'; import { + EventRecordDBO, Events, eventsTable, HousingEvents, @@ -59,14 +61,25 @@ import { } from '~/repositories/campaignHousingRepository'; import { faker } from '@faker-js/faker/locale/fr'; import { OwnerApi } from '~/models/OwnerApi'; -import { Occupancy, OCCUPANCY_VALUES } from '@zerologementvacant/models'; +import { + HOUSING_STATUS_VALUES, + HousingDTO, + HousingUpdatePayloadDTO, + Occupancy, + OCCUPANCY_VALUES +} from '@zerologementvacant/models'; import { EstablishmentApi } from '~/models/EstablishmentApi'; +import { UserApi, UserRoles } from '~/models/UserApi'; describe('Housing API', () => { const { app } = createServer(); const establishment = genEstablishmentApi(); const user = genUserApi(establishment.id); + const visitor: UserApi = { + ...genUserApi(establishment.id), + role: UserRoles.Visitor + }; const anotherEstablishment = genEstablishmentApi(); const anotherUser = genUserApi(anotherEstablishment.id); @@ -74,7 +87,7 @@ describe('Housing API', () => { await Establishments().insert( [establishment, anotherEstablishment].map(formatEstablishmentApi) ); - await Users().insert([user, anotherUser].map(formatUserApi)); + await Users().insert([user, visitor, anotherUser].map(formatUserApi)); }); describe('GET /housing/{id}', () => { @@ -529,6 +542,165 @@ describe('Housing API', () => { // All the others tests are covered by updateHousingList one's }); + describe('PUT /housing/{id}', () => { + const testRoute = (id: string) => `/api/housing/${id}`; + + let housing: HousingApi; + let owner: OwnerApi; + let payload: HousingUpdatePayloadDTO; + + beforeEach(async () => { + housing = genHousingApi(oneOf(establishment.geoCodes)); + owner = genOwnerApi(); + payload = { + status: faker.helpers.arrayElement( + HOUSING_STATUS_VALUES.filter( + (status) => status !== toHousingStatus(housing.status) + ) + ), + subStatus: null, + precisions: null, + vacancyReasons: null, + occupancy: faker.helpers.arrayElement( + OCCUPANCY_VALUES.filter( + (occupancy) => occupancy !== housing.occupancy + ) + ), + occupancyIntended: null + }; + + await Housing().insert(formatHousingRecordApi(housing)); + await Owners().insert(formatOwnerApi(owner)); + await HousingOwners().insert(formatHousingOwnersApi(housing, [owner])); + }); + + it('should throw if the housing was not found', async () => { + const { status } = await request(app) + .put(testRoute(faker.string.uuid())) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_NOT_FOUND); + }); + + it('should throw if the user is a visitor', async () => { + const { status } = await request(app) + .put(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(visitor)); + + expect(status).toBe(constants.HTTP_STATUS_UNAUTHORIZED); + }); + + it('should return the housing', async () => { + const { body, status } = await request(app) + .put(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_OK); + expect(body).toMatchObject>({ + id: housing.id, + status: payload.status, + subStatus: null, + precisions: payload.precisions, + vacancyReasons: payload.vacancyReasons, + occupancy: payload.occupancy, + occupancyIntended: payload.occupancyIntended + }); + }); + + it('should update the housing', async () => { + const { status } = await request(app) + .put(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_OK); + const actual = await Housing().where('id', housing.id).first(); + expect(actual).toMatchObject>({ + id: housing.id, + status: payload.status as unknown as HousingStatusApi.Blocked, + sub_status: null, + precisions: payload.precisions, + vacancy_reasons: payload.vacancyReasons, + occupancy: payload.occupancy, + occupancy_intended: payload.occupancyIntended + }); + }); + + it('should not create events if there is no change', async () => { + const payload: HousingUpdatePayloadDTO = { + status: toHousingStatus(housing.status), + subStatus: housing.subStatus, + occupancy: housing.occupancy, + occupancyIntended: housing.occupancyIntended, + precisions: housing.precisions, + vacancyReasons: housing.vacancyReasons + }; + + const { status } = await request(app) + .put(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_OK); + const events = await HousingEvents().where({ + housing_geo_code: housing.geoCode, + housing_id: housing.id + }); + expect(events).toHaveLength(0); + }); + + it('should create an event related to the status change', async () => { + const { status } = await request(app) + .put(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_OK); + const event = await Events() + .join(housingEventsTable, 'event_id', 'id') + .where({ + housing_id: housing.id, + housing_geo_code: housing.geoCode, + name: 'Changement de statut de suivi' + }) + .first(); + expect(event).toMatchObject>>({ + name: 'Changement de statut de suivi', + created_by: user.id + }); + }); + + it('should create an event related to the occupancy change', async () => { + const { status } = await request(app) + .put(testRoute(housing.id)) + .send(payload) + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_OK); + const event = await Events() + .join(housingEventsTable, 'event_id', 'id') + .where({ + housing_geo_code: housing.geoCode, + housing_id: housing.id, + name: "Modification du statut d'occupation" + }) + .first(); + expect(event).toMatchObject>>({ + name: "Modification du statut d'occupation", + created_by: user.id + }); + }); + }); + describe('POST /housing/list', () => { const testRoute = '/api/housing/list'; diff --git a/server/src/controllers/housingController.ts b/server/src/controllers/housingController.ts index ef145a997..1926b38a1 100644 --- a/server/src/controllers/housingController.ts +++ b/server/src/controllers/housingController.ts @@ -12,7 +12,8 @@ import { HousingApi, HousingRecordApi, HousingSortableApi, - OccupancyKindApi + OccupancyKindApi, + toHousingDTO } from '~/models/HousingApi'; import housingFiltersApi, { HousingFiltersApi @@ -20,7 +21,7 @@ import housingFiltersApi, { import { UserRoles } from '~/models/UserApi'; import eventRepository from '~/repositories/eventRepository'; import { AuthenticatedRequest } from 'express-jwt'; -import { HousingStatusApi } from '~/models/HousingStatusApi'; +import { fromHousingStatus, HousingStatusApi } from '~/models/HousingStatusApi'; import sortApi from '~/models/SortApi'; import { HousingPaginatedResultApi } from '~/models/PaginatedResultApi'; import { isArrayOf, isUUID } from '~/utils/validators'; @@ -28,7 +29,12 @@ import HousingMissingError from '~/errors/housingMissingError'; import noteRepository from '~/repositories/noteRepository'; import { NoteApi } from '~/models/NoteApi'; import { logger } from '~/infra/logger'; -import { HousingFiltersDTO, Pagination } from '@zerologementvacant/models'; +import { + HousingDTO, + HousingFiltersDTO, + HousingUpdatePayloadDTO, + Pagination +} from '@zerologementvacant/models'; import { toHousingRecordApi, toOwnerApi } from '~/scripts/shared'; import HousingExistsError from '~/errors/housingExistsError'; import ownerRepository from '~/repositories/ownerRepository'; @@ -40,6 +46,11 @@ import { HousingEventApi } from '~/models/EventApi'; import createDatafoncierHousingRepository from '~/repositories/datafoncierHousingRepository'; import createDatafoncierOwnersRepository from '~/repositories/datafoncierOwnersRepository'; import fp from 'lodash/fp'; +import { startTransaction } from '~/infra/database/transaction'; + +interface HousingPathParams extends Record { + id: string; +} const getValidators = oneOf([ param('id').isString().isLength({ min: 12, max: 12 }), // localId @@ -165,14 +176,12 @@ async function count(request: Request, response: Response): Promise { multiOwners: query?.multiOwners?.map((value: boolean) => value ? 'true' : 'false' ), - roomsCounts: query?.roomsCounts?.map((value: string) => - value.toString() - ), + roomsCounts: query?.roomsCounts?.map((value: string) => value.toString()), isTaxedValues: query?.isTaxedValues?.map((value: boolean) => value ? 'true' : 'false' ), energyConsumption: - query?.energyConsumption as unknown as EnergyConsumptionGradesApi[], + query?.energyConsumption as unknown as EnergyConsumptionGradesApi[] }; const count = await housingRepository.count({ @@ -309,6 +318,57 @@ async function update(request: Request, response: Response) { response.status(constants.HTTP_STATUS_OK).json(updatedHousing); } +async function updateNext( + request: Request, + response: Response +): Promise { + const { auth, body, establishment, params } = request as AuthenticatedRequest< + HousingPathParams, + HousingDTO, + HousingUpdatePayloadDTO + >; + + const housing = await housingRepository.findOne({ + id: params.id, + geoCode: establishment.geoCodes, + includes: ['owner'] + }); + if (!housing) { + throw new HousingMissingError(params.id); + } + + const updated: HousingApi = { + ...housing, + status: fromHousingStatus(body.status), + subStatus: body.subStatus, + precisions: body.precisions ?? undefined, + vacancyReasons: body.vacancyReasons ?? undefined, + occupancy: body.occupancy, + occupancyIntended: body.occupancyIntended ?? undefined + }; + await startTransaction(async () => { + await housingRepository.update(updated); + await createHousingUpdateEvents( + housing, + { + statusUpdate: { + status: fromHousingStatus(body.status), + subStatus: body.subStatus, + precisions: body.precisions, + vacancyReasons: body.vacancyReasons + }, + occupancyUpdate: { + occupancy: body.occupancy, + occupancyIntended: body.occupancyIntended + } + }, + auth.userId + ); + }); + + response.status(constants.HTTP_STATUS_OK).json(toHousingDTO(updated)); +} + const updateHousing = async ( housingId: string, housingUpdate: HousingUpdateBody, @@ -433,8 +493,14 @@ async function createHousingUpdateEvents( statusUpdate && (housingApi.status !== statusUpdate.status || housingApi.subStatus !== statusUpdate.subStatus || - !_.isEqual(housingApi.precisions, statusUpdate.precisions) || - !_.isEqual(housingApi.vacancyReasons, statusUpdate.vacancyReasons)) + !_.isEqual( + housingApi.precisions ?? null, + statusUpdate.precisions ?? null + ) || + !_.isEqual( + housingApi.vacancyReasons ?? null, + statusUpdate.vacancyReasons ?? null + )) ) { await eventRepository.insertHousingEvent({ id: uuidv4(), @@ -486,7 +552,7 @@ async function createHousingUpdateNote( geoCode: string ) { if (housingUpdate.note) { - await noteRepository.insertHousingNote({ + await noteRepository.createByHousing({ id: uuidv4(), ...housingUpdate.note, createdBy: userId, @@ -506,6 +572,7 @@ const housingController = { create, updateValidators, update, + updateNext, updateListValidators, updateList }; diff --git a/server/src/controllers/housingExportController.ts b/server/src/controllers/housingExportController.ts index a8bee434f..bf808b078 100644 --- a/server/src/controllers/housingExportController.ts +++ b/server/src/controllers/housingExportController.ts @@ -214,8 +214,10 @@ function writeHousingWorksheet( vacancyStartYear: housing.vacancyStartYear, status: getHousingStatusApiLabel(housing.status), subStatus: housing.subStatus, - vacancyReasons: reduceStringArray(housing.vacancyReasons), - precisions: reduceStringArray(housing.precisions), + vacancyReasons: reduceStringArray( + housing.vacancyReasons ?? undefined + ), + precisions: reduceStringArray(housing.precisions ?? undefined), campaigns: reduceStringArray( housing.campaignIds?.map( (campaignId) => diff --git a/server/src/controllers/noteController.test.ts b/server/src/controllers/noteController.test.ts index b4c4eb7a1..2692fd80f 100644 --- a/server/src/controllers/noteController.test.ts +++ b/server/src/controllers/noteController.test.ts @@ -1,6 +1,9 @@ +import { faker } from '@faker-js/faker/locale/fr'; +import { fc, test } from '@fast-check/jest'; import { constants } from 'http2'; import request from 'supertest'; +import { NoteDTO, NotePayloadDTO } from '@zerologementvacant/models'; import { createServer } from '~/infra/server'; import { tokenProvider } from '~/test/testUtils'; import { @@ -8,40 +11,45 @@ import { genHousingApi, genHousingNoteApi, genUserApi, - oneOf, + oneOf } from '~/test/testFixtures'; import { Establishments, - formatEstablishmentApi, + formatEstablishmentApi } from '~/repositories/establishmentRepository'; import { formatUserApi, Users } from '~/repositories/userRepository'; import { formatHousingRecordApi, - Housing, + Housing } from '~/repositories/housingRepository'; import { formatHousingNoteApi, formatNoteApi, HousingNotes, - Notes, + Notes } from '~/repositories/noteRepository'; import { NoteApi } from '~/models/NoteApi'; +import { UserApi, UserRoles } from '~/models/UserApi'; describe('Note API', () => { const { app } = createServer(); const establishment = genEstablishmentApi(); const user = genUserApi(establishment.id); + const visitor: UserApi = { + ...genUserApi(establishment.id), + role: UserRoles.Visitor + }; const housing = genHousingApi(oneOf(establishment.geoCodes)); beforeAll(async () => { await Establishments().insert(formatEstablishmentApi(establishment)); - await Users().insert(formatUserApi(user)); + await Users().insert([user, visitor].map(formatUserApi)); await Housing().insert(formatHousingRecordApi(housing)); }); describe('listByHousingId', () => { - const testRoute = (housingId: string) => `/api/notes/housing/${housingId}`; + const testRoute = (housingId: string) => `/api/housing/${housingId}/notes`; it('should be forbidden for a non-authenticated user', async () => { const { status } = await request(app).get(testRoute(housing.id)); @@ -59,7 +67,7 @@ describe('Note API', () => { it('should list the housing notes', async () => { const notes = Array.from({ length: 3 }, () => - genHousingNoteApi(user, housing), + genHousingNoteApi(user, housing) ); await Notes().insert(notes.map(formatNoteApi)); await HousingNotes().insert(notes.map(formatHousingNoteApi)); @@ -74,4 +82,79 @@ describe('Note API', () => { }); }); }); + + describe('createByHousing', () => { + const testRoute = (id: string) => `/api/housing/${id}/notes`; + + it('should be forbidden for a non-authenticated user', async () => { + const { status } = await request(app).post(testRoute(housing.id)); + + expect(status).toBe(constants.HTTP_STATUS_UNAUTHORIZED); + }); + + it('should be forbidden for a visitor', async () => { + const { status } = await request(app) + .post(testRoute(housing.id)) + .use(tokenProvider(visitor)); + + expect(status).toBe(constants.HTTP_STATUS_UNAUTHORIZED); + }); + + test.prop({ + content: fc.string({ minLength: 1 }) + })('should validate inputs', async (payload) => { + const { status } = await request(app) + .post(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_CREATED); + }); + + it('should fail if the housing was not found', async () => { + const payload: NotePayloadDTO = { + content: 'Nouvelle note' + }; + + const { status } = await request(app) + .post(testRoute(faker.string.uuid())) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_NOT_FOUND); + }); + + it('should create the note', async () => { + const payload: NotePayloadDTO = { + content: 'This is a test note' + }; + + const { body, status } = await request(app) + .post(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_CREATED); + expect(body).toStrictEqual({ + id: expect.any(String), + content: payload.content, + noteKind: 'Note courante', + createdBy: user.id, + createdAt: expect.any(String) + }); + const actualNote = await Notes().where({ id: body.id }).first(); + expect(actualNote).toBeDefined(); + const actualHousingNote = await HousingNotes() + .where({ + note_id: body.id, + housing_id: housing.id, + housing_geo_code: housing.geoCode + }) + .first(); + expect(actualHousingNote).toBeDefined(); + }); + }); }); diff --git a/server/src/controllers/noteController.ts b/server/src/controllers/noteController.ts index 6ff5eb733..2a262f0cb 100644 --- a/server/src/controllers/noteController.ts +++ b/server/src/controllers/noteController.ts @@ -1,30 +1,68 @@ import { Request, Response } from 'express'; +import { AuthenticatedRequest } from 'express-jwt'; import { constants } from 'http2'; +import { v4 as uuidv4 } from 'uuid'; +import { NoteDTO, NotePayloadDTO } from '@zerologementvacant/models'; import noteRepository from '~/repositories/noteRepository'; -import { logger } from '~/infra/logger'; +import { createLogger } from '~/infra/logger'; +import { HousingNoteApi, toNoteDTO } from '~/models/NoteApi'; +import housingRepository from '~/repositories/housingRepository'; +import HousingMissingError from '~/errors/housingMissingError'; -async function listByOwnerId(request: Request, response: Response) { - const ownerId = request.params.ownerId; +const logger = createLogger('noteController'); - logger.info('List notes for owner', ownerId); +interface PathParams extends Record { + id: string; +} + +async function findByHousing( + request: Request, + response: Response +) { + const { params } = request; + logger.debug('Find notes by housing', { housing: params.id }); - const notes = await noteRepository.findOwnerNotes(ownerId); - response.status(constants.HTTP_STATUS_OK).json(notes); + const notes = await noteRepository.findHousingNotes(params.id); + response.status(constants.HTTP_STATUS_OK).json(notes.map(toNoteDTO)); } -async function listByHousingId(request: Request, response: Response) { - const housingId = request.params.housingId; +async function createByHousing( + request: Request, + response: Response +) { + const { auth, body, establishment, params } = request as AuthenticatedRequest< + PathParams, + NoteDTO, + NotePayloadDTO, + never + >; + logger.debug('Create a note by housing', { housing: params.id, note: body }); - logger.info('List notes for housing', housingId); + const housing = await housingRepository.findOne({ + geoCode: establishment.geoCodes, + id: params.id + }); + if (!housing) { + throw new HousingMissingError(params.id); + } - const notes = await noteRepository.findHousingNotes(housingId); - response.status(constants.HTTP_STATUS_OK).json(notes); + const note: HousingNoteApi = { + id: uuidv4(), + content: body.content, + noteKind: 'Note courante', + createdBy: auth.userId, + createdAt: new Date(), + housingId: housing.id, + housingGeoCode: housing.geoCode + }; + await noteRepository.createByHousing(note); + response.status(constants.HTTP_STATUS_CREATED).json(toNoteDTO(note)); } const noteController = { - listByOwnerId, - listByHousingId, + createByHousing, + findByHousing }; export default noteController; diff --git a/server/src/infra/database/test/transaction.test.ts b/server/src/infra/database/test/transaction.test.ts new file mode 100644 index 000000000..43eb9c0fc --- /dev/null +++ b/server/src/infra/database/test/transaction.test.ts @@ -0,0 +1,91 @@ +import { faker } from '@faker-js/faker/locale/fr'; +import async from 'async'; + +import db from '~/infra/database/index'; +import { getTransaction, startTransaction } from '~/infra/database/transaction'; + +describe('Transaction', () => { + const tables = ['t1', 't2']; + const original = { + id: faker.string.uuid() + }; + + beforeAll(async () => { + await async.forEach(tables, async (name) => { + if (await db.schema.hasTable(name)) { + await db.schema.dropTable(name); + } + + await db.schema.createTable(name, (table) => { + table.uuid('id').primary(); + }); + + await db(name).insert(original); + }); + }); + + afterAll(async () => { + await async.forEach(tables, async (name) => { + await db.schema.dropTable(name); + }); + }); + + it('should correctly update a record', async () => { + const updated = { id: faker.string.uuid() }; + + await startTransaction(async () => { + const transaction = getTransaction(); + if (!transaction) { + throw new Error('Transaction should be defined'); + } + + await async.forEach(tables, async (name) => { + await transaction(name).update(updated); + }); + }); + + await async.forEach(tables, async (name) => { + const actual = await db(name).first(); + expect(actual).toStrictEqual(updated); + }); + }); + + it('should roll back if one of the queries fails', async () => { + try { + await startTransaction(async () => { + const transaction = getTransaction(); + if (!transaction) { + throw new Error('Transaction should be defined'); + } + + await transaction('t1').update({ id: faker.string.uuid() }); + await transaction('t2').update({ id: 'should-fail' }); + }); + } catch { + await async.forEach(tables, async (name) => { + const actual = await db(name).first(); + expect(actual).toStrictEqual(original); + }); + } + }); + + it('should handle multiple transactions', async () => { + const updateds = Array.from({ length: 3 }, () => ({ + id: faker.string.uuid() + })); + + await async.forEach(updateds, async (updated) => { + await startTransaction(async () => { + const transaction = getTransaction(); + if (!transaction) { + throw new Error('Transaction should be defined'); + } + + await transaction('t1').update(updated); + }); + }); + + const actual = await db('t1').first(); + expect(updateds).toContainEqual(actual); + }); +}); diff --git a/server/src/infra/database/transaction.ts b/server/src/infra/database/transaction.ts new file mode 100644 index 000000000..2e078c9af --- /dev/null +++ b/server/src/infra/database/transaction.ts @@ -0,0 +1,31 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import { Knex } from 'knex'; +import { AsyncOrSync } from 'ts-essentials'; + +import db from '~/infra/database/index'; + +interface TransactionStore { + transaction: Knex.Transaction; +} + +const storage: AsyncLocalStorage = + new AsyncLocalStorage(); + +export async function startTransaction( + cb: () => AsyncOrSync, + options?: Knex.TransactionConfig +) { + const transaction = await db.transaction(options); + try { + await storage.run({ transaction }, cb); + await transaction.commit(); + } catch { + await transaction.rollback(); + } +} + +/** + * Get the active transaction, if any. + * {@link startTransaction} must be called before calling this function. + */ +export const getTransaction = () => storage.getStore()?.transaction; diff --git a/server/src/middlewares/auth.ts b/server/src/middlewares/auth.ts index e8cc97c37..fe26c5c69 100644 --- a/server/src/middlewares/auth.ts +++ b/server/src/middlewares/auth.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from 'express'; -import { expressjwt } from 'express-jwt'; +import { AuthenticatedRequest, expressjwt } from 'express-jwt'; import memoize from 'memoizee'; import config from '~/infra/config'; @@ -8,6 +8,7 @@ import UserMissingError from '~/errors/userMissingError'; import AuthenticationMissingError from '~/errors/authenticationMissingError'; import EstablishmentMissingError from '~/errors/establishmentMissingError'; import establishmentRepository from '~/repositories/establishmentRepository'; +import { UserRoles } from '~/models/UserApi'; export const jwtCheck = (credentialsRequired: boolean) => expressjwt({ @@ -16,23 +17,23 @@ export const jwtCheck = (credentialsRequired: boolean) => credentialsRequired, getToken: (request: Request) => (request.headers['x-access-token'] ?? - request.query['x-access-token']) as string, + request.query['x-access-token']) as string }); export const userCheck = () => { const getUser = memoize(userRepository.get, { promise: true, - primitive: true, + primitive: true }); const getEstablishment = memoize(establishmentRepository.get, { promise: true, - primitive: true, + primitive: true }); return async function ( request: Request, response: Response, - next: NextFunction, + next: NextFunction ) { if (!request.auth || !request.auth.userId) { throw new AuthenticationMissingError(); @@ -40,7 +41,7 @@ export const userCheck = () => { const [user, establishment] = await Promise.all([ getUser(request.auth.userId), - getEstablishment(request.auth.establishmentId), + getEstablishment(request.auth.establishmentId) ]); if (!user) { // Should never happen @@ -56,3 +57,13 @@ export const userCheck = () => { next(); }; }; + +export function hasRole(roles: UserRoles[]) { + return (request: Request, response: Response, next: NextFunction) => { + const { user } = request as AuthenticatedRequest; + if (!roles.includes(user.role)) { + throw new AuthenticationMissingError(); + } + next(); + }; +} diff --git a/server/src/middlewares/test/auth.test.ts b/server/src/middlewares/test/auth.test.ts new file mode 100644 index 000000000..5de7b0b95 --- /dev/null +++ b/server/src/middlewares/test/auth.test.ts @@ -0,0 +1,76 @@ +import express, { NextFunction, Request, Response } from 'express'; +import { constants } from 'http2'; +import request from 'supertest'; + +import { genEstablishmentApi, genUserApi } from '~/test/testFixtures'; +import { UserApi, UserRoles } from '~/models/UserApi'; +import { hasRole } from '~/middlewares/auth'; + +describe('Auth', () => { + describe('hasRoles', () => { + const establishment = genEstablishmentApi(); + + function createUser(role: UserRoles): UserApi { + return { ...genUserApi(establishment.id), role }; + } + + function setUser(user: UserApi) { + return (request: Request, _: Response, next: NextFunction) => { + request.user = user; + next(); + }; + } + + const visitor = createUser(UserRoles.Visitor); + const admin = createUser(UserRoles.Admin); + const user = createUser(UserRoles.Usual); + + it('should succeed if the user has the given role', async () => { + const app = express(); + app.get( + '/', + setUser(admin), + hasRole([UserRoles.Admin]), + (_, response) => { + response.status(constants.HTTP_STATUS_OK).send(); + } + ); + + const { status } = await request(app).get('/'); + + expect(status).toBe(constants.HTTP_STATUS_OK); + }); + + it('should succeed if the user has one of the given roles', async () => { + const app = express(); + app.get( + '/', + setUser(user), + hasRole([UserRoles.Admin, UserRoles.Usual]), + (_, response) => { + response.status(constants.HTTP_STATUS_OK).send(); + } + ); + + const { status } = await request(app).get('/'); + + expect(status).toBe(constants.HTTP_STATUS_OK); + }); + + it('should fail if the user has none of the given roles', async () => { + const app = express(); + app.get( + '/', + setUser(visitor), + hasRole([UserRoles.Admin, UserRoles.Usual]), + (_, response) => { + response.status(constants.HTTP_STATUS_OK).send(); + } + ); + + const { status } = await request(app).get('/'); + + expect(status).toBe(constants.HTTP_STATUS_UNAUTHORIZED); + }); + }); +}); diff --git a/server/src/models/HousingApi.ts b/server/src/models/HousingApi.ts index 0785bd5df..07214e204 100644 --- a/server/src/models/HousingApi.ts +++ b/server/src/models/HousingApi.ts @@ -1,11 +1,17 @@ import fp from 'lodash/fp'; import { assert, MarkRequired } from 'ts-essentials'; -import { HousingSource, Occupancy } from '@zerologementvacant/models'; -import { OwnerApi } from './OwnerApi'; -import { HousingStatusApi } from './HousingStatusApi'; +import { + HousingDTO, + HousingKind, + HousingSource, + Occupancy +} from '@zerologementvacant/models'; +import { OwnerApi, toOwnerDTO } from './OwnerApi'; +import { HousingStatusApi, toHousingStatus } from './HousingStatusApi'; import { Sort } from './SortApi'; import { HousingEventApi, isUserModified } from '~/models/EventApi'; +import OwnerMissingError from '~/errors/ownerMissingError'; export type HousingId = Pick; @@ -36,7 +42,7 @@ export interface HousingRecordApi { buildingYear?: number; mutationDate: Date | null; taxed?: boolean; - vacancyReasons?: string[]; + vacancyReasons?: string[] | null; /** * @deprecated See {@link dataFileYears} */ @@ -49,12 +55,12 @@ export interface HousingRecordApi { ownershipKind?: string; status: HousingStatusApi; subStatus?: string | null; - precisions?: string[]; + precisions?: string[] | null; energyConsumption?: EnergyConsumptionGradesApi; energyConsumptionAt?: Date; occupancy: Occupancy; occupancyRegistered: Occupancy; - occupancyIntended?: Occupancy; + occupancyIntended?: Occupancy | null; source: HousingSource | null; } @@ -69,6 +75,47 @@ export interface HousingApi extends HousingRecordApi { lastContact?: Date; } +export function toHousingDTO(housing: HousingApi): HousingDTO { + if (!housing.owner) { + throw new OwnerMissingError(); + } + + return { + id: housing.id, + invariant: housing.invariant, + localId: housing.localId, + rawAddress: housing.rawAddress, + geoCode: housing.geoCode, + longitude: housing.longitude, + latitude: housing.latitude, + cadastralClassification: housing.cadastralClassification, + uncomfortable: housing.uncomfortable, + vacancyStartYear: housing.vacancyStartYear, + housingKind: housing.housingKind as HousingKind, + roomsCount: housing.roomsCount, + livingArea: housing.livingArea, + cadastralReference: housing.cadastralReference, + buildingYear: housing.buildingYear, + taxed: housing.taxed, + vacancyReasons: housing.vacancyReasons ?? null, + dataYears: housing.dataYears, + dataFileYears: housing.dataFileYears, + beneficiaryCount: housing.beneficiaryCount, + buildingLocation: housing.buildingLocation, + rentalValue: housing.rentalValue, + ownershipKind: housing.ownershipKind, + status: toHousingStatus(housing.status), + subStatus: housing.subStatus ?? null, + precisions: housing.precisions ?? null, + energyConsumption: housing.energyConsumption, + energyConsumptionAt: housing.energyConsumptionAt, + occupancy: housing.occupancy, + occupancyIntended: housing.occupancyIntended ?? null, + source: housing.source, + owner: toOwnerDTO(housing.owner) + }; +} + export function assertOwner( housing: T ): asserts housing is T & MarkRequired { diff --git a/server/src/models/HousingStatusApi.ts b/server/src/models/HousingStatusApi.ts index 3041a344a..8ef4ce62e 100644 --- a/server/src/models/HousingStatusApi.ts +++ b/server/src/models/HousingStatusApi.ts @@ -1,3 +1,5 @@ +import { HousingStatus } from '@zerologementvacant/models'; + export enum HousingStatusApi { NeverContacted, Waiting, @@ -7,6 +9,14 @@ export enum HousingStatusApi { Blocked } +export function fromHousingStatus(status: HousingStatus): HousingStatusApi { + return status as unknown as HousingStatusApi; +} + +export function toHousingStatus(status: HousingStatusApi): HousingStatus { + return status as unknown as HousingStatus; +} + export const HOUSING_STATUS_VALUES = Object.values(HousingStatusApi).filter( (value): value is HousingStatusApi => typeof value === 'number' ); diff --git a/server/src/models/NoteApi.ts b/server/src/models/NoteApi.ts index 818d289d2..4561cf6a3 100644 --- a/server/src/models/NoteApi.ts +++ b/server/src/models/NoteApi.ts @@ -1,14 +1,22 @@ import { assert } from 'ts-essentials'; -import { UserApi } from '~/models/UserApi'; +import { NoteDTO } from '@zerologementvacant/models'; +import { toUserDTO, UserApi } from '~/models/UserApi'; -export interface NoteApi { - id: string; - content: string; - noteKind: string; - createdBy: string; - creator?: UserApi; +export interface NoteApi extends Omit { createdAt: Date; + creator?: UserApi; +} + +export function toNoteDTO(note: NoteApi): NoteDTO { + return { + id: note.id, + content: note.content, + noteKind: note.noteKind, + createdBy: note.createdBy, + createdAt: note.createdAt.toJSON(), + creator: note.creator ? toUserDTO(note.creator) : undefined + }; } export interface OwnerNoteApi extends NoteApi { diff --git a/server/src/repositories/housingRepository.ts b/server/src/repositories/housingRepository.ts index 86dc02f1f..0e03e2aff 100644 --- a/server/src/repositories/housingRepository.ts +++ b/server/src/repositories/housingRepository.ts @@ -38,6 +38,7 @@ import { groupsHousingTable } from './groupRepository'; import { housingOwnersTable } from './housingOwnerRepository'; import { campaignsHousingTable } from './campaignHousingRepository'; import { campaignsTable } from './campaignRepository'; +import { getTransaction } from '~/infra/database/transaction'; export const housingTable = 'fast_housing'; export const buildingTable = 'buildings'; @@ -358,7 +359,8 @@ function include(includes: HousingInclude[], filters?: HousingFiltersApi) { async function update(housing: HousingApi): Promise { logger.debug('Update housing', housing.id); - return db(housingTable) + const transaction = getTransaction(); + return Housing(transaction) .where({ // Use the index on the partitioned table geo_code: housing.geoCode, @@ -897,7 +899,7 @@ export interface HousingRecordDBO { building_year?: number; mutation_date?: Date; taxed?: boolean; - vacancy_reasons?: string[]; + vacancy_reasons: string[] | null; /** * @deprecated See {@link data_file_years} */ @@ -912,11 +914,11 @@ export interface HousingRecordDBO { rental_value?: number; condominium?: string; status: HousingStatusApi; - sub_status?: string | null; - precisions?: string[]; + sub_status: string | null; + precisions: string[] | null; occupancy: Occupancy; occupancy_source: Occupancy; - occupancy_intended?: Occupancy; + occupancy_intended: Occupancy | null; energy_consumption_bdnb?: EnergyConsumptionGradesApi; energy_consumption_at_bdnb?: Date; } @@ -971,8 +973,8 @@ export const parseHousingApi = (housing: HousingDBO): HousingApi => ({ dataFileYears: housing.data_file_years ?? [], ownershipKind: housing.condominium, status: housing.status, - subStatus: housing.sub_status ?? undefined, - precisions: housing.precisions ?? undefined, + subStatus: housing.sub_status, + precisions: housing.precisions, energyConsumption: housing.energy_consumption_bdnb, energyConsumptionAt: housing.energy_consumption_at_bdnb, occupancy: housing.occupancy, @@ -1022,19 +1024,23 @@ export const formatHousingRecordApi = ( rooms_count: housingRecordApi.roomsCount, living_area: housingRecordApi.livingArea, cadastral_reference: housingRecordApi.cadastralReference, - vacancy_reasons: housingRecordApi.vacancyReasons, + vacancy_reasons: !housingRecordApi.vacancyReasons?.length + ? null + : housingRecordApi.vacancyReasons, taxed: housingRecordApi.taxed, condominium: housingRecordApi.ownershipKind, data_years: housingRecordApi.dataYears, data_file_years: housingRecordApi.dataFileYears, status: housingRecordApi.status, - sub_status: housingRecordApi.subStatus, - precisions: housingRecordApi.precisions, + sub_status: housingRecordApi.subStatus ?? null, + precisions: !housingRecordApi.precisions?.length + ? null + : housingRecordApi.precisions, energy_consumption_bdnb: housingRecordApi.energyConsumption, energy_consumption_at_bdnb: housingRecordApi.energyConsumptionAt, occupancy: housingRecordApi.occupancy, occupancy_source: housingRecordApi.occupancyRegistered, - occupancy_intended: housingRecordApi.occupancyIntended, + occupancy_intended: housingRecordApi.occupancyIntended ?? null, data_source: housingRecordApi.source }); diff --git a/server/src/repositories/noteRepository.ts b/server/src/repositories/noteRepository.ts index c90f66910..5615c7045 100644 --- a/server/src/repositories/noteRepository.ts +++ b/server/src/repositories/noteRepository.ts @@ -26,11 +26,11 @@ async function insertOwnerNote(ownerNoteApi: OwnerNoteApi): Promise { }); } -async function insertHousingNote(housingNote: HousingNoteApi): Promise { - await insertManyHousingNotes([housingNote]); +async function createByHousing(housingNote: HousingNoteApi): Promise { + await createManyByHousing([housingNote]); } -async function insertManyHousingNotes( +async function createManyByHousing( housingNotes: HousingNoteApi[] ): Promise { logger.info('Insert %d HousingNoteApi', housingNotes.length); @@ -73,19 +73,27 @@ async function findHousingNotes(housingId: string): Promise { return findNotes(housingNotesTable, 'housing_id', housingId); } -interface NoteRecordDBO { +export interface NoteRecordDBO { id: string; content: string; note_kind: string; created_by: string; created_at: Date; + /** + * @deprecated + */ + contact_kind_deprecated: string | null; + /** + * @deprecated + */ + title_deprecated: string | null; } -interface NoteDBO extends NoteRecordDBO { +export interface NoteDBO extends NoteRecordDBO { creator?: UserDBO; } -interface HousingNoteDBO { +export interface HousingNoteDBO { note_id: string; housing_id: string; housing_geo_code: string; @@ -96,7 +104,9 @@ export const formatNoteApi = (noteApi: NoteApi): NoteRecordDBO => ({ created_by: noteApi.createdBy, created_at: noteApi.createdAt, note_kind: noteApi.noteKind, - content: noteApi.content + content: noteApi.content, + contact_kind_deprecated: null, + title_deprecated: null }); export const formatHousingNoteApi = (note: HousingNoteApi): HousingNoteDBO => ({ @@ -116,9 +126,8 @@ export const parseNoteApi = (noteDbo: NoteDBO): NoteApi => ({ export default { insertOwnerNote, - insertHousingNote, - insertManyHousingNotes, + createByHousing, + createManyByHousing, findHousingNotes, - findOwnerNotes, - formatNoteApi + findOwnerNotes }; diff --git a/server/src/repositories/test/noteRepository.test.ts b/server/src/repositories/test/noteRepository.test.ts new file mode 100644 index 000000000..da7e05ba1 --- /dev/null +++ b/server/src/repositories/test/noteRepository.test.ts @@ -0,0 +1,63 @@ +import noteRepository, { + HousingNotes, + NoteRecordDBO, + Notes +} from '~/repositories/noteRepository'; +import { HousingNoteApi } from '~/models/NoteApi'; +import { + genEstablishmentApi, + genHousingApi, + genHousingNoteApi, + genUserApi +} from '~/test/testFixtures'; +import { + formatHousingRecordApi, + Housing +} from '~/repositories/housingRepository'; +import { + Establishments, + formatEstablishmentApi +} from '~/repositories/establishmentRepository'; +import { formatUserApi, Users } from '~/repositories/userRepository'; + +describe('Note repository', () => { + describe('createByHousing', () => { + const establishment = genEstablishmentApi(); + const creator = genUserApi(establishment.id); + const housing = genHousingApi(); + const note: HousingNoteApi = genHousingNoteApi(creator, housing); + + beforeAll(async () => { + await Establishments().insert(formatEstablishmentApi(establishment)); + await Users().insert(formatUserApi(creator)); + await Housing().insert(formatHousingRecordApi(housing)); + + await noteRepository.createByHousing(note); + }); + + it('should create the note', async () => { + const actual = await Notes().where({ id: note.id }).first(); + expect(actual).toStrictEqual({ + id: note.id, + content: note.content, + note_kind: note.noteKind, + created_by: note.createdBy, + created_at: note.createdAt, + // Weird fields still present in the database + contact_kind_deprecated: null, + title_deprecated: null + }); + }); + + it('should link the note to its housing', async () => { + const actual = await HousingNotes() + .where({ + note_id: note.id, + housing_id: housing.id, + housing_geo_code: housing.geoCode + }) + .first(); + expect(actual).toBeDefined(); + }); + }); +}); diff --git a/server/src/routers/protected.ts b/server/src/routers/protected.ts index 883bb9d1c..6114c9d09 100644 --- a/server/src/routers/protected.ts +++ b/server/src/routers/protected.ts @@ -1,6 +1,7 @@ import fileUpload from 'express-fileupload'; import Router from 'express-promise-router'; import { param } from 'express-validator'; +import { object } from 'yup'; import schemas from '@zerologementvacant/schemas'; import accountController from '~/controllers/accountController'; @@ -20,7 +21,7 @@ import ownerController from '~/controllers/ownerController'; import ownerProspectController from '~/controllers/ownerProspectController'; import settingsController from '~/controllers/settingsController'; import userController from '~/controllers/userController'; -import { jwtCheck, userCheck } from '~/middlewares/auth'; +import { hasRole, jwtCheck, userCheck } from '~/middlewares/auth'; import { upload } from '~/middlewares/upload'; import validator from '~/middlewares/validator'; import { isUUIDParam } from '~/utils/validators'; @@ -28,6 +29,7 @@ import draftController from '~/controllers/draftController'; import validatorNext from '~/middlewares/validator-next'; import { paginationSchema } from '~/models/PaginationApi'; import sortApi from '~/models/SortApi'; +import { UserRoles } from '~/models/UserApi'; const router = Router(); @@ -51,13 +53,15 @@ router.post( validator.validate, housingController.create ); -router.get('/housing/count', +router.get( + '/housing/count', validatorNext.validate({ query: schemas.housingFilters .concat(sortApi.sortSchema) .concat(paginationSchema) }), -housingController.count); + housingController.count +); router.get( '/housing/:id', housingController.getValidators, @@ -70,7 +74,15 @@ router.post( validator.validate, housingController.updateList ); -// TODO: replace by PUT /housing/:id +router.put( + '/housing/:id', + hasRole([UserRoles.Usual, UserRoles.Admin]), + validatorNext.validate({ + params: object({ id: schemas.id }), + body: schemas.housingUpdatePayload + }), + housingController.updateNext +); router.post( '/housing/:housingId', [param('housingId').isUUID(), ...housingController.updateValidators], @@ -243,10 +255,19 @@ router.get( ); router.get( - '/notes/housing/:housingId', - [isUUIDParam('housingId')], + '/housing/:id/notes', + [isUUIDParam('id')], validator.validate, - noteController.listByHousingId + noteController.findByHousing +); +router.post( + '/housing/:id/notes', + hasRole([UserRoles.Usual, UserRoles.Admin]), + validatorNext.validate({ + params: object({ id: schemas.id }), + body: schemas.notePayload + }), + noteController.createByHousing ); // TODO: rework and merge this API with the User API diff --git a/yarn.lock b/yarn.lock index 49464c5c9..4b9963395 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10854,6 +10854,7 @@ __metadata: lodash: "npm:^4.17.21" nodemon: "npm:^3.1.7" rimraf: "npm:^5.0.10" + ts-essentials: "npm:^10.0.2" ts-jest: "npm:^29.2.5" typescript: "npm:^5.6.2" languageName: unknown