mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
1st approach for projects, items, db structure
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import express from 'express';
|
||||
import apiRouter from './routes/api-router.js';
|
||||
import { env } from './config/env.js';
|
||||
import { errorHandler } from './middleware/error-handler.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(env.apiBasePath, apiRouter);
|
||||
app.use(errorHandler);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
export function getHealth(_req: Request, res: Response) {
|
||||
res.json({ status: 'ok', service: 'best-choice-api' });
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
createItemPrice as createItemPriceRepo,
|
||||
deleteItemPrice as deleteItemPriceRepo,
|
||||
getItemPriceById,
|
||||
listItemPrices as listItemPricesRepo,
|
||||
updateItemPrice as updateItemPriceRepo
|
||||
} from '../db/item-prices-repository.js';
|
||||
import { HttpError } from '../errors/http-error.js';
|
||||
import { resolveUrlId } from './url-helpers.js';
|
||||
import { parsePaginationParams, parseUuid } from '../validation/common.js';
|
||||
import {
|
||||
parseItemPriceCreatePayload,
|
||||
parseItemPriceUpdatePayload,
|
||||
parsePriceConditionFilter
|
||||
} from '../validation/item-prices.js';
|
||||
|
||||
export const listItemPrices = async (req: Request, res: Response) => {
|
||||
const itemId = parseUuid(req.params.itemId, 'itemId');
|
||||
const pagination = parsePaginationParams(req.query as Record<string, unknown>);
|
||||
const condition = parsePriceConditionFilter(
|
||||
(req.query as Record<string, unknown>).condition
|
||||
);
|
||||
|
||||
const prices = await listItemPricesRepo({
|
||||
itemId,
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
condition
|
||||
});
|
||||
|
||||
res.json({
|
||||
data: prices,
|
||||
meta: {
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
count: prices.length
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const createItemPrice = async (req: Request, res: Response) => {
|
||||
const itemId = parseUuid(req.params.itemId, 'itemId');
|
||||
const payload = parseItemPriceCreatePayload(req.body);
|
||||
|
||||
let sourceUrlId: string | null = null;
|
||||
if (payload.sourceType === 'url') {
|
||||
sourceUrlId = await resolveUrlId({
|
||||
sourceUrlId: payload.sourceUrlId,
|
||||
sourceUrl: payload.sourceUrl
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const price = await createItemPriceRepo({
|
||||
itemId,
|
||||
condition: payload.condition,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
sourceType: payload.sourceType,
|
||||
sourceUrlId,
|
||||
sourceNote: payload.sourceNote,
|
||||
note: payload.note,
|
||||
observedAt: payload.observedAt,
|
||||
isPrimary: payload.isPrimary
|
||||
});
|
||||
|
||||
res.status(201).json({ data: price });
|
||||
} 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 getItemPrice = async (req: Request, res: Response) => {
|
||||
const priceId = parseUuid(req.params.priceId, 'priceId');
|
||||
const price = await getItemPriceById(priceId);
|
||||
|
||||
if (!price) {
|
||||
throw new HttpError(404, 'Price not found');
|
||||
}
|
||||
|
||||
res.json({ data: price });
|
||||
};
|
||||
|
||||
export const updateItemPrice = async (req: Request, res: Response) => {
|
||||
const priceId = parseUuid(req.params.priceId, 'priceId');
|
||||
const payload = parseItemPriceUpdatePayload(req.body);
|
||||
|
||||
let sourceUrlId: string | null | undefined;
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
|
||||
const hasSourceUrlField = Object.prototype.hasOwnProperty.call(
|
||||
payloadRecord,
|
||||
'sourceUrl'
|
||||
);
|
||||
const hasSourceUrlIdField = Object.prototype.hasOwnProperty.call(
|
||||
payloadRecord,
|
||||
'sourceUrlId'
|
||||
);
|
||||
|
||||
if (payload.sourceType === 'manual') {
|
||||
sourceUrlId = null;
|
||||
} else if (payload.sourceType === 'url' || hasSourceUrlField || hasSourceUrlIdField) {
|
||||
sourceUrlId = await resolveUrlId({
|
||||
sourceUrlId: payload.sourceUrlId ?? null,
|
||||
sourceUrl: payload.sourceUrl ?? null
|
||||
});
|
||||
}
|
||||
|
||||
const price = await updateItemPriceRepo(priceId, {
|
||||
condition: payload.condition,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
sourceType: payload.sourceType,
|
||||
sourceUrlId,
|
||||
sourceNote: payload.sourceNote,
|
||||
note: payload.note,
|
||||
observedAt: payload.observedAt,
|
||||
isPrimary: payload.isPrimary
|
||||
});
|
||||
|
||||
if (!price) {
|
||||
throw new HttpError(404, 'Price not found');
|
||||
}
|
||||
|
||||
res.json({ data: price });
|
||||
};
|
||||
|
||||
export const deleteItemPrice = async (req: Request, res: Response) => {
|
||||
const priceId = parseUuid(req.params.priceId, 'priceId');
|
||||
const deleted = await deleteItemPriceRepo(priceId);
|
||||
|
||||
if (!deleted) {
|
||||
throw new HttpError(404, 'Price not found');
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
createItem as createItemRepo,
|
||||
deleteItem as deleteItemRepo,
|
||||
getItemById,
|
||||
listItems as listItemsRepo,
|
||||
updateItem as updateItemRepo
|
||||
} from '../db/items-repository.js';
|
||||
import { HttpError } from '../errors/http-error.js';
|
||||
import { resolveUrlId } from './url-helpers.js';
|
||||
import { parsePaginationParams, parseUuid } from '../validation/common.js';
|
||||
import {
|
||||
parseItemCreatePayload,
|
||||
parseItemStatusFilter,
|
||||
parseItemUpdatePayload
|
||||
} from '../validation/items.js';
|
||||
|
||||
export const listItems = async (req: Request, res: Response) => {
|
||||
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||
const pagination = parsePaginationParams(req.query as Record<string, unknown>);
|
||||
const status = parseItemStatusFilter((req.query as Record<string, unknown>).status);
|
||||
|
||||
const items = await listItemsRepo({
|
||||
projectId,
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
status
|
||||
});
|
||||
|
||||
res.json({
|
||||
data: items,
|
||||
meta: {
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
count: items.length
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const createItem = async (req: Request, res: Response) => {
|
||||
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||
const payload = parseItemCreatePayload(req.body);
|
||||
|
||||
const sourceUrlId = await resolveUrlId({
|
||||
sourceUrlId: payload.sourceUrlId,
|
||||
sourceUrl: payload.sourceUrl
|
||||
});
|
||||
|
||||
try {
|
||||
const item = await createItemRepo({
|
||||
projectId,
|
||||
manufacturer: payload.manufacturer,
|
||||
model: payload.model,
|
||||
sourceUrlId,
|
||||
status: payload.status,
|
||||
note: payload.note,
|
||||
attributes: payload.attributes
|
||||
});
|
||||
|
||||
res.status(201).json({ data: item });
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code: string }).code === '23503'
|
||||
) {
|
||||
throw new HttpError(404, 'Project not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getItem = async (req: Request, res: Response) => {
|
||||
const itemId = parseUuid(req.params.itemId, 'itemId');
|
||||
const item = await getItemById(itemId);
|
||||
|
||||
if (!item) {
|
||||
throw new HttpError(404, 'Item not found');
|
||||
}
|
||||
|
||||
res.json({ data: item });
|
||||
};
|
||||
|
||||
export const updateItem = async (req: Request, res: Response) => {
|
||||
const itemId = parseUuid(req.params.itemId, 'itemId');
|
||||
const payload = parseItemUpdatePayload(req.body);
|
||||
|
||||
let sourceUrlId: string | null | undefined;
|
||||
if ('sourceUrl' in payload || 'sourceUrlId' in payload) {
|
||||
sourceUrlId = await resolveUrlId({
|
||||
sourceUrlId: payload.sourceUrlId ?? null,
|
||||
sourceUrl: payload.sourceUrl ?? null
|
||||
});
|
||||
}
|
||||
|
||||
const item = await updateItemRepo(itemId, {
|
||||
manufacturer: payload.manufacturer,
|
||||
model: payload.model,
|
||||
sourceUrlId,
|
||||
status: payload.status,
|
||||
note: payload.note,
|
||||
attributes: payload.attributes
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new HttpError(404, 'Item not found');
|
||||
}
|
||||
|
||||
res.json({ data: item });
|
||||
};
|
||||
|
||||
export const deleteItem = async (req: Request, res: Response) => {
|
||||
const itemId = parseUuid(req.params.itemId, 'itemId');
|
||||
const deleted = await deleteItemRepo(itemId);
|
||||
|
||||
if (!deleted) {
|
||||
throw new HttpError(404, 'Item not found');
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
createProject as createProjectRepo,
|
||||
deleteProject as deleteProjectRepo,
|
||||
getProjectById,
|
||||
listProjects as listProjectsRepo,
|
||||
updateProject as updateProjectRepo
|
||||
} from '../db/projects-repository.js';
|
||||
import { HttpError } from '../errors/http-error.js';
|
||||
import { parsePaginationParams, parseUuid } from '../validation/common.js';
|
||||
import {
|
||||
parseProjectCreatePayload,
|
||||
parseProjectStatusFilter,
|
||||
parseProjectUpdatePayload
|
||||
} from '../validation/projects.js';
|
||||
|
||||
export const listProjects = async (req: Request, res: Response) => {
|
||||
const pagination = parsePaginationParams(req.query as Record<string, unknown>);
|
||||
const status = parseProjectStatusFilter((req.query as Record<string, unknown>).status);
|
||||
|
||||
const projects = await listProjectsRepo({
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
status
|
||||
});
|
||||
|
||||
res.json({
|
||||
data: projects,
|
||||
meta: {
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
count: projects.length
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const createProject = async (req: Request, res: Response) => {
|
||||
const payload = parseProjectCreatePayload(req.body);
|
||||
const project = await createProjectRepo({
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: payload.status,
|
||||
attributes: payload.projectAttributes,
|
||||
priorityRules: payload.projectPriorityRules
|
||||
});
|
||||
|
||||
res.status(201).json({ data: project });
|
||||
};
|
||||
|
||||
export const getProject = async (req: Request, res: Response) => {
|
||||
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||
const project = await getProjectById(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError(404, 'Project not found');
|
||||
}
|
||||
|
||||
res.json({ data: project });
|
||||
};
|
||||
|
||||
export const updateProject = async (req: Request, res: Response) => {
|
||||
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||
const payload = parseProjectUpdatePayload(req.body);
|
||||
|
||||
const project = await updateProjectRepo(projectId, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: payload.status,
|
||||
attributes: payload.projectAttributes,
|
||||
priorityRules: payload.projectPriorityRules
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError(404, 'Project not found');
|
||||
}
|
||||
|
||||
res.json({ data: project });
|
||||
};
|
||||
|
||||
export const deleteProject = async (req: Request, res: Response) => {
|
||||
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||
const deleted = await deleteProjectRepo(projectId);
|
||||
|
||||
if (!deleted) {
|
||||
throw new HttpError(404, 'Project not found');
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createUrl, findUrlById } from '../db/urls-repository.js';
|
||||
import { HttpError } from '../errors/http-error.js';
|
||||
import { normalizeUrl } from '../utils/url-normalizer.js';
|
||||
|
||||
export const resolveUrlId = async (params: {
|
||||
sourceUrlId: string | null;
|
||||
sourceUrl: string | null;
|
||||
}) => {
|
||||
let urlId = params.sourceUrlId ?? null;
|
||||
|
||||
if (params.sourceUrl) {
|
||||
const normalized = normalizeUrl(params.sourceUrl);
|
||||
const url = await createUrl({ url: normalized });
|
||||
urlId = url.id;
|
||||
} else if (urlId) {
|
||||
const existing = await findUrlById(urlId);
|
||||
if (!existing) {
|
||||
throw new HttpError(400, 'sourceUrlId does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
return urlId;
|
||||
};
|
||||
@@ -0,0 +1,350 @@
|
||||
import { pool, query } from './pool.js';
|
||||
|
||||
const priceColumns = `
|
||||
p.id,
|
||||
p.item_id,
|
||||
p.condition,
|
||||
p.amount,
|
||||
p.currency,
|
||||
p.source_type,
|
||||
p.source_url_id,
|
||||
p.source_note,
|
||||
p.note,
|
||||
p.observed_at,
|
||||
p.is_primary,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
u.url AS source_url
|
||||
`;
|
||||
|
||||
export interface ItemPriceRow {
|
||||
id: string;
|
||||
item_id: string;
|
||||
condition: 'new' | 'used';
|
||||
amount: string;
|
||||
currency: string;
|
||||
source_type: 'url' | 'manual';
|
||||
source_url_id: string | null;
|
||||
source_note: string | null;
|
||||
note: string | null;
|
||||
observed_at: Date;
|
||||
is_primary: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
source_url: string | null;
|
||||
}
|
||||
|
||||
export interface ItemPriceRecord {
|
||||
id: string;
|
||||
itemId: string;
|
||||
condition: 'new' | 'used';
|
||||
amount: number;
|
||||
currency: string;
|
||||
sourceType: 'url' | 'manual';
|
||||
sourceUrlId: string | null;
|
||||
sourceUrl: string | null;
|
||||
sourceNote: string | null;
|
||||
note: string | null;
|
||||
observedAt: string;
|
||||
isPrimary: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const mapItemPriceRow = (row: ItemPriceRow): ItemPriceRecord => ({
|
||||
id: row.id,
|
||||
itemId: row.item_id,
|
||||
condition: row.condition,
|
||||
amount: Number(row.amount),
|
||||
currency: row.currency,
|
||||
sourceType: row.source_type,
|
||||
sourceUrlId: row.source_url_id,
|
||||
sourceUrl: row.source_url,
|
||||
sourceNote: row.source_note,
|
||||
note: row.note,
|
||||
observedAt: row.observed_at.toISOString(),
|
||||
isPrimary: row.is_primary,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
});
|
||||
|
||||
export interface ListItemPricesOptions {
|
||||
itemId: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
condition?: 'new' | 'used';
|
||||
}
|
||||
|
||||
export const listItemPrices = async (
|
||||
options: ListItemPricesOptions
|
||||
): Promise<ItemPriceRecord[]> => {
|
||||
const params: unknown[] = [options.itemId];
|
||||
const filters: string[] = ['p.item_id = $1'];
|
||||
|
||||
if (options.condition) {
|
||||
params.push(options.condition);
|
||||
filters.push(`p.condition = $${params.length}`);
|
||||
}
|
||||
|
||||
params.push(options.limit);
|
||||
const limitIndex = params.length;
|
||||
params.push(options.offset);
|
||||
const offsetIndex = params.length;
|
||||
|
||||
const result = await query<ItemPriceRow>(
|
||||
`
|
||||
SELECT ${priceColumns}
|
||||
FROM item_prices p
|
||||
LEFT JOIN urls u ON u.id = p.source_url_id
|
||||
WHERE ${filters.join(' AND ')}
|
||||
ORDER BY p.observed_at DESC
|
||||
LIMIT $${limitIndex}
|
||||
OFFSET $${offsetIndex}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows.map(mapItemPriceRow);
|
||||
};
|
||||
|
||||
export interface CreateItemPriceParams {
|
||||
itemId: string;
|
||||
condition: 'new' | 'used';
|
||||
amount: number;
|
||||
currency: string;
|
||||
sourceType: 'url' | 'manual';
|
||||
sourceUrlId: string | null;
|
||||
sourceNote: string | null;
|
||||
note: string | null;
|
||||
observedAt?: string;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export const createItemPrice = async (
|
||||
params: CreateItemPriceParams
|
||||
): Promise<ItemPriceRecord> => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
if (params.isPrimary) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE item_prices
|
||||
SET is_primary = FALSE
|
||||
WHERE item_id = $1 AND condition = $2
|
||||
`,
|
||||
[params.itemId, params.condition]
|
||||
);
|
||||
}
|
||||
|
||||
const result = await client.query<{ id: string }>(
|
||||
`
|
||||
INSERT INTO item_prices (
|
||||
item_id,
|
||||
condition,
|
||||
amount,
|
||||
currency,
|
||||
source_type,
|
||||
source_url_id,
|
||||
source_note,
|
||||
note,
|
||||
observed_at,
|
||||
is_primary
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, NOW()), $10)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
params.itemId,
|
||||
params.condition,
|
||||
params.amount,
|
||||
params.currency,
|
||||
params.sourceType,
|
||||
params.sourceUrlId,
|
||||
params.sourceNote,
|
||||
params.note,
|
||||
params.observedAt ?? null,
|
||||
params.isPrimary
|
||||
]
|
||||
);
|
||||
|
||||
const insertedId = result.rows[0]?.id;
|
||||
if (!insertedId) {
|
||||
throw new Error('Failed to create item price');
|
||||
}
|
||||
|
||||
const record = await client.query<ItemPriceRow>(
|
||||
`
|
||||
SELECT ${priceColumns}
|
||||
FROM item_prices p
|
||||
LEFT JOIN urls u ON u.id = p.source_url_id
|
||||
WHERE p.id = $1
|
||||
`,
|
||||
[insertedId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return mapItemPriceRow(record.rows[0]);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
export const getItemPriceById = async (id: string): Promise<ItemPriceRecord | null> => {
|
||||
const result = await query<ItemPriceRow>(
|
||||
`
|
||||
SELECT ${priceColumns}
|
||||
FROM item_prices p
|
||||
LEFT JOIN urls u ON u.id = p.source_url_id
|
||||
WHERE p.id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapItemPriceRow(result.rows[0]);
|
||||
};
|
||||
|
||||
export interface UpdateItemPriceParams {
|
||||
condition?: 'new' | 'used';
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
sourceType?: 'url' | 'manual';
|
||||
sourceUrlId?: string | null;
|
||||
sourceNote?: string | null;
|
||||
note?: string | null;
|
||||
observedAt?: string;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
export const updateItemPrice = async (
|
||||
id: string,
|
||||
params: UpdateItemPriceParams
|
||||
): Promise<ItemPriceRecord | null> => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const existingResult = await client.query<ItemPriceRow>(
|
||||
`
|
||||
SELECT ${priceColumns}
|
||||
FROM item_prices p
|
||||
LEFT JOIN urls u ON u.id = p.source_url_id
|
||||
WHERE p.id = $1
|
||||
FOR UPDATE
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (existingResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = existingResult.rows[0];
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (params.condition !== undefined) {
|
||||
values.push(params.condition);
|
||||
fields.push(`condition = $${values.length}`);
|
||||
}
|
||||
if (params.amount !== undefined) {
|
||||
values.push(params.amount);
|
||||
fields.push(`amount = $${values.length}`);
|
||||
}
|
||||
if (params.currency !== undefined) {
|
||||
values.push(params.currency);
|
||||
fields.push(`currency = $${values.length}`);
|
||||
}
|
||||
if (params.sourceType !== undefined) {
|
||||
values.push(params.sourceType);
|
||||
fields.push(`source_type = $${values.length}`);
|
||||
}
|
||||
if (params.sourceUrlId !== undefined) {
|
||||
values.push(params.sourceUrlId);
|
||||
fields.push(`source_url_id = $${values.length}`);
|
||||
}
|
||||
if (params.sourceNote !== undefined) {
|
||||
values.push(params.sourceNote);
|
||||
fields.push(`source_note = $${values.length}`);
|
||||
}
|
||||
if (params.note !== undefined) {
|
||||
values.push(params.note);
|
||||
fields.push(`note = $${values.length}`);
|
||||
}
|
||||
if (params.observedAt !== undefined) {
|
||||
values.push(params.observedAt);
|
||||
fields.push(`observed_at = COALESCE($${values.length}, NOW())`);
|
||||
}
|
||||
if (params.isPrimary !== undefined) {
|
||||
values.push(params.isPrimary);
|
||||
fields.push(`is_primary = $${values.length}`);
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
values.push(id);
|
||||
const idIndex = values.length;
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE item_prices
|
||||
SET ${fields.join(', ')}, updated_at = NOW()
|
||||
WHERE id = $${idIndex}
|
||||
`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
const newCondition = params.condition ?? existing.condition;
|
||||
const newIsPrimary = params.isPrimary ?? existing.is_primary;
|
||||
|
||||
if (newIsPrimary) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE item_prices
|
||||
SET is_primary = FALSE
|
||||
WHERE item_id = $1 AND condition = $2 AND id <> $3
|
||||
`,
|
||||
[existing.item_id, newCondition, id]
|
||||
);
|
||||
}
|
||||
|
||||
const updatedResult = await client.query<ItemPriceRow>(
|
||||
`
|
||||
SELECT ${priceColumns}
|
||||
FROM item_prices p
|
||||
LEFT JOIN urls u ON u.id = p.source_url_id
|
||||
WHERE p.id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return mapItemPriceRow(updatedResult.rows[0]);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteItemPrice = async (id: string): Promise<boolean> => {
|
||||
const result = await query(
|
||||
`
|
||||
DELETE FROM item_prices
|
||||
WHERE id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import { query } from './pool.js';
|
||||
|
||||
const itemColumns = `
|
||||
i.id,
|
||||
i.project_id,
|
||||
i.manufacturer,
|
||||
i.model,
|
||||
i.source_url_id,
|
||||
i.status,
|
||||
i.note,
|
||||
i.attributes,
|
||||
i.created_at,
|
||||
i.updated_at,
|
||||
u.url AS source_url,
|
||||
price_summary.min_amount AS price_min_amount,
|
||||
price_summary.max_amount AS price_max_amount,
|
||||
price_summary.total_count AS price_count,
|
||||
price_summary.currency AS price_currency,
|
||||
price_summary.currency_count AS price_currency_count
|
||||
`;
|
||||
|
||||
export interface ItemRow {
|
||||
id: string;
|
||||
project_id: string;
|
||||
manufacturer: string | null;
|
||||
model: string;
|
||||
source_url_id: string | null;
|
||||
status: 'active' | 'rejected';
|
||||
note: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
source_url: string | null;
|
||||
price_min_amount: string | null;
|
||||
price_max_amount: string | null;
|
||||
price_count: number | null;
|
||||
price_currency: string | null;
|
||||
price_currency_count: number | null;
|
||||
}
|
||||
|
||||
export interface ItemRecord {
|
||||
id: string;
|
||||
projectId: string;
|
||||
manufacturer: string | null;
|
||||
model: string;
|
||||
sourceUrlId: string | null;
|
||||
sourceUrl: string | null;
|
||||
status: 'active' | 'rejected';
|
||||
note: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
priceSummary: ItemPriceSummary | null;
|
||||
}
|
||||
|
||||
export interface ItemPriceSummary {
|
||||
minAmount: number;
|
||||
maxAmount: number;
|
||||
currency: string | null;
|
||||
priceCount: number;
|
||||
hasMixedCurrency: boolean;
|
||||
}
|
||||
|
||||
const mapItemRow = (row: ItemRow): ItemRecord => {
|
||||
const priceCount = row.price_count ?? 0;
|
||||
const priceSummary: ItemPriceSummary | null =
|
||||
priceCount > 0 && row.price_min_amount !== null && row.price_max_amount !== null
|
||||
? {
|
||||
minAmount: Number(row.price_min_amount),
|
||||
maxAmount: Number(row.price_max_amount),
|
||||
currency: row.price_currency,
|
||||
priceCount,
|
||||
hasMixedCurrency: (row.price_currency_count ?? 0) > 1
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.project_id,
|
||||
manufacturer: row.manufacturer,
|
||||
model: row.model,
|
||||
sourceUrlId: row.source_url_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
|
||||
};
|
||||
};
|
||||
|
||||
export interface ListItemsOptions {
|
||||
projectId: string;
|
||||
status?: 'active' | 'rejected';
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export const listItems = async (options: ListItemsOptions): Promise<ItemRecord[]> => {
|
||||
const params: unknown[] = [options.projectId];
|
||||
const filters: string[] = ['i.project_id = $1'];
|
||||
|
||||
if (options.status) {
|
||||
params.push(options.status);
|
||||
filters.push(`i.status = $${params.length}`);
|
||||
}
|
||||
|
||||
params.push(options.limit);
|
||||
const limitIndex = params.length;
|
||||
params.push(options.offset);
|
||||
const offsetIndex = params.length;
|
||||
|
||||
const result = await query<ItemRow>(
|
||||
`
|
||||
SELECT ${itemColumns}
|
||||
FROM items i
|
||||
LEFT JOIN urls u ON u.id = i.source_url_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
MIN(ip.amount) AS min_amount,
|
||||
MAX(ip.amount) AS max_amount,
|
||||
COUNT(*)::INT AS total_count,
|
||||
COUNT(DISTINCT ip.currency)::INT AS currency_count,
|
||||
MIN(ip.currency) AS currency
|
||||
FROM item_prices ip
|
||||
WHERE ip.item_id = i.id
|
||||
) price_summary ON TRUE
|
||||
WHERE ${filters.join(' AND ')}
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT $${limitIndex}
|
||||
OFFSET $${offsetIndex}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows.map(mapItemRow);
|
||||
};
|
||||
|
||||
export interface CreateItemParams {
|
||||
projectId: string;
|
||||
manufacturer: string | null;
|
||||
model: string;
|
||||
sourceUrlId: string | null;
|
||||
status: 'active' | 'rejected';
|
||||
note: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const createItem = async (params: CreateItemParams): Promise<ItemRecord> => {
|
||||
const result = await query<{ id: string }>(
|
||||
`
|
||||
INSERT INTO items (
|
||||
project_id,
|
||||
manufacturer,
|
||||
model,
|
||||
source_url_id,
|
||||
status,
|
||||
note,
|
||||
attributes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
params.projectId,
|
||||
params.manufacturer,
|
||||
params.model,
|
||||
params.sourceUrlId,
|
||||
params.status,
|
||||
params.note,
|
||||
params.attributes
|
||||
]
|
||||
);
|
||||
|
||||
const insertedId = result.rows[0]?.id;
|
||||
if (!insertedId) {
|
||||
throw new Error('Failed to create item');
|
||||
}
|
||||
|
||||
const inserted = await getItemById(insertedId);
|
||||
if (!inserted) {
|
||||
throw new Error('Created item could not be loaded');
|
||||
}
|
||||
|
||||
return inserted;
|
||||
};
|
||||
|
||||
export const getItemById = async (id: string): Promise<ItemRecord | null> => {
|
||||
const result = await query<ItemRow>(
|
||||
`
|
||||
SELECT ${itemColumns}
|
||||
FROM items i
|
||||
LEFT JOIN urls u ON u.id = i.source_url_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
MIN(ip.amount) AS min_amount,
|
||||
MAX(ip.amount) AS max_amount,
|
||||
COUNT(*)::INT AS total_count,
|
||||
COUNT(DISTINCT ip.currency)::INT AS currency_count,
|
||||
MIN(ip.currency) AS currency
|
||||
FROM item_prices ip
|
||||
WHERE ip.item_id = i.id
|
||||
) price_summary ON TRUE
|
||||
WHERE i.id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapItemRow(result.rows[0]);
|
||||
};
|
||||
|
||||
export interface UpdateItemParams {
|
||||
manufacturer?: string | null;
|
||||
model?: string;
|
||||
sourceUrlId?: string | null;
|
||||
status?: 'active' | 'rejected';
|
||||
note?: string | null;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const updateItem = async (
|
||||
id: string,
|
||||
params: UpdateItemParams
|
||||
): Promise<ItemRecord | null> => {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (params.manufacturer !== undefined) {
|
||||
values.push(params.manufacturer);
|
||||
fields.push(`manufacturer = $${values.length}`);
|
||||
}
|
||||
|
||||
if (params.model !== undefined) {
|
||||
values.push(params.model);
|
||||
fields.push(`model = $${values.length}`);
|
||||
}
|
||||
|
||||
if (params.sourceUrlId !== undefined) {
|
||||
values.push(params.sourceUrlId);
|
||||
fields.push(`source_url_id = $${values.length}`);
|
||||
}
|
||||
|
||||
if (params.status !== undefined) {
|
||||
values.push(params.status);
|
||||
fields.push(`status = $${values.length}`);
|
||||
}
|
||||
|
||||
if (params.note !== undefined) {
|
||||
values.push(params.note);
|
||||
fields.push(`note = $${values.length}`);
|
||||
}
|
||||
|
||||
if (params.attributes !== undefined) {
|
||||
values.push(params.attributes);
|
||||
fields.push(`attributes = $${values.length}`);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return getItemById(id);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const idParamIndex = values.length;
|
||||
|
||||
const result = await query<{ id: string }>(
|
||||
`
|
||||
UPDATE items
|
||||
SET ${fields.join(', ')}, updated_at = NOW()
|
||||
WHERE id = $${idParamIndex}
|
||||
RETURNING id
|
||||
`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedId = result.rows[0]?.id;
|
||||
if (!updatedId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getItemById(updatedId);
|
||||
};
|
||||
|
||||
export const deleteItem = async (id: string): Promise<boolean> => {
|
||||
const result = await query(
|
||||
`
|
||||
DELETE FROM items
|
||||
WHERE id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Migration } from './types.js';
|
||||
|
||||
export const migration: Migration = {
|
||||
name: '0002-create-projects-table',
|
||||
up: async (client) => {
|
||||
await client.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
|
||||
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TYPE project_status AS ENUM ('active', 'archived');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END$$;
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(150) NOT NULL,
|
||||
description TEXT,
|
||||
status project_status NOT NULL DEFAULT 'active',
|
||||
project_attributes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
project_priority_rules JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Migration } from './types.js';
|
||||
|
||||
export const migration: Migration = {
|
||||
name: '0003-create-urls-table',
|
||||
up: async (client) => {
|
||||
await client.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS urls (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
note TEXT,
|
||||
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
has_price BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Migration } from './types.js';
|
||||
|
||||
export const migration: Migration = {
|
||||
name: '0004-create-items-table',
|
||||
up: async (client) => {
|
||||
await client.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
|
||||
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TYPE item_status AS ENUM ('active', 'rejected');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END$$;
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
manufacturer VARCHAR(120),
|
||||
model VARCHAR(150) NOT NULL,
|
||||
source_url_id UUID REFERENCES urls(id) ON DELETE SET NULL,
|
||||
status item_status NOT NULL DEFAULT 'active',
|
||||
note TEXT,
|
||||
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS items_project_id_idx ON items (project_id);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS items_source_url_id_idx ON items (source_url_id);
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Migration } from './types.js';
|
||||
|
||||
export const migration: Migration = {
|
||||
name: '0005-create-item-prices-table',
|
||||
up: async (client) => {
|
||||
await client.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
|
||||
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TYPE price_condition AS ENUM ('new', 'used');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END$$;
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TYPE price_source AS ENUM ('url', 'manual');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END$$;
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS item_prices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
condition price_condition NOT NULL,
|
||||
amount NUMERIC(12, 2) NOT NULL CHECK (amount >= 0),
|
||||
currency CHAR(3) NOT NULL,
|
||||
source_type price_source NOT NULL,
|
||||
source_url_id UUID REFERENCES urls(id) ON DELETE SET NULL,
|
||||
source_note TEXT,
|
||||
note TEXT,
|
||||
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CHECK (char_length(currency) = 3),
|
||||
CHECK (
|
||||
(source_type = 'manual'::price_source AND source_url_id IS NULL)
|
||||
OR (source_type = 'url'::price_source AND source_url_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS item_prices_item_id_idx ON item_prices (item_id);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS item_prices_source_url_id_idx ON item_prices (source_url_id);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS item_prices_observed_at_idx ON item_prices (observed_at);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS item_prices_primary_condition_idx
|
||||
ON item_prices (item_id, condition)
|
||||
WHERE is_primary = TRUE;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,14 @@
|
||||
import type { Migration } from './types.js';
|
||||
import { migration as enablePgvector } from './0001-enable-pgvector.js';
|
||||
import { migration as createProjectsTable } from './0002-create-projects-table.js';
|
||||
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';
|
||||
|
||||
export const migrations: Migration[] = [enablePgvector];
|
||||
export const migrations: Migration[] = [
|
||||
enablePgvector,
|
||||
createProjectsTable,
|
||||
createUrlsTable,
|
||||
createItemsTable,
|
||||
createItemPricesTable,
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pool } from 'pg';
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
if (!env.databaseUrl) {
|
||||
@@ -15,4 +15,7 @@ pool.on('error', (error) => {
|
||||
|
||||
export const getClient = () => pool.connect();
|
||||
|
||||
export const query = (text: string, params?: unknown[]) => pool.query(text, params);
|
||||
export const query = <T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
) => pool.query<T>(text, params);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { query } from './pool.js';
|
||||
|
||||
const projectColumns = `
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
project_attributes,
|
||||
project_priority_rules,
|
||||
created_at,
|
||||
updated_at
|
||||
`;
|
||||
|
||||
export interface ProjectRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: 'active' | 'archived';
|
||||
project_attributes: string[];
|
||||
project_priority_rules: unknown;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: 'active' | 'archived';
|
||||
attributes: string[];
|
||||
priorityRules: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const mapProjectRow = (row: ProjectRow): Project => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
attributes: row.project_attributes ?? [],
|
||||
priorityRules: row.project_priority_rules ?? [],
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
});
|
||||
|
||||
export interface ListProjectsOptions {
|
||||
limit: number;
|
||||
offset: number;
|
||||
status?: 'active' | 'archived';
|
||||
}
|
||||
|
||||
export const listProjects = async (
|
||||
options: ListProjectsOptions
|
||||
): Promise<Project[]> => {
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (options.status) {
|
||||
params.push(options.status);
|
||||
conditions.push(`status = $${params.length}`);
|
||||
}
|
||||
|
||||
params.push(options.limit);
|
||||
const limitParamIndex = params.length;
|
||||
params.push(options.offset);
|
||||
const offsetParamIndex = params.length;
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await query<ProjectRow>(
|
||||
`
|
||||
SELECT ${projectColumns}
|
||||
FROM projects
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${limitParamIndex}
|
||||
OFFSET $${offsetParamIndex}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows.map(mapProjectRow);
|
||||
};
|
||||
|
||||
export interface CreateProjectParams {
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: 'active' | 'archived';
|
||||
attributes: string[];
|
||||
priorityRules: unknown;
|
||||
}
|
||||
|
||||
export const createProject = async (params: CreateProjectParams): Promise<Project> => {
|
||||
const result = await query<ProjectRow>(
|
||||
`
|
||||
INSERT INTO projects (
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
project_attributes,
|
||||
project_priority_rules
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING ${projectColumns}
|
||||
`,
|
||||
[
|
||||
params.name,
|
||||
params.description,
|
||||
params.status,
|
||||
params.attributes,
|
||||
params.priorityRules ?? []
|
||||
]
|
||||
);
|
||||
|
||||
return mapProjectRow(result.rows[0]);
|
||||
};
|
||||
|
||||
export const getProjectById = async (id: string): Promise<Project | null> => {
|
||||
const result = await query<ProjectRow>(
|
||||
`
|
||||
SELECT ${projectColumns}
|
||||
FROM projects
|
||||
WHERE id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapProjectRow(result.rows[0]);
|
||||
};
|
||||
|
||||
export interface UpdateProjectParams {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
status?: 'active' | 'archived';
|
||||
attributes?: string[];
|
||||
priorityRules?: unknown;
|
||||
}
|
||||
|
||||
export const updateProject = async (
|
||||
id: string,
|
||||
params: UpdateProjectParams
|
||||
): Promise<Project | null> => {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (params.name !== undefined) {
|
||||
values.push(params.name);
|
||||
fields.push(`name = $${values.length}`);
|
||||
}
|
||||
|
||||
if (params.description !== undefined) {
|
||||
values.push(params.description);
|
||||
fields.push(`description = $${values.length}`);
|
||||
}
|
||||
|
||||
if (params.status !== undefined) {
|
||||
values.push(params.status);
|
||||
fields.push(`status = $${values.length}`);
|
||||
}
|
||||
|
||||
if (params.attributes !== undefined) {
|
||||
values.push(params.attributes);
|
||||
fields.push(`project_attributes = $${values.length}`);
|
||||
}
|
||||
|
||||
if (params.priorityRules !== undefined) {
|
||||
values.push(params.priorityRules);
|
||||
fields.push(`project_priority_rules = $${values.length}`);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return getProjectById(id);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const idParamIndex = values.length;
|
||||
|
||||
const result = await query<ProjectRow>(
|
||||
`
|
||||
UPDATE projects
|
||||
SET ${fields.join(', ')}, updated_at = NOW()
|
||||
WHERE id = $${idParamIndex}
|
||||
RETURNING ${projectColumns}
|
||||
`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapProjectRow(result.rows[0]);
|
||||
};
|
||||
|
||||
export const deleteProject = async (id: string): Promise<boolean> => {
|
||||
const result = await query(
|
||||
`
|
||||
DELETE FROM projects
|
||||
WHERE id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { query } from './pool.js';
|
||||
|
||||
export interface UrlRow {
|
||||
id: string;
|
||||
url: string;
|
||||
note: string | null;
|
||||
attributes: unknown;
|
||||
has_price: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface UrlRecord {
|
||||
id: string;
|
||||
url: string;
|
||||
note: string | null;
|
||||
attributes: unknown;
|
||||
hasPrice: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const mapUrlRow = (row: UrlRow): UrlRecord => ({
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
note: row.note,
|
||||
attributes: row.attributes ?? {},
|
||||
hasPrice: row.has_price,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
});
|
||||
|
||||
export const findUrlById = async (id: string): Promise<UrlRecord | null> => {
|
||||
const result = await query<UrlRow>(
|
||||
`
|
||||
SELECT id, url, note, attributes, has_price, created_at, updated_at
|
||||
FROM urls
|
||||
WHERE id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapUrlRow(result.rows[0]);
|
||||
};
|
||||
|
||||
export const findUrlByValue = async (url: string): Promise<UrlRecord | null> => {
|
||||
const result = await query<UrlRow>(
|
||||
`
|
||||
SELECT id, url, note, attributes, has_price, created_at, updated_at
|
||||
FROM urls
|
||||
WHERE url = $1
|
||||
`,
|
||||
[url]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapUrlRow(result.rows[0]);
|
||||
};
|
||||
|
||||
export interface CreateUrlParams {
|
||||
url: string;
|
||||
note?: string | null;
|
||||
attributes?: unknown;
|
||||
hasPrice?: boolean;
|
||||
}
|
||||
|
||||
export const createUrl = async (params: CreateUrlParams): Promise<UrlRecord> => {
|
||||
const result = await query<UrlRow>(
|
||||
`
|
||||
INSERT INTO urls (url, note, attributes, has_price)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (url) DO UPDATE
|
||||
SET updated_at = NOW()
|
||||
RETURNING id, url, note, attributes, has_price, created_at, updated_at
|
||||
`,
|
||||
[
|
||||
params.url,
|
||||
params.note ?? null,
|
||||
params.attributes ?? {},
|
||||
params.hasPrice ?? false
|
||||
]
|
||||
);
|
||||
|
||||
return mapUrlRow(result.rows[0]);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
details?: unknown;
|
||||
|
||||
constructor(status: number, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ErrorRequestHandler } from 'express';
|
||||
import { HttpError } from '../errors/http-error.js';
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
if (error instanceof HttpError) {
|
||||
res.status(error.status).json({
|
||||
error: {
|
||||
message: error.message,
|
||||
details: error.details ?? null
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Unhandled error', error);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Internal Server Error'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,8 +1,50 @@
|
||||
import { Router } from 'express';
|
||||
import { getHealth } from '../controllers/health-controller.js';
|
||||
import {
|
||||
createItem,
|
||||
deleteItem,
|
||||
getItem,
|
||||
listItems,
|
||||
updateItem
|
||||
} from '../controllers/items-controller.js';
|
||||
import {
|
||||
createItemPrice,
|
||||
deleteItemPrice,
|
||||
getItemPrice,
|
||||
listItemPrices,
|
||||
updateItemPrice
|
||||
} from '../controllers/item-prices-controller.js';
|
||||
import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
getProject,
|
||||
listProjects,
|
||||
updateProject
|
||||
} from '../controllers/projects-controller.js';
|
||||
import { asyncHandler } from '../utils/async-handler.js';
|
||||
|
||||
const apiRouter = Router();
|
||||
|
||||
apiRouter.get('/health', getHealth);
|
||||
// Projects
|
||||
apiRouter.get('/projects', asyncHandler(listProjects));
|
||||
apiRouter.post('/projects', asyncHandler(createProject));
|
||||
apiRouter.get('/projects/:projectId', asyncHandler(getProject));
|
||||
apiRouter.patch('/projects/:projectId', asyncHandler(updateProject));
|
||||
apiRouter.delete('/projects/:projectId', asyncHandler(deleteProject));
|
||||
|
||||
// Project items
|
||||
apiRouter.get('/projects/:projectId/items', asyncHandler(listItems));
|
||||
apiRouter.post('/projects/:projectId/items', asyncHandler(createItem));
|
||||
|
||||
// Items
|
||||
apiRouter.get('/items/:itemId', asyncHandler(getItem));
|
||||
apiRouter.patch('/items/:itemId', asyncHandler(updateItem));
|
||||
apiRouter.delete('/items/:itemId', asyncHandler(deleteItem));
|
||||
|
||||
// Item prices
|
||||
apiRouter.get('/items/:itemId/prices', asyncHandler(listItemPrices));
|
||||
apiRouter.post('/items/:itemId/prices', asyncHandler(createItemPrice));
|
||||
apiRouter.get('/prices/:priceId', asyncHandler(getItemPrice));
|
||||
apiRouter.patch('/prices/:priceId', asyncHandler(updateItemPrice));
|
||||
apiRouter.delete('/prices/:priceId', asyncHandler(deleteItemPrice));
|
||||
|
||||
export default apiRouter;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
export const asyncHandler = (handler: RequestHandler): RequestHandler => {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(handler(req, res, next)).catch(next);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { HttpError } from '../errors/http-error.js';
|
||||
|
||||
export const normalizeUrl = (input: unknown): string => {
|
||||
if (typeof input !== 'string') {
|
||||
throw new HttpError(400, 'URL must be a string');
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new HttpError(400, 'URL must not be empty');
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = decodeURI(trimmed);
|
||||
return decoded.toLowerCase();
|
||||
} catch (error) {
|
||||
throw new HttpError(400, 'URL could not be decoded', { cause: (error as Error).message });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { HttpError } from '../errors/http-error.js';
|
||||
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const parseUuid = (value: unknown, field: string): string => {
|
||||
if (typeof value !== 'string' || !uuidRegex.test(value)) {
|
||||
throw new HttpError(400, `${field} must be a valid UUID`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export interface PaginationParams {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export const parsePaginationParams = (
|
||||
query: Record<string, unknown>,
|
||||
defaults: PaginationParams = { limit: 20, offset: 0 }
|
||||
): PaginationParams => {
|
||||
const limitRaw = query.limit;
|
||||
const offsetRaw = query.offset;
|
||||
|
||||
const limit =
|
||||
typeof limitRaw === 'string' && limitRaw.trim() !== ''
|
||||
? Number(limitRaw)
|
||||
: defaults.limit;
|
||||
const offset =
|
||||
typeof offsetRaw === 'string' && offsetRaw.trim() !== ''
|
||||
? Number(offsetRaw)
|
||||
: defaults.offset;
|
||||
|
||||
if (!Number.isFinite(limit) || limit <= 0 || limit > 100) {
|
||||
throw new HttpError(400, 'limit must be between 1 and 100');
|
||||
}
|
||||
|
||||
if (!Number.isFinite(offset) || offset < 0) {
|
||||
throw new HttpError(400, 'offset must be greater than or equal to 0');
|
||||
}
|
||||
|
||||
return { limit: Math.floor(limit), offset: Math.floor(offset) };
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import { HttpError } from '../errors/http-error.js';
|
||||
import { parseUuid } from './common.js';
|
||||
|
||||
const priceConditions = new Set(['new', 'used']);
|
||||
const priceSources = new Set(['url', 'manual']);
|
||||
|
||||
const normalizeCurrency = (value: unknown): string => {
|
||||
if (typeof value !== 'string' || value.trim().length !== 3) {
|
||||
throw new HttpError(400, 'currency must be a 3-letter code');
|
||||
}
|
||||
return value.trim().toUpperCase();
|
||||
};
|
||||
|
||||
const parseObservedAt = (value: unknown): string | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === null) {
|
||||
throw new HttpError(400, 'observedAt cannot be null');
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, 'observedAt must be an ISO datetime string');
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.valueOf())) {
|
||||
throw new HttpError(400, 'observedAt must be a valid ISO datetime string');
|
||||
}
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
export interface ItemPriceCreateInput {
|
||||
condition: 'new' | 'used';
|
||||
amount: number;
|
||||
currency: string;
|
||||
sourceType: 'url' | 'manual';
|
||||
sourceUrlId: string | null;
|
||||
sourceUrl: string | null;
|
||||
sourceNote: string | null;
|
||||
note: string | null;
|
||||
observedAt: string | undefined;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export type ItemPriceUpdateInput = Partial<ItemPriceCreateInput>;
|
||||
|
||||
const parseAmount = (value: unknown): number => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
|
||||
throw new HttpError(400, 'amount must be a non-negative number');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseSourceNote = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, 'sourceNote must be a string or null');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseNote = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, 'note must be a string or null');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseIsPrimary = (value: unknown): boolean => {
|
||||
if (value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new HttpError(400, 'isPrimary must be a boolean');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseSourceFields = (
|
||||
sourceType: 'url' | 'manual',
|
||||
sourceUrlIdValue: unknown,
|
||||
sourceUrlValue: unknown
|
||||
): { sourceUrlId: string | null; sourceUrl: string | null } => {
|
||||
if (sourceType === 'manual') {
|
||||
if (sourceUrlIdValue !== undefined && sourceUrlIdValue !== null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'sourceUrlId cannot be provided when sourceType is manual'
|
||||
);
|
||||
}
|
||||
if (sourceUrlValue !== undefined && sourceUrlValue !== null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'sourceUrl cannot be provided when sourceType is manual'
|
||||
);
|
||||
}
|
||||
return { sourceUrlId: null, sourceUrl: null };
|
||||
}
|
||||
|
||||
let sourceUrlId: string | null = null;
|
||||
if (sourceUrlIdValue !== undefined && sourceUrlIdValue !== null) {
|
||||
sourceUrlId = parseUuid(sourceUrlIdValue, 'sourceUrlId');
|
||||
}
|
||||
|
||||
let sourceUrl: string | null = null;
|
||||
if (sourceUrlValue !== undefined && sourceUrlValue !== null) {
|
||||
if (typeof sourceUrlValue !== 'string' || sourceUrlValue.trim().length === 0) {
|
||||
throw new HttpError(400, 'sourceUrl must be a non-empty string');
|
||||
}
|
||||
sourceUrl = sourceUrlValue;
|
||||
}
|
||||
|
||||
if (!sourceUrlId && !sourceUrl) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'sourceUrl or sourceUrlId must be provided when sourceType is url'
|
||||
);
|
||||
}
|
||||
|
||||
return { sourceUrlId, sourceUrl };
|
||||
};
|
||||
|
||||
export const parseItemPriceCreatePayload = (
|
||||
payload: unknown
|
||||
): ItemPriceCreateInput => {
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
throw new HttpError(400, 'Body must be an object');
|
||||
}
|
||||
|
||||
const {
|
||||
condition,
|
||||
amount,
|
||||
currency,
|
||||
sourceType,
|
||||
sourceUrlId,
|
||||
sourceUrl,
|
||||
sourceNote,
|
||||
note,
|
||||
observedAt,
|
||||
isPrimary
|
||||
} = payload as Record<string, unknown>;
|
||||
|
||||
if (typeof condition !== 'string' || !priceConditions.has(condition)) {
|
||||
throw new HttpError(400, 'condition must be one of new, used');
|
||||
}
|
||||
|
||||
if (typeof sourceType !== 'string' || !priceSources.has(sourceType)) {
|
||||
throw new HttpError(400, 'sourceType must be one of url, manual');
|
||||
}
|
||||
|
||||
const source = parseSourceFields(
|
||||
sourceType as 'url' | 'manual',
|
||||
sourceUrlId,
|
||||
sourceUrl
|
||||
);
|
||||
|
||||
return {
|
||||
condition: condition as 'new' | 'used',
|
||||
amount: parseAmount(amount),
|
||||
currency: normalizeCurrency(currency),
|
||||
sourceType: sourceType as 'url' | 'manual',
|
||||
sourceUrlId: source.sourceUrlId,
|
||||
sourceUrl: source.sourceUrl,
|
||||
sourceNote: parseSourceNote(sourceNote),
|
||||
note: parseNote(note),
|
||||
observedAt: parseObservedAt(observedAt),
|
||||
isPrimary: parseIsPrimary(isPrimary)
|
||||
};
|
||||
};
|
||||
|
||||
export const parseItemPriceUpdatePayload = (
|
||||
payload: unknown
|
||||
): ItemPriceUpdateInput => {
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
throw new HttpError(400, 'Body must be an object');
|
||||
}
|
||||
|
||||
const result: ItemPriceUpdateInput = {};
|
||||
const {
|
||||
condition,
|
||||
amount,
|
||||
currency,
|
||||
sourceType,
|
||||
sourceUrlId,
|
||||
sourceUrl,
|
||||
sourceNote,
|
||||
note,
|
||||
observedAt,
|
||||
isPrimary
|
||||
} = payload as Record<string, unknown>;
|
||||
|
||||
if (condition !== undefined) {
|
||||
if (typeof condition !== 'string' || !priceConditions.has(condition)) {
|
||||
throw new HttpError(400, 'condition must be one of new, used');
|
||||
}
|
||||
result.condition = condition as 'new' | 'used';
|
||||
}
|
||||
|
||||
if (amount !== undefined) {
|
||||
result.amount = parseAmount(amount);
|
||||
}
|
||||
|
||||
if (currency !== undefined) {
|
||||
result.currency = normalizeCurrency(currency);
|
||||
}
|
||||
|
||||
if (sourceType !== undefined) {
|
||||
if (typeof sourceType !== 'string' || !priceSources.has(sourceType)) {
|
||||
throw new HttpError(400, 'sourceType must be one of url, manual');
|
||||
}
|
||||
result.sourceType = sourceType as 'url' | 'manual';
|
||||
}
|
||||
|
||||
if (sourceUrlId !== undefined || sourceUrl !== undefined) {
|
||||
const type = result.sourceType ?? (sourceType as 'url' | 'manual' | undefined);
|
||||
if (!type) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'sourceType must be provided when changing sourceUrl or sourceUrlId'
|
||||
);
|
||||
}
|
||||
const parsed = parseSourceFields(type, sourceUrlId, sourceUrl);
|
||||
result.sourceUrlId = parsed.sourceUrlId;
|
||||
result.sourceUrl = parsed.sourceUrl;
|
||||
}
|
||||
|
||||
if (sourceNote !== undefined) {
|
||||
result.sourceNote = parseSourceNote(sourceNote);
|
||||
}
|
||||
|
||||
if (note !== undefined) {
|
||||
result.note = parseNote(note);
|
||||
}
|
||||
|
||||
if (observedAt !== undefined) {
|
||||
result.observedAt = parseObservedAt(observedAt);
|
||||
}
|
||||
|
||||
if (isPrimary !== undefined) {
|
||||
result.isPrimary = parseIsPrimary(isPrimary);
|
||||
}
|
||||
|
||||
if (Object.keys(result).length === 0) {
|
||||
throw new HttpError(400, 'At least one field must be provided for update');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const parsePriceConditionFilter = (
|
||||
value: unknown
|
||||
): 'new' | 'used' | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string' || !priceConditions.has(value)) {
|
||||
throw new HttpError(400, 'condition filter must be one of new, used');
|
||||
}
|
||||
return value as 'new' | 'used';
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import { HttpError } from '../errors/http-error.js';
|
||||
import { parseUuid } from './common.js';
|
||||
|
||||
const itemStatuses = new Set(['active', 'rejected']);
|
||||
|
||||
const ensureAttributesObject = (value: unknown): Record<string, unknown> => {
|
||||
if (value === undefined) {
|
||||
return {};
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new HttpError(400, 'attributes must be an object');
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface ItemCreateInput {
|
||||
manufacturer: string | null;
|
||||
model: string;
|
||||
sourceUrlId: string | null;
|
||||
sourceUrl: string | null;
|
||||
status: 'active' | 'rejected';
|
||||
note: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ItemUpdateInput = Partial<ItemCreateInput>;
|
||||
|
||||
export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => {
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
throw new HttpError(400, 'Body must be an object');
|
||||
}
|
||||
|
||||
const {
|
||||
manufacturer = null,
|
||||
brand,
|
||||
model,
|
||||
status = 'active',
|
||||
note = null,
|
||||
attributes,
|
||||
sourceUrl,
|
||||
sourceUrlId
|
||||
} = payload as Record<string, unknown>;
|
||||
|
||||
const manufacturerValue =
|
||||
manufacturer ?? (typeof brand === 'string' ? brand : null);
|
||||
|
||||
if (
|
||||
manufacturerValue !== null &&
|
||||
(typeof manufacturerValue !== 'string' || manufacturerValue.trim().length === 0)
|
||||
) {
|
||||
throw new HttpError(400, 'manufacturer must be a non-empty string or null');
|
||||
}
|
||||
|
||||
if (typeof model !== 'string' || model.trim().length === 0 || model.length > 150) {
|
||||
throw new HttpError(400, 'model must be a non-empty string up to 150 characters');
|
||||
}
|
||||
|
||||
if (typeof status !== 'string' || !itemStatuses.has(status)) {
|
||||
throw new HttpError(400, 'status must be one of active, rejected');
|
||||
}
|
||||
|
||||
if (note !== null && typeof note !== 'string') {
|
||||
throw new HttpError(400, 'note must be a string or null');
|
||||
}
|
||||
|
||||
let normalizedSourceUrlId: string | null = null;
|
||||
if (sourceUrlId !== undefined && sourceUrlId !== null) {
|
||||
normalizedSourceUrlId = parseUuid(sourceUrlId, 'sourceUrlId');
|
||||
}
|
||||
|
||||
let normalizedSourceUrl: string | null = null;
|
||||
if (sourceUrl !== undefined && sourceUrl !== null) {
|
||||
if (typeof sourceUrl !== 'string' || sourceUrl.trim().length === 0) {
|
||||
throw new HttpError(400, 'sourceUrl must be a non-empty string when provided');
|
||||
}
|
||||
normalizedSourceUrl = sourceUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
manufacturer:
|
||||
manufacturerValue === null ? null : (manufacturerValue as string).trim(),
|
||||
model: model.trim(),
|
||||
status: status as 'active' | 'rejected',
|
||||
note: note === null ? null : (note as string),
|
||||
attributes: ensureAttributesObject(attributes),
|
||||
sourceUrlId: normalizedSourceUrlId,
|
||||
sourceUrl: normalizedSourceUrl
|
||||
};
|
||||
};
|
||||
|
||||
export const parseItemUpdatePayload = (payload: unknown): ItemUpdateInput => {
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
throw new HttpError(400, 'Body must be an object');
|
||||
}
|
||||
|
||||
const result: ItemUpdateInput = {};
|
||||
const {
|
||||
manufacturer,
|
||||
brand,
|
||||
model,
|
||||
status,
|
||||
note,
|
||||
attributes,
|
||||
sourceUrl,
|
||||
sourceUrlId
|
||||
} = payload as Record<string, unknown>;
|
||||
|
||||
if (manufacturer !== undefined || brand !== undefined) {
|
||||
const value = manufacturer ?? brand;
|
||||
if (value === null) {
|
||||
result.manufacturer = null;
|
||||
} else if (typeof value === 'string' && value.trim().length > 0) {
|
||||
result.manufacturer = value.trim();
|
||||
} else {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'manufacturer/brand must be a non-empty string or null'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (model !== undefined) {
|
||||
if (typeof model !== 'string' || model.trim().length === 0 || model.length > 150) {
|
||||
throw new HttpError(400, 'model must be a non-empty string up to 150 characters');
|
||||
}
|
||||
result.model = model.trim();
|
||||
}
|
||||
|
||||
if (status !== undefined) {
|
||||
if (typeof status !== 'string' || !itemStatuses.has(status)) {
|
||||
throw new HttpError(400, 'status must be one of active, rejected');
|
||||
}
|
||||
result.status = status as 'active' | 'rejected';
|
||||
}
|
||||
|
||||
if (note !== undefined) {
|
||||
if (note === null) {
|
||||
result.note = null;
|
||||
} else if (typeof note === 'string') {
|
||||
result.note = note;
|
||||
} else {
|
||||
throw new HttpError(400, 'note must be a string or null');
|
||||
}
|
||||
}
|
||||
|
||||
if (attributes !== undefined) {
|
||||
result.attributes = ensureAttributesObject(attributes);
|
||||
}
|
||||
|
||||
if (sourceUrlId !== undefined) {
|
||||
if (sourceUrlId === null) {
|
||||
result.sourceUrlId = null;
|
||||
} else {
|
||||
result.sourceUrlId = parseUuid(sourceUrlId, 'sourceUrlId');
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceUrl !== undefined) {
|
||||
if (sourceUrl === null) {
|
||||
result.sourceUrl = null;
|
||||
} else if (typeof sourceUrl === 'string' && sourceUrl.trim().length > 0) {
|
||||
result.sourceUrl = sourceUrl;
|
||||
} else {
|
||||
throw new HttpError(400, 'sourceUrl 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');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const parseItemStatusFilter = (
|
||||
value: unknown
|
||||
): 'active' | 'rejected' | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string' || !itemStatuses.has(value)) {
|
||||
throw new HttpError(400, 'status filter must be one of active, rejected');
|
||||
}
|
||||
return value as 'active' | 'rejected';
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { HttpError } from '../errors/http-error.js';
|
||||
|
||||
const projectStatuses = new Set(['active', 'archived']);
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] => {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => typeof item === 'string' && item.trim().length > 0)
|
||||
);
|
||||
};
|
||||
|
||||
export interface ProjectCreateInput {
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: 'active' | 'archived';
|
||||
projectAttributes: string[];
|
||||
projectPriorityRules: unknown;
|
||||
}
|
||||
|
||||
export type ProjectUpdateInput = Partial<ProjectCreateInput>;
|
||||
|
||||
export const parseProjectCreatePayload = (payload: unknown): ProjectCreateInput => {
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
throw new HttpError(400, 'Body must be an object');
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description = null,
|
||||
status = 'active',
|
||||
attributes,
|
||||
priorityRules
|
||||
} = payload as Record<string, unknown>;
|
||||
|
||||
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 150) {
|
||||
throw new HttpError(400, 'name must be a non-empty string up to 150 characters');
|
||||
}
|
||||
|
||||
if (description !== null && typeof description !== 'string') {
|
||||
throw new HttpError(400, 'description must be a string or null');
|
||||
}
|
||||
|
||||
if (typeof status !== 'string' || !projectStatuses.has(status)) {
|
||||
throw new HttpError(400, 'status must be one of active, archived');
|
||||
}
|
||||
|
||||
const projectAttributes = attributes ?? [];
|
||||
if (!isStringArray(projectAttributes)) {
|
||||
throw new HttpError(400, 'attributes must be an array of non-empty strings');
|
||||
}
|
||||
|
||||
return {
|
||||
name: name.trim(),
|
||||
description: description === null ? null : description,
|
||||
status: status as 'active' | 'archived',
|
||||
projectAttributes,
|
||||
projectPriorityRules: priorityRules ?? []
|
||||
};
|
||||
};
|
||||
|
||||
export const parseProjectUpdatePayload = (
|
||||
payload: unknown
|
||||
): ProjectUpdateInput => {
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
throw new HttpError(400, 'Body must be an object');
|
||||
}
|
||||
|
||||
const result: ProjectUpdateInput = {};
|
||||
const { name, description, status, attributes, priorityRules } =
|
||||
payload as Record<string, unknown>;
|
||||
|
||||
if (name !== undefined) {
|
||||
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 150) {
|
||||
throw new HttpError(400, 'name must be a non-empty string up to 150 characters');
|
||||
}
|
||||
result.name = name.trim();
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
if (description !== null && typeof description !== 'string') {
|
||||
throw new HttpError(400, 'description must be a string or null');
|
||||
}
|
||||
result.description = description === null ? null : (description as string);
|
||||
}
|
||||
|
||||
if (status !== undefined) {
|
||||
if (typeof status !== 'string' || !projectStatuses.has(status)) {
|
||||
throw new HttpError(400, 'status must be one of active, archived');
|
||||
}
|
||||
result.status = status as 'active' | 'archived';
|
||||
}
|
||||
|
||||
if (attributes !== undefined) {
|
||||
if (!isStringArray(attributes)) {
|
||||
throw new HttpError(400, 'attributes must be an array of non-empty strings');
|
||||
}
|
||||
result.projectAttributes = attributes;
|
||||
}
|
||||
|
||||
if (priorityRules !== undefined) {
|
||||
result.projectPriorityRules = priorityRules;
|
||||
}
|
||||
|
||||
if (Object.keys(result).length === 0) {
|
||||
throw new HttpError(400, 'At least one field must be provided for update');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const parseProjectStatusFilter = (value: unknown): 'active' | 'archived' | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string' || !projectStatuses.has(value)) {
|
||||
throw new HttpError(400, 'status filter must be one of active, archived');
|
||||
}
|
||||
return value as 'active' | 'archived';
|
||||
};
|
||||
Reference in New Issue
Block a user