diff --git a/.cursor/rules/function-style.mdc b/.cursor/rules/function-style.mdc new file mode 100644 index 0000000..350bdf0 --- /dev/null +++ b/.cursor/rules/function-style.mdc @@ -0,0 +1,37 @@ +--- +description: Function style convention — arrow functions for non-components, function declarations for React components; use when writing or reviewing TS/TSX +globs: "**/*.{ts,tsx}" +alwaysApply: false +--- + +# Function Style Convention + +Unify function style across the codebase: + +## Use arrow functions + +For **all non-component code** (helpers, API functions, handlers, mappers, hooks, async functions, etc.): + +- **Format**: `const name = (params): ReturnType => { ... }` or `export const name = (params): ReturnType => { ... }` +- **Async**: `export const name = async (params): Promise => { ... }` +- **Single expression**: `const name = (x: T): R => expression;` when the body is a single return. + +Examples: `mapHttpError`, `getApiUrl`, `request`, `buildProductsQuery`, `getProducts`, `toUserResponse`, `toListItem`, `healthHandler`, `useNotification`, `useLocalStorage`, `getErrorMessage`, `formatDateTime`, `notifyError`. + +## Use `function` declarations + +For **React components** (anything that returns JSX or is used as ``): + +- **Format**: `export function ComponentName(props): React.ReactElement` or `function ComponentName(props) { ... }` +- Keep subcomponents (e.g. `ProductImageUploadField`, `LayoutContent`, `GroupLinks`) and providers (e.g. `NotificationProvider`, `DataBoundary`) as `function` as well. + +Examples: `ProductsPage`, `ProductDetailPage`, `DataBoundary`, `ProductEditModal`, `NotificationProvider`, `DefaultLayout`, `AuthGuard`, `SidebarProvider`. + +## Summary + +| Kind | Style | +|------|--------| +| React component / returns JSX | `function Name()` | +| Everything else (utils, API, handlers, hooks, mappers) | `const name = () =>` | + +Preserve existing explicit return types and type annotations when converting. diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 0a2d7e1..5b6816f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -205,26 +205,27 @@ const deleteUserRoute = createRoute({ }); // Single OpenAPIHono with one continuous chain so RPC schema is preserved for hc. +// Handlers are typed as Promise / Response; OpenAPIHono expects TypedResponse, so cast is required. const api = new OpenAPIHono() .openapi(healthRoute, healthHandler as unknown as RouteHandler) .openapi(healthLiveRoute, healthLiveHandler as unknown as RouteHandler) - .openapi(listPriceHistoryRoute, listPriceHistoryHandler as RouteHandler) - .openapi(getPriceHistoryByIdRoute, getPriceHistoryByIdHandler as RouteHandler) - .openapi(createPriceHistoryRoute, createPriceHistoryHandler as RouteHandler) - .openapi(listInventoryAdjustmentsRoute, listInventoryAdjustmentsHandler as RouteHandler) - .openapi(getInventoryAdjustmentByIdRoute, getInventoryAdjustmentByIdHandler as RouteHandler) - .openapi(createInventoryAdjustmentRoute, createInventoryAdjustmentHandler as RouteHandler) - .openapi(listProductsRoute, listProductsHandler as RouteHandler) - .openapi(getProductByIdRoute, getProductByIdHandler as RouteHandler) - .openapi(createProductRoute, createProductHandler as RouteHandler) - .openapi(updateProductRoute, updateProductHandler as RouteHandler) - .openapi(deleteProductRoute, deleteProductHandler as RouteHandler) - .openapi(uploadRoute, uploadHandler as RouteHandler) - .openapi(listUsersRoute, listUsersHandler as RouteHandler) - .openapi(getUserByIdRoute, getUserByIdHandler as RouteHandler) - .openapi(createUserRoute, createUserHandler as RouteHandler) - .openapi(updateUserRoute, updateUserHandler as RouteHandler) - .openapi(deleteUserRoute, deleteUserHandler as RouteHandler) + .openapi(listPriceHistoryRoute, listPriceHistoryHandler as unknown as RouteHandler) + .openapi(getPriceHistoryByIdRoute, getPriceHistoryByIdHandler as unknown as RouteHandler) + .openapi(createPriceHistoryRoute, createPriceHistoryHandler as unknown as RouteHandler) + .openapi(listInventoryAdjustmentsRoute, listInventoryAdjustmentsHandler as unknown as RouteHandler) + .openapi(getInventoryAdjustmentByIdRoute, getInventoryAdjustmentByIdHandler as unknown as RouteHandler) + .openapi(createInventoryAdjustmentRoute, createInventoryAdjustmentHandler as unknown as RouteHandler) + .openapi(listProductsRoute, listProductsHandler as unknown as RouteHandler) + .openapi(getProductByIdRoute, getProductByIdHandler as unknown as RouteHandler) + .openapi(createProductRoute, createProductHandler as unknown as RouteHandler) + .openapi(updateProductRoute, updateProductHandler as unknown as RouteHandler) + .openapi(deleteProductRoute, deleteProductHandler as unknown as RouteHandler) + .openapi(uploadRoute, uploadHandler as unknown as RouteHandler) + .openapi(listUsersRoute, listUsersHandler as unknown as RouteHandler) + .openapi(getUserByIdRoute, getUserByIdHandler as unknown as RouteHandler) + .openapi(createUserRoute, createUserHandler as unknown as RouteHandler) + .openapi(updateUserRoute, updateUserHandler as unknown as RouteHandler) + .openapi(deleteUserRoute, deleteUserHandler as unknown as RouteHandler) .doc('/openapi.json', (c) => { const baseUrl = c.req.url.replace(/\/openapi\.json.*$/, ''); return { diff --git a/apps/api/src/common/filters/error-handler.ts b/apps/api/src/common/filters/error-handler.ts index 00fe7b7..a530f50 100644 --- a/apps/api/src/common/filters/error-handler.ts +++ b/apps/api/src/common/filters/error-handler.ts @@ -7,7 +7,7 @@ type ErrorLike = Error & { code?: string; statusCode?: number }; /** * Central error handler. Masks internal details in production. */ -export function errorHandler(err: ErrorLike, c: Context): Response { +export const errorHandler = (err: ErrorLike, c: Context): Response => { // Log server-side (never expose stack to client in production) console.error('[Error]', err.message, env.NODE_ENV === 'development' ? err.stack : ''); @@ -29,4 +29,4 @@ export function errorHandler(err: ErrorLike, c: Context): Response { : err.message; return c.json({ error: 'Internal Server Error', message }, 500); -} +}; diff --git a/apps/api/src/common/guards/auth.guard.ts b/apps/api/src/common/guards/auth.guard.ts index 7e6d243..f0cdf3d 100644 --- a/apps/api/src/common/guards/auth.guard.ts +++ b/apps/api/src/common/guards/auth.guard.ts @@ -5,11 +5,11 @@ import { HTTPException } from 'hono/http-exception'; * Placeholder auth guard. Replace with JWT verification when implementing auth. * Usage: app.use('/protected/*', authGuard); */ -export async function authGuard(c: Context, next: Next): Promise { +export const authGuard = async (c: Context, next: Next): Promise => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { throw new HTTPException(401, { message: 'Missing or invalid Authorization header' }); } // TODO: verify JWT, set c.set('user', payload) await next(); -} +}; diff --git a/apps/api/src/common/interceptors/logging.ts b/apps/api/src/common/interceptors/logging.ts index d40a1f3..3930003 100644 --- a/apps/api/src/common/interceptors/logging.ts +++ b/apps/api/src/common/interceptors/logging.ts @@ -3,7 +3,7 @@ import type { Context, Next } from 'hono'; /** * Request logging interceptor. Log method, path, status, and duration. */ -export async function requestLogger(c: Context, next: Next): Promise { +export const requestLogger = async (c: Context, next: Next): Promise => { const start = Date.now(); await next(); const ms = Date.now() - start; @@ -11,4 +11,4 @@ export async function requestLogger(c: Context, next: Next): Promise { const method = c.req.method; const path = c.req.path; console.info(`[${method}] ${path} ${status} ${ms}ms`); -} +}; diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index 338e9e0..fa42a72 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -17,13 +17,13 @@ const envSchema = z.object({ export type Env = z.infer; -function loadEnv(): Env { +const loadEnv = (): Env => { const parsed = envSchema.safeParse(process.env); if (!parsed.success) { console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors); throw new Error('Invalid environment variables'); } return parsed.data; -} +}; export const env = loadEnv(); diff --git a/apps/api/src/lib/cloudinary.ts b/apps/api/src/lib/cloudinary.ts new file mode 100644 index 0000000..1804aea --- /dev/null +++ b/apps/api/src/lib/cloudinary.ts @@ -0,0 +1,24 @@ +/** + * Cloudinary delivery URL transforms for outbound image URLs. + * All Cloudinary image URLs must include format optimization (f_auto), + * quality adjustment (q_auto), and explicit dimensions with crop mode. + */ + +const CLOUDINARY_UPLOAD_PREFIX = 'res.cloudinary.com/'; +const UPLOAD_PATH = '/image/upload/'; +const TRANSFORMS = 'f_auto,q_auto,w_800,h_600,c_fill'; + +/** + * Applies standard delivery transforms to a Cloudinary image URL. + * Inserts f_auto, q_auto, and explicit dimensions (w_800, h_600, c_fill). + * Non-Cloudinary URLs are returned unchanged. + */ +export const toOptimizedImageUrl = (url: string | null | undefined): string | undefined => { + if (url == null || url === '') return undefined; + if (!url.includes(CLOUDINARY_UPLOAD_PREFIX) || !url.includes(UPLOAD_PATH)) return url; + const insert = `${UPLOAD_PATH}${TRANSFORMS}/`; + const afterUpload = url.indexOf(UPLOAD_PATH) + UPLOAD_PATH.length; + const alreadyHasTransforms = url.slice(afterUpload, afterUpload + 20).includes('f_auto'); + if (alreadyHasTransforms) return url; + return url.replace(UPLOAD_PATH, insert); +}; diff --git a/apps/api/src/modules/ecommerce/inventory-adjustment.controller.ts b/apps/api/src/modules/ecommerce/inventory-adjustment.controller.ts index 6005f66..373dbeb 100644 --- a/apps/api/src/modules/ecommerce/inventory-adjustment.controller.ts +++ b/apps/api/src/modules/ecommerce/inventory-adjustment.controller.ts @@ -21,13 +21,13 @@ type ListQuery = z.infer; type ParamId = z.infer; type CreateBody = z.infer; -export async function listInventoryAdjustmentsHandler(c: Context) { +export const listInventoryAdjustmentsHandler = async (c: Context): Promise => { const { page, pageSize, q } = c.req.valid('query'); const list = await inventoryAdjustmentService.getList(page, pageSize, { q }); return c.json(list); -} +}; -export async function getInventoryAdjustmentByIdHandler(c: Context) { +export const getInventoryAdjustmentByIdHandler = async (c: Context): Promise => { const { id } = c.req.valid('param'); const item = await inventoryAdjustmentService.getById(id); if (!item) { @@ -37,9 +37,9 @@ export async function getInventoryAdjustmentByIdHandler(c: Context) { +export const createInventoryAdjustmentHandler = async (c: Context): Promise => { try { const body = c.req.valid('json'); const created = await inventoryAdjustmentService.create(body); @@ -53,4 +53,4 @@ export async function createInventoryAdjustmentHandler(c: Context; + +type OrCondition = + | { sku: { product: { name: { contains: string } } } } + | { skuId: number }; +type WhereClause = { OR: OrCondition[] }; + +const buildWhere = (q?: string): WhereClause | undefined => { const qTrim = q?.trim(); if (!qTrim) return undefined; const or: Array< @@ -10,7 +21,7 @@ function buildWhere(q?: string) { const id = /^\d+$/.test(qTrim) ? parseInt(qTrim, 10) : NaN; if (!Number.isNaN(id)) or.push({ skuId: id }); return { OR: or }; -} +}; /** Sentinel returned by create() when Decrease would make stock negative. */ export const INSUFFICIENT_STOCK = 'INSUFFICIENT_STOCK' as const; @@ -23,7 +34,7 @@ export const inventoryAdjustmentRepository = { page: number, pageSize: number, options: { q?: string } - ) { + ): Promise<{ items: InventoryAdjustmentWithSkuAndProduct[]; total: number }> { const where = buildWhere(options.q); const skip = (page - 1) * pageSize; return prisma.$transaction(async (tx) => { @@ -41,14 +52,16 @@ export const inventoryAdjustmentRepository = { }); }, - async findById(id: number) { + async findById(id: number): Promise { return prisma.inventoryAdjustment.findUnique({ where: { id }, include: { sku: { include: { product: true } } }, }); }, - async create(data: CreateInventoryAdjustmentInput) { + async create( + data: CreateInventoryAdjustmentInput + ): Promise { return prisma.$transaction(async (tx) => { const sku = await tx.sku.findUnique({ where: { id: data.skuId }, diff --git a/apps/api/src/modules/ecommerce/inventory-adjustment.service.ts b/apps/api/src/modules/ecommerce/inventory-adjustment.service.ts index 45568f1..3da9819 100644 --- a/apps/api/src/modules/ecommerce/inventory-adjustment.service.ts +++ b/apps/api/src/modules/ecommerce/inventory-adjustment.service.ts @@ -28,12 +28,12 @@ type PrismaRow = Awaited< >; type PrismaRowNonNull = NonNullable; -function toListItem(row: PrismaRowNonNull): InventoryAdjustmentListItem { +const isStringRecord = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === 'string'); + +const toListItem = (row: PrismaRowNonNull): InventoryAdjustmentListItem => { const attrs = row.sku.attributes; - const skuAttributes = - typeof attrs === 'object' && attrs !== null && !Array.isArray(attrs) - ? (attrs as Record) - : {}; + const skuAttributes = isStringRecord(attrs) ? attrs : {}; return { id: row.id, skuId: row.skuId, @@ -46,7 +46,7 @@ function toListItem(row: PrismaRowNonNull): InventoryAdjustmentListItem { productId: row.sku.productId, skuAttributes, }; -} +}; /** * Business logic only. No HTTP/framework types. diff --git a/apps/api/src/modules/ecommerce/mappers/enum.mapper.test.ts b/apps/api/src/modules/ecommerce/mappers/enum.mapper.test.ts index a25afed..730d67d 100644 --- a/apps/api/src/modules/ecommerce/mappers/enum.mapper.test.ts +++ b/apps/api/src/modules/ecommerce/mappers/enum.mapper.test.ts @@ -22,9 +22,7 @@ import { refundStatusMap, } from './enum.mapper.js'; -function sorted(values: readonly string[]): string[] { - return [...values].sort(); -} +const sorted = (values: readonly string[]): string[] => [...values].sort(); describe('ecommerce enum mappings', () => { it('stays consistent with API ecommerce product statuses', () => { diff --git a/apps/api/src/modules/ecommerce/mappers/enum.mapper.ts b/apps/api/src/modules/ecommerce/mappers/enum.mapper.ts index 04c1e16..810c155 100644 --- a/apps/api/src/modules/ecommerce/mappers/enum.mapper.ts +++ b/apps/api/src/modules/ecommerce/mappers/enum.mapper.ts @@ -80,42 +80,31 @@ export const refundStatusMap = { Failed: 'Failed', } as const satisfies Record; -export function toSharedProductStatus(status: PrismaProductStatus): ProductStatus { - return productStatusMap[status]; -} +export const toSharedProductStatus = (status: PrismaProductStatus): ProductStatus => + productStatusMap[status]; -export function toSharedDiscountType(type: PrismaDiscountType): DiscountType { - return discountTypeMap[type]; -} +export const toSharedDiscountType = (type: PrismaDiscountType): DiscountType => + discountTypeMap[type]; -export function toSharedOrderStatus(status: PrismaOrderStatus): OrderStatus { - return orderStatusMap[status]; -} +export const toSharedOrderStatus = (status: PrismaOrderStatus): OrderStatus => + orderStatusMap[status]; -export function toSharedPaymentStatus(status: PrismaPaymentStatus): PaymentStatus { - return paymentStatusMap[status]; -} +export const toSharedPaymentStatus = (status: PrismaPaymentStatus): PaymentStatus => + paymentStatusMap[status]; -export function toSharedInventoryAdjustmentType( +export const toSharedInventoryAdjustmentType = ( type: PrismaInventoryAdjustmentType, -): InventoryAdjustmentType { - return inventoryAdjustmentTypeMap[type]; -} +): InventoryAdjustmentType => inventoryAdjustmentTypeMap[type]; -export function toSharedPaymentMethod(method: PrismaPaymentMethod): PaymentMethod { - return paymentMethodMap[method]; -} +export const toSharedPaymentMethod = (method: PrismaPaymentMethod): PaymentMethod => + paymentMethodMap[method]; -export function toSharedPaymentTransactionStatus( +export const toSharedPaymentTransactionStatus = ( status: PrismaPaymentTransactionStatus, -): PaymentTransactionStatus { - return paymentTransactionStatusMap[status]; -} +): PaymentTransactionStatus => paymentTransactionStatusMap[status]; -export function toSharedPromotionStatus(status: PrismaPromotionStatus): PromotionStatusValue { - return promotionStatusMap[status]; -} +export const toSharedPromotionStatus = (status: PrismaPromotionStatus): PromotionStatusValue => + promotionStatusMap[status]; -export function toSharedRefundStatus(status: PrismaRefundStatus): RefundStatus { - return refundStatusMap[status]; -} +export const toSharedRefundStatus = (status: PrismaRefundStatus): RefundStatus => + refundStatusMap[status]; diff --git a/apps/api/src/modules/ecommerce/product.controller.ts b/apps/api/src/modules/ecommerce/product.controller.ts index d4230c2..9b17474 100644 --- a/apps/api/src/modules/ecommerce/product.controller.ts +++ b/apps/api/src/modules/ecommerce/product.controller.ts @@ -15,7 +15,7 @@ type ParamId = z.infer; type CreateBody = z.infer; type UpdateBody = z.infer; -export async function listProductsHandler(c: Context) { +export const listProductsHandler = async (c: Context): Promise => { const { page, pageSize, status, sortBy, sortOrder, q } = c.req.valid('query'); const list = await productService.getProducts(page, pageSize, { status, @@ -24,24 +24,24 @@ export async function listProductsHandler(c: Context) { +export const getProductByIdHandler = async (c: Context): Promise => { const { id } = c.req.valid('param'); const product = await productService.getProductById(id); if (!product) { return c.json({ error: 'Not Found', message: 'Product not found' }, 404); } return c.json(product); -} +}; -export async function createProductHandler(c: Context) { +export const createProductHandler = async (c: Context): Promise => { const body = c.req.valid('json'); const created = await productService.createProduct(body); return c.json(created, 201); -} +}; -export async function updateProductHandler(c: Context) { +export const updateProductHandler = async (c: Context): Promise => { const { id } = c.req.valid('param'); const body = c.req.valid('json'); const updated = await productService.updateProduct(id, body); @@ -49,10 +49,10 @@ export async function updateProductHandler(c: Context) { +export const deleteProductHandler = async (c: Context): Promise => { const { id } = c.req.valid('param'); await productService.deleteProduct(id); return c.body(null, 204); -} +}; diff --git a/apps/api/src/modules/ecommerce/product.repository.ts b/apps/api/src/modules/ecommerce/product.repository.ts index cdb4e06..8dfc70e 100644 --- a/apps/api/src/modules/ecommerce/product.repository.ts +++ b/apps/api/src/modules/ecommerce/product.repository.ts @@ -3,21 +3,25 @@ import type { ProductSortBy, UpdateProductInput, } from './dto/ecommerce.dto.js'; +import type { ProductGetPayload } from '../../../generated/prisma/models/Product.js'; import { prisma } from '../../lib/prisma.js'; +/** Prisma payload for Product with skus included (matches our queries). */ +type ProductWithSkus = ProductGetPayload<{ include: { skus: true } }>; + type ProductStatus = 'Draft' | 'Active' | 'Inactive'; /** Product name search uses contains; on SQLite, LIKE is case-sensitive. */ -function buildWhere(options: { status?: ProductStatus; q?: string }) { +const buildWhere = (options: { status?: ProductStatus; q?: string }): { status?: ProductStatus; name?: { contains: string } } | undefined => { const conditions: { status?: ProductStatus; name?: { contains: string } } = {}; if (options.status !== undefined) conditions.status = options.status; if (options.q !== undefined && options.q.trim() !== '') { conditions.name = { contains: options.q.trim() }; } return Object.keys(conditions).length > 0 ? conditions : undefined; -} +}; -function buildOrderBy(sortBy?: ProductSortBy, sortOrder: 'asc' | 'desc' = 'desc') { +const buildOrderBy = (sortBy?: ProductSortBy, sortOrder: 'asc' | 'desc' = 'desc'): Record => { if (sortBy === undefined || sortBy === 'createdAt') { return { createdAt: sortOrder }; } @@ -25,13 +29,15 @@ function buildOrderBy(sortBy?: ProductSortBy, sortOrder: 'asc' | 'desc' = 'desc' return { skus: { _count: sortOrder } }; } return { [sortBy]: sortOrder }; -} +}; /** * Data access only. No business logic; all SQL/ORM here. */ +const toPrismaAttributes = (attrs: Record): object => ({ ...attrs }); + export const productRepository = { - async create(data: CreateProductInput) { + async create(data: CreateProductInput): Promise { return prisma.$transaction(async (tx) => { const product = await tx.product.create({ data: { @@ -46,7 +52,7 @@ export const productRepository = { productId: product.id, price: sku.price, stock: sku.stock, - attributes: sku.attributes as object, + attributes: toPrismaAttributes(sku.attributes), })), }); } @@ -57,7 +63,7 @@ export const productRepository = { }); }, - async findById(id: number) { + async findById(id: number): Promise { return prisma.product.findUnique({ where: { id }, include: { skus: true }, @@ -73,7 +79,7 @@ export const productRepository = { sortOrder?: 'asc' | 'desc'; q?: string; } - ) { + ): Promise { const where = buildWhere({ status: options.status, q: options.q }); const orderBy = buildOrderBy(options.sortBy, options.sortOrder ?? 'desc'); const skip = (page - 1) * pageSize; @@ -86,7 +92,7 @@ export const productRepository = { }); }, - async count(options: { status?: ProductStatus; q?: string }) { + async count(options: { status?: ProductStatus; q?: string }): Promise { const where = buildWhere(options); return prisma.product.count({ where }); }, @@ -100,7 +106,7 @@ export const productRepository = { sortOrder?: 'asc' | 'desc'; q?: string; } - ) { + ): Promise<{ items: ProductWithSkus[]; total: number }> { const where = buildWhere({ status: options.status, q: options.q }); const orderBy = buildOrderBy(options.sortBy, options.sortOrder ?? 'desc'); const skip = (page - 1) * pageSize; @@ -119,7 +125,10 @@ export const productRepository = { }); }, - async update(id: number, data: UpdateProductInput) { + async update( + id: number, + data: UpdateProductInput + ): Promise { return prisma.$transaction(async (tx) => { const product = await tx.product.findUnique({ where: { id } }); if (!product) return null; @@ -132,7 +141,7 @@ export const productRepository = { productId: id, price: sku.price, stock: sku.stock, - attributes: sku.attributes as object, + attributes: toPrismaAttributes(sku.attributes), })), }); } @@ -150,7 +159,7 @@ export const productRepository = { }); }, - async delete(id: number) { + async delete(id: number): Promise { await prisma.product.delete({ where: { id }, }); diff --git a/apps/api/src/modules/ecommerce/product.service.ts b/apps/api/src/modules/ecommerce/product.service.ts index 907c8de..39d39a1 100644 --- a/apps/api/src/modules/ecommerce/product.service.ts +++ b/apps/api/src/modules/ecommerce/product.service.ts @@ -5,27 +5,29 @@ import type { ProductSortBy, UpdateProductInput, } from './dto/ecommerce.dto.js'; +import { toOptimizedImageUrl } from '../../lib/cloudinary.js'; import { toSharedProductStatus } from './mappers/enum.mapper.js'; import { productRepository } from './product.repository.js'; type PrismaProductWithSkus = Awaited>; type PrismaProductWithSkusNonNull = NonNullable; -function toProduct(row: PrismaProductWithSkusNonNull): Product { - return { - id: row.id, - name: row.name, - status: toSharedProductStatus(row.status), - skus: row.skus.map((sku) => ({ - id: sku.id, - productId: sku.productId, - price: sku.price, - stock: sku.stock, - attributes: sku.attributes as Record, - })), - imageUrl: row.imageUrl || undefined, - }; -} +const isStringRecord = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === 'string'); + +const toProduct = (row: PrismaProductWithSkusNonNull): Product => ({ + id: row.id, + name: row.name, + status: toSharedProductStatus(row.status), + skus: row.skus.map((sku) => ({ + id: sku.id, + productId: sku.productId, + price: sku.price, + stock: sku.stock, + attributes: isStringRecord(sku.attributes) ? sku.attributes : {}, + })), + imageUrl: toOptimizedImageUrl(row.imageUrl), +}); type ProductStatus = 'Draft' | 'Active' | 'Inactive'; diff --git a/apps/api/src/modules/ecommerce/sku-price-history.controller.ts b/apps/api/src/modules/ecommerce/sku-price-history.controller.ts index 71578de..4c31463 100644 --- a/apps/api/src/modules/ecommerce/sku-price-history.controller.ts +++ b/apps/api/src/modules/ecommerce/sku-price-history.controller.ts @@ -21,22 +21,22 @@ type ListQuery = z.infer; type ParamId = z.infer; type CreateBody = z.infer; -export async function listPriceHistoryHandler(c: Context) { +export const listPriceHistoryHandler = async (c: Context): Promise => { const { page, pageSize, q } = c.req.valid('query'); const list = await skuPriceHistoryService.getList(page, pageSize, { q }); return c.json(list); -} +}; -export async function getPriceHistoryByIdHandler(c: Context) { +export const getPriceHistoryByIdHandler = async (c: Context): Promise => { const { id } = c.req.valid('param'); const item = await skuPriceHistoryService.getById(id); if (!item) { return c.json({ error: 'Not Found', message: 'Price history not found' }, 404); } return c.json(item); -} +}; -export async function createPriceHistoryHandler(c: Context) { +export const createPriceHistoryHandler = async (c: Context): Promise => { try { const body = c.req.valid('json'); const created = await skuPriceHistoryService.create(body); @@ -47,4 +47,4 @@ export async function createPriceHistoryHandler(c: Context; + /** Product name search uses contains; on SQLite, LIKE is case-sensitive. */ -function buildWhere(q?: string) { +type OrCondition = + | { sku: { product: { name: { contains: string } } } } + | { skuId: number }; +type WhereClause = { OR: OrCondition[] }; + +const buildWhere = (q?: string): WhereClause | undefined => { const qTrim = q?.trim(); if (!qTrim) return undefined; const or: Array< @@ -11,7 +22,7 @@ function buildWhere(q?: string) { const id = /^\d+$/.test(qTrim) ? parseInt(qTrim, 10) : NaN; if (!Number.isNaN(id)) or.push({ skuId: id }); return { OR: or }; -} +}; /** Sentinel returned by create() when SKU price equals newPrice (no-op). */ export const PRICE_UNCHANGED = 'PRICE_UNCHANGED' as const; @@ -24,7 +35,7 @@ export const skuPriceHistoryRepository = { page: number, pageSize: number, options: { q?: string } - ) { + ): Promise<{ items: SkuPriceHistoryWithSkuAndProduct[]; total: number }> { const where = buildWhere(options.q); const skip = (page - 1) * pageSize; return prisma.$transaction(async (tx) => { @@ -42,14 +53,16 @@ export const skuPriceHistoryRepository = { }); }, - async findById(id: number) { + async findById(id: number): Promise { return prisma.skuPriceHistory.findUnique({ where: { id }, include: { sku: { include: { product: true } } }, }); }, - async create(data: CreateSkuPriceHistoryInput) { + async create( + data: CreateSkuPriceHistoryInput + ): Promise { const effectiveDate = data.effectiveDate ?? new Date(); return prisma.$transaction(async (tx) => { const sku = await tx.sku.findUnique({ diff --git a/apps/api/src/modules/ecommerce/sku-price-history.service.ts b/apps/api/src/modules/ecommerce/sku-price-history.service.ts index ce5fb88..eea1ba8 100644 --- a/apps/api/src/modules/ecommerce/sku-price-history.service.ts +++ b/apps/api/src/modules/ecommerce/sku-price-history.service.ts @@ -27,12 +27,12 @@ type PrismaRow = Awaited< >; type PrismaRowNonNull = NonNullable; -function toListItem(row: PrismaRowNonNull): SkuPriceHistoryListItem { +const isStringRecord = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === 'string'); + +const toListItem = (row: PrismaRowNonNull): SkuPriceHistoryListItem => { const attrs = row.sku.attributes; - const skuAttributes = - typeof attrs === 'object' && attrs !== null && !Array.isArray(attrs) - ? (attrs as Record) - : {}; + const skuAttributes = isStringRecord(attrs) ? attrs : {}; return { id: row.id, skuId: row.skuId, @@ -47,7 +47,7 @@ function toListItem(row: PrismaRowNonNull): SkuPriceHistoryListItem { productId: row.sku.productId, skuAttributes, }; -} +}; /** * Business logic only. No HTTP/framework types. diff --git a/apps/api/src/modules/health/health.controller.ts b/apps/api/src/modules/health/health.controller.ts index 12e1b67..48543a4 100644 --- a/apps/api/src/modules/health/health.controller.ts +++ b/apps/api/src/modules/health/health.controller.ts @@ -1,10 +1,6 @@ import type { Context } from 'hono'; import { healthService } from './health.service.js'; -export function healthHandler(c: Context) { - return c.json(healthService.getStatus()); -} +export const healthHandler = (c: Context): Response => c.json(healthService.getStatus()); -export function healthLiveHandler(c: Context) { - return c.json({ status: 'ok' }); -} +export const healthLiveHandler = (c: Context): Response => c.json({ status: 'ok' }); diff --git a/apps/api/src/modules/upload/upload.controller.ts b/apps/api/src/modules/upload/upload.controller.ts index 07e291e..72bbf31 100644 --- a/apps/api/src/modules/upload/upload.controller.ts +++ b/apps/api/src/modules/upload/upload.controller.ts @@ -8,7 +8,7 @@ import { env } from '../../config/index.js'; const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); -function configureCloudinary() { +const configureCloudinary = (): typeof cloudinary | null => { const { CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET } = env; if (!CLOUDINARY_CLOUD_NAME || !CLOUDINARY_API_KEY || !CLOUDINARY_API_SECRET) { return null; @@ -19,10 +19,10 @@ function configureCloudinary() { api_secret: CLOUDINARY_API_SECRET, }); return cloudinary; -} +}; -function uploadBufferToCloudinary(buffer: Buffer): Promise<{ secure_url: string }> { - return new Promise((resolve, reject) => { +const uploadBufferToCloudinary = (buffer: Buffer): Promise<{ secure_url: string }> => + new Promise((resolve, reject) => { const uploadStream = cloudinary.uploader.upload_stream( { folder: 'products' }, (err, result) => { @@ -39,9 +39,8 @@ function uploadBufferToCloudinary(buffer: Buffer): Promise<{ secure_url: string ); Readable.from(buffer).pipe(uploadStream); }); -} -export async function uploadHandler(c: Context) { +export const uploadHandler = async (c: Context): Promise => { const config = configureCloudinary(); if (!config) { throw new HTTPException(503, { @@ -55,7 +54,10 @@ export async function uploadHandler(c: Context) { throw new HTTPException(400, { message: 'Missing or invalid file. Use form field "file" or "image".' }); } - const blob = file as Blob; + if (!(file instanceof Blob)) { + throw new HTTPException(400, { message: 'Invalid file. Expected a file blob.' }); + } + const blob = file; if (blob.size > MAX_SIZE_BYTES) { throw new HTTPException(400, { message: `File too large. Max size: ${MAX_SIZE_BYTES / 1024 / 1024}MB` }); } @@ -80,4 +82,4 @@ export async function uploadHandler(c: Context) { console.error('Cloudinary upload error:', err); throw new HTTPException(502, { message: 'Upload failed' }); } -} +}; diff --git a/apps/api/src/modules/user/dto/user-response.dto.ts b/apps/api/src/modules/user/dto/user-response.dto.ts index 6885ae0..02f8506 100644 --- a/apps/api/src/modules/user/dto/user-response.dto.ts +++ b/apps/api/src/modules/user/dto/user-response.dto.ts @@ -12,11 +12,9 @@ export const listUsersResponseSchema = z.array(userResponseSchema); export type UserResponse = z.infer; -export function toUserResponse(user: User): UserResponse { - return { - id: user.id, - email: user.email, - name: user.name, - createdAt: user.createdAt.toISOString(), - }; -} +export const toUserResponse = (user: User): UserResponse => ({ + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt.toISOString(), +}); diff --git a/apps/api/src/modules/user/user.controller.ts b/apps/api/src/modules/user/user.controller.ts index 5775b5b..3b51212 100644 --- a/apps/api/src/modules/user/user.controller.ts +++ b/apps/api/src/modules/user/user.controller.ts @@ -7,35 +7,31 @@ export { createUserSchema, updateUserSchema }; type CreateBody = z.infer; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types -- return type inferred from Hono TypedResponse -export async function listUsersHandler(c: Context) { +export const listUsersHandler = async (c: Context): Promise => { const limit = Number(c.req.query('limit')) || 50; const offset = Number(c.req.query('offset')) || 0; const list = await userService.getUsers(limit, offset); return c.json(list); -} +}; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types -- return type inferred from Hono TypedResponse -export async function getUserByIdHandler(c: Context) { +export const getUserByIdHandler = async (c: Context): Promise => { const id = c.req.param('id'); const userEntity = await userService.getUserById(id); if (!userEntity) { return c.json({ error: 'Not Found', message: 'User not found' }, 404); } return c.json(userEntity); -} +}; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types -- return type inferred from Hono TypedResponse -export async function createUserHandler(c: Context) { +export const createUserHandler = async (c: Context): Promise => { const body = c.req.valid('json'); const created = await userService.createUser(body); return c.json(created, 201); -} +}; type UpdateBody = z.infer; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types -- return type inferred from Hono TypedResponse -export async function updateUserHandler(c: Context) { +export const updateUserHandler = async (c: Context): Promise => { const { id } = c.req.valid('param'); const body = c.req.valid('json'); const name = 'name' in body ? body.name : undefined; @@ -44,11 +40,10 @@ export async function updateUserHandler(c: Context => { const id = c.req.param('id'); await userService.deleteUser(id); return c.body(null, 204); -} +}; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 534b252..21ee6b0 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -2,7 +2,7 @@ import { hc } from 'hono/client'; import type { AppType } from '@salesops/api/app'; import { mapHttpError } from '@/api/errorHandler'; -function getApiBaseUrl(): string { +const getApiBaseUrl = (): string => { try { const env = import.meta.env; const v = @@ -14,22 +14,22 @@ function getApiBaseUrl(): string { // ignore } return 'http://localhost:3000'; -} +}; const apiBaseUrl = getApiBaseUrl(); -export function getApiUrl(path: string): string { +export const getApiUrl = (path: string): string => { const normalized = path.startsWith('/') ? path : `/${path}`; return `${apiBaseUrl}/api${normalized}`; -} +}; /** * Centralized fetch wrapper. Uses res.ok to throw on 4xx/5xx so TanStack Query * receives errors. Parses JSON safely; treats 204 as success with undefined. */ -export async function request( +export const request = async ( input: RequestInfo, init?: RequestInit -): Promise { +): Promise => { const res = await fetch(input, init); const text = await res.text(); @@ -52,7 +52,7 @@ export async function request( } catch { throw new Error('Invalid JSON response'); } -} +}; /** Type-only: RPC client shape for InferResponseType in types.ts. Do not use for network calls. */ export type RpcClient = ReturnType>; diff --git a/apps/web/src/api/errorHandler.ts b/apps/web/src/api/errorHandler.ts index 3d53a6d..a32de5f 100644 --- a/apps/web/src/api/errorHandler.ts +++ b/apps/web/src/api/errorHandler.ts @@ -2,7 +2,7 @@ * Maps HTTP status codes to user-facing error messages. * If the backend response contains { message: string }, that is used instead. */ -export function mapHttpError(status: number, data?: unknown): Error { +export const mapHttpError = (status: number, data?: unknown): Error => { const backendMessage = getBackendMessage(data); if (backendMessage !== null) { return new Error(backendMessage); @@ -25,9 +25,9 @@ export function mapHttpError(status: number, data?: unknown): Error { } return new Error(`Request failed with status ${status}.`); } -} +}; -function getBackendMessage(data: unknown): string | null { +const getBackendMessage = (data: unknown): string | null => { if (data === null || typeof data !== 'object') { return null; } @@ -36,4 +36,4 @@ function getBackendMessage(data: unknown): string | null { return obj.message; } return null; -} +}; diff --git a/apps/web/src/api/inventoryAdjustments.ts b/apps/web/src/api/inventoryAdjustments.ts index 72b080b..33fd388 100644 --- a/apps/web/src/api/inventoryAdjustments.ts +++ b/apps/web/src/api/inventoryAdjustments.ts @@ -14,36 +14,36 @@ export const inventoryAdjustmentKeys = { [...inventoryAdjustmentKeys.all, 'detail', id] as const, }; -function buildInventoryAdjustmentsQuery(params: ListInventoryAdjustmentsQuery): string { +const buildInventoryAdjustmentsQuery = (params: ListInventoryAdjustmentsQuery): string => { const search = new URLSearchParams(); search.set('page', String(params.page)); search.set('pageSize', String(params.pageSize)); if (params.q != null && params.q.trim() !== '') search.set('q', params.q.trim()); const qs = search.toString(); return qs ? `?${qs}` : ''; -} +}; -export async function getInventoryAdjustmentsList( +export const getInventoryAdjustmentsList = async ( params: ListInventoryAdjustmentsQuery -): Promise { +): Promise => { const url = getApiUrl('/inventoryAdjustments') + buildInventoryAdjustmentsQuery(params); return request(url); -} +}; -export async function getInventoryAdjustmentById( +export const getInventoryAdjustmentById = async ( id: number -): Promise { +): Promise => { const url = getApiUrl(`/inventoryAdjustments/${id}`); return request(url); -} +}; -export async function createInventoryAdjustment( +export const createInventoryAdjustment = async ( body: CreateInventoryAdjustmentInput -): Promise { +): Promise => { const url = getApiUrl('/inventoryAdjustments'); return request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); -} +}; diff --git a/apps/web/src/api/priceHistory.ts b/apps/web/src/api/priceHistory.ts index ce09f5e..c65c768 100644 --- a/apps/web/src/api/priceHistory.ts +++ b/apps/web/src/api/priceHistory.ts @@ -13,32 +13,32 @@ export const priceHistoryKeys = { detail: (id: number) => [...priceHistoryKeys.all, 'detail', id] as const, }; -function buildPriceHistoryQuery(params: ListPriceHistoryQuery): string { +const buildPriceHistoryQuery = (params: ListPriceHistoryQuery): string => { const search = new URLSearchParams(); search.set('page', String(params.page)); search.set('pageSize', String(params.pageSize)); if (params.q != null && params.q.trim() !== '') search.set('q', params.q.trim()); const qs = search.toString(); return qs ? `?${qs}` : ''; -} +}; -export async function getPriceHistoryList( +export const getPriceHistoryList = async ( params: ListPriceHistoryQuery -): Promise { +): Promise => { const url = getApiUrl('/priceHistory') + buildPriceHistoryQuery(params); return request(url); -} +}; -export async function getPriceHistoryById( +export const getPriceHistoryById = async ( id: number -): Promise { +): Promise => { const url = getApiUrl(`/priceHistory/${id}`); return request(url); -} +}; -export async function createPriceHistory( +export const createPriceHistory = async ( body: CreateSkuPriceHistoryInput -): Promise { +): Promise => { const url = getApiUrl('/priceHistory'); const payload = { skuId: body.skuId, @@ -53,4 +53,4 @@ export async function createPriceHistory( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); -} +}; diff --git a/apps/web/src/api/products.ts b/apps/web/src/api/products.ts index ef13ba7..90cf0fd 100644 --- a/apps/web/src/api/products.ts +++ b/apps/web/src/api/products.ts @@ -14,7 +14,7 @@ export const productKeys = { detail: (id: number) => [...productKeys.all, 'detail', id] as const, }; -function buildProductsQuery(params: ListProductsQuery): string { +const buildProductsQuery = (params: ListProductsQuery): string => { const search = new URLSearchParams(); search.set('page', String(params.page)); search.set('pageSize', String(params.pageSize)); @@ -24,25 +24,25 @@ function buildProductsQuery(params: ListProductsQuery): string { if (params.q != null && params.q.trim() !== '') search.set('q', params.q.trim()); const qs = search.toString(); return qs ? `?${qs}` : ''; -} +}; -export async function getProducts( +export const getProducts = async ( params: ListProductsQuery -): Promise { +): Promise => { const url = getApiUrl('/products') + buildProductsQuery(params); return request(url); -} +}; -export async function getProductById( +export const getProductById = async ( id: number -): Promise { +): Promise => { const url = getApiUrl(`/products/${id}`); return request(url); -} +}; -export async function createProduct( +export const createProduct = async ( body: CreateProductInput -): Promise { +): Promise => { const url = getApiUrl('/products'); return request(url, { method: 'POST', @@ -54,12 +54,12 @@ export async function createProduct( ...(body.imageUrl != null && body.imageUrl !== '' && { imageUrl: body.imageUrl }), }), }); -} +}; -export async function updateProduct( +export const updateProduct = async ( id: number, body: UpdateProductInput -): Promise { +): Promise => { const payload: Record = {}; if (body.name !== undefined) payload.name = body.name; if (body.status !== undefined) payload.status = body.status; @@ -77,9 +77,9 @@ export async function updateProduct( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); -} +}; -export async function deleteProduct(id: number): Promise { +export const deleteProduct = async (id: number): Promise => { const url = getApiUrl(`/products/${id}`); await request(url, { method: 'DELETE' }); -} +}; diff --git a/apps/web/src/api/upload.ts b/apps/web/src/api/upload.ts index 560d1a4..38d652a 100644 --- a/apps/web/src/api/upload.ts +++ b/apps/web/src/api/upload.ts @@ -1,7 +1,7 @@ import { getApiUrl, request } from '@/api/client'; import type { UploadResponse } from '@/api/types'; -export async function uploadProductImage(file: File): Promise { +export const uploadProductImage = async (file: File): Promise => { const formData = new FormData(); formData.append('file', file); const url = getApiUrl('/upload'); @@ -9,4 +9,4 @@ export async function uploadProductImage(file: File): Promise { method: 'POST', body: formData, }); -} +}; diff --git a/apps/web/src/components/DataBoundary.tsx b/apps/web/src/components/DataBoundary.tsx index 123dd49..5ec4e67 100644 --- a/apps/web/src/components/DataBoundary.tsx +++ b/apps/web/src/components/DataBoundary.tsx @@ -15,9 +15,8 @@ export interface DataBoundaryProps { className?: string; } -function getErrorMessage(error: unknown, fallback: string): string { - return error instanceof Error ? error.message : fallback; -} +const getErrorMessage = (error: unknown, fallback: string): string => + error instanceof Error ? error.message : fallback; export function DataBoundary({ isLoading, diff --git a/apps/web/src/context/NotificationContext.tsx b/apps/web/src/context/NotificationContext.tsx index 4d3e704..84a1099 100644 --- a/apps/web/src/context/NotificationContext.tsx +++ b/apps/web/src/context/NotificationContext.tsx @@ -7,17 +7,16 @@ const NotificationContext = createContext(undefined let antdAppApi: ReturnType | null = null; -export function getAntdAppApi(): ReturnType | null { - return antdAppApi; -} +export const getAntdAppApi = (): ReturnType | null => + antdAppApi; -export function useNotification(): NotificationApi { +export const useNotification = (): NotificationApi => { const notification = useContext(NotificationContext); if (notification === undefined) { throw new Error('useNotification must be used within NotificationProvider'); } return notification; -} +}; interface NotificationProviderProps { children: ReactNode; diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index f0c2a47..de756cc 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -1,10 +1,14 @@ import { useEffect, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; -export function useLocalStorage(key: string, initialValue: T) { +export const useLocalStorage = (key: string, initialValue: T): [T, Dispatch>] => { const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); - return item ? (JSON.parse(item) as T) : initialValue; + if (!item) return initialValue; + // Generic deserialization: caller is responsible for T matching stored shape + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON.parse returns unknown; T is trusted by caller + return JSON.parse(item) as T; } catch { return initialValue; } @@ -18,6 +22,6 @@ export function useLocalStorage(key: string, initialValue: T) { } }, [key, storedValue]); - return [storedValue, setStoredValue] as const; -} + return [storedValue, setStoredValue]; +}; diff --git a/apps/web/src/mocks/ecommerce/products.ts b/apps/web/src/mocks/ecommerce/products.ts index dcfe164..1aae4b0 100644 --- a/apps/web/src/mocks/ecommerce/products.ts +++ b/apps/web/src/mocks/ecommerce/products.ts @@ -2,11 +2,10 @@ import type { ProductListItem, Category, Brand } from '@/api/types'; import { STANDARD_ATTRIBUTE_DEFINITIONS } from '@/constants/attributeDefinitions'; import type { AttributeDefinition } from '@/constants/attributeDefinitions'; -function pickAttributeDefinitions(keys: string[]): AttributeDefinition[] { - return keys +const pickAttributeDefinitions = (keys: string[]): AttributeDefinition[] => + keys .map((key) => STANDARD_ATTRIBUTE_DEFINITIONS.find((d) => d.key === key)) - .filter(Boolean) as AttributeDefinition[]; -} + .filter((d): d is AttributeDefinition => d != null); export const categoryMocks: Category[] = [ { id: 1, name: 'Audio' }, diff --git a/apps/web/src/ui/notification.ts b/apps/web/src/ui/notification.ts index b1d362e..8e1f315 100644 --- a/apps/web/src/ui/notification.ts +++ b/apps/web/src/ui/notification.ts @@ -1,47 +1,46 @@ import { getAntdAppApi, type NotificationApi } from '@/context/NotificationContext'; -function getNotification(): NotificationApi | null { - return getAntdAppApi()?.notification ?? null; -} +const getNotification = (): NotificationApi | null => + getAntdAppApi()?.notification ?? null; /** * Show an error notification. * @param message - Main message (or description when only one arg) * @param description - Optional detail (when provided, message is used as title) */ -export function notifyError(message: string, description?: string): void { +export const notifyError = (message: string, description?: string): void => { getNotification()?.error({ message: description !== undefined ? message : 'Error', description: description ?? message, }); -} +}; /** * Show a success notification. */ -export function notifySuccess(message: string, description?: string): void { +export const notifySuccess = (message: string, description?: string): void => { getNotification()?.success({ message: description !== undefined ? message : 'Success', description: description ?? message, }); -} +}; /** * Show an info notification. */ -export function notifyInfo(message: string, description?: string): void { +export const notifyInfo = (message: string, description?: string): void => { getNotification()?.info({ message: description !== undefined ? message : 'Info', description: description ?? message, }); -} +}; /** * Show a warning notification. */ -export function notifyWarning(message: string, description?: string): void { +export const notifyWarning = (message: string, description?: string): void => { getNotification()?.warning({ message: description !== undefined ? message : 'Warning', description: description ?? message, }); -} +}; diff --git a/apps/web/src/views/InventoryAdjustmentPage.tsx b/apps/web/src/views/InventoryAdjustmentPage.tsx index aeb9419..9308309 100644 --- a/apps/web/src/views/InventoryAdjustmentPage.tsx +++ b/apps/web/src/views/InventoryAdjustmentPage.tsx @@ -23,7 +23,7 @@ import { inventoryAdjustmentTypeValues } from '@/constants/inventoryAdjustment'; import { DEFAULT_TABLE_PAGE_SIZE, DEFAULT_TABLE_PAGE_SIZE_OPTIONS } from '@/constants/pagination'; import { formatKeyValuePairs } from '@/utils/format'; -function formatDateTime(date: Date | string): string { +const formatDateTime = (date: Date | string): string => { const d = typeof date === 'string' ? new Date(date) : date; return d.toLocaleDateString('en-US', { month: 'short', @@ -32,7 +32,7 @@ function formatDateTime(date: Date | string): string { hour: '2-digit', minute: '2-digit', }); -} +}; export function InventoryAdjustmentPage(): React.ReactElement { const notification = useNotification(); diff --git a/apps/web/src/views/PriceHistoryPage.tsx b/apps/web/src/views/PriceHistoryPage.tsx index e77a95e..bec3b33 100644 --- a/apps/web/src/views/PriceHistoryPage.tsx +++ b/apps/web/src/views/PriceHistoryPage.tsx @@ -21,7 +21,7 @@ import { getProductById, getProducts, productKeys } from '@/api/products'; import { DEFAULT_TABLE_PAGE_SIZE, DEFAULT_TABLE_PAGE_SIZE_OPTIONS } from '@/constants/pagination'; import { formatPrice, formatKeyValuePairs } from '@/utils/format'; -function formatDateTime(date: Date | string): string { +const formatDateTime = (date: Date | string): string => { const d = typeof date === 'string' ? new Date(date) : date; return d.toLocaleDateString('en-US', { month: 'short', @@ -30,7 +30,7 @@ function formatDateTime(date: Date | string): string { hour: '2-digit', minute: '2-digit', }); -} +}; export function PriceHistoryPage(): React.ReactElement { const notification = useNotification(); diff --git a/apps/web/src/views/Products/index.tsx b/apps/web/src/views/Products/index.tsx index b076771..b681c31 100644 --- a/apps/web/src/views/Products/index.tsx +++ b/apps/web/src/views/Products/index.tsx @@ -56,10 +56,10 @@ const renderHeaderTitle = (label: string): React.ReactElement => ( const formatSkuId = (id: number): string => `SKU${String(id).padStart(3, '0')}`; -function getAttributesFromForm( +const getAttributesFromForm = ( raw: Record, definitions: AttributeDefinition[] -): Record { +): Record => { const result: Record = {}; for (const def of definitions) { const value = raw[def.key]?.trim() ?? ''; @@ -71,7 +71,7 @@ function getAttributesFromForm( } } return result; -} +}; function ProductImageUploadField({ form, diff --git a/eslint.config.js b/eslint.config.js index 73c1467..ad2ef88 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -95,6 +95,9 @@ export default defineConfig( '@typescript-eslint/consistent-type-assertions': 'off', }, }, + // Ecommerce: repository layer uses explicit Prisma GetPayload<{ include: ... }> return types + // (see inventory-adjustment.repository, product.repository, sku-price-history.repository) + // so services get fully typed rows and no file-level no-unsafe-* disables are needed. // Web: allow type assertions / unsafe assignment where necessary (env, API response, form attrs) { files: [