feat: add product variant management functionality

- Implemented CreateProductVariantDto and UpdateProductVariantDto for product variant data transfer.
- Developed ProductVariantsController to handle CRUD operations for product variants.
- Created ProductVariantsService to interact with the database using Prisma.
- Added ProductVariantsModule to encapsulate the product variant feature.
- Included response mapping for consistent API responses.
This commit is contained in:
2025-12-06 17:48:10 +03:30
parent 7cb9d7d037
commit a782d61890
68 changed files with 4155 additions and 3334 deletions
@@ -0,0 +1,145 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common'
import type { Request, Response } from 'express'
import { Prisma } from '../../generated/prisma/client'
@Catch()
export class PrismaExceptionFilter implements ExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const request = ctx.getRequest<Request>()
const now = new Date().toISOString()
// Helper: normalize various payload shapes into an array of text errors
const extractErrors = (payload: any): string[] => {
if (!payload) return []
// If payload is already an array of strings
if (Array.isArray(payload) && payload.every(p => typeof p === 'string')) {
return payload
}
// If payload itself is a string
if (typeof payload === 'string') return [payload]
// Common Nest ValidationPipe shape: { message: [...], error: 'Bad Request' }
if (typeof payload === 'object') {
if (Array.isArray(payload.message)) {
return payload.message.map(m => String(m))
}
// Zod/other validation libraries sometimes expose issues array
if (Array.isArray(payload.errors)) return payload.errors.map(e => String(e))
// If payload has a nested 'issues' array (zod)
if (Array.isArray(payload.issues))
return payload.issues.map(i => i.message ?? String(i))
// If payload has a 'message' string
if (typeof payload.message === 'string') return [payload.message]
}
// Fallback to stringifying the payload
return [String(payload)]
}
// If Nest already threw an HttpException, normalize and return
if (exception instanceof HttpException) {
const status = exception.getStatus()
const payload = exception.getResponse()
if (status === HttpStatus.BAD_REQUEST) {
const errors = extractErrors(payload)
return response.status(status).json({
statusCode: status,
timestamp: now,
path: request.url,
errors,
})
}
return response.status(status).json({
statusCode: status,
timestamp: now,
path: request.url,
...(typeof payload === 'object' ? payload : { message: payload }),
})
}
// Default values
let status = HttpStatus.INTERNAL_SERVER_ERROR
let message: any = 'Internal server error'
let errors: string[] | undefined = undefined
// Prisma errors often expose a `code` property (e.g., P2002)
const code = exception?.code
if (code) {
switch (code) {
case 'P2002':
// Unique constraint failed
status = HttpStatus.CONFLICT
message = 'Unique constraint failed'
break
case 'P2025':
// Record not found for update/delete
status = HttpStatus.NOT_FOUND
message = exception?.meta?.cause ?? 'Record not found'
break
default:
// Map other Prisma known errors to 400 Bad Request
status = HttpStatus.BAD_REQUEST
// try to extract readable errors (may contain newlines)
errors = (exception.message || String(exception))
.toString()
.split('\n')
.map(s => s.trim())
.filter(Boolean)
}
} else if (exception instanceof Prisma.PrismaClientValidationError) {
status = HttpStatus.BAD_REQUEST
errors = exception.message
.toString()
.split('\n')
.map(s => s.trim())
.filter(Boolean)
} else if (exception?.name === 'ValidationError' || exception?.name === 'ZodError') {
// Common validation libraries
status = HttpStatus.BAD_REQUEST
// ZodError exposes issues array
if (Array.isArray(exception.issues)) {
errors = exception.issues.map((i: any) => i.message ?? String(i))
} else {
errors = exception.message
? exception.message
.toString()
.split('\n')
.map((s: string) => s.trim())
.filter(Boolean)
: [String(exception)]
}
} else if (exception instanceof Error) {
// Generic JS error
status = HttpStatus.INTERNAL_SERVER_ERROR
message = exception.message
}
const body: any = {
statusCode: status,
timestamp: now,
path: request.url,
}
if (status === HttpStatus.BAD_REQUEST) {
body.errors = errors ?? [String(message)]
} else {
body.message = message
}
response.status(status).json(body)
}
}
@@ -4,6 +4,7 @@ import {
Injectable,
NestInterceptor,
} from '@nestjs/common'
import { Response } from 'express'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
@@ -28,98 +29,177 @@ export class ResponseMappingInterceptor implements NestInterceptor {
return next.handle().pipe(
map(data => {
const statusCode = response?.statusCode ?? 200
const statusCode: number = (response as Response)?.statusCode ?? 200
// Normalize list/paginated responses into { data, meta }
if (data && typeof data === 'object') {
if (Array.isArray((data as any).errors) && statusCode >= 400) {
return {
statusCode,
timestamp: new Date().toISOString(),
path: request?.url ?? '',
errors: (data as any).errors,
}
}
if ((data as any).error && statusCode >= 400) {
const e = (data as any).error
const errors =
typeof e === 'string' ? [e] : Array.isArray(e) ? e : [String(e)]
return {
statusCode,
timestamp: new Date().toISOString(),
path: request?.url ?? '',
errors,
}
}
}
// Normalize responses produced by ResponseMapper wrappers when present.
let payload = data
let meta: any = undefined
let computedStatus = statusCode
// Case: plain array -> treat as list
if (Array.isArray(data)) {
payload = data
meta = {
totalRecords: data.length,
totalPages: 1,
page: 1,
perPage: data.length,
if (data && typeof data === 'object' && (data as any).__mapped) {
const wrapped = data as any
switch (wrapped.__mapped) {
case 'create':
computedStatus = 201
payload = wrapped.data
break
case 'update':
computedStatus = 200
payload = wrapped.data
break
case 'single':
computedStatus = 200
payload = wrapped.data
break
case 'list':
payload = Array.isArray(wrapped.items) ? wrapped.items : []
meta = {
totalRecords: payload.length,
totalPages: 1,
page: 1,
perPage: payload.length,
}
break
case 'paginate': {
const items = Array.isArray(wrapped.items) ? wrapped.items : []
const total =
typeof wrapped.total === 'number' ? wrapped.total : items.length
const perPage = wrapped.perPage ?? wrapped.limit ?? items.length
const page = wrapped.page ?? 1
payload = items
meta = {
totalRecords: total,
totalPages: Math.max(1, Math.ceil(total / perPage)),
page,
perPage,
}
break
}
case 'sequelize': {
const items = Array.isArray(wrapped.rows) ? wrapped.rows : []
const total =
typeof wrapped.count === 'number' ? wrapped.count : items.length
const perPage = wrapped.perPage ?? wrapped.limit ?? items.length
const page = wrapped.page ?? 1
payload = items
meta = {
totalRecords: total,
totalPages: Math.max(1, Math.ceil(total / perPage)),
page,
perPage,
}
break
}
default:
// Unknown wrapper — pass through payload as-is
payload = wrapped
}
} else if (data && typeof data === 'object') {
// Common pagination shapes
// 1) { items: [...], total: N, page, perPage }
if (
Array.isArray((data as any).items) &&
typeof (data as any).total === 'number'
) {
const items = (data as any).items
const total = (data as any).total
const perPage = (data as any).perPage ?? (data as any).limit ?? items.length
const page = (data as any).page ?? 1
payload = items
} else {
// Fallbacks for common shapes (arrays, items/rows/data shapes)
if (Array.isArray(data)) {
payload = data
meta = {
totalRecords: total,
totalPages: Math.max(1, Math.ceil(total / perPage)),
page,
perPage,
totalRecords: data.length,
totalPages: 1,
page: 1,
perPage: data.length,
}
// 2) Sequelize-like: { rows: [...], count: N }
} else if (
Array.isArray((data as any).rows) &&
typeof (data as any).count === 'number'
) {
const items = (data as any).rows
const total = (data as any).count
const perPage = (data as any).limit ?? (data as any).perPage ?? items.length
const page = (data as any).page ?? 1
payload = items
meta = {
totalRecords: total,
totalPages: Math.max(1, Math.ceil(total / perPage)),
page,
perPage,
}
// 3) { data: [...], total, page, perPage }
} else if (
Array.isArray((data as any).data) &&
typeof (data as any).total === 'number'
) {
const items = (data as any).data
const total = (data as any).total
const perPage = (data as any).perPage ?? (data as any).limit ?? items.length
const page = (data as any).page ?? 1
payload = items
meta = {
totalRecords: total,
totalPages: Math.max(1, Math.ceil(total / perPage)),
page,
perPage,
}
// 4) fallback: object contains items array and optional total
} else if (Array.isArray((data as any).items)) {
const items = (data as any).items
const total =
typeof (data as any).total === 'number' ? (data as any).total : items.length
const perPage = (data as any).perPage ?? (data as any).limit ?? items.length
const page = (data as any).page ?? 1
payload = items
meta = {
totalRecords: total,
totalPages: Math.max(1, Math.ceil(total / perPage)),
page,
perPage,
} else if (data && typeof data === 'object') {
// Common pagination shapes
if (
Array.isArray((data as any).items) &&
typeof (data as any).total === 'number'
) {
const items = (data as any).items
const total = (data as any).total
const perPage = (data as any).perPage ?? (data as any).limit ?? items.length
const page = (data as any).page ?? 1
payload = items
meta = {
totalRecords: total,
totalPages: Math.max(1, Math.ceil(total / perPage)),
page,
perPage,
}
} else if (
Array.isArray((data as any).rows) &&
typeof (data as any).count === 'number'
) {
const items = (data as any).rows
const total = (data as any).count
const perPage = (data as any).limit ?? (data as any).perPage ?? items.length
const page = (data as any).page ?? 1
payload = items
meta = {
totalRecords: total,
totalPages: Math.max(1, Math.ceil(total / perPage)),
page,
perPage,
}
} else if (
Array.isArray((data as any).data) &&
typeof (data as any).total === 'number'
) {
const items = (data as any).data
const total = (data as any).total
const perPage = (data as any).perPage ?? (data as any).limit ?? items.length
const page = (data as any).page ?? 1
payload = items
meta = {
totalRecords: total,
totalPages: Math.max(1, Math.ceil(total / perPage)),
page,
perPage,
}
} else if (Array.isArray((data as any).items)) {
const items = (data as any).items
const total =
typeof (data as any).total === 'number'
? (data as any).total
: items.length
const perPage = (data as any).perPage ?? (data as any).limit ?? items.length
const page = (data as any).page ?? 1
payload = items
meta = {
totalRecords: total,
totalPages: Math.max(1, Math.ceil(total / perPage)),
page,
perPage,
}
}
}
}
const envelope: any = {
statusCode,
timestamp: new Date().toISOString(),
path: request?.url ?? '',
data: payload,
}
if (meta)
return {
data: data.items,
meta,
}
if (meta) envelope.meta = meta
return envelope
return data.data || data.items
}),
)
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Response mapper helpers
*
* Services should use these helpers to return predictable shapes that
* the global `ResponseMappingInterceptor` understands and normalizes.
*/
export type MapperWrapper<T> =
| { __mapped: 'create' | 'update' | 'single'; data: T }
| { __mapped: 'list'; items: T[] }
| { __mapped: 'paginate'; items: T[]; total: number; page?: number; perPage?: number }
| { __mapped: 'sequelize'; rows: T[]; count: number; page?: number; perPage?: number }
export const ResponseMapper = {
create<T>(item: T): MapperWrapper<T> {
return { __mapped: 'create', data: item }
},
update<T>(item: T): MapperWrapper<T> {
return { __mapped: 'update', data: item }
},
single<T>(item: T): MapperWrapper<T> {
return { __mapped: 'single', data: item }
},
list<T>(items: T[]): MapperWrapper<T> {
return { __mapped: 'list', items }
},
paginate<T>(items: T[], total: number, page = 1, perPage = items.length) {
return { __mapped: 'paginate', items, total, page, perPage }
},
sequelizePaginate<T>(rows: T[], count: number, page = 1, perPage = rows.length) {
return { __mapped: 'sequelize', rows, count, page, perPage }
},
}
export type Paginated<T> = ReturnType<typeof ResponseMapper.paginate>