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:
@@ -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
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user