1st approach for projects, items, db structure

This commit is contained in:
Jurgis Sakalauskas
2025-10-13 14:04:22 +03:00
parent ad2cf25bfd
commit afc3da3e65
45 changed files with 4743 additions and 117 deletions
+2
View File
@@ -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();
};
+122
View File
@@ -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();
};
+23
View File
@@ -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;
};
+350
View File
@@ -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;
};
+302
View File
@@ -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;
`);
}
};
+11 -1
View File
@@ -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,
];
+5 -2
View File
@@ -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);
+210
View File
@@ -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;
};
+92
View File
@@ -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]);
};
+10
View File
@@ -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;
}
}
+21
View File
@@ -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'
}
});
};
+44 -2
View File
@@ -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;
+7
View File
@@ -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);
};
};
+19
View File
@@ -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 });
}
};
+43
View File
@@ -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) };
};
+264
View File
@@ -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';
};
+185
View File
@@ -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';
};
+119
View File
@@ -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';
};