diff --git a/client/src/api/images.ts b/client/src/api/images.ts new file mode 100644 index 0000000..ed4c0b9 --- /dev/null +++ b/client/src/api/images.ts @@ -0,0 +1,36 @@ +import type { ItemImage } from '@shared/models/item-image'; +import { apiFetch } from './http-client'; + +interface ItemImagesResponse { + data: ItemImage[]; +} + +interface SingleImageResponse { + data: ItemImage; +} + +export const fetchItemImages = async (itemId: string): Promise => { + const response = await apiFetch(`/items/${itemId}/images`); + return response.data; +}; + +export const createItemImage = async ( + itemId: string, + url: string +): Promise => { + const response = await apiFetch( + `/items/${itemId}/images`, + { + method: 'POST', + body: { url } + } + ); + + return response.data; +}; + +export const deleteItemImage = async (imageId: string): Promise => { + await apiFetch(`/images/${imageId}`, { + method: 'DELETE' + }); +}; diff --git a/client/src/api/items.ts b/client/src/api/items.ts index 3b37ea2..cfac014 100644 --- a/client/src/api/items.ts +++ b/client/src/api/items.ts @@ -45,6 +45,7 @@ export interface CreateItemPayload { attributes?: Record; sourceUrl?: string | null; sourceUrlId?: string | null; + defaultImageId?: string | null; } interface SingleItemResponse { @@ -57,6 +58,8 @@ export interface ImportedItemData { note: string | null; attributes: Record; sourceUrl?: string | null; + images?: Array<{ id?: string; url: string }>; + defaultImageId?: string | null; } export const createItem = async ( @@ -74,7 +77,9 @@ export const createItem = async ( note: payload.note ?? null, attributes: payload.attributes ?? {}, sourceUrl: payload.sourceUrl ?? null, - sourceUrlId: payload.sourceUrlId ?? null + sourceUrlId: payload.sourceUrlId ?? null, + defaultImageId: + payload.defaultImageId === undefined ? null : payload.defaultImageId } } ); @@ -109,6 +114,7 @@ export interface UpdateItemPayload { attributes?: Record; sourceUrl?: string | null; sourceUrlId?: string | null; + defaultImageId?: string | null; } export const updateItem = async ( @@ -145,6 +151,10 @@ export const updateItem = async ( body.sourceUrlId = payload.sourceUrlId ?? null; } + if ('defaultImageId' in payload) { + body.defaultImageId = payload.defaultImageId ?? null; + } + const response = await apiFetch(`/items/${itemId}`, { method: 'PATCH', body @@ -152,3 +162,8 @@ export const updateItem = async ( return response.data; }; + +export const fetchItem = async (itemId: string): Promise => { + const response = await apiFetch(`/items/${itemId}`); + return response.data; +}; diff --git a/client/src/components/item-form-modal.tsx b/client/src/components/item-form-modal.tsx index 8f7f470..70d769f 100644 --- a/client/src/components/item-form-modal.tsx +++ b/client/src/components/item-form-modal.tsx @@ -1,15 +1,54 @@ -import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CreateItemPayload, ImportedItemData } from '../api/items'; import type { AttributeScalarType } from '../utils/attribute-values'; import { inferScalarValue } from '../utils/attribute-values'; +interface ImageEntry { + id: string; + existingId: string | null; + url: string; + hasPreviewError: boolean; +} + +const IMAGE_URL_MAX_LENGTH = 2048; + +const createUniqueId = () => Math.random().toString(36).slice(2); + +const createBlankImageEntry = (): ImageEntry => ({ + id: createUniqueId(), + existingId: null, + url: '', + hasPreviewError: false +}); + +const isValidImageUrl = (value: string): boolean => { + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +}; + +export interface ItemFormSubmitPayload { + itemPayload: CreateItemPayload; + imageChanges: { + toCreate: Array<{ id: string; url: string }>; + toDelete: string[]; + defaultSelection: + | { type: 'existing'; id: string } + | { type: 'new'; id: string } + | null; + }; +} + export interface ItemFormModalProps { isOpen: boolean; mode: 'url' | 'manual' | 'edit'; initialUrl: string | null; onClose: () => void; onSuccess: () => void; - onSubmit: (payload: CreateItemPayload) => Promise; + onSubmit: (payload: ItemFormSubmitPayload) => Promise; isSubmitting: boolean; projectAttributes: string[]; initialData: ImportedItemData | null; @@ -26,8 +65,6 @@ interface AttributeEntry { isArrayMember: boolean; } -const createUniqueId = () => Math.random().toString(36).slice(2); - const createBlankEntry = (key = '', isArrayMember = false): AttributeEntry => ({ id: createUniqueId(), key, @@ -308,7 +345,11 @@ export function ItemFormModal({ const [note, setNote] = useState(''); const [sourceUrl, setSourceUrl] = useState(''); const [attributeEntries, setAttributeEntries] = useState([]); + const [imageEntries, setImageEntries] = useState([]); + const [selectedDefaultImageId, setSelectedDefaultImageId] = useState(null); const [error, setError] = useState(null); + const initialImageIdsRef = useRef>(new Set()); + const initialImageUrlsRef = useRef>(new Map()); const isFormDisabled = isSubmitting || isInitialDataLoading; const trackedAttributeMetadata = useMemo( () => computeTrackedKeyMetadata(projectAttributes), @@ -324,6 +365,13 @@ export function ItemFormModal({ [trackedAttributeMetadata] ); + const updateImageEntries = useCallback( + (updater: (previous: ImageEntry[]) => ImageEntry[]) => { + setImageEntries((previous) => updater(previous)); + }, + [] + ); + useEffect(() => { if (!isOpen) { return; @@ -374,6 +422,37 @@ export function ItemFormModal({ trackedAttributeMetadata ) ); + + const existingImageIds = new Set(); + const existingImageUrlMap = new Map(); + const initialImages = initialData?.images ?? []; + const nextImageEntries: ImageEntry[] = initialImages.map((image) => { + const existingId = image.id ?? null; + const entryId = existingId ?? createUniqueId(); + if (existingId) { + existingImageIds.add(existingId); + existingImageUrlMap.set(existingId, image.url.trim()); + } + return { + id: entryId, + existingId, + url: image.url, + hasPreviewError: false + }; + }); + + initialImageIdsRef.current = existingImageIds; + initialImageUrlsRef.current = existingImageUrlMap; + setImageEntries(nextImageEntries); + + if (initialData?.defaultImageId) { + const entryWithDefault = nextImageEntries.find( + (entry) => entry.existingId === initialData.defaultImageId + ); + setSelectedDefaultImageId(entryWithDefault ? entryWithDefault.id : null); + } else { + setSelectedDefaultImageId(null); + } }, [isOpen, initialUrl, initialData, projectAttributes, trackedAttributeMetadata]); useEffect(() => { @@ -389,6 +468,9 @@ export function ItemFormModal({ return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose]); + const initialDataStatusLabel = + mode === 'edit' ? 'Loading item details…' : 'Importing item details…'; + if (!isOpen) { return null; } @@ -476,6 +558,70 @@ export function ItemFormModal({ } }); + const normalizedImages = imageEntries.map((entry) => ({ + ...entry, + url: entry.url.trim() + })); + + const imagesToCreateMap = new Map(); + const imagesToDeleteSet = new Set(initialImageIdsRef.current); + const existingImageUrlMap = initialImageUrlsRef.current; + + for (const image of normalizedImages) { + if (!image.url.length) { + continue; + } + + if (image.url.length > IMAGE_URL_MAX_LENGTH) { + setError('Image URL must be 2048 characters or fewer.'); + return; + } + + if (!isValidImageUrl(image.url)) { + setError('Each image must use a valid http(s) URL.'); + return; + } + + if (image.existingId) { + const initialUrl = existingImageUrlMap.get(image.existingId) ?? ''; + if (image.url === initialUrl) { + imagesToDeleteSet.delete(image.existingId); + } else { + imagesToCreateMap.set(image.id, image.url); + } + } else { + imagesToCreateMap.set(image.id, image.url); + } + } + + let defaultSelection: ItemFormSubmitPayload['imageChanges']['defaultSelection'] = + null; + + if (selectedDefaultImageId) { + const selectedEntry = normalizedImages.find( + (entry) => entry.id === selectedDefaultImageId + ); + if (selectedEntry) { + if ( + selectedEntry.existingId && + !imagesToDeleteSet.has(selectedEntry.existingId) && + selectedEntry.url.length + ) { + defaultSelection = { type: 'existing', id: selectedEntry.existingId }; + } else if (selectedEntry.url.length) { + imagesToCreateMap.set(selectedEntry.id, selectedEntry.url); + defaultSelection = { type: 'new', id: selectedEntry.id }; + } else { + defaultSelection = null; + } + } + } + + const imagesToCreate = Array.from(imagesToCreateMap.entries()).map( + ([id, url]) => ({ id, url }) + ); + const imagesToDelete = Array.from(imagesToDeleteSet); + try { const payload: CreateItemPayload = { manufacturer: manufacturer.trim() ? manufacturer.trim() : null, @@ -489,7 +635,14 @@ export function ItemFormModal({ payload.status = 'active'; } - await onSubmit(payload); + await onSubmit({ + itemPayload: payload, + imageChanges: { + toCreate: imagesToCreate, + toDelete: imagesToDelete, + defaultSelection + } + }); onSuccess(); } catch (submitError) { setError((submitError as Error).message); @@ -560,7 +713,7 @@ export function ItemFormModal({ d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /> - Importing item details… + {initialDataStatusLabel} ) : null} @@ -837,6 +990,174 @@ export function ItemFormModal({ +
+
+ Images + +
+ {imageEntries.length ? ( +
+ {imageEntries.map((image) => { + const trimmedUrl = image.url.trim(); + const isDefault = selectedDefaultImageId === image.id; + const cardHighlight = isDefault + ? 'border-blue-400 shadow-md shadow-blue-100' + : 'border-slate-200'; + + return ( +
+
+
+ {trimmedUrl ? ( + image.hasPreviewError ? ( + + Preview unavailable + + ) : ( + Item preview { + updateImageEntries((previous) => + previous.map((entry) => + entry.id === image.id + ? { ...entry, hasPreviewError: true } + : entry + ) + ); + }} + onLoad={() => { + updateImageEntries((previous) => + previous.map((entry) => + entry.id === image.id && entry.hasPreviewError + ? { ...entry, hasPreviewError: false } + : entry + ) + ); + }} + /> + ) + ) : ( + + Add an image URL to preview it here. + + )} +
+
+ { + const nextValue = event.target.value; + updateImageEntries((previous) => + previous.map((entry) => + entry.id === image.id + ? { + ...entry, + url: nextValue, + hasPreviewError: false + } + : entry + ) + ); + if ( + selectedDefaultImageId === image.id && + !nextValue.trim() + ) { + setSelectedDefaultImageId(null); + } + setError(null); + }} + placeholder="https://example.com/photo.jpg" + className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100" + /> +
+ + +
+
+
+
+ ); + })} +
+ ) : ( +

+ No images added yet. Use the button above to attach product photos. +

+ )} + {selectedDefaultImageId ? ( +
+ +
+ ) : null} +

+ Previews load directly from the provided URLs. Broken previews will not block saving. +

+
+
Fields marked with * are required. Remove any attribute row you do not need. diff --git a/client/src/pages/project-detail/project-detail-page.tsx b/client/src/pages/project-detail/project-detail-page.tsx index 0a8342d..43462f9 100644 --- a/client/src/pages/project-detail/project-detail-page.tsx +++ b/client/src/pages/project-detail/project-detail-page.tsx @@ -1,7 +1,9 @@ import { useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import type { Item } from '@shared/models/item'; +import { useQueryClient } from '@tanstack/react-query'; import { + projectsKeys, useCreateItemMutation, useUpdateItemMutation, useProjectItemsQuery, @@ -9,12 +11,13 @@ import { useUpdateProjectMutation } from '../../query/projects'; import { + fetchItem, importItemFromUrl, - type CreateItemPayload, type ImportedItemData, type UpdateItemPayload } from '../../api/items'; -import { ItemFormModal } from '../../components/item-form-modal'; +import { createItemImage, deleteItemImage } from '../../api/images'; +import { ItemFormModal, type ItemFormSubmitPayload } from '../../components/item-form-modal'; import { ProjectHeader } from './project-header'; import { ProjectDescriptionSection } from './project-description-section'; import { TrackedAttributesSection } from './tracked-attributes-section'; @@ -27,6 +30,7 @@ export function ProjectDetailPage() { const createItemMutation = useCreateItemMutation(projectId); const updateItemMutation = useUpdateItemMutation(projectId); const updateProjectMutation = useUpdateProjectMutation(projectId); + const queryClient = useQueryClient(); const [quickUrl, setQuickUrl] = useState(''); const [quickError, setQuickError] = useState(null); @@ -37,7 +41,12 @@ export function ProjectDetailPage() { const [isImportingItem, setIsImportingItem] = useState(false); const [importError, setImportError] = useState(null); const importRequestIdRef = useRef(0); + const editRequestIdRef = useRef(0); const [itemBeingEdited, setItemBeingEdited] = useState(null); + const [editItemInitialData, setEditItemInitialData] = useState(null); + const [isLoadingItemDetails, setIsLoadingItemDetails] = useState(false); + const [editItemError, setEditItemError] = useState(null); + const [isSavingItem, setIsSavingItem] = useState(false); const project = projectQuery.data; const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]); @@ -97,7 +106,11 @@ export function ProjectDetailPage() { setQuickError(null); setImportError(null); setImportedItemData(null); + setEditItemInitialData(null); + setEditItemError(null); + setIsLoadingItemDetails(false); setItemBeingEdited(null); + editRequestIdRef.current += 1; const requestId = importRequestIdRef.current + 1; importRequestIdRef.current = requestId; @@ -134,18 +147,74 @@ export function ProjectDetailPage() { setImportedItemData(null); setIsImportingItem(false); importRequestIdRef.current += 1; + editRequestIdRef.current += 1; setItemBeingEdited(null); + setEditItemInitialData(null); + setEditItemError(null); + setIsLoadingItemDetails(false); + setIsSavingItem(false); openItemModal('manual', quickUrl.trim() || null); }; - const handleEditItem = (item: Item) => { + const handleEditItem = async (item: Item) => { setQuickError(null); setImportError(null); setImportedItemData(null); setIsImportingItem(false); importRequestIdRef.current += 1; + editRequestIdRef.current += 1; + setEditItemError(null); + setIsSavingItem(false); + setIsLoadingItemDetails(true); + const initialImages = + item.images?.map((image) => ({ id: image.id, url: image.url })) ?? []; + setEditItemInitialData({ + manufacturer: item.manufacturer, + model: item.model, + note: item.note, + attributes: item.attributes ?? {}, + sourceUrl: item.sourceUrl ?? null, + images: initialImages, + defaultImageId: item.defaultImageId ?? null + }); setItemBeingEdited(item); openItemModal('edit', item.sourceUrl ?? null); + + const requestId = editRequestIdRef.current; + + try { + const fullItem = await fetchItem(item.id); + if (editRequestIdRef.current !== requestId) { + return; + } + setItemBeingEdited(fullItem); + setEditItemInitialData({ + manufacturer: fullItem.manufacturer, + model: fullItem.model, + note: fullItem.note, + attributes: fullItem.attributes ?? {}, + sourceUrl: fullItem.sourceUrl ?? null, + images: + fullItem.images?.map((image) => ({ + id: image.id, + url: image.url + })) ?? [], + defaultImageId: fullItem.defaultImageId ?? null + }); + } catch (error) { + if (editRequestIdRef.current !== requestId) { + return; + } + const message = + error instanceof Error && error.message.trim().length + ? error.message.trim() + : 'Failed to load item details.'; + setEditItemError(message); + } finally { + if (editRequestIdRef.current === requestId) { + setIsLoadingItemDetails(false); + } + } }; const handleModalClose = () => { @@ -155,7 +224,12 @@ export function ProjectDetailPage() { setImportedItemData(null); setIsImportingItem(false); importRequestIdRef.current += 1; + editRequestIdRef.current += 1; setItemBeingEdited(null); + setEditItemInitialData(null); + setEditItemError(null); + setIsLoadingItemDetails(false); + setIsSavingItem(false); }; const handleModalSuccess = () => { @@ -168,7 +242,12 @@ export function ProjectDetailPage() { setImportedItemData(null); setIsImportingItem(false); importRequestIdRef.current += 1; + editRequestIdRef.current += 1; setItemBeingEdited(null); + setEditItemInitialData(null); + setEditItemError(null); + setIsLoadingItemDetails(false); + setIsSavingItem(false); }; const handleDescriptionUpdate = async (description: string | null) => { @@ -179,24 +258,126 @@ export function ProjectDetailPage() { await updateProjectMutation.mutateAsync({ attributes }); }; - const handleUpdateItemSubmit = (payload: CreateItemPayload) => { - if (!itemBeingEdited) { - return Promise.reject(new Error('No item selected for editing')); + const handleCreateItemSubmit = async (payload: ItemFormSubmitPayload) => { + if (!projectId) { + throw new Error('Project identifier is missing. Reload the page and try again.'); } - const updatePayload: UpdateItemPayload = { - manufacturer: payload.manufacturer, - model: payload.model, - note: payload.note, - attributes: payload.attributes, - sourceUrl: payload.sourceUrl, - sourceUrlId: payload.sourceUrlId - }; + setIsSavingItem(true); - return updateItemMutation.mutateAsync({ - itemId: itemBeingEdited.id, - payload: updatePayload - }); + try { + const createdItem = await createItemMutation.mutateAsync(payload.itemPayload); + + const createdImageIds = new Map(); + if (payload.imageChanges.toCreate.length) { + await Promise.all( + payload.imageChanges.toCreate.map(async (image) => { + const created = await createItemImage(createdItem.id, image.url); + createdImageIds.set(image.id, created.id); + }) + ); + } + + const { defaultSelection } = payload.imageChanges; + if (defaultSelection) { + let resolvedDefaultId: string | null = null; + if (defaultSelection.type === 'existing') { + resolvedDefaultId = defaultSelection.id; + } else { + const mappedId = createdImageIds.get(defaultSelection.id); + if (!mappedId) { + throw new Error('Failed to determine the default image.'); + } + resolvedDefaultId = mappedId; + } + + if (resolvedDefaultId) { + await updateItemMutation.mutateAsync({ + itemId: createdItem.id, + payload: { defaultImageId: resolvedDefaultId } + }); + } + } + + await queryClient.invalidateQueries({ queryKey: projectsKeys.items(projectId) }); + await queryClient.invalidateQueries({ queryKey: projectsKeys.detail(projectId) }); + } finally { + setIsSavingItem(false); + } + }; + + const handleUpdateItemSubmit = async (payload: ItemFormSubmitPayload) => { + if (!itemBeingEdited) { + throw new Error('No item selected for editing'); + } + + setIsSavingItem(true); + + try { + const createdImageIds = new Map(); + + if (payload.imageChanges.toCreate.length) { + await Promise.all( + payload.imageChanges.toCreate.map(async (image) => { + const created = await createItemImage(itemBeingEdited.id, image.url); + createdImageIds.set(image.id, created.id); + }) + ); + } + + const { defaultSelection, toDelete } = payload.imageChanges; + let resolvedDefaultId: string | null | undefined; + + if (defaultSelection) { + if (defaultSelection.type === 'existing') { + resolvedDefaultId = defaultSelection.id; + } else { + const mappedId = createdImageIds.get(defaultSelection.id); + if (!mappedId) { + throw new Error('Failed to determine the default image.'); + } + resolvedDefaultId = mappedId; + } + } else if (itemBeingEdited.defaultImageId) { + resolvedDefaultId = null; + } + + const updatePayload: UpdateItemPayload = { + manufacturer: payload.itemPayload.manufacturer, + model: payload.itemPayload.model, + note: payload.itemPayload.note, + attributes: payload.itemPayload.attributes, + sourceUrl: payload.itemPayload.sourceUrl, + sourceUrlId: payload.itemPayload.sourceUrlId + }; + + if (resolvedDefaultId !== undefined) { + updatePayload.defaultImageId = resolvedDefaultId; + } + + await updateItemMutation.mutateAsync({ + itemId: itemBeingEdited.id, + payload: updatePayload + }); + + if (toDelete.length) { + const protectedId = + typeof resolvedDefaultId === 'string' ? resolvedDefaultId : null; + const targets = protectedId + ? toDelete.filter((id) => id !== protectedId) + : toDelete; + if (targets.length) { + await Promise.all(targets.map((id) => deleteItemImage(id))); + } + } + + if (projectId) { + await queryClient.invalidateQueries({ queryKey: projectsKeys.items(projectId) }); + await queryClient.invalidateQueries({ queryKey: projectsKeys.detail(projectId) }); + } + } finally { + setIsSavingItem(false); + } }; if (!projectId) { @@ -225,27 +406,32 @@ export function ProjectDetailPage() { const modalInitialData = itemModalMode === 'url' ? importedItemData - : itemModalMode === 'edit' && itemBeingEdited - ? { - manufacturer: itemBeingEdited.manufacturer, - model: itemBeingEdited.model, - note: itemBeingEdited.note, - attributes: itemBeingEdited.attributes ?? {}, - sourceUrl: itemBeingEdited.sourceUrl ?? null - } + : itemModalMode === 'edit' + ? editItemInitialData : null; const itemModalSubmit = - itemModalMode === 'edit' ? handleUpdateItemSubmit : createItemMutation.mutateAsync; + itemModalMode === 'edit' ? handleUpdateItemSubmit : handleCreateItemSubmit; - const itemModalIsSubmitting = itemModalMode === 'edit' - ? updateItemMutation.isPending - : createItemMutation.isPending; + const itemModalIsSubmitting = + isSavingItem || + (itemModalMode === 'edit' + ? updateItemMutation.isPending + : createItemMutation.isPending); const itemModalInitialDataLoading = - itemModalMode === 'url' ? isImportingItem : false; + itemModalMode === 'url' + ? isImportingItem + : itemModalMode === 'edit' + ? isLoadingItemDetails + : false; - const itemModalInitialDataError = itemModalMode === 'url' ? importError : null; + const itemModalInitialDataError = + itemModalMode === 'url' + ? importError + : itemModalMode === 'edit' + ? editItemError + : null; return (
diff --git a/server/src/controllers/images-controller.ts b/server/src/controllers/images-controller.ts new file mode 100644 index 0000000..67e0037 --- /dev/null +++ b/server/src/controllers/images-controller.ts @@ -0,0 +1,51 @@ +import type { Request, Response } from 'express'; +import { + createImage as createImageRepo, + deleteImage as deleteImageRepo, + listImagesByItemId +} from '../db/images-repository.js'; +import { HttpError } from '../errors/http-error.js'; +import { parseUuid } from '../validation/common.js'; +import { parseImageCreatePayload } from '../validation/images.js'; + +export const listItemImages = async (req: Request, res: Response) => { + const itemId = parseUuid(req.params.itemId, 'itemId'); + const images = await listImagesByItemId(itemId); + + res.json({ data: images }); +}; + +export const createItemImage = async (req: Request, res: Response) => { + const itemId = parseUuid(req.params.itemId, 'itemId'); + const payload = parseImageCreatePayload(req.body); + + try { + const image = await createImageRepo({ + itemId, + url: payload.url + }); + + res.status(201).json({ data: image }); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: string }).code === '23503' + ) { + throw new HttpError(404, 'Item not found'); + } + throw error; + } +}; + +export const deleteItemImage = async (req: Request, res: Response) => { + const imageId = parseUuid(req.params.imageId, 'imageId'); + const deleted = await deleteImageRepo(imageId); + + if (!deleted) { + throw new HttpError(404, 'Image not found'); + } + + res.status(204).send(); +}; diff --git a/server/src/controllers/items-controller.ts b/server/src/controllers/items-controller.ts index 1df0350..3cd133e 100644 --- a/server/src/controllers/items-controller.ts +++ b/server/src/controllers/items-controller.ts @@ -6,6 +6,7 @@ import { listItems as listItemsRepo, updateItem as updateItemRepo } from '../db/items-repository.js'; +import { findImageById } from '../db/images-repository.js'; import { HttpError } from '../errors/http-error.js'; import { resolveUrlId } from './url-helpers.js'; import { parsePaginationParams, parseUuid } from '../validation/common.js'; @@ -53,6 +54,7 @@ export const createItem = async (req: Request, res: Response) => { manufacturer: payload.manufacturer, model: payload.model, sourceUrlId, + defaultImageId: payload.defaultImageId, status: payload.status, note: payload.note, attributes: payload.attributes @@ -95,10 +97,28 @@ export const updateItem = async (req: Request, res: Response) => { }); } + let defaultImageId: string | null | undefined; + if ('defaultImageId' in payload) { + defaultImageId = payload.defaultImageId ?? null; + if (defaultImageId !== null) { + const image = await findImageById(defaultImageId); + if (!image) { + throw new HttpError(404, 'Default image not found'); + } + if (image.itemId !== itemId) { + throw new HttpError( + 400, + 'defaultImageId must reference an image belonging to this item' + ); + } + } + } + const item = await updateItemRepo(itemId, { manufacturer: payload.manufacturer, model: payload.model, sourceUrlId, + defaultImageId, status: payload.status, note: payload.note, attributes: payload.attributes diff --git a/server/src/db/images-repository.ts b/server/src/db/images-repository.ts new file mode 100644 index 0000000..022ade5 --- /dev/null +++ b/server/src/db/images-repository.ts @@ -0,0 +1,86 @@ +import { query } from './pool.js'; + +export interface ImageRow { + id: string; + item_id: string; + url: string; + created_at: Date; + updated_at: Date; +} + +export interface ImageRecord { + id: string; + itemId: string; + url: string; + createdAt: string; + updatedAt: string; +} + +const mapImageRow = (row: ImageRow): ImageRecord => ({ + id: row.id, + itemId: row.item_id, + url: row.url, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString() +}); + +export const findImageById = async (id: string): Promise => { + const result = await query( + ` + SELECT id, item_id, url, created_at, updated_at + FROM images + WHERE id = $1 + `, + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapImageRow(result.rows[0]); +}; + +export const listImagesByItemId = async (itemId: string): Promise => { + const result = await query( + ` + SELECT id, item_id, url, created_at, updated_at + FROM images + WHERE item_id = $1 + ORDER BY created_at ASC, id ASC + `, + [itemId] + ); + + return result.rows.map(mapImageRow); +}; + +export interface CreateImageParams { + itemId: string; + url: string; +} + +export const createImage = async (params: CreateImageParams): Promise => { + const result = await query( + ` + INSERT INTO images (item_id, url) + VALUES ($1, $2) + RETURNING id, item_id, url, created_at, updated_at + `, + [params.itemId, params.url] + ); + + return mapImageRow(result.rows[0]); +}; + +export const deleteImage = async (id: string): Promise => { + const result = await query( + ` + DELETE FROM images + WHERE id = $1 + `, + [id] + ); + + return (result.rowCount ?? 0) > 0; +}; diff --git a/server/src/db/items-repository.ts b/server/src/db/items-repository.ts index 331b391..9ee921e 100644 --- a/server/src/db/items-repository.ts +++ b/server/src/db/items-repository.ts @@ -1,5 +1,7 @@ import type { Item, ItemPriceSummary, ItemStatus } from '@shared/models/item'; +import type { ItemImage } from '@shared/models/item-image'; import { query } from './pool.js'; +import { listImagesByItemId } from './images-repository.js'; const itemColumns = ` i.id, @@ -7,6 +9,7 @@ const itemColumns = ` i.manufacturer, i.model, i.source_url_id, + i.default_image_id, i.status, i.note, i.attributes, @@ -34,6 +37,7 @@ export interface ItemRow { manufacturer: string | null; model: string; source_url_id: string | null; + default_image_id: string | null; status: ItemStatus; note: string | null; attributes: Record; @@ -57,7 +61,7 @@ export interface ItemRow { export type ItemRecord = Item; -const mapItemRow = (row: ItemRow): ItemRecord => { +const mapItemRow = (row: ItemRow, images?: ItemImage[]): ItemRecord => { const priceCount = row.price_count ?? 0; const priceSummary: ItemPriceSummary | null = priceCount > 0 && row.price_min_amount !== null && row.price_max_amount !== null @@ -86,13 +90,15 @@ const mapItemRow = (row: ItemRow): ItemRecord => { manufacturer: row.manufacturer, model: row.model, sourceUrlId: row.source_url_id, + defaultImageId: row.default_image_id, sourceUrl: row.source_url, status: row.status, note: row.note, attributes: row.attributes ?? {}, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), - priceSummary + priceSummary, + images: images && images.length ? images : images ? [] : undefined }; }; @@ -148,7 +154,7 @@ export const listItems = async (options: ListItemsOptions): Promise mapItemRow(row)); }; export interface CreateItemParams { @@ -156,6 +162,7 @@ export interface CreateItemParams { manufacturer: string | null; model: string; sourceUrlId: string | null; + defaultImageId: string | null; status: ItemStatus; note: string | null; attributes: Record; @@ -169,11 +176,12 @@ export const createItem = async (params: CreateItemParams): Promise manufacturer, model, source_url_id, + default_image_id, status, note, attributes ) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id `, [ @@ -181,6 +189,7 @@ export const createItem = async (params: CreateItemParams): Promise params.manufacturer, params.model, params.sourceUrlId, + params.defaultImageId, params.status, params.note, params.attributes @@ -233,13 +242,16 @@ export const getItemById = async (id: string): Promise => { return null; } - return mapItemRow(result.rows[0]); + const images = await listImagesByItemId(id); + + return mapItemRow(result.rows[0], images); }; export interface UpdateItemParams { manufacturer?: string | null; model?: string; sourceUrlId?: string | null; + defaultImageId?: string | null; status?: ItemStatus; note?: string | null; attributes?: Record; @@ -267,6 +279,11 @@ export const updateItem = async ( fields.push(`source_url_id = $${values.length}`); } + if (params.defaultImageId !== undefined) { + values.push(params.defaultImageId); + fields.push(`default_image_id = $${values.length}`); + } + if (params.status !== undefined) { values.push(params.status); fields.push(`status = $${values.length}`); diff --git a/server/src/db/migrations/0006-create-images-table.ts b/server/src/db/migrations/0006-create-images-table.ts new file mode 100644 index 0000000..723983b --- /dev/null +++ b/server/src/db/migrations/0006-create-images-table.ts @@ -0,0 +1,22 @@ +import type { Migration } from './types.js'; + +export const migration: Migration = { + name: '0006-create-images-table', + up: async (client) => { + await client.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";'); + + await client.query(` + CREATE TABLE IF NOT EXISTS images ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS images_item_id_idx ON images (item_id); + `); + } +}; diff --git a/server/src/db/migrations/0007-add-default-image-to-items.ts b/server/src/db/migrations/0007-add-default-image-to-items.ts new file mode 100644 index 0000000..ba21969 --- /dev/null +++ b/server/src/db/migrations/0007-add-default-image-to-items.ts @@ -0,0 +1,16 @@ +import type { Migration } from './types.js'; + +export const migration: Migration = { + name: '0007-add-default-image-to-items', + up: async (client) => { + await client.query(` + ALTER TABLE items + ADD COLUMN IF NOT EXISTS default_image_id UUID REFERENCES images(id) ON DELETE SET NULL + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS items_default_image_id_idx + ON items (default_image_id) + `); + } +}; diff --git a/server/src/db/migrations/index.ts b/server/src/db/migrations/index.ts index 703432d..20fe585 100644 --- a/server/src/db/migrations/index.ts +++ b/server/src/db/migrations/index.ts @@ -4,6 +4,8 @@ import { migration as createProjectsTable } from './0002-create-projects-table.j import { migration as createUrlsTable } from './0003-create-urls-table.js'; import { migration as createItemsTable } from './0004-create-items-table.js'; import { migration as createItemPricesTable } from './0005-create-item-prices-table.js'; +import { migration as createImagesTable } from './0006-create-images-table.js'; +import { migration as addDefaultImageToItems } from './0007-add-default-image-to-items.js'; export const migrations: Migration[] = [ enablePgvector, @@ -11,4 +13,6 @@ export const migrations: Migration[] = [ createUrlsTable, createItemsTable, createItemPricesTable, + createImagesTable, + addDefaultImageToItems, ]; diff --git a/server/src/routes/api-router.ts b/server/src/routes/api-router.ts index 164fb90..9c1ae1a 100644 --- a/server/src/routes/api-router.ts +++ b/server/src/routes/api-router.ts @@ -7,6 +7,11 @@ import { listItems, updateItem } from '../controllers/items-controller.js'; +import { + createItemImage, + deleteItemImage, + listItemImages +} from '../controllers/images-controller.js'; import { createItemPrice, deleteItemPrice, @@ -42,6 +47,11 @@ apiRouter.get('/items/:itemId', asyncHandler(getItem)); apiRouter.patch('/items/:itemId', asyncHandler(updateItem)); apiRouter.delete('/items/:itemId', asyncHandler(deleteItem)); +// Item images +apiRouter.get('/items/:itemId/images', asyncHandler(listItemImages)); +apiRouter.post('/items/:itemId/images', asyncHandler(createItemImage)); +apiRouter.delete('/images/:imageId', asyncHandler(deleteItemImage)); + // Item prices apiRouter.get('/items/:itemId/prices', asyncHandler(listItemPrices)); apiRouter.post('/items/:itemId/prices', asyncHandler(createItemPrice)); diff --git a/server/src/validation/images.ts b/server/src/validation/images.ts new file mode 100644 index 0000000..146a4df --- /dev/null +++ b/server/src/validation/images.ts @@ -0,0 +1,34 @@ +import { HttpError } from '../errors/http-error.js'; + +export interface ImageCreateInput { + url: string; +} + +export const parseImageCreatePayload = (payload: unknown): ImageCreateInput => { + if (payload === null || typeof payload !== 'object') { + throw new HttpError(400, 'Body must be an object'); + } + + const { url } = payload as Record; + + if (typeof url !== 'string' || url.trim().length === 0) { + throw new HttpError(400, 'url must be a non-empty string'); + } + + const trimmed = url.trim(); + + if (trimmed.length > 2048) { + throw new HttpError(400, 'url must be 2048 characters or fewer'); + } + + try { + const parsed = new URL(trimmed); + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new HttpError(400, 'url must use http or https scheme'); + } + } catch { + throw new HttpError(400, 'url must be a valid URL'); + } + + return { url: trimmed }; +}; diff --git a/server/src/validation/items.ts b/server/src/validation/items.ts index 7e96fa8..dca721b 100644 --- a/server/src/validation/items.ts +++ b/server/src/validation/items.ts @@ -23,6 +23,7 @@ export interface ItemCreateInput { model: string; sourceUrlId: string | null; sourceUrl: string | null; + defaultImageId: string | null; status: ItemStatus; note: string | null; attributes: Record; @@ -43,7 +44,8 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => { note = null, attributes, sourceUrl, - sourceUrlId + sourceUrlId, + defaultImageId } = payload as Record; const manufacturerValue = @@ -81,6 +83,16 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => { normalizedSourceUrl = sourceUrl; } + if (defaultImageId !== undefined && defaultImageId !== null) { + if (typeof defaultImageId !== 'string' || defaultImageId.trim().length === 0) { + throw new HttpError( + 400, + 'defaultImageId must be null when creating an item' + ); + } + throw new HttpError(400, 'defaultImageId cannot be set when creating an item'); + } + return { manufacturer: manufacturerValue === null ? null : (manufacturerValue as string).trim(), @@ -89,7 +101,8 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => { note: note === null ? null : (note as string), attributes: ensureAttributesObject(attributes), sourceUrlId: normalizedSourceUrlId, - sourceUrl: normalizedSourceUrl + sourceUrl: normalizedSourceUrl, + defaultImageId: null }; }; @@ -107,7 +120,8 @@ export const parseItemUpdatePayload = (payload: unknown): ItemUpdateInput => { note, attributes, sourceUrl, - sourceUrlId + sourceUrlId, + defaultImageId } = payload as Record; if (manufacturer !== undefined || brand !== undefined) { @@ -170,6 +184,22 @@ export const parseItemUpdatePayload = (payload: unknown): ItemUpdateInput => { } } + if (defaultImageId !== undefined) { + if (defaultImageId === null) { + result.defaultImageId = null; + } else if ( + typeof defaultImageId === 'string' && + defaultImageId.trim().length > 0 + ) { + result.defaultImageId = parseUuid(defaultImageId, 'defaultImageId'); + } else { + throw new HttpError( + 400, + 'defaultImageId must be a non-empty string or null' + ); + } + } + if (Object.keys(result).length === 0) { throw new HttpError(400, 'At least one field must be provided for update'); } diff --git a/shared/models/item-image.d.ts b/shared/models/item-image.d.ts new file mode 100644 index 0000000..3e8aa48 --- /dev/null +++ b/shared/models/item-image.d.ts @@ -0,0 +1,7 @@ +export interface ItemImage { + id: string; + itemId: string; + url: string; + createdAt: string; + updatedAt: string; +} diff --git a/shared/models/item.d.ts b/shared/models/item.d.ts index fd082fc..547b559 100644 --- a/shared/models/item.d.ts +++ b/shared/models/item.d.ts @@ -16,6 +16,8 @@ export interface ItemPriceSummary { usedHasMixedCurrency: boolean; } +import type { ItemImage } from './item-image'; + export interface Item { id: string; projectId: string; @@ -23,10 +25,12 @@ export interface Item { model: string; sourceUrlId: string | null; sourceUrl: string | null; + defaultImageId: string | null; status: ItemStatus; note: string | null; attributes: Record; createdAt: string; updatedAt: string; priceSummary: ItemPriceSummary | null; + images?: ItemImage[]; }