From afc3da3e65a7c944acbe2ec7fcbac470306f6398 Mon Sep 17 00:00:00 2001 From: Jurgis Sakalauskas Date: Mon, 13 Oct 2025 14:04:22 +0300 Subject: [PATCH] 1st approach for projects, items, db structure --- client/src/api/health.ts | 10 - client/src/api/http-client.ts | 25 +- client/src/api/item-prices.ts | 108 ++++ client/src/api/items.ts | 107 ++++ client/src/api/projects.ts | 120 ++++ client/src/components/item-form-modal.tsx | 285 ++++++++++ .../components/tracked-attributes-modal.tsx | 147 +++++ client/src/layouts/app-layout.tsx | 238 ++++++-- client/src/pages/health-page.tsx | 39 -- .../project-detail/item-prices-panel.tsx | 315 +++++++++++ .../project-description-section.tsx | 105 ++++ .../project-detail/project-detail-page.tsx | 171 ++++++ .../pages/project-detail/project-header.tsx | 23 + .../project-detail/project-items-section.tsx | 519 ++++++++++++++++++ .../tracked-attributes-section.tsx | 93 ++++ client/src/pages/projects-page.tsx | 155 ++++++ client/src/query/item-prices.ts | 44 ++ client/src/query/projects.ts | 100 ++++ client/src/query/use-health-query.ts | 14 - client/src/routes/app-routes.tsx | 11 +- server/src/app.ts | 2 + server/src/controllers/health-controller.ts | 5 - .../src/controllers/item-prices-controller.ts | 146 +++++ server/src/controllers/items-controller.ts | 122 ++++ server/src/controllers/projects-controller.ts | 89 +++ server/src/controllers/url-helpers.ts | 23 + server/src/db/item-prices-repository.ts | 350 ++++++++++++ server/src/db/items-repository.ts | 302 ++++++++++ .../migrations/0002-create-projects-table.ts | 30 + .../db/migrations/0003-create-urls-table.ts | 20 + .../db/migrations/0004-create-items-table.ts | 40 ++ .../0005-create-item-prices-table.ts | 67 +++ server/src/db/migrations/index.ts | 12 +- server/src/db/pool.ts | 7 +- server/src/db/projects-repository.ts | 210 +++++++ server/src/db/urls-repository.ts | 92 ++++ server/src/errors/http-error.ts | 10 + server/src/middleware/error-handler.ts | 21 + server/src/routes/api-router.ts | 46 +- server/src/utils/async-handler.ts | 7 + server/src/utils/url-normalizer.ts | 19 + server/src/validation/common.ts | 43 ++ server/src/validation/item-prices.ts | 264 +++++++++ server/src/validation/items.ts | 185 +++++++ server/src/validation/projects.ts | 119 ++++ 45 files changed, 4743 insertions(+), 117 deletions(-) delete mode 100644 client/src/api/health.ts create mode 100644 client/src/api/item-prices.ts create mode 100644 client/src/api/items.ts create mode 100644 client/src/api/projects.ts create mode 100644 client/src/components/item-form-modal.tsx create mode 100644 client/src/components/tracked-attributes-modal.tsx delete mode 100644 client/src/pages/health-page.tsx create mode 100644 client/src/pages/project-detail/item-prices-panel.tsx create mode 100644 client/src/pages/project-detail/project-description-section.tsx create mode 100644 client/src/pages/project-detail/project-detail-page.tsx create mode 100644 client/src/pages/project-detail/project-header.tsx create mode 100644 client/src/pages/project-detail/project-items-section.tsx create mode 100644 client/src/pages/project-detail/tracked-attributes-section.tsx create mode 100644 client/src/pages/projects-page.tsx create mode 100644 client/src/query/item-prices.ts create mode 100644 client/src/query/projects.ts delete mode 100644 client/src/query/use-health-query.ts delete mode 100644 server/src/controllers/health-controller.ts create mode 100644 server/src/controllers/item-prices-controller.ts create mode 100644 server/src/controllers/items-controller.ts create mode 100644 server/src/controllers/projects-controller.ts create mode 100644 server/src/controllers/url-helpers.ts create mode 100644 server/src/db/item-prices-repository.ts create mode 100644 server/src/db/items-repository.ts create mode 100644 server/src/db/migrations/0002-create-projects-table.ts create mode 100644 server/src/db/migrations/0003-create-urls-table.ts create mode 100644 server/src/db/migrations/0004-create-items-table.ts create mode 100644 server/src/db/migrations/0005-create-item-prices-table.ts create mode 100644 server/src/db/projects-repository.ts create mode 100644 server/src/db/urls-repository.ts create mode 100644 server/src/errors/http-error.ts create mode 100644 server/src/middleware/error-handler.ts create mode 100644 server/src/utils/async-handler.ts create mode 100644 server/src/utils/url-normalizer.ts create mode 100644 server/src/validation/common.ts create mode 100644 server/src/validation/item-prices.ts create mode 100644 server/src/validation/items.ts create mode 100644 server/src/validation/projects.ts diff --git a/client/src/api/health.ts b/client/src/api/health.ts deleted file mode 100644 index 7d7f06b..0000000 --- a/client/src/api/health.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { apiFetch } from './http-client'; - -export type HealthResponse = { - status: string; - service: string; -}; - -export function getHealth() { - return apiFetch('/health'); -} diff --git a/client/src/api/http-client.ts b/client/src/api/http-client.ts index 20c96fb..2387cb8 100644 --- a/client/src/api/http-client.ts +++ b/client/src/api/http-client.ts @@ -1,7 +1,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'; type ApiFetchOptions = Omit & { - body?: Record; + body?: Record | null; }; export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { @@ -18,7 +18,28 @@ export async function apiFetch(path: string, options: ApiFetchOptions const response = await fetch(`${API_BASE_URL}${path}`, init); if (!response.ok) { - throw new Error(`Request failed: ${response.status}`); + let message = `Request failed: ${response.status}`; + + try { + const errorPayload = await response.clone().json(); + const extractedMessage = + (errorPayload && errorPayload.error && errorPayload.error.message) ?? + errorPayload.message; + if (typeof extractedMessage === 'string' && extractedMessage.trim().length > 0) { + message = extractedMessage; + } + } catch { + // ignore JSON parsing issues - fall back to status text when available + if (response.statusText) { + message = `${message} ${response.statusText}`; + } + } + + throw new Error(message); + } + + if (response.status === 204) { + return null as TResponse; } return response.json() as Promise; diff --git a/client/src/api/item-prices.ts b/client/src/api/item-prices.ts new file mode 100644 index 0000000..50bd326 --- /dev/null +++ b/client/src/api/item-prices.ts @@ -0,0 +1,108 @@ +import { apiFetch } from './http-client'; + +export type PriceCondition = 'new' | 'used'; +export type PriceSourceType = 'url' | 'manual'; + +export interface ItemPrice { + id: string; + itemId: string; + condition: PriceCondition; + amount: number; + currency: string; + sourceType: PriceSourceType; + sourceUrlId: string | null; + sourceUrl: string | null; + sourceNote: string | null; + note: string | null; + observedAt: string; + isPrimary: boolean; + createdAt: string; + updatedAt: string; +} + +interface ListResponseMeta { + limit: number; + offset: number; + count: number; +} + +export interface ItemPricesResponse { + data: ItemPrice[]; + meta: ListResponseMeta; +} + +export interface FetchItemPricesParams { + limit?: number; + offset?: number; + condition?: PriceCondition; + signal?: AbortSignal; +} + +export const fetchItemPrices = async ( + itemId: string, + params: FetchItemPricesParams = {} +): Promise => { + const searchParams = new URLSearchParams(); + + if (params.limit !== undefined) { + searchParams.set('limit', String(params.limit)); + } + + if (params.offset !== undefined) { + searchParams.set('offset', String(params.offset)); + } + + if (params.condition) { + searchParams.set('condition', params.condition); + } + + const query = searchParams.toString(); + const path = query + ? `/items/${itemId}/prices?${query}` + : `/items/${itemId}/prices`; + + return apiFetch(path, { signal: params.signal }); +}; + +export interface CreateItemPricePayload { + condition: PriceCondition; + amount: number; + currency: string; + sourceType: PriceSourceType; + sourceUrl?: string | null; + sourceUrlId?: string | null; + sourceNote?: string | null; + note?: string | null; + observedAt?: string; + isPrimary?: boolean; +} + +interface SingleItemPriceResponse { + data: ItemPrice; +} + +export const createItemPrice = async ( + itemId: string, + payload: CreateItemPricePayload +): Promise => { + const response = await apiFetch( + `/items/${itemId}/prices`, + { + method: 'POST', + body: { + condition: payload.condition, + amount: payload.amount, + currency: payload.currency, + sourceType: payload.sourceType, + sourceUrl: payload.sourceUrl ?? null, + sourceUrlId: payload.sourceUrlId ?? null, + sourceNote: payload.sourceNote ?? null, + note: payload.note ?? null, + observedAt: payload.observedAt ?? null, + isPrimary: payload.isPrimary ?? false + } + } + ); + + return response.data; +}; diff --git a/client/src/api/items.ts b/client/src/api/items.ts new file mode 100644 index 0000000..196cd7c --- /dev/null +++ b/client/src/api/items.ts @@ -0,0 +1,107 @@ +import { apiFetch } from './http-client'; + +export type ItemStatus = 'active' | 'rejected'; + +export interface Item { + id: string; + projectId: string; + manufacturer: string | null; + model: string; + sourceUrlId: string | null; + sourceUrl: string | null; + status: ItemStatus; + note: string | null; + attributes: Record; + createdAt: string; + updatedAt: string; + priceSummary: ItemPriceSummary | null; +} + +export interface ItemPriceSummary { + minAmount: number; + maxAmount: number; + currency: string | null; + priceCount: number; + hasMixedCurrency: boolean; +} + +interface ListResponseMeta { + limit: number; + offset: number; + count: number; +} + +export interface ProjectItemsResponse { + data: Item[]; + meta: ListResponseMeta; +} + +export interface FetchProjectItemsParams { + limit?: number; + offset?: number; + status?: ItemStatus; + signal?: AbortSignal; +} + +export const fetchProjectItems = async ( + projectId: string, + params: FetchProjectItemsParams = {} +): Promise => { + const searchParams = new URLSearchParams(); + + if (params.limit !== undefined) { + searchParams.set('limit', String(params.limit)); + } + + if (params.offset !== undefined) { + searchParams.set('offset', String(params.offset)); + } + + if (params.status) { + searchParams.set('status', params.status); + } + + const query = searchParams.toString(); + const path = query + ? `/projects/${projectId}/items?${query}` + : `/projects/${projectId}/items`; + + return apiFetch(path, { signal: params.signal }); +}; + +export interface CreateItemPayload { + manufacturer?: string | null; + model: string; + status?: ItemStatus; + note?: string | null; + attributes?: Record; + sourceUrl?: string | null; + sourceUrlId?: string | null; +} + +interface SingleItemResponse { + data: Item; +} + +export const createItem = async ( + projectId: string, + payload: CreateItemPayload +): Promise => { + const response = await apiFetch( + `/projects/${projectId}/items`, + { + method: 'POST', + body: { + manufacturer: payload.manufacturer ?? null, + model: payload.model, + status: payload.status ?? 'active', + note: payload.note ?? null, + attributes: payload.attributes ?? {}, + sourceUrl: payload.sourceUrl ?? null, + sourceUrlId: payload.sourceUrlId ?? null + } + } + ); + + return response.data; +}; diff --git a/client/src/api/projects.ts b/client/src/api/projects.ts new file mode 100644 index 0000000..662a253 --- /dev/null +++ b/client/src/api/projects.ts @@ -0,0 +1,120 @@ +import { apiFetch } from './http-client'; + +export type ProjectStatus = 'active' | 'archived'; + +export interface Project { + id: string; + name: string; + description: string | null; + status: ProjectStatus; + attributes: string[]; + priorityRules: unknown; + createdAt: string; + updatedAt: string; +} + +interface ListResponseMeta { + limit: number; + offset: number; + count: number; +} + +export interface ProjectsListResponse { + data: Project[]; + meta: ListResponseMeta; +} + +export interface FetchProjectsParams { + limit?: number; + offset?: number; + status?: ProjectStatus; + signal?: AbortSignal; +} + +export const fetchProjects = async ( + params: FetchProjectsParams = {} +): Promise => { + const searchParams = new URLSearchParams(); + + if (params.limit !== undefined) { + searchParams.set('limit', String(params.limit)); + } + + if (params.offset !== undefined) { + searchParams.set('offset', String(params.offset)); + } + + if (params.status) { + searchParams.set('status', params.status); + } + + const query = searchParams.toString(); + const path = query ? `/projects?${query}` : '/projects'; + + return apiFetch(path, { signal: params.signal }); +}; + +export interface CreateProjectPayload { + name: string; + description?: string | null; + status?: ProjectStatus; + attributes?: string[]; + priorityRules?: unknown; +} + +interface SingleProjectResponse { + data: Project; +} + +export interface UpdateProjectPayload { + name?: string; + description?: string | null; + status?: ProjectStatus; + attributes?: string[]; + priorityRules?: unknown; +} + +export const createProject = async ( + payload: CreateProjectPayload +): Promise => { + const response = await apiFetch('/projects', { + method: 'POST', + body: { + name: payload.name, + description: payload.description ?? null, + status: payload.status ?? undefined, + attributes: payload.attributes ?? [], + priorityRules: payload.priorityRules ?? [] + } + }); + + return response.data; +}; + +export interface FetchProjectParams { + signal?: AbortSignal; +} + +export const fetchProject = async ( + projectId: string, + params: FetchProjectParams = {} +): Promise => { + const response = await apiFetch( + `/projects/${projectId}`, + { signal: params.signal } + ); + + return response.data; +}; + +export const updateProject = async ( + projectId: string, + payload: UpdateProjectPayload +): Promise => { + const response = await apiFetch(`/projects/${projectId}`, { + method: 'PATCH', + body: payload + }); + + return response.data; +}; diff --git a/client/src/components/item-form-modal.tsx b/client/src/components/item-form-modal.tsx new file mode 100644 index 0000000..a212810 --- /dev/null +++ b/client/src/components/item-form-modal.tsx @@ -0,0 +1,285 @@ +import { FormEvent, useEffect, useState } from 'react'; +import type { CreateItemPayload, ItemStatus } from '../api/items'; + +const itemStatusOptions: ItemStatus[] = ['active', 'rejected']; + +const isPlainObject = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +export interface ItemFormModalProps { + isOpen: boolean; + mode: 'url' | 'manual'; + initialUrl: string | null; + onClose: () => void; + onSuccess: () => void; + onSubmit: (payload: CreateItemPayload) => Promise; + isSubmitting: boolean; + projectAttributes: string[]; +} + +export function ItemFormModal({ + isOpen, + mode, + initialUrl, + onClose, + onSuccess, + onSubmit, + isSubmitting, + projectAttributes +}: ItemFormModalProps) { + const [manufacturer, setManufacturer] = useState(''); + const [model, setModel] = useState(''); + const [status, setStatus] = useState('active'); + const [note, setNote] = useState(''); + const [sourceUrl, setSourceUrl] = useState(''); + const [attributesJson, setAttributesJson] = useState('{}'); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) { + return; + } + + setManufacturer(''); + setModel(''); + setStatus('active'); + setNote(''); + setAttributesJson('{}'); + setError(null); + setSourceUrl(initialUrl ?? ''); + }, [isOpen, initialUrl, mode]); + + useEffect(() => { + if (!isOpen) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) { + return null; + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + if (!model.trim()) { + setError('Model is required.'); + return; + } + + let attributes: Record = {}; + const trimmedAttributes = attributesJson.trim(); + + if (trimmedAttributes) { + try { + const parsed = JSON.parse(trimmedAttributes); + if (!isPlainObject(parsed)) { + setError('Attributes must be a JSON object.'); + return; + } + attributes = parsed; + } catch (parseError) { + setError(`Attributes JSON is invalid: ${(parseError as Error).message}`); + return; + } + } + + try { + await onSubmit({ + manufacturer: manufacturer.trim() ? manufacturer.trim() : null, + model: model.trim(), + status, + note: note.trim() ? note.trim() : null, + attributes, + sourceUrl: sourceUrl.trim() ? sourceUrl.trim() : null + }); + onSuccess(); + } catch (submitError) { + setError((submitError as Error).message); + } + }; + + return ( +
{ + if (!isSubmitting) { + onClose(); + } + }} + > +
event.stopPropagation()} + > + + +
+
+

+ {mode === 'url' ? 'Review Item Details' : 'Add Item Manually'} +

+

+ {mode === 'url' + ? 'Double-check the imported information and fill in any gaps.' + : 'Provide the specs and notes for this item.'} +

+
+ +
+
+ + setManufacturer(event.target.value)} + className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" + placeholder="Logitech" + /> +
+ +
+ + setModel(event.target.value)} + required + className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" + placeholder="MX Master 3" + /> +
+ +
+ + +
+ +
+ + setSourceUrl(event.target.value)} + className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" + placeholder="https://example.com/specs" + /> +
+ +
+ +