mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
add image
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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,
|
||||
];
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user