diff --git a/frontend/package.json b/frontend/package.json index 1e69c57d62..be24286c07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "jose": "4.15.9", "lexical": "0.38.2", "lodash-es": "4.17.21", - "maplibre-gl": "5.13.0", + "maplibre-gl": "5.16.0", "mime": "4.1.0", "msw": "2.12.4", "posthog-js": "1.298.0", diff --git a/frontend/src/components/EventsHistory/HistoryCard.tsx b/frontend/src/components/EventsHistory/HistoryCard.tsx index f5f600b5b9..3d63358050 100644 --- a/frontend/src/components/EventsHistory/HistoryCard.tsx +++ b/frontend/src/components/EventsHistory/HistoryCard.tsx @@ -6,6 +6,7 @@ import type { PropsWithChildren } from 'react'; export type HistoryCardProps = PropsWithChildren<{ icon: FrIconClassName | RiIconClassName; + hideIcon?: boolean; }>; function HistoryCard(props: HistoryCardProps) { @@ -14,25 +15,28 @@ function HistoryCard(props: HistoryCardProps) { component="article" direction="row" spacing="1rem" + useFlexGap sx={{ alignItems: 'center' }} > - - - + {!props.hideIcon && ( + + + + )} {props.children} diff --git a/frontend/src/components/EventsHistory/NoteCard.tsx b/frontend/src/components/EventsHistory/NoteCard.tsx index 89d900f6b4..8387998cdd 100644 --- a/frontend/src/components/EventsHistory/NoteCard.tsx +++ b/frontend/src/components/EventsHistory/NoteCard.tsx @@ -10,10 +10,10 @@ import localeFR from 'date-fns/locale/fr'; import { useMemo, useState } from 'react'; import { FormProvider, type SubmitHandler, useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; +import * as yup from 'yup'; + import { useNotification } from '../../hooks/useNotification'; import { useUser } from '../../hooks/useUser'; - -import * as yup from 'yup'; import type { Establishment } from '../../models/Establishment'; import type { Note } from '../../models/Note'; import { formatAuthor } from '../../models/User'; @@ -29,6 +29,12 @@ import HistoryCard from './HistoryCard'; export interface NoteCardProps { note: Note; establishment: Pick | null; + hideIcon?: boolean; + /** + * If true, the note cannot be edited or deleted. + * @default false + */ + readOnly?: boolean; } function NoteCard(props: NoteCardProps) { @@ -66,7 +72,9 @@ function NoteCard(props: NoteCardProps) { }); const { isAdmin, isUsual, user } = useUser(); - const canUpdate = isAdmin || (isUsual && user?.id === props.note.createdBy); + const canUpdate = + !props.readOnly && + (isAdmin || (isUsual && user?.id === props.note.createdBy)); const removeModal = useMemo( () => @@ -123,7 +131,7 @@ function NoteCard(props: NoteCardProps) { } return ( - + {document ? ( diff --git a/frontend/src/components/HousingDetails/DocumentsTab.tsx b/frontend/src/components/HousingDetails/DocumentsTab.tsx index a6655b4e25..e28f6858fe 100644 --- a/frontend/src/components/HousingDetails/DocumentsTab.tsx +++ b/frontend/src/components/HousingDetails/DocumentsTab.tsx @@ -82,7 +82,6 @@ function DocumentsTab() { function onCancelRename(): void { setSelectedDocument(null); - documentRenameModal.close(); } function rename(filename: string): void { diff --git a/frontend/src/components/HousingEdition/HousingEditionNoteTab.tsx b/frontend/src/components/HousingEdition/HousingEditionNoteTab.tsx new file mode 100644 index 0000000000..040a2ce441 --- /dev/null +++ b/frontend/src/components/HousingEdition/HousingEditionNoteTab.tsx @@ -0,0 +1,44 @@ +import Stack from '@mui/material/Stack'; +import { skipToken } from '@reduxjs/toolkit/query'; + +import AppTextInputNext from '~/components/_app/AppTextInput/AppTextInputNext'; +import NoteCard from '~/components/EventsHistory/NoteCard'; +import type { Housing } from '~/models/Housing'; +import { useFindNotesByHousingQuery } from '~/services/note.service'; + +interface Props { + housingId: Housing['id'] | null; +} + +function HousingEditionNoteTab(props: Props) { + const { data: notes = [] } = useFindNotesByHousingQuery( + props.housingId ?? skipToken + ); + + return ( + + + + {notes.length > 0 && ( + + {notes.map((note) => ( + + ))} + + )} + + ); +} + +export default HousingEditionNoteTab; diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index 9a5850f7db..24df518ff2 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -19,11 +19,11 @@ import { HousingStates } from '../../models/HousingState'; import { useUpdateHousingMutation } from '../../services/housing.service'; import { useCreateNoteByHousingMutation } from '../../services/note.service'; import AppLink from '../_app/AppLink/AppLink'; -import AppTextInputNext from '../_app/AppTextInput/AppTextInputNext'; import OccupancySelect from '../HousingListFilters/OccupancySelect'; import AsideNext from '../Aside/AsideNext'; import LabelNext from '../Label/LabelNext'; import HousingEditionMobilizationTab from './HousingEditionMobilizationTab'; +import HousingEditionNoteTab from './HousingEditionNoteTab'; import { useHousingEdition } from './useHousingEdition'; import type { Housing, HousingUpdate } from '../../models/Housing'; import type { HousingEditionContext } from './useHousingEdition'; @@ -173,12 +173,7 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { )) .with('note', () => ( - + )) .exhaustive(); diff --git a/frontend/src/components/Precision/PrecisionColumn.tsx b/frontend/src/components/Precision/PrecisionColumn.tsx index ad9d74342a..cb0c02fd44 100644 --- a/frontend/src/components/Precision/PrecisionColumn.tsx +++ b/frontend/src/components/Precision/PrecisionColumn.tsx @@ -5,27 +5,84 @@ import type { CheckboxProps } from '@codegouvfr/react-dsfr/Checkbox'; import RadioButtons from '@codegouvfr/react-dsfr/RadioButtons'; import Typography from '@mui/material/Typography'; import classNames from 'classnames'; -import type { ChangeEvent } from 'react'; import type { ElementOf } from 'ts-essentials'; import type { Precision, PrecisionCategory } from '@zerologementvacant/models'; +import { NULL_PRECISION_ID } from '../../models/Precision'; import styles from './precision-modal.module.scss'; -interface PrecisionColumnProps { +type PrecisionColumnCommonProps = { category: PrecisionCategory; icon: FrIconClassName | RiIconClassName; options: Precision[]; - value: Precision[]; title: string; - /** - * @default 'checkbox' - */ - input?: 'checkbox' | 'radio'; - onChange(event: ChangeEvent): void; -} +}; + +type PrecisionColumnCheckboxProps = PrecisionColumnCommonProps & { + input?: 'checkbox'; + value: Precision[]; + onChange(value: Precision[]): void; +}; + +type PrecisionColumnRadioProps = PrecisionColumnCommonProps & { + input: 'radio'; + value: Precision | null; + onChange(value: Precision | null): void; +}; + +type PrecisionColumnProps = + | PrecisionColumnCheckboxProps + | PrecisionColumnRadioProps; function PrecisionColumn(props: PrecisionColumnProps) { - const Fieldset = props.input === 'radio' ? RadioButtons : Checkbox; + const isRadio = props.input === 'radio'; + const Fieldset = isRadio ? RadioButtons : Checkbox; + + // Add null option for radio inputs + const nullOption: Precision | null = isRadio + ? { + id: NULL_PRECISION_ID, + label: 'Pas d’information', + category: props.category + } + : null; + + const allOptions = nullOption + ? [nullOption, ...props.options] + : props.options; + + function isOptionChecked(option: Precision): boolean { + if (option.id === NULL_PRECISION_ID) { + // Null option is checked when there's no selection + return isRadio && props.value === null; + } + + if (isRadio) { + return props.value?.id === option.id; + } else { + return props.value.some((value) => value.id === option.id); + } + } + + function handleOptionClick(option: Precision, checked: boolean): void { + if (isRadio) { + // Radio button mode: always set the clicked option + if (option.id === NULL_PRECISION_ID) { + props.onChange(null); + } else { + props.onChange(option); + } + } else { + // Checkbox mode: toggle + if (checked) { + props.onChange([...props.value, option]); + } else { + props.onChange( + props.value.filter((precision) => precision.id !== option.id) + ); + } + } + } return ( <> @@ -36,13 +93,13 @@ function PrecisionColumn(props: PrecisionColumnProps) { {props.title}
=> ({ label: option.label, nativeInputProps: { - checked: props.value.some((value) => value.id === option.id), - value: option.id, - onChange: props.onChange + checked: isOptionChecked(option), + onChange: () => + handleOptionClick(option, !isOptionChecked(option)) } }) )} diff --git a/frontend/src/components/Precision/PrecisionLists.tsx b/frontend/src/components/Precision/PrecisionLists.tsx index 6990f7ae91..10ec1fc740 100644 --- a/frontend/src/components/Precision/PrecisionLists.tsx +++ b/frontend/src/components/Precision/PrecisionLists.tsx @@ -19,7 +19,7 @@ import { useSaveHousingPrecisionsMutation } from '../../services/precision.service'; import styles from '../HousingEdition/housing-edition.module.scss'; -import createPrecisionModalNext from './PrecisionModalNext'; +import createPrecisionModal from './PrecisionModal'; import type { PrecisionTabId } from './PrecisionTabs'; import { useFilteredPrecisions } from './useFilteredPrecisions'; @@ -29,7 +29,7 @@ interface Props { function PrecisionLists(props: Props) { const precisionModal = useMemo( - () => createPrecisionModalNext(new Date().toJSON()), + () => createPrecisionModal(new Date().toJSON()), [] ); @@ -96,7 +96,7 @@ function PrecisionLists(props: Props) { if (props.housingId) { saveHousingPrecisions({ housing: props.housingId, - precisions: precisions.map((p) => p.id) + precisions: precisions.map((precision) => precision.id) }).then(() => { precisionModal.close(); }); diff --git a/frontend/src/components/Precision/PrecisionModalNext.tsx b/frontend/src/components/Precision/PrecisionModal.tsx similarity index 94% rename from frontend/src/components/Precision/PrecisionModalNext.tsx rename to frontend/src/components/Precision/PrecisionModal.tsx index c74fc87366..a1242eb5f3 100644 --- a/frontend/src/components/Precision/PrecisionModalNext.tsx +++ b/frontend/src/components/Precision/PrecisionModal.tsx @@ -18,7 +18,7 @@ export type PrecisionModalProps = Omit< onSubmit(value: Precision[]): void; }; -function createPrecisionModalNext(id: string) { +function createPrecisionModal(id: string) { const precisionModalOptions = { id: `precision-modal-${id}`, isOpenedByDefault: false @@ -59,4 +59,4 @@ function createPrecisionModalNext(id: string) { }; } -export default createPrecisionModalNext; +export default createPrecisionModal; diff --git a/frontend/src/components/Precision/PrecisionTabs.tsx b/frontend/src/components/Precision/PrecisionTabs.tsx index a7fe7bd74b..2a6040ef8b 100644 --- a/frontend/src/components/Precision/PrecisionTabs.tsx +++ b/frontend/src/components/Precision/PrecisionTabs.tsx @@ -1,9 +1,9 @@ import Tabs from '@codegouvfr/react-dsfr/Tabs'; import Grid from '@mui/material/Grid'; -import type { Precision } from '@zerologementvacant/models'; +import type { Precision, PrecisionCategory } from '@zerologementvacant/models'; import { List } from 'immutable'; -import type { ChangeEvent, ReactElement } from 'react'; +import type { ReactElement } from 'react'; import { useMemo } from 'react'; import PrecisionColumn from './PrecisionColumn'; @@ -28,27 +28,23 @@ function PrecisionTabs(props: PrecisionTabs) { [props.options] ); - function onChange(event: ChangeEvent) { - if (event.target.checked) { - const option = props.options.find( - (option) => option.id === event.target.value - ) as Precision; - - if (event.target.type === 'radio') { - props.onChange( - props.value - // Remove mutually exclusive options - .filter((selected) => selected.category !== option.category) - .concat(option) - ); - } else { - props.onChange([...props.value, option as Precision]); - } - } else { - props.onChange( - props.value.filter((precision) => precision.id !== event.target.value) + function handleCheckboxChange(precisions: Precision[]) { + props.onChange(precisions); + } + + function handleRadioChange(category: PrecisionCategory) { + return (precision: Precision | null) => { + const others = props.value.filter( + (precision) => precision.category !== category ); - } + props.onChange(precision ? [...others, precision] : others); + }; + } + + function getRadioValue(category: PrecisionCategory): Precision | null { + return ( + props.value.find((precision) => precision.category === category) ?? null + ); } const MechanismsTab: PrecisionTab = { @@ -65,7 +61,7 @@ function PrecisionTabs(props: PrecisionTabs) { } title="Dispositifs incitatifs" value={props.value} - onChange={onChange} + onChange={handleCheckboxChange} /> @@ -78,7 +74,7 @@ function PrecisionTabs(props: PrecisionTabs) { } title="Dispositifs coercitifs" value={props.value} - onChange={onChange} + onChange={handleCheckboxChange} /> @@ -91,7 +87,7 @@ function PrecisionTabs(props: PrecisionTabs) { } title="Hors dispositif public" value={props.value} - onChange={onChange} + onChange={handleCheckboxChange} /> @@ -112,7 +108,7 @@ function PrecisionTabs(props: PrecisionTabs) { } title="Blocage involontaire" value={props.value} - onChange={onChange} + onChange={handleCheckboxChange} /> @@ -125,7 +121,7 @@ function PrecisionTabs(props: PrecisionTabs) { } title="Blocage volontaire" value={props.value} - onChange={onChange} + onChange={handleCheckboxChange} /> @@ -138,7 +134,7 @@ function PrecisionTabs(props: PrecisionTabs) { } title="Immeuble / Environnement" value={props.value} - onChange={onChange} + onChange={handleCheckboxChange} /> @@ -149,7 +145,7 @@ function PrecisionTabs(props: PrecisionTabs) { options={optionsByCategory.get('tiers-en-cause')?.toArray() ?? []} title="Tiers en cause" value={props.value} - onChange={onChange} + onChange={handleCheckboxChange} /> @@ -168,8 +164,8 @@ function PrecisionTabs(props: PrecisionTabs) { input="radio" options={optionsByCategory.get('travaux')?.toArray() ?? []} title="Travaux" - value={props.value} - onChange={onChange} + value={getRadioValue('travaux')} + onChange={handleRadioChange('travaux')} /> @@ -180,8 +176,8 @@ function PrecisionTabs(props: PrecisionTabs) { input="radio" options={optionsByCategory.get('occupation')?.toArray() ?? []} title="Location ou autre occupation" - value={props.value} - onChange={onChange} + value={getRadioValue('occupation')} + onChange={handleRadioChange('occupation')} /> @@ -192,8 +188,8 @@ function PrecisionTabs(props: PrecisionTabs) { input="radio" options={optionsByCategory.get('mutation')?.toArray() ?? []} title="Vente ou autre mutation" - value={props.value} - onChange={onChange} + value={getRadioValue('mutation')} + onChange={handleRadioChange('mutation')} /> diff --git a/frontend/src/components/Precision/test/PrecisionLists.test.tsx b/frontend/src/components/Precision/test/PrecisionLists.test.tsx index 7e6c634709..02183873b2 100644 --- a/frontend/src/components/Precision/test/PrecisionLists.test.tsx +++ b/frontend/src/components/Precision/test/PrecisionLists.test.tsx @@ -1,18 +1,31 @@ import { render, screen } from '@testing-library/react'; -import type { HousingDTO, HousingOwnerDTO } from '@zerologementvacant/models'; -import { - genHousingDTO, - genHousingOwnerDTO, - genOwnerDTO -} from '@zerologementvacant/models/fixtures'; +import userEvent from '@testing-library/user-event'; +import { type HousingDTO, type Precision } from '@zerologementvacant/models'; +import { genHousingDTO } from '@zerologementvacant/models/fixtures'; import { Provider } from 'react-redux'; import data from '../../../mocks/handlers/data'; +import { NULL_PRECISION_ID } from '../../../models/Precision'; import configureTestStore from '../../../utils/storeUtils'; import PrecisionLists from '../PrecisionLists'; +interface RenderComponentOptions { + precisions: ReadonlyArray; +} + describe('PrecisionLists', () => { - function renderComponent(housing: HousingDTO): void { + const user = userEvent.setup(); + + function renderComponent( + housing: HousingDTO, + options: RenderComponentOptions + ): void { + data.housings.push(housing); + data.housingPrecisions.set( + housing.id, + options.precisions.map((precision) => precision.id) + ); + const store = configureTestStore(); render( @@ -21,18 +34,16 @@ describe('PrecisionLists', () => { ); } + beforeEach(() => { + data.reset(); + }); + it('should display fallback texts if there is no precision', async () => { - const owner = genOwnerDTO(); - const housing = genHousingDTO(owner); - const housingOwner: HousingOwnerDTO = { - ...genHousingOwnerDTO(owner), - rank: 1 - }; - data.housings.push(housing); - data.owners.push(owner); - data.housingOwners.set(housing.id, [housingOwner]); + const housing = genHousingDTO(null); - renderComponent(housing); + renderComponent(housing, { + precisions: [] + }); const mechanism = await screen.findByText('Aucun dispositif'); expect(mechanism).toBeVisible(); @@ -41,4 +52,121 @@ describe('PrecisionLists', () => { const evolution = await screen.findByText('Aucune évolution'); expect(evolution).toBeVisible(); }); + + describe('Null option for radio groups', () => { + it('should display "Pas d’information" option in evolution radio groups', async () => { + const housing = genHousingDTO(null); + const travaux = data.precisions.filter( + (precision) => precision.category === 'travaux' + ); + + renderComponent(housing, { + precisions: travaux + }); + + const modify = await screen.findByTitle( + 'Modifier les évolutions du logement' + ); + await user.click(modify); + + const nullOptions = await screen.findAllByLabelText('Pas d’information'); + expect(nullOptions.length).toBeGreaterThan(0); + }); + + it('should not display "Pas d’information" option in checkbox groups', async () => { + const housing = genHousingDTO(null); + + renderComponent(housing, { + precisions: [] + }); + + const modify = await screen.findByTitle('Modifier les dispositifs'); + await user.click(modify); + + const nullOptions = screen.queryAllByLabelText('Pas d’information'); + expect(nullOptions).toHaveLength(0); + }); + + it('should have "Pas d’information" checked when no selection exists for that category', async () => { + const housing = genHousingDTO(null); + + renderComponent(housing, { + precisions: [] + }); + + const modify = await screen.findByTitle( + 'Modifier les évolutions du logement' + ); + await user.click(modify); + + const nullOptions = await screen.findAllByLabelText('Pas d’information'); + nullOptions.forEach((option) => { + expect(option).toBeChecked(); + }); + }); + + it('should uncheck "Pas d’information" when a real option is selected', async () => { + const housing = genHousingDTO(null); + + renderComponent(housing, { + precisions: [] + }); + + const modify = await screen.findByTitle( + 'Modifier les évolutions du logement' + ); + await user.click(modify); + + const realOptions = screen.getAllByRole('radio', { + name: (accessibleName) => accessibleName !== 'Pas d’information' + }); + + if (realOptions.length > 0) { + await user.click(realOptions[0]); + + const confirm = await screen.findByRole('button', { + name: /confirmer/i + }); + await user.click(confirm); + + const heading = await screen.findByRole('heading', { + name: 'Évolutions du logement (1)' + }); + expect(heading).toBeVisible(); + } + }); + + it('should check "Pas d’information" to remove the selection', async () => { + const housing = genHousingDTO(null); + const precision: Precision = { + id: NULL_PRECISION_ID, + category: 'travaux', + label: 'Pas d’information' + }; + + renderComponent(housing, { + precisions: [precision] + }); + + const modify = await screen.findByTitle( + 'Modifier les évolutions du logement' + ); + await user.click(modify); + + const nullOptions = await screen.findAllByLabelText('Pas d’information'); + if (nullOptions.length > 0) { + await user.click(nullOptions[0]); + } + + const confirm = await screen.findByRole('button', { + name: /confirmer/i + }); + await user.click(confirm); + + const heading = await screen.findByRole('heading', { + name: 'Évolutions du logement (0)' + }); + expect(heading).toBeVisible(); + }); + }); }); diff --git a/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx b/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx index 655684b6da..414d9c0070 100644 --- a/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx +++ b/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx @@ -28,12 +28,11 @@ export function createConfirmationModal(options: ConfirmationModalOptions) { { children: 'Annuler', className: 'fr-mr-2w', - doClosesModal: false, + doClosesModal: true, priority: 'secondary', nativeButtonProps: { type: 'reset' - }, - onClick: props.onClose + } }, { children: 'Confirmer', diff --git a/frontend/src/mocks/handlers/precision-handlers.ts b/frontend/src/mocks/handlers/precision-handlers.ts index 10cb31c549..6f68357167 100644 --- a/frontend/src/mocks/handlers/precision-handlers.ts +++ b/frontend/src/mocks/handlers/precision-handlers.ts @@ -1,9 +1,49 @@ import { http, HttpResponse, RequestHandler } from 'msw'; import type { Precision } from '@zerologementvacant/models'; +import { Predicate } from 'effect'; import config from '../../utils/config'; import data from './data'; -import { Predicate } from 'effect'; + +const replace = http.put< + { id: string }, + ReadonlyArray, + Precision[] +>( + `${config.apiEndpoint}/api/housing/:id/precisions`, + async ({ params, request }) => { + const housing = data.housings.find((housing) => housing.id === params.id); + if (!housing) { + throw HttpResponse.json( + { + name: 'HousingMissingError', + message: `Housing ${params.id} missing` + }, + { status: 404 } + ); + } + + const body = await request.json(); + const missingPrecisions = body.filter( + (id) => !data.precisions.some((precision) => precision.id === id) + ); + if (missingPrecisions.length > 0) { + throw HttpResponse.json( + { + name: 'PrecisionMissingError', + message: `Precisions ${missingPrecisions.join(', ')} missing` + }, + { status: 404 } + ); + } + + data.housingPrecisions.set(params.id, body); + const precisions = body.map( + (id) => data.precisions.find((precision) => precision.id === id)! + ); + return HttpResponse.json(precisions); + } +); export const precisionHandlers: RequestHandler[] = [ // Fetch the referential of precisions @@ -24,5 +64,7 @@ export const precisionHandlers: RequestHandler[] = [ .filter(Predicate.isNotUndefined); return HttpResponse.json(precisions); } - ) + ), + + replace ]; diff --git a/frontend/src/mocks/handlers/user-handlers.ts b/frontend/src/mocks/handlers/user-handlers.ts index 13919824ec..90a467f905 100644 --- a/frontend/src/mocks/handlers/user-handlers.ts +++ b/frontend/src/mocks/handlers/user-handlers.ts @@ -41,7 +41,8 @@ const create = http.post, UserPayload, never>( lastAuthenticatedAt: null, suspendedAt: null, suspendedCause: null, - updatedAt: '' + updatedAt: '', + kind: null }; return HttpResponse.json(user, { status: constants.HTTP_STATUS_CREATED diff --git a/frontend/src/models/Precision.ts b/frontend/src/models/Precision.ts index 1c24f66e2e..dcd0060316 100644 --- a/frontend/src/models/Precision.ts +++ b/frontend/src/models/Precision.ts @@ -2,6 +2,8 @@ import type { FrIconClassName, RiIconClassName } from '@codegouvfr/react-dsfr'; import type { Precision, PrecisionCategory } from '@zerologementvacant/models'; +export const NULL_PRECISION_ID = '__NULL_PRECISION__' as const; + export const PRECISION_CATEGORY_LABELS: Record< PrecisionCategory, { label: string; icon: FrIconClassName | RiIconClassName } diff --git a/frontend/src/models/User.tsx b/frontend/src/models/User.tsx index 2461bf5732..5d9048ba45 100644 --- a/frontend/src/models/User.tsx +++ b/frontend/src/models/User.tsx @@ -55,7 +55,8 @@ export const fromUserDTO = (user: UserDTO): User => ({ lastAuthenticatedAt: user.lastAuthenticatedAt, suspendedAt: user.suspendedAt, suspendedCause: user.suspendedCause, - updatedAt: user.updatedAt + updatedAt: user.updatedAt, + kind: user.kind }); export const toUserDTO = (user: User): UserDTO => ({ @@ -75,7 +76,8 @@ export const toUserDTO = (user: User): UserDTO => ({ lastAuthenticatedAt: user.lastAuthenticatedAt, suspendedAt: user.suspendedAt, suspendedCause: user.suspendedCause, - updatedAt: user.updatedAt + updatedAt: user.updatedAt, + kind: user.kind }); export type UserAccount = UserAccountDTO; diff --git a/frontend/src/views/Housing/test/HousingView.test.tsx b/frontend/src/views/Housing/test/HousingView.test.tsx index 9fec000976..f5d35d8a1d 100644 --- a/frontend/src/views/Housing/test/HousingView.test.tsx +++ b/frontend/src/views/Housing/test/HousingView.test.tsx @@ -662,63 +662,6 @@ describe('Housing view', () => { expect(renamedDocument).toBeVisible(); }); - it('should reset the form after closing without saving', async () => { - const housing = genHousingDTO(null); - const auth = genUserDTO(UserRole.USUAL); - const document = genDocumentDTO(auth); - const originalFilename = document.filename; - - renderView(housing, { - documents: [document], - user: auth - }); - - const tab = await screen.findByRole('tab', { - name: 'Documents' - }); - await user.click(tab); - const tabpanel = await screen.findByRole('tabpanel', { - name: 'Documents' - }); - const dropdown = await within(tabpanel).findByRole('button', { - name: 'Options' - }); - await user.click(dropdown); - const renameButton = await screen.findByRole('button', { - name: 'Renommer' - }); - await user.click(renameButton); - const modal = await screen.findByRole('dialog', { - name: 'Renommer le document' - }); - const input = await within(modal).findByRole('textbox', { - name: /^Nouveau nom du document/ - }); - await user.clear(input); - await user.type(input, 'temporary-name.pdf'); - const cancel = await within(modal).findByRole('button', { - name: 'Annuler' - }); - await user.click(cancel); - - // Re-open the modal to check if form was reset - const dropdownAgain = await within(tabpanel).findByRole('button', { - name: 'Options' - }); - await user.click(dropdownAgain); - const renameButtonAgain = await screen.findByRole('button', { - name: 'Renommer' - }); - await user.click(renameButtonAgain); - const modalAgain = await screen.findByRole('dialog', { - name: 'Renommer le document' - }); - const inputAgain = await within(modalAgain).findByRole('textbox', { - name: /^Nouveau nom du document/ - }); - expect(inputAgain).toHaveValue(originalFilename); - }); - it('should be invisible to a visitor', async () => { const housing = genHousingDTO(null); const creator = genUserDTO(UserRole.USUAL); diff --git a/packages/models/src/UserDTO.ts b/packages/models/src/UserDTO.ts index 0ced638cb8..f40ee1b8a7 100644 --- a/packages/models/src/UserDTO.ts +++ b/packages/models/src/UserDTO.ts @@ -32,6 +32,7 @@ export interface UserDTO { suspendedAt: string | null; suspendedCause: SuspendedCauseField; updatedAt: string; + kind: string | null; } export type UserCreationPayload = Pick< diff --git a/packages/models/src/test/fixtures.ts b/packages/models/src/test/fixtures.ts index 350cac3d3e..d502c8174d 100644 --- a/packages/models/src/test/fixtures.ts +++ b/packages/models/src/test/fixtures.ts @@ -779,7 +779,8 @@ export function genUserDTO( suspendedCause: null, updatedAt: faker.date.recent().toJSON(), establishmentId: establishment?.id ?? null, - role + role, + kind: null }; } diff --git a/server/src/controllers/accountController.ts b/server/src/controllers/accountController.ts index cb17d8d56e..df3568309c 100644 --- a/server/src/controllers/accountController.ts +++ b/server/src/controllers/accountController.ts @@ -24,6 +24,7 @@ import { import establishmentRepository from '~/repositories/establishmentRepository'; import resetLinkRepository from '~/repositories/resetLinkRepository'; import userRepository from '~/repositories/userRepository'; +import { fetchUserKind } from '~/services/ceremaService/userKindService'; import mailService from '~/services/mailService'; import { generateSimpleCode, @@ -102,16 +103,29 @@ async function signIn(request: Request, response: Response) { } // For non-admin users, proceed with normal login - await userRepository.update({ + // Fetch and update user kind from Portail DF API + const kind = await fetchUserKind(user.email); + + const updatedUser: UserApi = { ...user, + kind, lastAuthenticatedAt: new Date().toJSON() + }; + + await userRepository.update(updatedUser); + + logger.info('User signed in', { + userId: user.id, + email: user.email, + kind }); - const establishmentId = user.establishmentId ?? payload.establishmentId; + + const establishmentId = updatedUser.establishmentId ?? payload.establishmentId; if (!establishmentId) { throw new UnprocessableEntityError(); } - await signInToEstablishment(user, establishmentId, response); + await signInToEstablishment(updatedUser, establishmentId, response); } async function signInToEstablishment( @@ -339,23 +353,35 @@ async function verifyTwoFactor(request: Request, response: Response) { action: '2fa_verify_success' }); + // Fetch and update user kind from Portail DF API + const kind = await fetchUserKind(user.email); + // Clear the 2FA code and reset counters - await userRepository.update({ + const updatedUser: UserApi = { ...user, + kind, twoFactorCode: null, twoFactorCodeGeneratedAt: null, twoFactorFailedAttempts: 0, twoFactorLockedUntil: null, lastAuthenticatedAt: new Date().toJSON() + }; + + await userRepository.update(updatedUser); + + logger.info('Admin user signed in after 2FA', { + userId: user.id, + email: user.email, + kind }); // Complete the sign-in process - const establishmentId = user.establishmentId ?? payload.establishmentId; + const establishmentId = updatedUser.establishmentId ?? payload.establishmentId; if (!establishmentId) { throw new UnprocessableEntityError(); } - await signInToEstablishment(user, establishmentId, response); + await signInToEstablishment(updatedUser, establishmentId, response); } export default { diff --git a/server/src/controllers/userController.ts b/server/src/controllers/userController.ts index 9ea0ebf5c7..c971f5ebe8 100644 --- a/server/src/controllers/userController.ts +++ b/server/src/controllers/userController.ts @@ -26,6 +26,7 @@ import establishmentRepository from '~/repositories/establishmentRepository'; import prospectRepository from '~/repositories/prospectRepository'; import userRepository from '~/repositories/userRepository'; import { isTestAccount } from '~/services/ceremaService/consultUserService'; +import { fetchUserKind } from '~/services/ceremaService/userKindService'; import mailService from '~/services/mailService'; type ListQuery = UserFilters; @@ -106,6 +107,9 @@ async function create(request: Request, response: Response) { throw new EstablishmentMissingError(body.establishmentId); } + // Fetch user kind from Portail DF API + const kind = await fetchUserKind(body.email); + const user: UserApi = { id: uuidv4(), email: body.email, @@ -126,6 +130,7 @@ async function create(request: Request, response: Response) { suspendedCause: null, updatedAt: new Date().toJSON(), deletedAt: null, + kind, twoFactorSecret: null, twoFactorEnabledAt: null, twoFactorCode: null, @@ -137,7 +142,8 @@ async function create(request: Request, response: Response) { logger.info('Create user', { id: user.id, email: user.email, - establishmentId: user.establishmentId + establishmentId: user.establishmentId, + kind }); const createdUser = await userRepository.insert(user); diff --git a/server/src/infra/database/migrations/20241128175635-users-add-kind.ts b/server/src/infra/database/migrations/20241128175635-users-add-kind.ts new file mode 100644 index 0000000000..613d3b8340 --- /dev/null +++ b/server/src/infra/database/migrations/20241128175635-users-add-kind.ts @@ -0,0 +1,13 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('users', (table) => { + table.string('kind'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('users', (table) => { + table.dropColumn('kind'); + }); +} diff --git a/server/src/infra/database/seeds/development/20240404235457_users.ts b/server/src/infra/database/seeds/development/20240404235457_users.ts index 25f6247623..8f50d36ba5 100644 --- a/server/src/infra/database/seeds/development/20240404235457_users.ts +++ b/server/src/infra/database/seeds/development/20240404235457_users.ts @@ -84,6 +84,7 @@ function createBaseUser(overrides: Partial & Pick(array: ReadonlyArray, size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size) as T[]); + } + return chunks; +} + +// Helper function to query with batched whereIn for composite keys +async function batchedWhereIn( + knex: Knex, + tableFn: (knex: Knex) => Knex.QueryBuilder, + columns: [string, string], + values: ReadonlyArray<[string, string]> +): Promise { + const batches = chunk(values, BATCH_SIZE); + const results: T[] = []; + for (const batch of batches) { + const batchResults = await tableFn(knex).whereIn(columns, batch); + results.push(...batchResults); + } + return results; +} export async function seed(knex: Knex): Promise { const admin = await Users(knex) @@ -139,17 +165,18 @@ export async function seed(knex: Knex): Promise { }) .flat(); + const housingKeys = housings.map((housing): [string, string] => [housing.geo_code, housing.id]); const housingPrecisions: ReadonlyArray = - await HousingPrecisions(knex) - .whereIn( - ['housing_geo_code', 'housing_id'], - housings.map((housing) => [housing.geo_code, housing.id]) - ) - .join( + await batchedWhereIn( + knex, + (k) => HousingPrecisions(k).join( PRECISION_TABLE, `${PRECISION_TABLE}.id`, `${HOUSING_PRECISION_TABLE}.precision_id` - ); + ), + ['housing_geo_code', 'housing_id'], + housingKeys + ); const precisionHousingEvents: ReadonlyArray = faker.helpers .arrayElements(housingPrecisions) @@ -187,9 +214,11 @@ export async function seed(knex: Knex): Promise { }); const housingOwners: ReadonlyArray = - await HousingOwners(knex).whereIn( + await batchedWhereIn( + knex, + (k) => HousingOwners(k), ['housing_geo_code', 'housing_id'], - housings.map((housing) => [housing.geo_code, housing.id]) + housingKeys ); const housingOwnerEvents: ReadonlyArray = faker.helpers .arrayElements(housingOwners) @@ -244,9 +273,11 @@ export async function seed(knex: Knex): Promise { }); const groupHousings: ReadonlyArray = - await GroupsHousing(knex).whereIn( + await batchedWhereIn( + knex, + (k) => GroupsHousing(k), ['housing_geo_code', 'housing_id'], - housings.map((housing) => [housing.geo_code, housing.id]) + housingKeys ); const groupHousingEvents: ReadonlyArray = faker.helpers .arrayElements(groupHousings) @@ -308,9 +339,11 @@ export async function seed(knex: Knex): Promise { }); const campaignHousings: ReadonlyArray = - await CampaignsHousing(knex).whereIn( + await batchedWhereIn( + knex, + (k) => CampaignsHousing(k), ['housing_geo_code', 'housing_id'], - housings.map((housing) => [housing.geo_code, housing.id]) + housingKeys ); const campaignHousingEvents: ReadonlyArray = faker.helpers diff --git a/server/src/infra/database/seeds/production/20240405011127_users.ts b/server/src/infra/database/seeds/production/20240405011127_users.ts index 58f896dc39..17c5e7f56f 100644 --- a/server/src/infra/database/seeds/production/20240405011127_users.ts +++ b/server/src/infra/database/seeds/production/20240405011127_users.ts @@ -28,7 +28,8 @@ export const Lovac2023: UserApi = { twoFactorCode: null, twoFactorCodeGeneratedAt: null, twoFactorFailedAttempts: 0, - twoFactorLockedUntil: null + twoFactorLockedUntil: null, + kind: null }; export async function seed(knex: Knex): Promise { diff --git a/server/src/models/UserApi.ts b/server/src/models/UserApi.ts index 18617461c2..fb73edc5f6 100644 --- a/server/src/models/UserApi.ts +++ b/server/src/models/UserApi.ts @@ -51,7 +51,8 @@ export function toUserDTO(user: UserApi): UserDTO { lastAuthenticatedAt: user.lastAuthenticatedAt, suspendedAt: user.suspendedAt, suspendedCause: user.suspendedCause, - updatedAt: user.updatedAt + updatedAt: user.updatedAt, + kind: user.kind }; } diff --git a/server/src/models/test/GroupApi.test.ts b/server/src/models/test/GroupApi.test.ts index d09ac35f95..7d23cfa830 100644 --- a/server/src/models/test/GroupApi.test.ts +++ b/server/src/models/test/GroupApi.test.ts @@ -37,7 +37,8 @@ describe('GroupApi', () => { activatedAt: group.createdBy.activatedAt, establishmentId: group.createdBy.establishmentId, suspendedAt: group.createdBy.suspendedAt, - suspendedCause: group.createdBy.suspendedCause + suspendedCause: group.createdBy.suspendedCause, + kind: group.createdBy.kind } : undefined, archivedAt: null diff --git a/server/src/repositories/userRepository.ts b/server/src/repositories/userRepository.ts index c3475b7f38..6a58f460d4 100644 --- a/server/src/repositories/userRepository.ts +++ b/server/src/repositories/userRepository.ts @@ -109,6 +109,7 @@ export interface UserDBO { phone: string | null; position: string | null; time_per_week: TimePerWeek | null; + kind: string | null; two_factor_secret: string | null; two_factor_enabled_at: Date | string | null; two_factor_code: string | null; @@ -138,6 +139,7 @@ export const parseUserApi = (userDBO: UserDBO): UserApi => ({ phone: userDBO.phone, position: userDBO.position, timePerWeek: userDBO.time_per_week, + kind: userDBO.kind, twoFactorSecret: userDBO.two_factor_secret, twoFactorEnabledAt: userDBO.two_factor_enabled_at ? new Date(userDBO.two_factor_enabled_at).toJSON() @@ -173,6 +175,7 @@ export const formatUserApi = (userApi: UserApi): UserDBO => ({ phone: userApi.phone, position: userApi.position, time_per_week: userApi.timePerWeek, + kind: userApi.kind, two_factor_secret: userApi.twoFactorSecret, two_factor_enabled_at: userApi.twoFactorEnabledAt ? new Date(userApi.twoFactorEnabledAt).toJSON() diff --git a/server/src/scripts/sync-user-kind/README.md b/server/src/scripts/sync-user-kind/README.md new file mode 100644 index 0000000000..b5c640f36a --- /dev/null +++ b/server/src/scripts/sync-user-kind/README.md @@ -0,0 +1,223 @@ +# User Kind Synchronization Script + +Synchronize the `kind` column in the `users` table from Portail DF API data. + +## Overview + +This script fetches user information from the Portail DF API and updates the local `users` table with the appropriate `kind` value based on the `exterieur` and `gestionnaire` flags. + +## Mapping Rules + +| exterieur | gestionnaire | kind | +|-----------|--------------|----------------------------| +| `true` | `false` | `"prestataire"` | +| `false` | `true` | `"gestionnaire"` | +| `true` | `true` | `"prestataire, gestionnaire"` | +| `false` | `false` | `"aucun"` | + +## Prerequisites + +1. **Database Migration**: Run the migration to add the `kind` column: + ```bash + DATABASE_URL="" yarn knex migrate:latest + ``` + +2. **Python Dependencies**: + ```bash + pip install psycopg2-binary requests tqdm + ``` + +## Authentication + +The script uses token-based authentication with the Portail DF API: +1. Authenticates via POST to `/api-token-auth/` with username and password +2. Uses the returned token in `Authorization: Token ` header for subsequent requests + +Credentials must be provided via command-line arguments or environment variables. + +## Usage + +### Basic Usage + +```bash +python sync_user_kind.py \ + --db-url "postgresql://user:pass@localhost:5432/dbname" \ + --api-url "https://portaildf.cerema.fr/api" +``` + +### With Custom Credentials + +```bash +python sync_user_kind.py \ + --db-url "postgresql://user:pass@localhost:5432/dbname" \ + --api-url "https://portaildf.cerema.fr/api" \ + --username "my_user" \ + --password "my_password" +``` + +### Dry Run (Test Mode) + +Test the script without making database changes: + +```bash +python sync_user_kind.py \ + --db-url "postgresql://user:pass@localhost:5432/dbname" \ + --api-url "https://portaildf.cerema.fr/api" \ + --dry-run +``` + +### Limit Processing (Testing) + +Process only first 100 users: + +```bash +python sync_user_kind.py \ + --db-url "postgresql://user:pass@localhost:5432/dbname" \ + --api-url "https://portaildf.cerema.fr/api" \ + --limit 100 +``` + +### Verbose Output + +```bash +python sync_user_kind.py \ + --db-url "postgresql://user:pass@localhost:5432/dbname" \ + --api-url "https://portaildf.cerema.fr/api" \ + --verbose +``` + +### Debug Mode + +```bash +python sync_user_kind.py \ + --db-url "postgresql://user:pass@localhost:5432/dbname" \ + --api-url "https://portaildf.cerema.fr/api" \ + --debug +``` + +## Command-Line Arguments + +| Argument | Required | Default | Description | +|----------|----------|---------|-------------| +| `--db-url` | Yes | - | PostgreSQL connection URI | +| `--api-url` | Yes | - | Portail DF API base URL | +| `--username` | Yes | - | API username for authentication | +| `--password` | Yes | - | API password for authentication | +| `--dry-run` | No | `false` | Simulation mode (no DB changes) | +| `--limit` | No | - | Limit number of users to process | +| `--batch-size` | No | `1000` | Batch size for DB updates | +| `--num-workers` | No | `4` | Number of parallel workers | +| `--verbose`, `-v` | No | `false` | Verbose output | +| `--debug` | No | `false` | Debug logging | + +## How It Works + +1. **Authenticate**: Obtains a token from `/api-token-auth/` using username/password +2. **Fetch Users**: Retrieves all users with email addresses from local database +3. **API Lookup**: For each user, queries Portail DF API: `/utilisateurs?email=` with `Authorization: Token ` header +4. **Determine Kind**: Applies mapping rules based on `exterieur` and `gestionnaire` flags +5. **Batch Update**: Updates database in batches with parallel workers +6. **Summary**: Displays statistics about processed users + +## Performance + +- **API Requests**: Sequential (one per user) to avoid rate limiting +- **Database Updates**: Parallel batch processing (default: 4 workers, 1000 records/batch) +- **Expected Time**: ~1-2 seconds per user (API dependent) + +### Optimizations + +- Batch database updates (1000 records at a time) +- Parallel database workers (4 concurrent connections) +- Asynchronous commits (`synchronous_commit = off`) +- Progress tracking with `tqdm` + +## Example Output + +``` +================================================================================ +USER KIND SYNCHRONIZATION +================================================================================ +API URL: https://portaildf.cerema.fr/api +Dry run: False + +📋 Found 1,234 users to sync + +🔄 Fetching data from Portail DF API... +Processing: 100%|████████████████| 1234/1234 [20:30<00:00, 1.01users/s] + +💾 Updating 987 users... +Saving: 100%|████████████████████| 987/987 [00:02<00:00, 412.34users/s] +✅ Updated 987 users + +================================================================================ +SUMMARY +================================================================================ +Total processed: 1,234 +Updated: 987 +Skipped (not in API): 247 +Failed: 0 + +Kind distribution: + Prestataire: 123 + Gestionnaire: 789 + Both: 45 + None: 30 +================================================================================ +``` + +## Error Handling + +- **API Errors**: Users not found in API are skipped and logged +- **Network Issues**: Requests timeout after 10 seconds, errors are logged +- **Database Errors**: Failed batches are logged, processing continues +- **Resume Capability**: Script can be re-run safely (updates are idempotent) + +## Logging + +Logs are written to: +- **Console**: WARNING level (errors and important messages) +- **File**: INFO level (`sync_user_kind_YYYYMMDD_HHMMSS.log`) + +Use `--verbose` or `--debug` flags for more detailed output. + +## Troubleshooting + +### API Returns No Data + +``` +User not found in Portail DF: user@example.com +``` + +**Solution**: User doesn't exist in Portail DF API, will be skipped. + +### Connection Timeout + +``` +API request failed for user@example.com: Connection timeout +``` + +**Solution**: +- Check API URL is correct +- Verify network connectivity +- Try with `--limit 10` first to test + +### Database Connection Failed + +``` +❌ Database connection failed: connection refused +``` + +**Solution**: Verify `--db-url` parameter is correct. + +## Best Practices + +1. **Test First**: Always run with `--dry-run` and `--limit 10` first +2. **Monitor Progress**: Use `--verbose` to see detailed progress +3. **Handle Failures**: Script logs errors but continues processing +4. **Re-run Safe**: Can be re-run multiple times (updates are idempotent) + +## Related Files + +- Migration: `server/src/infra/database/migrations/20241128175635-users-add-kind.ts` +- Best Practices: `docs/SCRIPT_BEST_PRACTICES.md` diff --git a/server/src/scripts/sync-user-kind/sync_user_kind.py b/server/src/scripts/sync-user-kind/sync_user_kind.py new file mode 100644 index 0000000000..f98f6d8b7d --- /dev/null +++ b/server/src/scripts/sync-user-kind/sync_user_kind.py @@ -0,0 +1,538 @@ +#!/usr/bin/env python3 +""" +Synchronize user 'kind' field from Portail DF API. + +This script fetches user data from the Portail DF API and updates the 'kind' column +in the local users table based on the 'exterieur' and 'gestionnaire' fields. + +Mapping Rules: +- exterieur=true, gestionnaire=false → kind='prestataire' +- exterieur=false, gestionnaire=true → kind='gestionnaire' +- exterieur=true, gestionnaire=true → kind='prestataire, gestionnaire' +- exterieur=false, gestionnaire=false → kind='aucun' + +Usage: + python sync_user_kind.py --db-url --api-url + python sync_user_kind.py --db-url --api-url --dry-run + python sync_user_kind.py --db-url --api-url --limit 100 +""" + +import argparse +import logging +import psycopg2 +import requests +from psycopg2.extras import RealDictCursor, execute_values +from tqdm import tqdm +from datetime import datetime +from typing import List, Dict, Optional +from concurrent.futures import ThreadPoolExecutor, as_completed + + +class UserKindSync: + """Synchronize user kind from Portail DF API.""" + + def __init__( + self, + db_url: str, + api_url: str, + username: str, + password: str, + dry_run: bool = False, + batch_size: int = 1000, + num_workers: int = 4, + ): + """ + Initialize the sync processor. + + Args: + db_url: PostgreSQL connection URI + api_url: Portail DF API base URL + username: API username for authentication + password: API password for authentication + dry_run: Simulation mode (no database modifications) + batch_size: Batch size for database operations + num_workers: Number of parallel workers + """ + self.db_url = db_url + self.api_url = api_url.rstrip("/") + self.username = username + self.password = password + self.dry_run = dry_run + self.batch_size = batch_size + self.num_workers = num_workers + self.conn = None + self.cursor = None + self.auth_token = None + + self.stats = { + "processed": 0, + "updated": 0, + "skipped": 0, + "failed": 0, + "kind_prestataire": 0, + "kind_gestionnaire": 0, + "kind_both": 0, + "kind_none": 0, + } + + # Setup requests session + self.session = requests.Session() + self.session.headers.update( + {"Accept": "application/json", "Content-Type": "application/json"} + ) + + def authenticate(self): + """Authenticate with Portail DF API and get token.""" + try: + auth_url = f"{self.api_url}/api-token-auth/" + # Send as multipart/form-data (boundary is auto-calculated) + response = requests.post( + auth_url, + files={ + "username": (None, self.username), + "password": (None, self.password), + }, + timeout=60, + ) + response.raise_for_status() + + data = response.json() + self.auth_token = data.get("token") + + if not self.auth_token: + raise ValueError("No token in authentication response") + + # Update session headers with token + self.session.headers.update( + {"Authorization": f"Token {self.auth_token}"} + ) + logging.info("✅ Portail DF API authentication successful") + + except requests.exceptions.RequestException as e: + print(f"❌ Portail DF API authentication failed: {e}") + raise + except Exception as e: + print(f"❌ Authentication error: {e}") + raise + + def connect(self): + """Establish database connection.""" + try: + self.conn = psycopg2.connect(self.db_url) + self.cursor = self.conn.cursor(cursor_factory=RealDictCursor) + logging.info("✅ Database connection established") + except Exception as e: + print(f"❌ Database connection failed: {e}") + raise + + def disconnect(self): + """Close database connection.""" + if self.cursor: + self.cursor.close() + if self.conn: + self.conn.close() + if self.session: + self.session.close() + + def get_users_to_sync(self, limit: Optional[int] = None) -> List[Dict]: + """ + Get users from local database that need syncing. + + Args: + limit: Optional limit on number of users to process + + Returns: + List of user records with email addresses + """ + query = """ + SELECT id, email + FROM users + WHERE email IS NOT NULL + ORDER BY id + """ + + if limit: + query += f" LIMIT {limit}" + + self.cursor.execute(query) + users = self.cursor.fetchall() + logging.info(f"📋 Found {len(users):,} users to sync") + return users + + def fetch_user_from_api(self, email: str, max_retries: int = 3) -> Optional[Dict]: + """ + Fetch user data from Portail DF API with retry logic. + + Args: + email: User email address + max_retries: Maximum number of retry attempts + + Returns: + User data from API or None if not found/error + """ + import time + + url = f"{self.api_url}/utilisateurs" + params = {"email": email} + + for attempt in range(max_retries): + try: + response = self.session.get(url, params=params, timeout=30) + response.raise_for_status() + + data = response.json() + + # API returns paginated results + if data.get("count", 0) == 0: + logging.debug(f"User not found in Portail DF: {email}") + return None + + # Get first result (should be only one) + results = data.get("results", []) + if results: + return results[0] + + return None + + except requests.exceptions.Timeout as e: + wait_time = 2 ** attempt # Exponential backoff: 1s, 2s, 4s + if attempt < max_retries - 1: + logging.warning(f"Timeout for {email}, retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})") + time.sleep(wait_time) + else: + logging.warning(f"API request failed for {email} after {max_retries} attempts: {e}") + return None + except requests.exceptions.RequestException as e: + logging.warning(f"API request failed for {email}: {e}") + return None + except Exception as e: + logging.error(f"Error fetching user {email}: {e}") + return None + + return None + + def determine_kind(self, exterieur: bool, gestionnaire: bool) -> str: + """ + Determine user kind based on exterieur and gestionnaire flags. + + Args: + exterieur: Whether user is external + gestionnaire: Whether user is a manager + + Returns: + Kind value according to mapping rules + """ + if exterieur and not gestionnaire: + return "prestataire" + elif not exterieur and gestionnaire: + return "gestionnaire" + elif exterieur and gestionnaire: + return "prestataire, gestionnaire" + else: # not exterieur and not gestionnaire + return "aucun" + + def process_user(self, user: Dict) -> Optional[Dict]: + """ + Process a single user: fetch from API and determine kind. + + Args: + user: User record from local database + + Returns: + Update data or None if no update needed + """ + api_data = self.fetch_user_from_api(user["email"]) + + if not api_data: + self.stats["skipped"] += 1 + return None + + exterieur = api_data.get("exterieur", False) + gestionnaire = api_data.get("gestionnaire", False) + kind = self.determine_kind(exterieur, gestionnaire) + + # Update stats + if kind == "prestataire": + self.stats["kind_prestataire"] += 1 + elif kind == "gestionnaire": + self.stats["kind_gestionnaire"] += 1 + elif kind == "prestataire, gestionnaire": + self.stats["kind_both"] += 1 + else: + self.stats["kind_none"] += 1 + + return {"user_id": user["id"], "email": user["email"], "kind": kind} + + def _update_batch_worker(self, batch_data: tuple) -> tuple: + """ + Worker to update a batch of users in parallel. + + Args: + batch_data: Tuple of (batch_id, batch, db_url) + + Returns: + Tuple of (batch_id, count, error) + """ + batch_id, batch, db_url = batch_data + + conn = None + try: + conn = psycopg2.connect(db_url) + cursor = conn.cursor(cursor_factory=RealDictCursor) + + # Optimize for faster commits + cursor.execute("SET synchronous_commit = off") + + # Prepare update data + update_data = [(item["kind"], item["user_id"]) for item in batch] + + # Execute batch update + execute_values( + cursor, + """ + UPDATE users AS u + SET kind = data.kind + FROM (VALUES %s) AS data(kind, user_id) + WHERE u.id = data.user_id::uuid + """, + update_data, + page_size=500, + ) + + # Independent commit per batch + conn.commit() + cursor.close() + conn.close() + + return (batch_id, len(batch), None) + + except Exception as e: + if conn: + try: + conn.rollback() + conn.close() + except: + pass + return (batch_id, 0, str(e)) + + def update_database(self, updates: List[Dict]): + """ + Update database with user kind values. + + Args: + updates: List of update records + """ + if not updates: + print("✅ No updates to apply") + return + + if self.dry_run: + print(f"✅ Dry run: {len(updates):,} updates prepared") + print("\nSample updates:") + for update in updates[:5]: + print(f" {update['email']}: {update['kind']}") + return + + print(f"\n💾 Updating {len(updates):,} users...") + + # Split into batches + batches = [] + for i in range(0, len(updates), self.batch_size): + batch = updates[i : i + self.batch_size] + batches.append((i // self.batch_size, batch, self.db_url)) + + # Process in parallel + total_success = 0 + with tqdm(total=len(updates), desc="Saving", unit="users") as pbar: + with ThreadPoolExecutor(max_workers=self.num_workers) as executor: + futures = { + executor.submit(self._update_batch_worker, batch_data): batch_data + for batch_data in batches + } + + for future in as_completed(futures): + batch_id, count, error = future.result() + if error: + logging.error(f"Batch {batch_id} error: {error}") + self.stats["failed"] += count if count > 0 else len( + batches[batch_id][1] + ) + else: + total_success += count + self.stats["updated"] += count + + pbar.update(count if count > 0 else len(batches[batch_id][1])) + + print(f"✅ Updated {total_success:,} users") + + def run(self, limit: Optional[int] = None): + """ + Main execution method. + + Args: + limit: Optional limit on number of users to process + """ + print("=" * 80) + print("USER KIND SYNCHRONIZATION") + print("=" * 80) + print(f"API URL: {self.api_url}") + print(f"Dry run: {self.dry_run}") + if limit: + print(f"Limit: {limit:,} users") + print() + + self.connect() + self.authenticate() + + try: + # Get users to process + users = self.get_users_to_sync(limit) + + if not users: + print("✅ No users to process") + return + + # Process users and fetch kind from API + updates = [] + print(f"\n🔄 Fetching data from Portail DF API...") + + with tqdm(users, desc="Processing", unit="users") as pbar: + for user in pbar: + try: + update = self.process_user(user) + if update: + updates.append(update) + + self.stats["processed"] += 1 + + # Update progress bar with stats + pbar.set_postfix( + { + "updated": len(updates), + "skipped": self.stats["skipped"], + } + ) + + except Exception as e: + logging.error(f"Error processing user {user['email']}: {e}") + self.stats["failed"] += 1 + continue + + # Update database + if updates: + self.update_database(updates) + + # Print summary + print("\n" + "=" * 80) + print("SUMMARY") + print("=" * 80) + print(f"Total processed: {self.stats['processed']:,}") + print(f"Updated: {self.stats['updated']:,}") + print(f"Skipped (not in API): {self.stats['skipped']:,}") + print(f"Failed: {self.stats['failed']:,}") + print() + print("Kind distribution:") + print(f" Prestataire: {self.stats['kind_prestataire']:,}") + print(f" Gestionnaire: {self.stats['kind_gestionnaire']:,}") + print(f" Both: {self.stats['kind_both']:,}") + print(f" None: {self.stats['kind_none']:,}") + print("=" * 80) + + finally: + self.disconnect() + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Synchronize user kind from Portail DF API" + ) + + parser.add_argument( + "--db-url", + required=True, + help="PostgreSQL connection URI (postgresql://user:pass@host:port/dbname)", + ) + parser.add_argument( + "--api-url", + required=True, + help="Portail DF API base URL (e.g., https://portaildf.cerema.fr/api)", + ) + parser.add_argument( + "--username", + required=True, + help="API username for authentication", + ) + parser.add_argument( + "--password", + required=True, + help="API password for authentication", + ) + parser.add_argument( + "--dry-run", action="store_true", help="Simulation mode (no database changes)" + ) + parser.add_argument( + "--limit", type=int, help="Limit number of users to process (for testing)" + ) + parser.add_argument( + "--batch-size", + type=int, + default=1000, + help="Batch size for database updates (default: 1000)", + ) + parser.add_argument( + "--num-workers", + type=int, + default=4, + help="Number of parallel workers for DB updates (default: 4)", + ) + parser.add_argument( + "--debug", action="store_true", help="Enable debug logging" + ) + parser.add_argument( + "--verbose", "-v", action="store_true", help="Verbose output" + ) + + args = parser.parse_args() + + # Setup logging + log_level = logging.DEBUG if args.debug else ( + logging.INFO if args.verbose else logging.WARNING + ) + logging.basicConfig( + level=log_level, + format="%(levelname)s - %(message)s", + handlers=[ + logging.FileHandler( + f"sync_user_kind_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" + ), + logging.StreamHandler(), + ], + ) + + # Run synchronization + sync = UserKindSync( + db_url=args.db_url, + api_url=args.api_url, + username=args.username, + password=args.password, + dry_run=args.dry_run, + batch_size=args.batch_size, + num_workers=args.num_workers, + ) + + try: + sync.run(args.limit) + print("\n✅ Synchronization completed successfully") + except KeyboardInterrupt: + print("\n⚠️ Interrupted by user") + except Exception as e: + print(f"\n❌ Failed: {e}") + if args.debug: + import traceback + + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/server/src/services/ceremaService/userKindService.ts b/server/src/services/ceremaService/userKindService.ts new file mode 100644 index 0000000000..6423165388 --- /dev/null +++ b/server/src/services/ceremaService/userKindService.ts @@ -0,0 +1,132 @@ +import config from '~/infra/config'; +import { logger } from '~/infra/logger'; + +export interface PortailDFUser { + id_user: number; + email: string; + exterieur: boolean; + gestionnaire: boolean; +} + +export interface PortailDFResponse { + count: number; + next: string | null; + previous: string | null; + results: PortailDFUser[]; +} + +/** + * Determine user kind based on exterieur and gestionnaire flags from Portail DF API. + * + * Mapping rules: + * - exterieur=true, gestionnaire=false → "prestataire" + * - exterieur=false, gestionnaire=true → "gestionnaire" + * - exterieur=true, gestionnaire=true → "prestataire, gestionnaire" + * - exterieur=false, gestionnaire=false → "aucun" + * + * @param exterieur - Whether the user is external + * @param gestionnaire - Whether the user is a manager + * @returns The kind value according to mapping rules + */ +export function determineUserKind( + exterieur: boolean, + gestionnaire: boolean +): string { + if (exterieur && !gestionnaire) { + return 'prestataire'; + } else if (!exterieur && gestionnaire) { + return 'gestionnaire'; + } else if (exterieur && gestionnaire) { + return 'prestataire, gestionnaire'; + } else { + return 'aucun'; + } +} + +/** + * Fetch user kind from Portail DF API. + * + * @param email - User email address + * @returns The kind value or null if user not found or error occurred + */ +export async function fetchUserKind(email: string): Promise { + // Skip if Cerema integration is disabled + if (!config.cerema.enabled) { + logger.debug('Cerema integration disabled, skipping user kind fetch', { + email + }); + return null; + } + + try { + // Authenticate with Portail DF API using multipart/form-data + const formData = new FormData(); + formData.append('username', config.cerema.username); + formData.append('password', config.cerema.password); + + const authResponse = await fetch( + `${config.cerema.api}/api/api-token-auth/`, + { + method: 'POST', + body: formData + } + ); + + if (!authResponse.ok) { + logger.error('Failed to authenticate with Portail DF API', { + status: authResponse.status, + email + }); + return null; + } + + const { token }: any = await authResponse.json(); + + // Fetch user data from Portail DF + const userResponse = await fetch( + `${config.cerema.api}/api/utilisateurs?email=${encodeURIComponent(email)}`, + { + method: 'GET', + headers: { + Authorization: `Token ${token}`, + 'Content-Type': 'application/json' + } + } + ); + + if (!userResponse.ok) { + logger.error('Failed to fetch user from Portail DF API', { + status: userResponse.status, + email + }); + return null; + } + + const userContent = await userResponse.json() as PortailDFResponse; + + // No user found + if (!userContent.results || userContent.results.length === 0) { + logger.debug('User not found in Portail DF', { email }); + return null; + } + + // Get first result (should be only one for exact email match) + const user = userContent.results[0]; + const kind = determineUserKind(user.exterieur, user.gestionnaire); + + logger.info('Fetched user kind from Portail DF', { + email, + exterieur: user.exterieur, + gestionnaire: user.gestionnaire, + kind + }); + + return kind; + } catch (error) { + logger.error('Error fetching user kind from Portail DF', { + email, + error: error instanceof Error ? error.message : String(error) + }); + return null; + } +} diff --git a/yarn.lock b/yarn.lock index 155c475eac..7569fccb0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5106,7 +5106,7 @@ __metadata: languageName: node linkType: hard -"@maplibre/maplibre-gl-style-spec@npm:24.4.1, @maplibre/maplibre-gl-style-spec@npm:^24.3.1": +"@maplibre/maplibre-gl-style-spec@npm:24.4.1, @maplibre/maplibre-gl-style-spec@npm:^24.4.1": version: 24.4.1 resolution: "@maplibre/maplibre-gl-style-spec@npm:24.4.1" dependencies: @@ -5143,7 +5143,7 @@ __metadata: languageName: node linkType: hard -"@maplibre/mlt@npm:^1.1.0": +"@maplibre/mlt@npm:^1.1.2": version: 1.1.2 resolution: "@maplibre/mlt@npm:1.1.2" dependencies: @@ -5152,9 +5152,9 @@ __metadata: languageName: node linkType: hard -"@maplibre/vt-pbf@npm:^4.0.3": - version: 4.0.3 - resolution: "@maplibre/vt-pbf@npm:4.0.3" +"@maplibre/vt-pbf@npm:^4.2.0": + version: 4.2.0 + resolution: "@maplibre/vt-pbf@npm:4.2.0" dependencies: "@mapbox/point-geometry": "npm:^1.1.0" "@mapbox/vector-tile": "npm:^2.0.4" @@ -5163,7 +5163,7 @@ __metadata: geojson-vt: "npm:^4.0.2" pbf: "npm:^4.0.1" supercluster: "npm:^8.0.1" - checksum: 10c0/d3c84adabb9dc93ff72ea6ab8c17900479f09e4d473f34ccae1f8e22d29f814d288f529f0d85959545bc4d7021675e056f7f558e758f705a4cb060b1f75f0aff + checksum: 10c0/763b038c46e56f8393cf553a1dcea6bf51a32e2502a222d5b7cda24087dcba06b9175a9435b08e7a0df664d0251b0c1e714b2433f0dbdca7d824adb2d412c055 languageName: node linkType: hard @@ -13015,7 +13015,7 @@ __metadata: jose: "npm:4.15.9" lexical: "npm:0.38.2" lodash-es: "npm:4.17.21" - maplibre-gl: "npm:5.13.0" + maplibre-gl: "npm:5.16.0" mime: "npm:4.1.0" msw: "npm:2.12.4" posthog-js: "npm:1.298.0" @@ -24820,9 +24820,9 @@ __metadata: languageName: node linkType: hard -"maplibre-gl@npm:5.13.0": - version: 5.13.0 - resolution: "maplibre-gl@npm:5.13.0" +"maplibre-gl@npm:5.16.0": + version: 5.16.0 + resolution: "maplibre-gl@npm:5.16.0" dependencies: "@mapbox/geojson-rewind": "npm:^0.5.2" "@mapbox/jsonlint-lines-primitives": "npm:^2.0.2" @@ -24831,9 +24831,9 @@ __metadata: "@mapbox/unitbezier": "npm:^0.0.1" "@mapbox/vector-tile": "npm:^2.0.4" "@mapbox/whoots-js": "npm:^3.1.0" - "@maplibre/maplibre-gl-style-spec": "npm:^24.3.1" - "@maplibre/mlt": "npm:^1.1.0" - "@maplibre/vt-pbf": "npm:^4.0.3" + "@maplibre/maplibre-gl-style-spec": "npm:^24.4.1" + "@maplibre/mlt": "npm:^1.1.2" + "@maplibre/vt-pbf": "npm:^4.2.0" "@types/geojson": "npm:^7946.0.16" "@types/geojson-vt": "npm:3.2.5" "@types/supercluster": "npm:^7.1.3" @@ -24847,7 +24847,7 @@ __metadata: quickselect: "npm:^3.0.0" supercluster: "npm:^8.0.1" tinyqueue: "npm:^3.0.0" - checksum: 10c0/83c4df19979e361844cb30b644d1169e01d1e8312e4e0f6683896915e4456c6c3d2595d055cef5a0b2bc5927609ad51f7c54d6f3662dbd2305e32c3aa8741e24 + checksum: 10c0/573f560724d898e2825212fcd578e82fad4da2934375f3672ffcdc379ad21e358739754104f698a1874b0c42fe8062ab27841f04c31b65d049ec0fab62233c6c languageName: node linkType: hard