diff --git a/database/migrations/003-samples.ts b/database/migrations/003-samples.ts index dce92a36..f820ad68 100644 --- a/database/migrations/003-samples.ts +++ b/database/migrations/003-samples.ts @@ -18,10 +18,11 @@ exports.up = async (knex: Knex) => { table.string('matrix'); table.string('matrix_kind'); table.string('matrix_part'); + table.string('stage'); table.double('quantity'); table.string('quantity_unit'); table.string('culture_kind'); - table.boolean('compliance_2002_63'); + table.boolean('compliance200263'); table.string('storage_condition'); table.boolean('pooling'); table.boolean('release_control'); diff --git a/database/seeds/test/001-users.ts b/database/seeds/test/001-users.ts index 1d12b46a..b32ed6f1 100644 --- a/database/seeds/test/001-users.ts +++ b/database/seeds/test/001-users.ts @@ -1,19 +1,23 @@ import bcrypt from 'bcryptjs'; import { Knex } from 'knex'; import { UserApi } from '../../../server/models/UserApi'; -import userRepository, { - usersTable, -} from '../../../server/repositories/userRepository'; +import { usersTable } from '../../../server/repositories/userRepository'; import { genUserApi } from '../../../server/test/testFixtures'; export const User1: UserApi = genUserApi(); +export const User2: UserApi = genUserApi(); exports.seed = async function (knex: Knex) { - const hash = await bcrypt.hash(User1.password, 10); + const hash1 = await bcrypt.hash(User1.password, 10); + const hash2 = await bcrypt.hash(User2.password, 10); await knex.table(usersTable).insert([ - userRepository.formatUserApi({ + { ...User1, - password: hash, - }), + password: hash1, + }, + { + ...User2, + password: hash2, + }, ]); }; diff --git a/database/seeds/test/002-samples.ts b/database/seeds/test/002-samples.ts new file mode 100644 index 00000000..7afb4e76 --- /dev/null +++ b/database/seeds/test/002-samples.ts @@ -0,0 +1,12 @@ +import sampleRepository from '../../../server/repositories/sampleRepository'; +import { genSample } from '../../../server/test/testFixtures'; +import { Sample } from '../../../shared/schema/Sample'; +import { User1, User2 } from './001-users'; + +export const Sample1: Sample = genSample(User1.id); +export const Sample2: Sample = genSample(User2.id); + +exports.seed = async function () { + await sampleRepository.insert(Sample1); + await sampleRepository.insert(Sample2); +}; diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index b135364c..4cb60ae3 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -44,7 +44,7 @@ const Header = () => { to: '/prelevements', target: '_self', }, - text: 'Prélèvements', + text: 'Mes prélèvements', isActive: location.pathname.startsWith('/prelevements'), }, ] diff --git a/frontend/src/hooks/useAuthentication.tsx b/frontend/src/hooks/useAuthentication.tsx index 729f80f7..cbb8820a 100644 --- a/frontend/src/hooks/useAuthentication.tsx +++ b/frontend/src/hooks/useAuthentication.tsx @@ -37,6 +37,12 @@ export const useAuthentication = () => { key: 'sample_route', component: SampleView, }, + { + path: '/prelevements/:sampleId', + label: 'Prélèvement', + key: 'sample_route', + component: SampleView, + }, ] : [ { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 20c38e77..d43bbbd5 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -6,7 +6,11 @@ i18n.use(initReactI18next).init({ fallbackLng: 'fr', resources: { fr: { - translation: {}, + translation: { + sample_zero: 'Aucun prélèvement', + sample_one: 'Un prélèvement', + sample_other: '{{count}} prélèvements', + }, }, }, }); diff --git a/frontend/src/services/api.service.ts b/frontend/src/services/api.service.ts index 0bd4cda3..547be9b9 100644 --- a/frontend/src/services/api.service.ts +++ b/frontend/src/services/api.service.ts @@ -7,6 +7,6 @@ export const api = createApi({ baseUrl: `${config.apiEndpoint}/api`, prepareHeaders: withAuthHeader, }), - tagTypes: ['User'], + tagTypes: ['Sample', 'User'], endpoints: () => ({}), }); diff --git a/frontend/src/services/sample.service.ts b/frontend/src/services/sample.service.ts index 94eabb1e..4fceb74b 100644 --- a/frontend/src/services/sample.service.ts +++ b/frontend/src/services/sample.service.ts @@ -1,16 +1,53 @@ -import { SampleToCreate } from 'shared/schema/Sample'; +import fp from 'lodash'; +import { + PartialSample, + PartialSampleUpdate, + SampleToCreate, +} from 'shared/schema/Sample'; import { api } from 'src/services/api.service'; export const sampleApi = api.injectEndpoints({ endpoints: (builder) => ({ - createSample: builder.mutation({ + createSample: builder.mutation({ query: (draft) => ({ url: 'samples', method: 'POST', body: { ...draft }, }), + transformResponse: (response: any) => response.id, + }), + getSample: builder.query({ + query: (sampleId) => `samples/${sampleId}`, + transformResponse: (response: any) => + PartialSample.parse(fp.omitBy(response, fp.isNil)), + providesTags: (result, error, sampleId) => + result ? [{ type: 'Sample', id: sampleId }] : [], + }), + findSamples: builder.query({ + query: () => 'samples', + transformResponse: (response: any[]) => + response.map((_) => PartialSample.parse(fp.omitBy(_, fp.isNil))), + providesTags: (result) => + result ? result.map(({ id }) => ({ type: 'Sample', id })) : [], + }), + updateSample: builder.mutation< + void, + { sampleId: string; sampleUpdate: PartialSampleUpdate } + >({ + query: ({ sampleId, sampleUpdate }) => ({ + url: `samples/${sampleId}`, + method: 'PUT', + body: sampleUpdate, + }), + invalidatesTags: (result, error, { sampleId }) => + result ? [{ type: 'Sample', id: sampleId }] : [], }), }), }); -export const { useCreateSampleMutation } = sampleApi; +export const { + useCreateSampleMutation, + useFindSamplesQuery, + useGetSampleQuery, + useUpdateSampleMutation, +} = sampleApi; diff --git a/frontend/src/views/SampleListView/SampleListView.tsx b/frontend/src/views/SampleListView/SampleListView.tsx index 3fc8be53..fdbe5a8c 100644 --- a/frontend/src/views/SampleListView/SampleListView.tsx +++ b/frontend/src/views/SampleListView/SampleListView.tsx @@ -1,18 +1,39 @@ import Button from '@codegouvfr/react-dsfr/Button'; import { cx } from '@codegouvfr/react-dsfr/fr/cx'; +import Table from '@codegouvfr/react-dsfr/Table'; +import { format } from 'date-fns'; +import { t } from 'i18next'; +import { Link } from 'react-router-dom'; import { useDocumentTitle } from 'src/hooks/useDocumentTitle'; +import { useFindSamplesQuery } from 'src/services/sample.service'; const SampleListView = () => { - useDocumentTitle('Liste des prélèvements'); + useDocumentTitle('Liste de mes prélèvements'); + + const { data: samples } = useFindSamplesQuery(); return (
-

Liste des prélèvements

+

Mes prélèvements

+
+ {t('sample', { count: samples?.length || 0 })} +
+ {samples && samples.length > 0 && ( + [ + {sample.reference}, + format(sample.createdAt, 'dd/MM/yyyy'), + ])} + /> + )} diff --git a/frontend/src/views/SampleView/SampleFormStep1.tsx b/frontend/src/views/SampleView/SampleFormStep1.tsx index 0be03201..5c762828 100644 --- a/frontend/src/views/SampleView/SampleFormStep1.tsx +++ b/frontend/src/views/SampleView/SampleFormStep1.tsx @@ -3,6 +3,7 @@ import Button from '@codegouvfr/react-dsfr/Button'; import { cx } from '@codegouvfr/react-dsfr/fr/cx'; import Select from '@codegouvfr/react-dsfr/Select'; import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Department, DepartmentLabels, @@ -18,17 +19,20 @@ import AppSelect from 'src/components/_app/AppSelect/AppSelect'; import { selectOptionsFromList } from 'src/components/_app/AppSelect/AppSelectOption'; import AppTextInput from 'src/components/_app/AppTextInput/AppTextInput'; import { useForm } from 'src/hooks/useForm'; +import { useCreateSampleMutation } from 'src/services/sample.service'; -interface Props { - onValid: (draftSample: SampleToCreate) => void; -} +interface Props {} + +const SampleFormStep1 = ({}: Props) => { + const navigate = useNavigate(); -const SampleFormStep1 = ({ onValid }: Props) => { const [resytalId, setResytalId] = useState(''); const [context, setContext] = useState(); const [userLocation, setUserLocation] = useState(); const [department, setDepartment] = useState(); + const [createSample] = useCreateSampleMutation(); + const Form = SampleToCreate; const form = useForm(Form, { @@ -52,13 +56,20 @@ const SampleFormStep1 = ({ onValid }: Props) => { const submit = async (e: React.MouseEvent) => { e.preventDefault(); - await form.validate(() => { - onValid({ + await form.validate(async () => { + await createSample({ userLocation: userLocation as UserLocation, resytalId, context: context as SampleContext, department: department as Department, - }); + }) + .unwrap() + .then((result) => { + navigate(`/prelevements/${result}`, { replace: true }); + }) + .catch(() => { + //TODO handle error + }); }); }; @@ -127,7 +138,7 @@ const SampleFormStep1 = ({ onValid }: Props) => { nativeSelectProps={{ defaultValue: '' }} disabled={true} > - diff --git a/frontend/src/views/SampleView/SampleFormStep2.tsx b/frontend/src/views/SampleView/SampleFormStep2.tsx index 91d6372e..f2de01fb 100644 --- a/frontend/src/views/SampleView/SampleFormStep2.tsx +++ b/frontend/src/views/SampleView/SampleFormStep2.tsx @@ -1,43 +1,57 @@ -import Button from '@codegouvfr/react-dsfr/Button'; +import ButtonsGroup from '@codegouvfr/react-dsfr/ButtonsGroup'; import { cx } from '@codegouvfr/react-dsfr/fr/cx'; +import ToggleSwitch from '@codegouvfr/react-dsfr/ToggleSwitch'; +import { format, parse } from 'date-fns'; import { useState } from 'react'; +import { MatrixList, MatrixPartList } from 'shared/foodex2/Matrix'; +import { PartialSample, SampleUpdate } from 'shared/schema/Sample'; +import { SampleStage, SampleStageList } from 'shared/schema/SampleStage'; import { - MatrixKindList, - MatrixList, - MatrixPartList, -} from 'shared/foodex2/Matrix'; -import { SampleToUpdate } from 'shared/schema/Sample'; + SampleStorageCondition, + SampleStorageConditionList, +} from 'shared/schema/SampleStorageCondition'; import AppSelect from 'src/components/_app/AppSelect/AppSelect'; import { selectOptionsFromList } from 'src/components/_app/AppSelect/AppSelectOption'; import AppTextInput from 'src/components/_app/AppTextInput/AppTextInput'; import { useForm } from 'src/hooks/useForm'; +import { useUpdateSampleMutation } from 'src/services/sample.service'; interface Props { - onValid: () => void; + sample: PartialSample; } -const SampleFormStep2 = ({ onValid }: Props) => { - const [matrix, setMatrix] = useState(); - const [matrixKind, setMatrixKind] = useState(); - const [matrixPart, setMatrixPart] = useState(); - const [quantity, setQuantity] = useState(0); - const [quantityUnit, setQuantityUnit] = useState(''); - const [cultureKind, setCultureKind] = useState(''); - const [compliance200263, setCompliance200263] = useState(false); - const [storageCondition, setStorageCondition] = useState(''); - const [pooling, setPooling] = useState(false); - const [releaseControl, setReleaseControl] = useState(false); - const [sampleCount, setSampleCount] = useState(0); - const [temperatureMaintenance, setTemperatureMaintenance] = useState(false); - const [expiryDate, setExpiryDate] = useState(''); - const [sealId, setSealId] = useState(0); +const SampleFormStep2 = ({ sample }: Props) => { + const [matrixKind, setMatrixKind] = useState(sample.matrixKind); + const [matrix, setMatrix] = useState(sample.matrix); + const [matrixPart, setMatrixPart] = useState(sample.matrixPart); + const [stage, setStage] = useState(sample.stage); + const [quantity, setQuantity] = useState(sample.quantity); + const [quantityUnit, setQuantityUnit] = useState(sample.quantityUnit); + const [cultureKind, setCultureKind] = useState(sample.cultureKind); + const [compliance200263, setCompliance200263] = useState( + sample.compliance200263 + ); + const [storageCondition, setStorageCondition] = useState( + sample.storageCondition + ); + const [pooling, setPooling] = useState(sample.pooling); + const [releaseControl, setReleaseControl] = useState(sample.releaseControl); + const [sampleCount, setSampleCount] = useState(sample.sampleCount); + const [temperatureMaintenance, setTemperatureMaintenance] = useState( + sample.temperatureMaintenance + ); + const [expiryDate, setExpiryDate] = useState(sample.expiryDate); + const [sealId, setSealId] = useState(sample.sealId); - const Form = SampleToUpdate; + const [updateSample] = useUpdateSampleMutation(); + + const Form = SampleUpdate; const form = useForm(Form, { - matrix, matrixKind, + matrix, matrixPart, + stage, quantity, quantityUnit, cultureKind, @@ -57,42 +71,65 @@ const SampleFormStep2 = ({ onValid }: Props) => { e.preventDefault(); await form.validate(); if (form.isValid()) { - onValid(); + await save(); } }; + const save = async () => { + await updateSample({ + sampleId: sample.id, + sampleUpdate: { + matrixKind, + matrix, + matrixPart, + stage, + quantity, + quantityUnit, + cultureKind, + compliance200263, + storageCondition, + pooling, + releaseControl, + sampleCount, + temperatureMaintenance, + expiryDate, + sealId, + }, + }); + }; + return (
- defaultValue="" - options={selectOptionsFromList(MatrixList)} - onChange={(e) => setMatrix(e.target.value as string)} + defaultValue={matrixKind ?? ''} + options={selectOptionsFromList(['Fruits', 'Légumes'])} + onChange={(e) => setMatrixKind(e.target.value)} inputForm={form} - inputKey="matrix" - whenValid="Matrice correctement renseignée." - data-testid="matrix-select" - label="Matrice (obligatoire)" + inputKey="matrixKind" + whenValid="Catégorie de matrice correctement renseignée." + data-testid="matrixkind-select" + label="Catégorie de matrice (obligatoire)" required />
- defaultValue="" - options={selectOptionsFromList(MatrixKindList)} - onChange={(e) => setMatrixKind(e.target.value)} + defaultValue={matrix ?? ''} + options={selectOptionsFromList(MatrixList)} + onChange={(e) => setMatrix(e.target.value as string)} inputForm={form} - inputKey="matrixKind" - whenValid="Nature de la matrice correctement renseignée." - data-testid="matrixkind-select" - label="Nature de la matrice (obligatoire)" + inputKey="matrix" + whenValid="Matrice correctement renseignée." + data-testid="matrix-select" + label="Matrice (obligatoire)" required />
- defaultValue="" + defaultValue={matrixPart ?? ''} options={selectOptionsFromList(MatrixPartList)} onChange={(e) => setMatrixPart(e.target.value)} inputForm={form} @@ -104,15 +141,27 @@ const SampleFormStep2 = ({ onValid }: Props) => { />
- - type="text" - value={cultureKind} + + defaultValue={cultureKind ?? ''} + options={selectOptionsFromList(['Bio', 'Conventionnel'])} onChange={(e) => setCultureKind(e.target.value)} inputForm={form} inputKey="cultureKind" - whenValid="Type de la culture correctement renseignée." - data-testid="culturekind-input" - label="Type de la culture (obligatoire)" + whenValid="Type de culture correctement renseigné." + data-testid="culturekind-select" + label="Type de culture" + /> +
+
+ + defaultValue={stage ?? ''} + options={selectOptionsFromList(SampleStageList)} + onChange={(e) => setStage(e.target.value as SampleStage)} + inputForm={form} + inputKey="stage" + whenValid="Stade de prélèvement correctement renseigné." + data-testid="stage-select" + label="Stade de prélèvement (obligatoire)" required />
@@ -122,7 +171,7 @@ const SampleFormStep2 = ({ onValid }: Props) => {
type="number" - value={quantity} + defaultValue={quantity ?? ''} onChange={(e) => setQuantity(Number(e.target.value))} inputForm={form} inputKey="quantity" @@ -134,7 +183,7 @@ const SampleFormStep2 = ({ onValid }: Props) => {
- defaultValue="" + defaultValue={quantityUnit ?? ''} options={selectOptionsFromList(['kg', 'g', 'mg', 'µg'])} onChange={(e) => setQuantityUnit(e.target.value)} inputForm={form} @@ -148,7 +197,7 @@ const SampleFormStep2 = ({ onValid }: Props) => {
type="number" - value={sampleCount} + defaultValue={sampleCount ?? ''} onChange={(e) => setSampleCount(Number(e.target.value))} inputForm={form} inputKey="sampleCount" @@ -160,10 +209,109 @@ const SampleFormStep2 = ({ onValid }: Props) => {

+
+
+ setCompliance200263(checked)} + showCheckedHint={false} + /> +
+
+ setPooling(checked)} + showCheckedHint={false} + /> +
+
+ setReleaseControl(checked)} + showCheckedHint={false} + /> +
+
+ setTemperatureMaintenance(checked)} + showCheckedHint={false} + /> +
+
+ + type="date" + defaultValue={ + expiryDate ? format(expiryDate, 'yyyy-MM-dd') : undefined + } + onChange={(e) => + setExpiryDate(parse(e.target.value, 'yyyy-MM-dd', new Date())) + } + inputForm={form} + inputKey="expiryDate" + whenValid="Date de péremption correctement renseignée." + data-testid="expirydate-input" + label="Date de péremption" + /> +
+
+ + defaultValue={storageCondition ?? ''} + options={selectOptionsFromList(SampleStorageConditionList)} + onChange={(e) => + setStorageCondition(e.target.value as SampleStorageCondition) + } + inputForm={form} + inputKey="storageCondition" + whenValid="Condition de stockage correctement renseignée." + data-testid="storagecondition-select" + label="Condition de stockage" + /> +
+
+
+
+
+ + type="number" + defaultValue={sealId ?? ''} + onChange={(e) => setSealId(Number(e.target.value))} + inputForm={form} + inputKey="sealId" + whenValid="Numéro de scellé correctement renseigné." + data-testid="sealid-input" + label="Numéro de scellé (obligatoire)" + required + /> +
+
+
- +
); diff --git a/frontend/src/views/SampleView/SampleFormStep3.tsx b/frontend/src/views/SampleView/SampleFormStep3.tsx new file mode 100644 index 00000000..c536d555 --- /dev/null +++ b/frontend/src/views/SampleView/SampleFormStep3.tsx @@ -0,0 +1,33 @@ +import ButtonsGroup from '@codegouvfr/react-dsfr/ButtonsGroup'; +import { cx } from '@codegouvfr/react-dsfr/fr/cx'; +import { PartialSample, SampleUpdate } from 'shared/schema/Sample'; + +interface Props { + sample: PartialSample; +} + +const SampleFormStep3 = ({ sample }: Props) => { + const Form = SampleUpdate; + + type FormShape = typeof Form.shape; + + const submit = async (e: React.MouseEvent) => {}; + + return ( +
+
+ +
+ + ); +}; + +export default SampleFormStep3; diff --git a/frontend/src/views/SampleView/SampleView.tsx b/frontend/src/views/SampleView/SampleView.tsx index 24e71cfe..fb60933d 100644 --- a/frontend/src/views/SampleView/SampleView.tsx +++ b/frontend/src/views/SampleView/SampleView.tsx @@ -1,18 +1,23 @@ import { cx } from '@codegouvfr/react-dsfr/fr/cx'; import Stepper from '@codegouvfr/react-dsfr/Stepper'; -import { useState } from 'react'; -import { SampleToCreate } from 'shared/schema/Sample'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { SampleUpdate } from 'shared/schema/Sample'; import { useDocumentTitle } from 'src/hooks/useDocumentTitle'; -import { useCreateSampleMutation } from 'src/services/sample.service'; +import { useGetSampleQuery } from 'src/services/sample.service'; import SampleFormStep1 from 'src/views/SampleView/SampleFormStep1'; import SampleFormStep2 from 'src/views/SampleView/SampleFormStep2'; +import SampleFormStep3 from 'src/views/SampleView/SampleFormStep3'; const SampleView = () => { useDocumentTitle("Saisie d'un prélèvement"); + const { sampleId } = useParams<{ sampleId?: string }>(); - const [step, setStep] = useState(1); + const { data: sample } = useGetSampleQuery(sampleId as string, { + skip: !sampleId, + }); - const [createSample] = useCreateSampleMutation(); + const [step, setStep] = useState(1); const StepTitles = [ 'Création du prélèvement', @@ -20,26 +25,33 @@ const SampleView = () => { 'Validation', ]; - const validStep1 = async (draftSample: SampleToCreate) => { - await createSample(draftSample) - .unwrap() - .then(() => setStep(2)) - .catch(() => { - //TODO handle error - }); - }; + useEffect(() => { + if (sample) { + const sampleParse = SampleUpdate.safeParse(sample); + if (sampleParse.success) { + setStep(3); + } else { + setStep(2); + } + } + }, [sample]); + + if (sampleId && !sample) { + return <>; + } return (
-

Prélévement

+

Prélévement {sample?.reference}

- {step === 1 && } - {step === 2 && setStep(3)} />} + {step === 1 && } + {step === 2 && sample && } + {step === 3 && sample && }
); }; diff --git a/frontend/src/views/SampleView/test/SampleFormStep1.test.tsx b/frontend/src/views/SampleView/test/SampleFormStep1.test.tsx index 6faf91b5..39b58f23 100644 --- a/frontend/src/views/SampleView/test/SampleFormStep1.test.tsx +++ b/frontend/src/views/SampleView/test/SampleFormStep1.test.tsx @@ -7,7 +7,7 @@ describe('SampleFormStep1', () => { const user = userEvent.setup(); test('should display form', () => { - render( {}} />); + render(); expect(screen.getByTestId('draft_sample_1_form')).toBeInTheDocument(); expect(screen.getAllByTestId('department-select')).toHaveLength(2); @@ -18,7 +18,7 @@ describe('SampleFormStep1', () => { }); test('should handle errors on submitting', async () => { - render( {}} />); + render(); const resytalIdInput = screen.getAllByTestId('resytalId-input')[1]; await act(async () => { @@ -45,7 +45,7 @@ describe('SampleFormStep1', () => { test('should handle valid form', async () => { const onValid = jest.fn(); - render(); + render(); const departmentSelect = screen.getAllByTestId('department-select')[1]; const resytalIdInput = screen.getAllByTestId('resytalId-input')[1]; diff --git a/frontend/test/fixtures.test.ts b/frontend/test/fixtures.test.ts index c02fa055..8b193a65 100644 --- a/frontend/test/fixtures.test.ts +++ b/frontend/test/fixtures.test.ts @@ -4,7 +4,7 @@ const randomstring = require('randomstring'); export const genBoolean = () => Math.random() < 0.5; -export const genSiren = () => genNumber(9); +export const genSiret = () => genNumber(14); export function oneOf(array: Array): T { return array[Math.floor(Math.random() * array.length)]; diff --git a/server/controllers/sampleController.ts b/server/controllers/sampleController.ts index fbda4d28..72de13a2 100644 --- a/server/controllers/sampleController.ts +++ b/server/controllers/sampleController.ts @@ -6,6 +6,34 @@ import { v4 as uuidv4 } from 'uuid'; import { SampleToCreate } from '../../shared/schema/Sample'; import sampleRepository from '../repositories/sampleRepository'; +const getSample = async (request: Request, response: Response) => { + const { sampleId } = request.params; + + console.info('Get sample', sampleId); + + const sample = await sampleRepository.findUnique(sampleId); + + if (!sample) { + return response.sendStatus(constants.HTTP_STATUS_NOT_FOUND); + } + + if (sample.createdBy !== (request as AuthenticatedRequest).auth.userId) { + return response.sendStatus(constants.HTTP_STATUS_FORBIDDEN); + } + + response.status(constants.HTTP_STATUS_OK).send(sample); +}; + +const findSamples = async (request: Request, response: Response) => { + const { userId } = (request as AuthenticatedRequest).auth; + + console.info('Find samples for user', userId); + + const samples = await sampleRepository.findMany(userId); + + response.status(constants.HTTP_STATUS_OK).send(samples); +}; + const createSample = async (request: Request, response: Response) => { const { userId } = (request as AuthenticatedRequest).auth; const sampleToCreate = request.body as SampleToCreate; @@ -28,6 +56,35 @@ const createSample = async (request: Request, response: Response) => { response.status(constants.HTTP_STATUS_CREATED).send(sample); }; +const updateSample = async (request: Request, response: Response) => { + const { sampleId } = request.params; + const sampleUpdate = request.body; + + console.info('Update sample', sampleId, sampleUpdate); + + const sample = await sampleRepository.findUnique(sampleId); + + if (!sample) { + return response.sendStatus(constants.HTTP_STATUS_NOT_FOUND); + } + + if (sample.createdBy !== (request as AuthenticatedRequest).auth.userId) { + return response.sendStatus(constants.HTTP_STATUS_FORBIDDEN); + } + + const updatedSample = { + ...sample, + ...sampleUpdate, + }; + + await sampleRepository.update(updatedSample); + + response.status(constants.HTTP_STATUS_OK).send(updatedSample); +}; + export default { + getSample, + findSamples, createSample, + updateSample, }; diff --git a/server/controllers/test/sampleController.test.ts b/server/controllers/test/sampleController.test.ts index 2ee4c7a1..91d6dd21 100644 --- a/server/controllers/test/sampleController.test.ts +++ b/server/controllers/test/sampleController.test.ts @@ -1,14 +1,83 @@ import { constants } from 'http2'; +import randomstring from 'randomstring'; import request from 'supertest'; -import { User1 } from '../../../database/seeds/test/001-users'; +import { v4 as uuidv4 } from 'uuid'; +import { User1, User2 } from '../../../database/seeds/test/001-users'; +import { Sample1 } from '../../../database/seeds/test/002-samples'; +import { MatrixList } from '../../../shared/foodex2/Matrix'; import { Samples } from '../../repositories/sampleRepository'; import { createServer } from '../../server'; -import { genSampleToCreate } from '../../test/testFixtures'; +import { genSampleToCreate, oneOf } from '../../test/testFixtures'; import { withAccessToken } from '../../test/testUtils'; const { app } = createServer(); describe('Sample controller', () => { + describe('Get', () => { + const testRoute = '/api/samples'; + + it('should fail if the user is not authenticated', async () => { + await request(app) + .get(`${testRoute}/${Sample1.id}`) + .expect(constants.HTTP_STATUS_UNAUTHORIZED); + }); + + it('should get a valid sample id', async () => { + await withAccessToken( + request(app).get(`${testRoute}/${randomstring.generate()}`) + ).expect(constants.HTTP_STATUS_BAD_REQUEST); + }); + + it('should fail if the sample does not exist', async () => { + await withAccessToken( + request(app).get(`${testRoute}/${uuidv4()}`), + User1 + ).expect(constants.HTTP_STATUS_NOT_FOUND); + }); + + it('should fail if the sample does not belong to the user', async () => { + await withAccessToken( + request(app).get(`${testRoute}/${Sample1.id}`), + User2 + ).expect(constants.HTTP_STATUS_FORBIDDEN); + }); + + it('should get the sample', async () => { + const res = await withAccessToken( + request(app).get(`${testRoute}/${Sample1.id}`) + ).expect(constants.HTTP_STATUS_OK); + + expect(res.body).toMatchObject({ + ...Sample1, + createdAt: Sample1.createdAt.toISOString(), + expiryDate: Sample1.expiryDate?.toISOString(), + }); + }); + }); + + describe('Find', () => { + const testRoute = '/api/samples'; + + it('should fail if the user is not authenticated', async () => { + await request(app) + .get(testRoute) + .expect(constants.HTTP_STATUS_UNAUTHORIZED); + }); + + it('should find the samples of the authenticated user', async () => { + const res = await withAccessToken(request(app).get(testRoute)).expect( + constants.HTTP_STATUS_OK + ); + + expect(res.body).toMatchObject([ + { + ...Sample1, + createdAt: Sample1.createdAt.toISOString(), + expiryDate: Sample1.expiryDate?.toISOString(), + }, + ]); + }); + }); describe('Create', () => { const testRoute = '/api/samples'; @@ -67,4 +136,79 @@ describe('Sample controller', () => { ).resolves.toBeDefined(); }); }); + + describe('Update', () => { + const testRoute = (sampleId: string) => `/api/samples/${sampleId}`; + + it('should fail if the user is not authenticated', async () => { + await request(app) + .put(`${testRoute(Sample1.id)}`) + .send({}) + .expect(constants.HTTP_STATUS_UNAUTHORIZED); + }); + + it('should get a valid sample id', async () => { + await withAccessToken( + request(app) + .put(`${testRoute(randomstring.generate())}`) + .send({}) + ).expect(constants.HTTP_STATUS_BAD_REQUEST); + }); + + it('should fail if the sample does not exist', async () => { + await withAccessToken( + request(app) + .put(`${testRoute(uuidv4())}`) + .send({}), + User1 + ).expect(constants.HTTP_STATUS_NOT_FOUND); + }); + + it('should fail if the sample does not belong to the user', async () => { + await withAccessToken( + request(app) + .put(`${testRoute(Sample1.id)}`) + .send({}), + User2 + ).expect(constants.HTTP_STATUS_FORBIDDEN); + }); + + it('should get a valid body', async () => { + const badRequestTest = async (payload?: Record) => + withAccessToken( + request(app) + .put(`${testRoute(Sample1.id)}`) + .send(payload), + User1 + ).expect(constants.HTTP_STATUS_BAD_REQUEST); + + await badRequestTest({ matrix: 123 }); + }); + + const validBody = { + matrix: oneOf(MatrixList), + }; + + it('should update the sample', async () => { + const res = await withAccessToken( + request(app) + .put(`${testRoute(Sample1.id)}`) + .send(validBody), + User1 + ).expect(constants.HTTP_STATUS_OK); + + expect(res.body).toMatchObject({ + ...Sample1, + createdAt: Sample1.createdAt.toISOString(), + expiryDate: Sample1.expiryDate?.toISOString(), + matrix: validBody.matrix, + }); + + await expect( + Samples() + .where({ id: Sample1.id, matrix: validBody.matrix as string }) + .first() + ).resolves.toBeDefined(); + }); + }); }); diff --git a/server/middlewares/validator.ts b/server/middlewares/validator.ts index 5eddc89c..6e78e45b 100644 --- a/server/middlewares/validator.ts +++ b/server/middlewares/validator.ts @@ -7,6 +7,13 @@ export const body = (o: AnyZodObject) => body: o, }); +export const uuidParam = (paramName: string) => + z.object({ + params: z.object({ + [paramName]: z.string().uuid(), + }), + }); + const validate = (schema: AnyZodObject) => async (req: Request, res: Response, next: NextFunction) => { diff --git a/server/repositories/sampleRepository.ts b/server/repositories/sampleRepository.ts index 92aee18d..f0ce2d4b 100644 --- a/server/repositories/sampleRepository.ts +++ b/server/repositories/sampleRepository.ts @@ -1,5 +1,8 @@ -import { z } from 'zod'; -import { Sample, SampleToCreate } from '../../shared/schema/Sample'; +import { + CreatedSample, + PartialSample, + Sample, +} from '../../shared/schema/Sample'; import db from './db'; export const samplesTable = 'samples'; @@ -7,34 +10,51 @@ const samplesSerial = 'samples_serial'; export const Samples = () => db(samplesTable); -const SampleToInsert = SampleToCreate.merge( - Sample.pick({ - id: true, - reference: true, - createdAt: true, - createdBy: true, - }) -); - -const insert = async ( - sampleToInsert: z.infer -): Promise => { - console.info('Insert sample', sampleToInsert); +const findUnique = async (id: string): Promise => { + console.info('Find sample', id); + return Samples().where({ id }).first(); +}; + +const findMany = async (userId: string): Promise => { + console.info('Find samples for user', userId); + return Samples().where({ createdBy: userId }); +}; + +const getSerial = async (): Promise => { + const result = await db.select(db.raw(`nextval('${samplesSerial}')`)).first(); + return result.nextval; +}; + +const insert = async (createdSample: CreatedSample): Promise => { + console.info('Insert sample', createdSample); await Samples().insert({ - ...sampleToInsert, + ...createdSample, userLocation: db.raw('Point(?, ?)', [ - sampleToInsert.userLocation.x, - sampleToInsert.userLocation.y, + createdSample.userLocation.x, + createdSample.userLocation.y, ]), }); }; -const getSerial = async (): Promise => { - const result = await db.select(db.raw(`nextval('${samplesSerial}')`)).first(); - return result.nextval; +const update = async (partialSample: PartialSample): Promise => { + console.info('Update sample', partialSample.id); + if (Object.keys(partialSample).length > 0) { + await Samples() + .where({ id: partialSample.id }) + .update({ + ...partialSample, + userLocation: db.raw('Point(?, ?)', [ + partialSample.userLocation.x, + partialSample.userLocation.y, + ]), + }); + } }; export default { insert, + update, + findUnique, + findMany, getSerial, }; diff --git a/server/routers/protected.ts b/server/routers/protected.ts index db9e36b7..2c75846d 100644 --- a/server/routers/protected.ts +++ b/server/routers/protected.ts @@ -1,7 +1,7 @@ import express from 'express'; import { jwtCheck, userCheck } from '../middlewares/auth'; -import validator, { body } from '../middlewares/validator'; -import { SampleToCreate } from '../../shared/schema/Sample'; +import validator, { body, uuidParam } from '../middlewares/validator'; +import { PartialSampleUpdate, SampleToCreate } from '../../shared/schema/Sample'; import sampleController from '../controllers/sampleController'; const router = express.Router(); @@ -9,6 +9,9 @@ const router = express.Router(); router.use(jwtCheck(true)) router.use(userCheck(true)); +router.get('/samples', sampleController.findSamples); +router.get('/samples/:sampleId', validator.validate(uuidParam('sampleId')), sampleController.getSample); router.post('/samples', validator.validate(body(SampleToCreate)), sampleController.createSample); +router.put('/samples/:sampleId', validator.validate(uuidParam('sampleId').merge(body(PartialSampleUpdate))), sampleController.updateSample); export default router; diff --git a/server/test/testFixtures.ts b/server/test/testFixtures.ts index 1c054206..379d58b1 100644 --- a/server/test/testFixtures.ts +++ b/server/test/testFixtures.ts @@ -1,8 +1,9 @@ import randomstring from 'randomstring'; import { v4 as uuidv4 } from 'uuid'; import { DepartmentList } from '../../shared/schema/Department'; -import { SampleToCreate } from '../../shared/schema/Sample'; +import { Sample, SampleToCreate } from '../../shared/schema/Sample'; import { SampleContextList } from '../../shared/schema/SampleContext'; +import { SampleStorageConditionList } from '../../shared/schema/SampleStorageCondition'; import { UserApi } from '../models/UserApi'; export const genEmail = () => { @@ -35,6 +36,8 @@ export const genNumber = (length = 10) => { export const genBoolean = () => Math.random() < 0.5; +export const genSiret = () => genNumber(14); + export function oneOf(array: Array): T { return array[Math.floor(Math.random() * array.length)]; } @@ -56,3 +59,28 @@ export const genSampleToCreate = (): SampleToCreate => ({ context: oneOf(SampleContextList), department: oneOf(DepartmentList), }); + +export const genSample = (userId?: string): Sample => ({ + id: uuidv4(), + reference: `GES-${oneOf(DepartmentList)}-${genNumber(4)}`, + createdAt: new Date(), + createdBy: userId ?? uuidv4(), + ...genSampleToCreate(), + locationSiret: String(genSiret()), + locationName: randomstring.generate(), + locationAddress: randomstring.generate(), + matrix: randomstring.generate(), + matrixKind: randomstring.generate(), + matrixPart: randomstring.generate(), + quantity: genNumber(), + quantityUnit: randomstring.generate(), + cultureKind: randomstring.generate(), + compliance200263: genBoolean(), + storageCondition: oneOf(SampleStorageConditionList), + pooling: genBoolean(), + releaseControl: genBoolean(), + sampleCount: genNumber(1), + temperatureMaintenance: genBoolean(), + expiryDate: new Date(), + sealId: genNumber(4), +}); diff --git a/shared/foodex2/Matrix.tsx b/shared/foodex2/Matrix.ts similarity index 100% rename from shared/foodex2/Matrix.tsx rename to shared/foodex2/Matrix.ts diff --git a/shared/schema/Sample.ts b/shared/schema/Sample.ts index 706973ad..c6c48c0b 100644 --- a/shared/schema/Sample.ts +++ b/shared/schema/Sample.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; import { Department } from './Department'; import { SampleContext } from './SampleContext'; +import { SampleStage } from './SampleStage'; +import { SampleStorageCondition } from './SampleStorageCondition'; export const UserLocation = z.object( { @@ -28,23 +30,38 @@ export const Sample = z.object({ createdBy: z.string(), context: SampleContext, userLocation: UserLocation, - locationSiret: z.string(), - locationName: z.string(), - locationAddress: z.string(), - matrix: z.string(), - matrixKind: z.string(), - matrixPart: z.string(), - quantity: z.number(), - quantityUnit: z.string(), - cultureKind: z.string().optional(), - compliance200263: z.boolean().optional(), - storageCondition: z.string().optional(), - pooling: z.boolean().optional(), - releaseControl: z.boolean().optional(), - sampleCount: z.number().optional(), - temperatureMaintenance: z.boolean().optional(), - expiryDate: z.date().optional(), - sealId: z.number().optional(), + locationSiret: z.string().optional().nullable(), + locationName: z.string().optional().nullable(), + locationAddress: z.string().optional().nullable(), + matrixKind: z.string({ + required_error: 'Veuillez renseigner la catégorie de matrice.', + }), + matrix: z.string({ + required_error: 'Veuillez renseigner la matrice.', + }), + matrixPart: z.string({ + required_error: 'Veuillez renseigner la partie du végétal.', + }), + stage: SampleStage, + quantity: z.number({ + required_error: 'Veuillez renseigner la quantité.', + }), + quantityUnit: z.string({ + required_error: 'Veuillez renseigner l’unité de quantité.', + }), + cultureKind: z.string().optional().nullable(), + compliance200263: z.boolean().optional().nullable(), + storageCondition: SampleStorageCondition.optional().nullable(), + pooling: z.boolean().optional().nullable(), + releaseControl: z.boolean().optional().nullable(), + sampleCount: z.number({ + required_error: 'Veuillez renseigner le nombre de prélèvements.', + }), + temperatureMaintenance: z.boolean().optional().nullable(), + expiryDate: z.coerce.date().optional().nullable(), + sealId: z.number({ + required_error: 'Veuillez renseigner le numéro de scellé.', + }), }); export const SampleToCreate = Sample.pick({ @@ -54,10 +71,22 @@ export const SampleToCreate = Sample.pick({ department: true, }); -export const SampleToUpdate = Sample.pick({ - matrix: true, +export const CreatedSample = SampleToCreate.merge( + Sample.pick({ + id: true, + reference: true, + createdAt: true, + createdBy: true, + }) +); + +export const PartialSample = Sample.partial().merge(CreatedSample); + +export const SampleUpdate = Sample.pick({ matrixKind: true, + matrix: true, matrixPart: true, + stage: true, quantity: true, quantityUnit: true, cultureKind: true, @@ -69,8 +98,14 @@ export const SampleToUpdate = Sample.pick({ temperatureMaintenance: true, expiryDate: true, sealId: true, -}).partial(); +}); + +export const PartialSampleUpdate = SampleUpdate.partial(); +export type UserLocation = z.infer; export type Sample = z.infer; export type SampleToCreate = z.infer; -export type UserLocation = z.infer; +export type CreatedSample = z.infer; +export type PartialSample = z.infer; +export type SampleUpdate = z.infer; +export type PartialSampleUpdate = z.infer; diff --git a/shared/schema/SampleStage.ts b/shared/schema/SampleStage.ts new file mode 100644 index 00000000..f8f47b77 --- /dev/null +++ b/shared/schema/SampleStage.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const SampleStage = z.enum( + ['Avant récolte', 'Post récolte', 'Stockage', 'Autre'], + { + errorMap: () => ({ + message: 'Veuillez renseigner le stade de prélèvement.', + }), + } +); + +export type SampleStage = z.infer; + +export const SampleStageList: SampleStage[] = [ + 'Avant récolte', + 'Post récolte', + 'Stockage', + 'Autre', +]; diff --git a/shared/schema/SampleStorageCondition.ts b/shared/schema/SampleStorageCondition.ts new file mode 100644 index 00000000..7524a2c8 --- /dev/null +++ b/shared/schema/SampleStorageCondition.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const SampleStorageCondition = z.enum(['Champ', 'Entrepot', 'Caisse']); + +export type SampleStorageCondition = z.infer; + +export const SampleStorageConditionList: SampleStorageCondition[] = [ + 'Champ', + 'Entrepot', + 'Caisse', +]; + +export const SampleStorageConditionLabels: Record< + SampleStorageCondition, + string +> = { + Champ: 'Au champ', + Entrepot: 'En entrepot', + Caisse: 'En caisse', +};