add image

This commit is contained in:
Jurgis Sakalauskas
2025-10-16 09:33:41 +03:00
parent cea323ec3b
commit 9cdcd23fd9
16 changed files with 906 additions and 47 deletions
@@ -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();
};
@@ -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
+86
View File
@@ -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<ImageRecord | null> => {
const result = await query<ImageRow>(
`
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<ImageRecord[]> => {
const result = await query<ImageRow>(
`
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<ImageRecord> => {
const result = await query<ImageRow>(
`
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<boolean> => {
const result = await query(
`
DELETE FROM images
WHERE id = $1
`,
[id]
);
return (result.rowCount ?? 0) > 0;
};
+22 -5
View File
@@ -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<string, unknown>;
@@ -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<ItemRecord[]
params
);
return result.rows.map(mapItemRow);
return result.rows.map((row) => 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<string, unknown>;
@@ -169,11 +176,12 @@ export const createItem = async (params: CreateItemParams): Promise<ItemRecord>
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<ItemRecord>
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<ItemRecord | null> => {
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<string, unknown>;
@@ -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}`);
@@ -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);
`);
}
};
@@ -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)
`);
}
};
+4
View File
@@ -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,
];
+10
View File
@@ -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));
+34
View File
@@ -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<string, unknown>;
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 };
};
+33 -3
View File
@@ -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<string, unknown>;
@@ -43,7 +44,8 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => {
note = null,
attributes,
sourceUrl,
sourceUrlId
sourceUrlId,
defaultImageId
} = payload as Record<string, unknown>;
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<string, unknown>;
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');
}