diff --git a/client/src/api/item-prices.ts b/client/src/api/item-prices.ts index 50bd326..9fe9107 100644 --- a/client/src/api/item-prices.ts +++ b/client/src/api/item-prices.ts @@ -1,35 +1,14 @@ +import type { PaginatedResponse } from '@shared/models/pagination'; +import type { + ItemPrice, + PriceCondition, + PriceSourceType +} from '@shared/models/item-price'; import { apiFetch } from './http-client'; -export type PriceCondition = 'new' | 'used'; -export type PriceSourceType = 'url' | 'manual'; +export type { PriceCondition, PriceSourceType }; -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 type ItemPricesResponse = PaginatedResponse; export interface FetchItemPricesParams { limit?: number; diff --git a/client/src/api/items.ts b/client/src/api/items.ts index de4bc18..3b37ea2 100644 --- a/client/src/api/items.ts +++ b/client/src/api/items.ts @@ -1,48 +1,8 @@ +import type { PaginatedResponse } from '@shared/models/pagination'; +import type { Item, ItemStatus } from '@shared/models/item'; 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; - newMinAmount: number | null; - newCount: number; - newCurrency: string | null; - newHasMixedCurrency: boolean; - usedMinAmount: number | null; - usedCount: number; - usedCurrency: string | null; - usedHasMixedCurrency: boolean; -} - -interface ListResponseMeta { - limit: number; - offset: number; - count: number; -} - -export interface ProjectItemsResponse { - data: Item[]; - meta: ListResponseMeta; -} +export type ProjectItemsResponse = PaginatedResponse; export interface FetchProjectItemsParams { limit?: number; diff --git a/client/src/api/projects.ts b/client/src/api/projects.ts index 0cea8a0..73fa097 100644 --- a/client/src/api/projects.ts +++ b/client/src/api/projects.ts @@ -1,28 +1,8 @@ +import type { PaginatedResponse } from '@shared/models/pagination'; +import type { Project, ProjectStatus } from '@shared/models/project'; 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 type ProjectsListResponse = PaginatedResponse; export interface FetchProjectsParams { limit?: number; diff --git a/client/src/pages/project-detail/item-prices-modal.tsx b/client/src/pages/project-detail/item-prices-modal.tsx index fd33773..e4bd09c 100644 --- a/client/src/pages/project-detail/item-prices-modal.tsx +++ b/client/src/pages/project-detail/item-prices-modal.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import type { Item } from '../../api/items'; +import type { Item } from '@shared/models/item'; import { ItemPricesPanel } from './item-prices-panel'; interface ItemPricesModalProps { diff --git a/client/src/pages/project-detail/project-detail-page.tsx b/client/src/pages/project-detail/project-detail-page.tsx index 0cb1d43..01b31ee 100644 --- a/client/src/pages/project-detail/project-detail-page.tsx +++ b/client/src/pages/project-detail/project-detail-page.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; +import type { Item } from '@shared/models/item'; import { useCreateItemMutation, useUpdateItemMutation, @@ -11,7 +12,6 @@ import { importItemFromUrl, type CreateItemPayload, type ImportedItemData, - type Item, type UpdateItemPayload } from '../../api/items'; import { ItemFormModal } from '../../components/item-form-modal'; diff --git a/client/src/pages/project-detail/project-header.tsx b/client/src/pages/project-detail/project-header.tsx index cdb6f63..f95e9cf 100644 --- a/client/src/pages/project-detail/project-header.tsx +++ b/client/src/pages/project-detail/project-header.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom'; -import type { ProjectStatus } from '../../api/projects'; +import type { ProjectStatus } from '@shared/models/project'; interface ProjectHeaderProps { name: string; diff --git a/client/src/pages/project-detail/project-items-section.tsx b/client/src/pages/project-detail/project-items-section.tsx index eae4600..1224d6d 100644 --- a/client/src/pages/project-detail/project-items-section.tsx +++ b/client/src/pages/project-detail/project-items-section.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { ExternalLink, Pencil } from 'lucide-react'; -import type { Item } from '../../api/items'; +import type { Item } from '@shared/models/item'; interface ProjectItemsSectionProps { items: Item[]; diff --git a/client/src/query/projects.ts b/client/src/query/projects.ts index ed5ea5a..e558c6d 100644 --- a/client/src/query/projects.ts +++ b/client/src/query/projects.ts @@ -4,7 +4,6 @@ import { fetchProjects, updateProject, type CreateProjectPayload, - type Project, type ProjectsListResponse, type UpdateProjectPayload } from '../api/projects'; @@ -16,6 +15,7 @@ import { type ProjectItemsResponse, type UpdateItemPayload } from '../api/items'; +import type { Project } from '@shared/models/project'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; const DEFAULT_LIMIT = 50; diff --git a/client/tsconfig.json b/client/tsconfig.json index 8dc969b..7de1b46 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -7,7 +7,10 @@ "jsx": "react-jsx", "types": ["vite/client"], "allowSyntheticDefaultImports": true, - "baseUrl": "./src" + "baseUrl": "./src", + "paths": { + "@shared/*": ["../../shared/*.d.ts"] + } }, "include": [ "src" diff --git a/client/vite.config.ts b/client/vite.config.ts index f9702e4..62a6aec 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -14,6 +14,14 @@ export default defineConfig(({ mode }) => { port: clientPort, proxy: { '/api': proxyTarget + }, + fs: { + allow: [path.resolve(__dirname, '..')] + } + }, + resolve: { + alias: { + '@shared': path.resolve(__dirname, '../shared') } } }; diff --git a/package-lock.json b/package-lock.json index 2469652..e95b61b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "license": "MIT", "dependencies": { "@tanstack/react-query": "^5.90.2", + "lucide-react": "^0.545.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.4" @@ -3926,6 +3927,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.545.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz", + "integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", diff --git a/server/src/db/item-prices-repository.ts b/server/src/db/item-prices-repository.ts index b8449ae..55b735a 100644 --- a/server/src/db/item-prices-repository.ts +++ b/server/src/db/item-prices-repository.ts @@ -1,3 +1,4 @@ +import type { ItemPrice, PriceCondition, PriceSourceType } from '@shared/models/item-price'; import { pool, query } from './pool.js'; const priceColumns = ` @@ -20,10 +21,10 @@ const priceColumns = ` export interface ItemPriceRow { id: string; item_id: string; - condition: 'new' | 'used'; + condition: PriceCondition; amount: string; currency: string; - source_type: 'url' | 'manual'; + source_type: PriceSourceType; source_url_id: string | null; source_note: string | null; note: string | null; @@ -34,22 +35,7 @@ export interface ItemPriceRow { 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; -} +export type ItemPriceRecord = ItemPrice; const mapItemPriceRow = (row: ItemPriceRow): ItemPriceRecord => ({ id: row.id, @@ -72,7 +58,7 @@ export interface ListItemPricesOptions { itemId: string; limit: number; offset: number; - condition?: 'new' | 'used'; + condition?: PriceCondition; } export const listItemPrices = async ( @@ -109,10 +95,10 @@ export const listItemPrices = async ( export interface CreateItemPriceParams { itemId: string; - condition: 'new' | 'used'; + condition: PriceCondition; amount: number; currency: string; - sourceType: 'url' | 'manual'; + sourceType: PriceSourceType; sourceUrlId: string | null; sourceNote: string | null; note: string | null; @@ -213,10 +199,10 @@ export const getItemPriceById = async (id: string): Promise; created_at: Date; @@ -54,36 +55,7 @@ export interface ItemRow { price_used_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; - createdAt: string; - updatedAt: string; - priceSummary: ItemPriceSummary | null; -} - -export interface ItemPriceSummary { - minAmount: number; - maxAmount: number; - currency: string | null; - priceCount: number; - hasMixedCurrency: boolean; - newMinAmount: number | null; - newCount: number; - newCurrency: string | null; - newHasMixedCurrency: boolean; - usedMinAmount: number | null; - usedCount: number; - usedCurrency: string | null; - usedHasMixedCurrency: boolean; -} +export type ItemRecord = Item; const mapItemRow = (row: ItemRow): ItemRecord => { const priceCount = row.price_count ?? 0; @@ -126,7 +98,7 @@ const mapItemRow = (row: ItemRow): ItemRecord => { export interface ListItemsOptions { projectId: string; - status?: 'active' | 'rejected'; + status?: ItemStatus; limit: number; offset: number; } @@ -184,7 +156,7 @@ export interface CreateItemParams { manufacturer: string | null; model: string; sourceUrlId: string | null; - status: 'active' | 'rejected'; + status: ItemStatus; note: string | null; attributes: Record; } @@ -260,7 +232,7 @@ export interface UpdateItemParams { manufacturer?: string | null; model?: string; sourceUrlId?: string | null; - status?: 'active' | 'rejected'; + status?: ItemStatus; note?: string | null; attributes?: Record; } diff --git a/server/src/db/projects-repository.ts b/server/src/db/projects-repository.ts index 61a5a43..adb6cd9 100644 --- a/server/src/db/projects-repository.ts +++ b/server/src/db/projects-repository.ts @@ -1,3 +1,4 @@ +import type { Project, ProjectStatus } from '@shared/models/project'; import { query } from './pool.js'; const projectColumns = ` @@ -15,24 +16,13 @@ export interface ProjectRow { id: string; name: string; description: string | null; - status: 'active' | 'archived'; + status: ProjectStatus; 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, @@ -47,7 +37,7 @@ const mapProjectRow = (row: ProjectRow): Project => ({ export interface ListProjectsOptions { limit: number; offset: number; - status?: 'active' | 'archived'; + status?: ProjectStatus; } export const listProjects = async ( @@ -86,7 +76,7 @@ export const listProjects = async ( export interface CreateProjectParams { name: string; description: string | null; - status: 'active' | 'archived'; + status: ProjectStatus; attributes: string[]; priorityRules: unknown; } @@ -136,7 +126,7 @@ export const getProjectById = async (id: string): Promise => { export interface UpdateProjectParams { name?: string; description?: string | null; - status?: 'active' | 'archived'; + status?: ProjectStatus; attributes?: string[]; priorityRules?: unknown; } diff --git a/server/src/validation/item-prices.ts b/server/src/validation/item-prices.ts index 7ed8dde..f002ae0 100644 --- a/server/src/validation/item-prices.ts +++ b/server/src/validation/item-prices.ts @@ -1,8 +1,17 @@ +import type { PriceCondition, PriceSourceType } from '@shared/models/item-price'; 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 priceConditions = new Set(['new', 'used']); +const priceSources = new Set(['url', 'manual']); + +const isPriceCondition = (value: unknown): value is PriceCondition => { + return typeof value === 'string' && priceConditions.has(value as PriceCondition); +}; + +const isPriceSourceType = (value: unknown): value is PriceSourceType => { + return typeof value === 'string' && priceSources.has(value as PriceSourceType); +}; const normalizeCurrency = (value: unknown): string => { if (typeof value !== 'string' || value.trim().length !== 3) { @@ -29,10 +38,10 @@ const parseObservedAt = (value: unknown): string | undefined => { }; export interface ItemPriceCreateInput { - condition: 'new' | 'used'; + condition: PriceCondition; amount: number; currency: string; - sourceType: 'url' | 'manual'; + sourceType: PriceSourceType; sourceUrlId: string | null; sourceUrl: string | null; sourceNote: string | null; @@ -81,7 +90,7 @@ const parseIsPrimary = (value: unknown): boolean => { }; const parseSourceFields = ( - sourceType: 'url' | 'manual', + sourceType: PriceSourceType, sourceUrlIdValue: unknown, sourceUrlValue: unknown ): { sourceUrlId: string | null; sourceUrl: string | null } => { @@ -144,25 +153,25 @@ export const parseItemPriceCreatePayload = ( isPrimary } = payload as Record; - if (typeof condition !== 'string' || !priceConditions.has(condition)) { + if (!isPriceCondition(condition)) { throw new HttpError(400, 'condition must be one of new, used'); } - if (typeof sourceType !== 'string' || !priceSources.has(sourceType)) { + if (!isPriceSourceType(sourceType)) { throw new HttpError(400, 'sourceType must be one of url, manual'); } const source = parseSourceFields( - sourceType as 'url' | 'manual', + sourceType, sourceUrlId, sourceUrl ); return { - condition: condition as 'new' | 'used', + condition, amount: parseAmount(amount), currency: normalizeCurrency(currency), - sourceType: sourceType as 'url' | 'manual', + sourceType, sourceUrlId: source.sourceUrlId, sourceUrl: source.sourceUrl, sourceNote: parseSourceNote(sourceNote), @@ -194,10 +203,10 @@ export const parseItemPriceUpdatePayload = ( } = payload as Record; if (condition !== undefined) { - if (typeof condition !== 'string' || !priceConditions.has(condition)) { + if (!isPriceCondition(condition)) { throw new HttpError(400, 'condition must be one of new, used'); } - result.condition = condition as 'new' | 'used'; + result.condition = condition; } if (amount !== undefined) { @@ -209,14 +218,14 @@ export const parseItemPriceUpdatePayload = ( } if (sourceType !== undefined) { - if (typeof sourceType !== 'string' || !priceSources.has(sourceType)) { + if (!isPriceSourceType(sourceType)) { throw new HttpError(400, 'sourceType must be one of url, manual'); } - result.sourceType = sourceType as 'url' | 'manual'; + result.sourceType = sourceType; } if (sourceUrlId !== undefined || sourceUrl !== undefined) { - const type = result.sourceType ?? (sourceType as 'url' | 'manual' | undefined); + const type = result.sourceType ?? (isPriceSourceType(sourceType) ? sourceType : undefined); if (!type) { throw new HttpError( 400, @@ -253,12 +262,12 @@ export const parseItemPriceUpdatePayload = ( export const parsePriceConditionFilter = ( value: unknown -): 'new' | 'used' | undefined => { +): PriceCondition | undefined => { if (value === undefined) { return undefined; } - if (typeof value !== 'string' || !priceConditions.has(value)) { + if (!isPriceCondition(value)) { throw new HttpError(400, 'condition filter must be one of new, used'); } - return value as 'new' | 'used'; + return value; }; diff --git a/server/src/validation/items.ts b/server/src/validation/items.ts index b5dfd4d..7e96fa8 100644 --- a/server/src/validation/items.ts +++ b/server/src/validation/items.ts @@ -1,7 +1,12 @@ +import type { ItemStatus } from '@shared/models/item'; import { HttpError } from '../errors/http-error.js'; import { parseUuid } from './common.js'; -const itemStatuses = new Set(['active', 'rejected']); +const itemStatuses = new Set(['active', 'rejected']); + +const isItemStatus = (value: unknown): value is ItemStatus => { + return typeof value === 'string' && itemStatuses.has(value as ItemStatus); +}; const ensureAttributesObject = (value: unknown): Record => { if (value === undefined) { @@ -18,7 +23,7 @@ export interface ItemCreateInput { model: string; sourceUrlId: string | null; sourceUrl: string | null; - status: 'active' | 'rejected'; + status: ItemStatus; note: string | null; attributes: Record; } @@ -55,7 +60,7 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => { throw new HttpError(400, 'model must be a non-empty string up to 150 characters'); } - if (typeof status !== 'string' || !itemStatuses.has(status)) { + if (!isItemStatus(status)) { throw new HttpError(400, 'status must be one of active, rejected'); } @@ -80,7 +85,7 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => { manufacturer: manufacturerValue === null ? null : (manufacturerValue as string).trim(), model: model.trim(), - status: status as 'active' | 'rejected', + status: status as ItemStatus, note: note === null ? null : (note as string), attributes: ensureAttributesObject(attributes), sourceUrlId: normalizedSourceUrlId, @@ -127,10 +132,10 @@ export const parseItemUpdatePayload = (payload: unknown): ItemUpdateInput => { } if (status !== undefined) { - if (typeof status !== 'string' || !itemStatuses.has(status)) { + if (!isItemStatus(status)) { throw new HttpError(400, 'status must be one of active, rejected'); } - result.status = status as 'active' | 'rejected'; + result.status = status; } if (note !== undefined) { @@ -174,14 +179,14 @@ export const parseItemUpdatePayload = (payload: unknown): ItemUpdateInput => { export const parseItemStatusFilter = ( value: unknown -): 'active' | 'rejected' | undefined => { +): ItemStatus | undefined => { if (value === undefined) { return undefined; } - if (typeof value !== 'string' || !itemStatuses.has(value)) { + if (!isItemStatus(value)) { throw new HttpError(400, 'status filter must be one of active, rejected'); } - return value as 'active' | 'rejected'; + return value; }; export interface ItemImportPayload { diff --git a/server/src/validation/projects.ts b/server/src/validation/projects.ts index 983ed1f..a2867d1 100644 --- a/server/src/validation/projects.ts +++ b/server/src/validation/projects.ts @@ -1,6 +1,11 @@ +import type { ProjectStatus } from '@shared/models/project'; import { HttpError } from '../errors/http-error.js'; -const projectStatuses = new Set(['active', 'archived']); +const projectStatuses = new Set(['active', 'archived']); + +const isProjectStatus = (value: unknown): value is ProjectStatus => { + return typeof value === 'string' && projectStatuses.has(value as ProjectStatus); +}; const isStringArray = (value: unknown): value is string[] => { return ( @@ -12,7 +17,7 @@ const isStringArray = (value: unknown): value is string[] => { export interface ProjectCreateInput { name: string; description: string | null; - status: 'active' | 'archived'; + status: ProjectStatus; projectAttributes: string[]; projectPriorityRules: unknown; } @@ -40,7 +45,7 @@ export const parseProjectCreatePayload = (payload: unknown): ProjectCreateInput throw new HttpError(400, 'description must be a string or null'); } - if (typeof status !== 'string' || !projectStatuses.has(status)) { + if (!isProjectStatus(status)) { throw new HttpError(400, 'status must be one of active, archived'); } @@ -52,7 +57,7 @@ export const parseProjectCreatePayload = (payload: unknown): ProjectCreateInput return { name: name.trim(), description: description === null ? null : description, - status: status as 'active' | 'archived', + status: status as ProjectStatus, projectAttributes, projectPriorityRules: priorityRules ?? [] }; @@ -84,10 +89,10 @@ export const parseProjectUpdatePayload = ( } if (status !== undefined) { - if (typeof status !== 'string' || !projectStatuses.has(status)) { + if (!isProjectStatus(status)) { throw new HttpError(400, 'status must be one of active, archived'); } - result.status = status as 'active' | 'archived'; + result.status = status; } if (attributes !== undefined) { @@ -108,12 +113,12 @@ export const parseProjectUpdatePayload = ( return result; }; -export const parseProjectStatusFilter = (value: unknown): 'active' | 'archived' | undefined => { +export const parseProjectStatusFilter = (value: unknown): ProjectStatus | undefined => { if (value === undefined) { return undefined; } - if (typeof value !== 'string' || !projectStatuses.has(value)) { + if (!isProjectStatus(value)) { throw new HttpError(400, 'status filter must be one of active, archived'); } - return value as 'active' | 'archived'; + return value; }; diff --git a/server/tsconfig.json b/server/tsconfig.json index 6327f5e..009e846 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -2,7 +2,17 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "baseUrl": ".", + "rootDirs": [ + "src", + "../shared" + ], + "paths": { + "@shared/*": [ + "../shared/*.d.ts" + ] + } }, "include": [ "src" diff --git a/tsconfig.base.json b/tsconfig.base.json index fbdf861..8572ab3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,6 +7,12 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@shared/*": [ + "shared/*.d.ts" + ] + } } }