Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/api/src/controllers/certificationsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
imaginaryPost,
getPrivateS3BucketName,
getPrivateS3Url,
getRandomFileName,
getFileNameWithOriginalName,
} from '../util/digitalOceanSpaces.js';
import { HttpError, LiteFarmRequest } from '../types.js';

Expand Down Expand Up @@ -179,7 +179,7 @@ const certificationsController = {
const { farm_id } = req.params;
try {
const s3BucketName = getPrivateS3BucketName();
const fileName = `${farm_id}/certification/${getRandomFileName(req.file)}`;
const fileName = `${farm_id}/certification/${getFileNameWithOriginalName(req.file)}`;

const uploadOriginalDocument = () =>
s3.send(
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/util/digitalOceanSpaces.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ function getRandomFileName(file) {
return `${uuidv4()}${path.extname(file.originalname)}`;
}

// Like getRandomFileName, but keeps the original filename recoverable from the storage
// key itself (encodeURIComponent, not a strip-unsafe-chars sanitizer, so the name round-trips
// exactly via decodeURIComponent). The uuid prefix still guarantees no collisions.
function getFileNameWithOriginalName(file) {
const ext = path.extname(file.originalname);
const baseName = path.basename(file.originalname, ext);
return `${uuidv4()}-${encodeURIComponent(baseName)}${ext}`;
}

function getPrivateS3Url() {
if (process.env.NODE_ENV === 'development') {
return `${MINIO_ENDPOINT}/${getPrivateS3BucketName()}`;
Expand Down Expand Up @@ -155,6 +164,7 @@ export {
imaginaryPost,
imaginaryGet,
getRandomFileName,
getFileNameWithOriginalName,
getPublicS3Url,
getPrivateS3Url,
deleteImage,
Expand Down
3 changes: 3 additions & 0 deletions packages/api/tests/certifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ jest.mock('../src/util/digitalOceanSpaces.js', () => ({
getPrivateS3BucketName: jest.fn().mockReturnValue('test-bucket'),
getPrivateS3Url: jest.fn().mockReturnValue('http://localhost:9000/test-bucket'),
getRandomFileName: jest.fn().mockImplementation((file) => `random-uuid-${file.originalname}`),
getFileNameWithOriginalName: jest
.fn()
.mockImplementation((file) => `random-uuid-${file.originalname}`),
imaginaryPost: jest.fn().mockResolvedValue({ data: Buffer.from('fake-image') }),
}));

Expand Down
5 changes: 5 additions & 0 deletions packages/webapp/public/locales/en/message.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
"ADD": "Failed to add certification",
"DELETE": "Failed to delete certification",
"EDIT": "Failed to update certification"
},
"SUCCESS": {
"ADD": "Successfully saved certification",
"DELETE": "Successfully deleted certification",
"EDIT": "Successfully updated certification"
}
},
"CROP_VARIETY": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import RadioGroup from '../Form/RadioGroup';
import Switch from '../Form/Switch';
import ReactSelect from '../Form/ReactSelect';
import ImagePicker from '../ImagePicker';
import useImagePickerUpload from '../ImagePicker/useImagePickerUpload';
import useImagePickerUpload, { GetOnFileUpload } from '../ImagePicker/useImagePickerUpload';
import useMediaWithAuthentication from '../../containers/hooks/useMediaWithAuthentication';
import FormNavigationButtons from '../Form/FormNavigationButtons';
import InputBaseLabel from '../Form/InputBase/InputBaseLabel';
import { Error } from '../Typography';
Expand Down Expand Up @@ -73,6 +74,7 @@ type CertificationFormProps = {
defaultValues?: Partial<CertificationFormValues>;
onSubmit: (data: CertificationFormValues) => void;
onBack: () => void;
isSaving: boolean;
};

const DEFAULT_VALUES: CertificationFormValues = {
Expand Down Expand Up @@ -102,6 +104,38 @@ const DateError = ({
return <>{!areDatesProperlySet && <Error>{errorMessage}</Error>}</>;
};

const CertificateDocumentPicker = ({
label,
value,
onChange,
onRemove,
getOnFileUpload,
}: {
label: string;
value: string | null;
onChange: (url: string | null) => void;
onRemove: () => void;
getOnFileUpload: GetOnFileUpload;
}) => {
const { mediaUrl, isLoading } = useMediaWithAuthentication({ fileUrls: value ? [value] : [] });

// ImagePicker only reads defaultUrl once, at mount (useState(defaultUrl), no sync effect) —
// so it must not mount until the authenticated fetch for an existing document has resolved,
// otherwise it'd permanently capture an empty preview and never pick up mediaUrl once ready.
if (value && isLoading) {
return null;
}

return (
<ImagePicker
label={label}
defaultUrl={mediaUrl ?? ''}
onFileUpload={getOnFileUpload('certification', onChange)}
onRemoveImage={onRemove}
/>
);
};

const certificationTypes = [
'ORGANIC',
'BIODYNAMIC',
Expand All @@ -121,6 +155,7 @@ export default function CertificationForm({
defaultValues,
onSubmit,
onBack,
isSaving,
}: CertificationFormProps) {
const { t } = useTranslation(['translation', 'common']);
const {
Expand Down Expand Up @@ -345,11 +380,12 @@ export default function CertificationForm({
name={DOCUMENT_URL}
control={control}
render={({ field }) => (
<ImagePicker
<CertificateDocumentPicker
label={t('CERTIFICATION.CERTIFICATE_DOCUMENT')}
defaultUrl={field.value ?? ''}
onFileUpload={getOnFileUpload('certification', (url) => field.onChange(url))}
onRemoveImage={() => field.onChange(null)}
value={field.value}
onChange={field.onChange}
onRemove={() => field.onChange(null)}
getOnFileUpload={getOnFileUpload}
/>
)}
/>
Expand All @@ -358,7 +394,7 @@ export default function CertificationForm({
</div>

<FormNavigationButtons
isDisabled={!isValid}
isDisabled={!isValid || isSaving}
onCancel={onBack}
onContinue={handleSubmit(onSubmit)}
isFinalStep
Expand Down
11 changes: 7 additions & 4 deletions packages/webapp/src/components/Certifications/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ interface CertificationsProps {
onAddCertification: () => void;
onExport: () => void;
onEditCertification: (id: string) => void;
onDeleteCertification: (id: string) => void;
onDeleteCertification: (id: string) => Promise<void>;
isSaving: boolean;
}

export default function Certifications({
Expand All @@ -48,15 +49,16 @@ export default function Certifications({
onExport,
onEditCertification,
onDeleteCertification,
isSaving,
}: CertificationsProps) {
const { t } = useTranslation('translation');
const [deletingId, setDeletingId] = useState<string | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));

function handleDeleteConfirm() {
if (deletingId !== null) {
onDeleteCertification(deletingId);
async function handleDeleteConfirm() {
if (deletingId) {
await onDeleteCertification(deletingId);
}
setDeletingId(null);
}
Expand Down Expand Up @@ -108,6 +110,7 @@ export default function Certifications({
subject={t('CERTIFICATION.THIS_CERTIFICATION')}
onClose={() => setDeletingId(null)}
onConfirm={handleDeleteConfirm}
isLoading={isSaving}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import PureCertificationForm, {
CertificationFormValues,
} from '../../../components/Certifications/CertificationForm';
import { loginSelector } from '../../userFarmSlice';
import { enqueueErrorSnackbar } from '../../Snackbar/snackbarSlice';
import { enqueueErrorSnackbar, enqueueSuccessSnackbar } from '../../Snackbar/snackbarSlice';
import {
useGetCertificationsQuery,
useAddCertificationMutation,
Expand All @@ -44,11 +44,11 @@ export default function CertificationForm() {
const history = useHistory();
const { certification_id } = useParams<{ certification_id?: string }>();
const { farm_id } = useSelector(loginSelector);
const { data: certifications = [] } = useGetCertificationsQuery();
const { data: certifications = [], refetch: refetchCertifications } = useGetCertificationsQuery();
const { data: certifiers = [] } = useGetSupportedCertifiersQuery(farm_id!);
const { data: systemTypes = [] } = useGetSupportedCertificationSystemTypesQuery(farm_id!);
const [addCertification] = useAddCertificationMutation();
const [editCertification] = useEditCertificationMutation();
const [addCertification, { isLoading: isAdding }] = useAddCertificationMutation();
const [editCertification, { isLoading: isEditing }] = useEditCertificationMutation();

const certification = certification_id
? certifications.find((c) => c.id === certification_id)
Expand All @@ -71,6 +71,12 @@ export default function CertificationForm() {
} else {
await addCertification(body).unwrap();
}

const message = certification_id
? t('message:CERTIFICATION.SUCCESS.EDIT')
: t('message:CERTIFICATION.SUCCESS.ADD');
dispatch(enqueueSuccessSnackbar(message));

history.push('/certifications', { certificationSaved: true });
} catch {
const message = certification_id
Expand All @@ -88,6 +94,7 @@ export default function CertificationForm() {
defaultValues={defaultValues}
onSubmit={onSubmit}
onBack={onBack}
isSaving={isAdding || isEditing}
/>
</Layout>
);
Expand Down
33 changes: 26 additions & 7 deletions packages/webapp/src/containers/Certifications/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import { useLocation, useHistory } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import PureCertifications from '../../components/Certifications';
import Layout from '../../components/Layout';
import Loading from '../../components/Form/ContextForm/Loading';
import { loginSelector } from '../userFarmSlice';
import { enqueueErrorSnackbar } from '../Snackbar/snackbarSlice';
import { enqueueErrorSnackbar, enqueueSuccessSnackbar } from '../Snackbar/snackbarSlice';
import {
useGetCertificationsQuery,
useDeleteCertificationMutation,
Expand All @@ -29,7 +31,6 @@ import {
useGetSupportedCertificationSystemTypesQuery,
} from '../../store/api/certifiersApi';
import { toCertificationItems } from './utils';
import Layout from '../../components/Layout';

interface CertificationsProps {
isCompactSideMenu: boolean;
Expand All @@ -41,10 +42,16 @@ export default function Certifications({ isCompactSideMenu }: CertificationsProp
const location = useLocation();
const history = useHistory();
const { farm_id } = useSelector(loginSelector);
const { data: rawCertifications = [] } = useGetCertificationsQuery();
const { data: certifiers = [] } = useGetSupportedCertifiersQuery(farm_id!);
const { data: systemTypes = [] } = useGetSupportedCertificationSystemTypesQuery(farm_id!);
const [deleteCertification] = useDeleteCertificationMutation();
const { data: rawCertifications = [], isLoading: isCertificationsLoading } =
useGetCertificationsQuery();
const { data: certifiers = [], isLoading: isCertifiersLoading } = useGetSupportedCertifiersQuery(
farm_id!,
);
const { data: systemTypes = [], isLoading: isSystemTypesLoading } =
useGetSupportedCertificationSystemTypesQuery(farm_id!);
const [deleteCertification, { isLoading: isDeleting }] = useDeleteCertificationMutation();

const isLoading = isCertificationsLoading || isCertifiersLoading || isSystemTypesLoading;

// Captured once on mount so the banner stays visible for this page visit even after
// the underlying history state is cleared below.
Expand All @@ -69,11 +76,22 @@ export default function Certifications({ isCompactSideMenu }: CertificationsProp
const onDeleteCertification = async (id: string) => {
try {
await deleteCertification(id).unwrap();
} catch {
dispatch(enqueueSuccessSnackbar(t('message:CERTIFICATION.SUCCESS.DELETE')));
} catch (e) {
console.error(e);
dispatch(enqueueErrorSnackbar(t('message:CERTIFICATION.ERROR.DELETE')));
}
};

if (isLoading) {
return (
<Loading
dataName={t('MENU.CERTIFICATIONS').toLowerCase()}
isCompactSideMenu={isCompactSideMenu}
/>
);
}

return (
<Layout footer={false}>
<PureCertifications
Expand All @@ -85,6 +103,7 @@ export default function Certifications({ isCompactSideMenu }: CertificationsProp
onAddCertification={() => history.push('/certifications/add_certification')}
onEditCertification={(id) => history.push(`/certifications/${id}/edit_certification`)}
onDeleteCertification={onDeleteCertification}
isSaving={isDeleting}
/>
</Layout>
);
Expand Down
17 changes: 13 additions & 4 deletions packages/webapp/src/containers/Certifications/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,23 @@ const formatCertifierLabel = (
return systemTypeName ? `${certifierName} - ${systemTypeName}` : certifierName ?? '';
};

// The backend only ever persists a randomized `${uuid}.${ext}` storage key (see
// getRandomFileName in digitalOceanSpaces.js) — the user's original filename is never
// stored. Show a generic label instead of the UUID until a backend field exists for it.
// getFileNameWithOriginalName (digitalOceanSpaces.js) stores keys as `${uuid}-${name}.${ext}`,
// so the original filename can be recovered from the URL. Certifications uploaded before that
// change only have a bare `${uuid}.${ext}` key (see getRandomFileName) with no name to recover —
// those fall back to a generic label instead.
const UUID_NAME_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-(.+)$/i;

const toDocumentFileName = (documentUrl: string | null, t: TFunction): string | null => {
if (!documentUrl) {
return null;
}
const extension = documentUrl.split('.').pop();
const fullFileName = documentUrl.split('/').pop() ?? '';
const match = fullFileName.match(UUID_NAME_PATTERN);
if (match) {
return decodeURIComponent(match[1]);
}

const extension = fullFileName.split('.').pop();
return extension ? `${t('common:DOCUMENT')}.${extension}` : t('common:DOCUMENT');
};

Expand Down
Loading