x
This commit is contained in:
1 parent
75770ca122
commit
f258e3e176
16 files changed
+1207
-348
No files matched your search
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProductImage" ADD COLUMN "mobileUrl" TEXT;
|
||||
@@ -118,6 +118,7 @@ model ProductTranslation {
|
||||
model ProductImage {
|
||||
id Int @id @default(autoincrement())
|
||||
url String
|
||||
mobileUrl String?
|
||||
order Int @default(0)
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
assertProductImageIdsBelongToProduct,
|
||||
normalizeProductImageInput,
|
||||
normalizeProductImagesInput,
|
||||
ProductImageConflictError,
|
||||
ProductImagePayloadError,
|
||||
} from './productImages'
|
||||
|
||||
describe('product image request normalization', () => {
|
||||
test('normalizes mixed legacy and paired image arrays', () => {
|
||||
expect(normalizeProductImagesInput([
|
||||
'/uploads/products/legacy.webp',
|
||||
{
|
||||
url: '/uploads/products/desktop.webp',
|
||||
mobileUrl: '/uploads/products/mobile.webp',
|
||||
order: 4,
|
||||
},
|
||||
])).toEqual([
|
||||
{ url: '/uploads/products/legacy.webp' },
|
||||
{
|
||||
url: '/uploads/products/desktop.webp',
|
||||
mobileUrl: '/uploads/products/mobile.webp',
|
||||
order: 4,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('preserves omitted mobile URLs and normalizes empty values to null', () => {
|
||||
expect(normalizeProductImageInput({ url: '/desktop.webp' }).mobileUrl).toBeUndefined()
|
||||
expect(normalizeProductImageInput({ url: '/desktop.webp', mobileUrl: '' }).mobileUrl).toBeNull()
|
||||
})
|
||||
|
||||
test('returns indexed validation detail for invalid URL types', () => {
|
||||
try {
|
||||
normalizeProductImagesInput([{ url: '/desktop.webp' }, { url: false }])
|
||||
throw new Error('Expected normalization to fail')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ProductImagePayloadError)
|
||||
expect((error as ProductImagePayloadError).issues[0]?.path).toBe('images.1.url')
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects duplicate and foreign image IDs before destructive synchronization', () => {
|
||||
expect(() => assertProductImageIdsBelongToProduct([1, 2], [1, 1])).toThrow(ProductImageConflictError)
|
||||
expect(() => assertProductImageIdsBelongToProduct([1, 2], [1, 3])).toThrow(ProductImageConflictError)
|
||||
expect(() => assertProductImageIdsBelongToProduct([1, 2], [2, 1])).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
ProductImageInputSchema,
|
||||
type NormalizedProductImageInput,
|
||||
} from '@homaroma/types'
|
||||
|
||||
export class ProductImagePayloadError extends Error {
|
||||
readonly code = 'INVALID_PRODUCT_IMAGES'
|
||||
readonly issues: Array<{ path: string; message: string }>
|
||||
|
||||
constructor(index: number, issues: Array<{ path: PropertyKey[]; message: string; errors?: unknown }>) {
|
||||
super(`Invalid product image at index ${index}`)
|
||||
this.name = 'ProductImagePayloadError'
|
||||
const flattenedIssues = flattenValidationIssues(issues)
|
||||
const detailedIssues = flattenedIssues.some((issue) => issue.path.length > 0)
|
||||
? flattenedIssues.filter((issue) => issue.path.length > 0)
|
||||
: flattenedIssues
|
||||
this.issues = detailedIssues.map((issue) => ({
|
||||
path: ['images', index, ...issue.path].join('.'),
|
||||
message: issue.message,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductImageConflictError extends Error {
|
||||
readonly code = 'PRODUCT_IMAGE_CONFLICT'
|
||||
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ProductImageConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
function flattenValidationIssues(
|
||||
issues: Array<{ path: PropertyKey[]; message: string; errors?: unknown }>,
|
||||
): Array<{ path: PropertyKey[]; message: string }> {
|
||||
return issues.flatMap((issue) => {
|
||||
if (!Array.isArray(issue.errors)) {
|
||||
return [{ path: issue.path, message: issue.message }]
|
||||
}
|
||||
|
||||
const nested = issue.errors.flatMap((branch) =>
|
||||
Array.isArray(branch)
|
||||
? flattenValidationIssues(branch as Array<{ path: PropertyKey[]; message: string; errors?: unknown }>)
|
||||
: [],
|
||||
)
|
||||
return nested.length > 0 ? nested : [{ path: issue.path, message: issue.message }]
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeProductImageInput(
|
||||
input: unknown,
|
||||
index = 0,
|
||||
): NormalizedProductImageInput {
|
||||
const result = ProductImageInputSchema.safeParse(input)
|
||||
if (!result.success) {
|
||||
throw new ProductImagePayloadError(index, result.error.issues)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
export function normalizeProductImagesInput(input: unknown): NormalizedProductImageInput[] {
|
||||
if (!Array.isArray(input)) {
|
||||
throw new ProductImagePayloadError(0, [{
|
||||
path: [],
|
||||
message: 'Product images must be an array',
|
||||
}])
|
||||
}
|
||||
return input.map((image, index) => normalizeProductImageInput(image, index))
|
||||
}
|
||||
|
||||
export function assertProductImageIdsBelongToProduct(
|
||||
existingIds: number[],
|
||||
incomingIds: number[],
|
||||
) {
|
||||
if (new Set(incomingIds).size !== incomingIds.length) {
|
||||
throw new ProductImageConflictError('Product image IDs must be unique')
|
||||
}
|
||||
|
||||
const existing = new Set(existingIds)
|
||||
const unknownIds = incomingIds.filter((id) => !existing.has(id))
|
||||
if (unknownIds.length > 0) {
|
||||
throw new ProductImageConflictError(`Product images changed or do not belong to this product: ${unknownIds.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function productImageErrorResponse(error: ProductImagePayloadError) {
|
||||
return {
|
||||
success: false as const,
|
||||
message: 'Invalid product images',
|
||||
issues: error.issues,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
normalizeProductTranslationCreate,
|
||||
normalizeProductTranslationUpdate,
|
||||
ProductTranslationPayloadError,
|
||||
syncProductTranslations,
|
||||
} from './productTranslations'
|
||||
|
||||
describe('product translation update normalization', () => {
|
||||
test('normalizes multilingual objects and syncs the Thai legacy fields', () => {
|
||||
expect(normalizeProductTranslationUpdate({
|
||||
name: { th: 'ชื่อสินค้า', en: 'Product name' },
|
||||
description: { th: 'คำอธิบาย', ja: '商品説明' },
|
||||
})).toEqual({
|
||||
base: { name: 'ชื่อสินค้า', description: 'คำอธิบาย' },
|
||||
byLanguage: {
|
||||
th: { name: 'ชื่อสินค้า', description: 'คำอธิบาย' },
|
||||
en: { name: 'Product name' },
|
||||
ja: { description: '商品説明' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('treats strings as Thai values', () => {
|
||||
expect(normalizeProductTranslationUpdate({
|
||||
name: 'ชื่อใหม่',
|
||||
details: 'รายละเอียดใหม่',
|
||||
})).toEqual({
|
||||
base: { name: 'ชื่อใหม่', details: 'รายละเอียดใหม่' },
|
||||
byLanguage: {
|
||||
th: { name: 'ชื่อใหม่', details: 'รายละเอียดใหม่' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves omitted fields and locales', () => {
|
||||
expect(normalizeProductTranslationUpdate({ description: { en: 'Updated only' } })).toEqual({
|
||||
base: {},
|
||||
byLanguage: { en: { description: 'Updated only' } },
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves explicit empty strings as intentional updates', () => {
|
||||
expect(normalizeProductTranslationUpdate({
|
||||
name: { th: '' },
|
||||
description: '',
|
||||
details: { en: '' },
|
||||
})).toEqual({
|
||||
base: { name: '', description: '' },
|
||||
byLanguage: {
|
||||
th: { name: '', description: '' },
|
||||
en: { details: '' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
['__proto__', JSON.parse('{"name":{"__proto__":"polluted"}}')],
|
||||
['constructor', { name: { constructor: 'polluted' } }],
|
||||
['fr', { name: { fr: 'Nom du produit' } }],
|
||||
])('rejects unsupported or unsafe locale key %s', (locale, payload) => {
|
||||
expect(() => normalizeProductTranslationUpdate(payload)).toThrow(ProductTranslationPayloadError)
|
||||
try {
|
||||
normalizeProductTranslationUpdate(payload)
|
||||
throw new Error('Expected translation normalization to fail')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ProductTranslationPayloadError)
|
||||
expect((error as ProductTranslationPayloadError).code).toBe('INVALID_PRODUCT_TRANSLATIONS')
|
||||
expect((error as ProductTranslationPayloadError).issues[0]?.path).toBe(`name.${locale}`)
|
||||
}
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
||||
expect((Object.prototype as Record<string, unknown>).name).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('product translation transaction synchronization', () => {
|
||||
test('writes every locale through the supplied transaction client', async () => {
|
||||
const calls: unknown[] = []
|
||||
const tx = {
|
||||
productTranslation: {
|
||||
upsert: async (args: unknown) => {
|
||||
calls.push(args)
|
||||
return args
|
||||
},
|
||||
},
|
||||
}
|
||||
const plan = normalizeProductTranslationUpdate({
|
||||
name: { th: 'ชื่อใหม่', en: 'New name' },
|
||||
description: { en: '' },
|
||||
})
|
||||
|
||||
await syncProductTranslations(tx as never, 2, plan, 'ชื่อเดิม')
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls).toContainEqual({
|
||||
where: { productId_langCode: { productId: 2, langCode: 'th' } },
|
||||
update: { name: 'ชื่อใหม่' },
|
||||
create: {
|
||||
productId: 2,
|
||||
langCode: 'th',
|
||||
name: 'ชื่อใหม่',
|
||||
},
|
||||
})
|
||||
expect(calls).toContainEqual({
|
||||
where: { productId_langCode: { productId: 2, langCode: 'en' } },
|
||||
update: { name: 'New name', description: '' },
|
||||
create: {
|
||||
productId: 2,
|
||||
langCode: 'en',
|
||||
name: 'New name',
|
||||
description: '',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('uses the current product name when creating a description-only locale', async () => {
|
||||
let call: any
|
||||
const tx = {
|
||||
productTranslation: {
|
||||
upsert: async (args: unknown) => {
|
||||
call = args
|
||||
return args
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await syncProductTranslations(
|
||||
tx as never,
|
||||
2,
|
||||
normalizeProductTranslationUpdate({ description: { ja: '説明' } }),
|
||||
'Fallback name',
|
||||
)
|
||||
|
||||
expect(call.create.name).toBe('Fallback name')
|
||||
expect(call.update).toEqual({ description: '説明' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('product translation create normalization', () => {
|
||||
test('preserves nested translations and lets direct localized fields take precedence', () => {
|
||||
expect(normalizeProductTranslationCreate({
|
||||
name: { th: 'ชื่อไทย', en: 'Direct English' },
|
||||
description: { th: 'คำอธิบายไทย' },
|
||||
translations: {
|
||||
en: { name: 'Nested English', description: 'Nested description' },
|
||||
ja: { name: '日本語名' },
|
||||
},
|
||||
})).toEqual({
|
||||
base: { name: 'ชื่อไทย', description: 'คำอธิบายไทย', details: '' },
|
||||
byLanguage: {
|
||||
en: { name: 'Direct English', description: 'Nested description', details: '' },
|
||||
ja: { name: '日本語名', description: 'คำอธิบายไทย', details: '' },
|
||||
th: { name: 'ชื่อไทย', description: 'คำอธิบายไทย', details: '' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('creates the legacy Thai fallback row when no translations are supplied', () => {
|
||||
expect(normalizeProductTranslationCreate({ name: 'New product' })).toEqual({
|
||||
base: { name: 'New product', description: '', details: '' },
|
||||
byLanguage: {
|
||||
th: { name: 'New product', description: '', details: '' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('does not add a Thai row for string fields when nested translations select other locales', () => {
|
||||
expect(normalizeProductTranslationCreate({
|
||||
name: 'Fallback product',
|
||||
translations: { en: { name: 'English product' } },
|
||||
})).toEqual({
|
||||
base: { name: 'Fallback product', description: '', details: '' },
|
||||
byLanguage: {
|
||||
en: { name: 'English product', description: '', details: '' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.each(['__proto__', 'constructor', 'fr'])(
|
||||
'rejects unsafe or unsupported nested translation locale %s',
|
||||
(locale) => {
|
||||
const payload = JSON.parse(`{"translations":{"${locale}":{"name":"unsafe"}}}`)
|
||||
expect(() => normalizeProductTranslationCreate(payload)).toThrow(ProductTranslationPayloadError)
|
||||
expect((Object.prototype as Record<string, unknown>).name).toBeUndefined()
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const TRANSLATABLE_FIELDS = ['name', 'description', 'details'] as const
|
||||
export const PRODUCT_TRANSLATION_LOCALES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] as const
|
||||
const PRODUCT_TRANSLATION_LOCALE_SET = new Set<string>(PRODUCT_TRANSLATION_LOCALES)
|
||||
|
||||
type TranslatableField = (typeof TRANSLATABLE_FIELDS)[number]
|
||||
type TranslationValues = Partial<Record<TranslatableField, string>>
|
||||
|
||||
interface ProductTranslationCreatePlan {
|
||||
base: Required<TranslationValues>
|
||||
byLanguage: Record<string, Required<TranslationValues>>
|
||||
}
|
||||
|
||||
export interface ProductTranslationUpdatePlan {
|
||||
base: TranslationValues
|
||||
byLanguage: Record<string, TranslationValues>
|
||||
}
|
||||
|
||||
export class ProductTranslationPayloadError extends Error {
|
||||
readonly code = 'INVALID_PRODUCT_TRANSLATIONS'
|
||||
readonly issues: Array<{ path: string; message: string }>
|
||||
|
||||
constructor(path: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'ProductTranslationPayloadError'
|
||||
this.issues = [{ path, message }]
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function assertSupportedLocale(fieldPath: string, langCode: string) {
|
||||
if (!PRODUCT_TRANSLATION_LOCALE_SET.has(langCode)) {
|
||||
throw new ProductTranslationPayloadError(
|
||||
`${fieldPath}.${langCode}`,
|
||||
`Unsupported product translation locale: ${langCode}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function legacyBaseValue(rawValue: unknown, fallback: string) {
|
||||
if (typeof rawValue === 'string') return rawValue
|
||||
if (!isRecord(rawValue)) return fallback
|
||||
const thai = typeof rawValue.th === 'string' ? rawValue.th : ''
|
||||
const english = typeof rawValue.en === 'string' ? rawValue.en : ''
|
||||
return thai || english || fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the two request shapes supported by product writes:
|
||||
* - a string updates Thai
|
||||
* - an object updates only the explicitly supplied locales
|
||||
*
|
||||
* Empty strings are intentional values. Missing fields/locales are excluded so
|
||||
* partial updates never erase translations that were not sent by the caller.
|
||||
*/
|
||||
export function normalizeProductTranslationUpdate(
|
||||
payload: Record<string, unknown>,
|
||||
): ProductTranslationUpdatePlan {
|
||||
const plan: ProductTranslationUpdatePlan = {
|
||||
base: {},
|
||||
byLanguage: Object.create(null) as Record<string, TranslationValues>,
|
||||
}
|
||||
|
||||
for (const field of TRANSLATABLE_FIELDS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(payload, field)) continue
|
||||
|
||||
const rawValue = payload[field]
|
||||
if (typeof rawValue === 'string') {
|
||||
plan.byLanguage.th ??= {}
|
||||
plan.byLanguage.th[field] = rawValue
|
||||
plan.base[field] = rawValue
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isRecord(rawValue)) continue
|
||||
for (const [langCode, localizedValue] of Object.entries(rawValue)) {
|
||||
assertSupportedLocale(field, langCode)
|
||||
if (typeof localizedValue !== 'string') continue
|
||||
plan.byLanguage[langCode] ??= {}
|
||||
plan.byLanguage[langCode][field] = localizedValue
|
||||
if (langCode === 'th') plan.base[field] = localizedValue
|
||||
}
|
||||
}
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
/** Normalizes the legacy create payload, including its optional nested
|
||||
* `translations` object. Direct name/description/details values retain
|
||||
* precedence, matching the previous create behavior. */
|
||||
export function normalizeProductTranslationCreate(
|
||||
payload: Record<string, unknown>,
|
||||
): ProductTranslationCreatePlan {
|
||||
const direct = normalizeProductTranslationUpdate(payload)
|
||||
const base = {
|
||||
name: legacyBaseValue(payload.name, 'New Product'),
|
||||
description: legacyBaseValue(payload.description, ''),
|
||||
details: legacyBaseValue(payload.details, ''),
|
||||
}
|
||||
|
||||
const pending = Object.create(null) as Record<string, TranslationValues>
|
||||
if (isRecord(payload.translations)) {
|
||||
for (const [langCode, rawTranslation] of Object.entries(payload.translations)) {
|
||||
assertSupportedLocale('translations', langCode)
|
||||
if (!isRecord(rawTranslation)) continue
|
||||
const values: TranslationValues = {}
|
||||
for (const field of TRANSLATABLE_FIELDS) {
|
||||
if (typeof rawTranslation[field] === 'string') values[field] = rawTranslation[field]
|
||||
}
|
||||
if (Object.keys(values).length > 0) pending[langCode] = values
|
||||
}
|
||||
}
|
||||
|
||||
for (const [langCode, values] of Object.entries(direct.byLanguage)) {
|
||||
const objectLocalizedValues: TranslationValues = {}
|
||||
for (const field of TRANSLATABLE_FIELDS) {
|
||||
if (isRecord(payload[field]) && values[field] !== undefined) {
|
||||
objectLocalizedValues[field] = values[field]
|
||||
}
|
||||
}
|
||||
if (Object.keys(objectLocalizedValues).length > 0) {
|
||||
pending[langCode] = { ...(pending[langCode] ?? {}), ...objectLocalizedValues }
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(pending).length === 0) pending.th = { ...base }
|
||||
|
||||
const byLanguage = Object.create(null) as Record<string, Required<TranslationValues>>
|
||||
for (const [langCode, values] of Object.entries(pending)) {
|
||||
byLanguage[langCode] = {
|
||||
name: values.name || base.name,
|
||||
description: values.description || base.description,
|
||||
details: values.details || base.details,
|
||||
}
|
||||
}
|
||||
|
||||
return { base, byLanguage }
|
||||
}
|
||||
|
||||
export function productTranslationErrorResponse(error: ProductTranslationPayloadError) {
|
||||
return {
|
||||
success: false as const,
|
||||
code: error.code,
|
||||
message: 'Invalid product translations',
|
||||
issues: error.issues,
|
||||
}
|
||||
}
|
||||
|
||||
type ProductTranslationWriter = Pick<Prisma.TransactionClient, 'productTranslation'>
|
||||
|
||||
/**
|
||||
* Applies a normalized plan through the caller's transaction client. Keeping
|
||||
* this helper transaction-client-only prevents product and translation writes
|
||||
* from accidentally being split across independent requests/transactions.
|
||||
*/
|
||||
export async function syncProductTranslations(
|
||||
tx: ProductTranslationWriter,
|
||||
productId: number,
|
||||
plan: ProductTranslationUpdatePlan,
|
||||
fallbackName: string | null | undefined,
|
||||
) {
|
||||
for (const [langCode, values] of Object.entries(plan.byLanguage)) {
|
||||
await tx.productTranslation.upsert({
|
||||
where: { productId_langCode: { productId, langCode } },
|
||||
update: values,
|
||||
create: {
|
||||
productId,
|
||||
langCode,
|
||||
name: values.name ?? fallbackName ?? '',
|
||||
...(values.description !== undefined ? { description: values.description } : {}),
|
||||
...(values.details !== undefined ? { details: values.details } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import { adminOnly } from "../middleware/auth";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import {
|
||||
normalizeProductImagesInput,
|
||||
productImageErrorResponse,
|
||||
ProductImagePayloadError,
|
||||
} from "../lib/productImages";
|
||||
|
||||
// ─── Helper: stream JSON as file download ────────────────────────────────────
|
||||
|
||||
@@ -828,6 +833,18 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
||||
return { error: "Invalid catalog payload: Expected object with categories, attributes, brands, or products" };
|
||||
}
|
||||
|
||||
// Validate every product image payload before the restore performs any writes.
|
||||
// This keeps malformed paired-image data from causing a partially restored catalog.
|
||||
const products = Array.isArray(payload.products)
|
||||
? payload.products
|
||||
: (Array.isArray(payload) && payload[0]?.variants ? payload : []);
|
||||
const normalizedImagesByProduct = new Map<any, ReturnType<typeof normalizeProductImagesInput>>();
|
||||
for (const product of products) {
|
||||
if (product?.images !== undefined) {
|
||||
normalizedImagesByProduct.set(product, normalizeProductImagesInput(product.images));
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
categories: 0,
|
||||
attributes: 0,
|
||||
@@ -1010,10 +1027,6 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
||||
}
|
||||
|
||||
// 4. Products
|
||||
const products = Array.isArray(payload.products)
|
||||
? payload.products
|
||||
: (Array.isArray(payload) && payload[0]?.variants ? payload : []);
|
||||
|
||||
for (const prod of products) {
|
||||
if (!prod.name) continue;
|
||||
const catId = prod.categoryId ? (categoryMap.get(prod.categoryId) || prod.categoryId) : defaultCatId;
|
||||
@@ -1088,20 +1101,19 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
||||
}
|
||||
|
||||
// Images
|
||||
if (Array.isArray(prod.images) && prod.images.length > 0) {
|
||||
if (prod.images !== undefined) {
|
||||
const images = normalizedImagesByProduct.get(prod)!;
|
||||
await prisma.productImage.deleteMany({ where: { productId: savedProduct.id } });
|
||||
for (let i = 0; i < prod.images.length; i++) {
|
||||
const img = prod.images[i];
|
||||
const url = typeof img === "string" ? img : img.url;
|
||||
if (url) {
|
||||
await prisma.productImage.create({
|
||||
data: {
|
||||
productId: savedProduct.id,
|
||||
url,
|
||||
order: typeof img === "object" && img.order !== undefined ? img.order : i,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i]!;
|
||||
await prisma.productImage.create({
|
||||
data: {
|
||||
productId: savedProduct.id,
|
||||
url: img.url,
|
||||
mobileUrl: img.mobileUrl ?? null,
|
||||
order: img.order ?? i,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1142,6 +1154,10 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
||||
summary,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err instanceof ProductImagePayloadError) {
|
||||
set.status = 400;
|
||||
return productImageErrorResponse(err);
|
||||
}
|
||||
console.error("[Backup] Catalog restore error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Catalog restore failed", detail: err?.message };
|
||||
|
||||
@@ -9,6 +9,21 @@ import {
|
||||
validateProductDetailSectionPayload,
|
||||
} from "../lib/productDetailSections";
|
||||
import { hydrateSectionPayload } from "./pages";
|
||||
import {
|
||||
assertProductImageIdsBelongToProduct,
|
||||
normalizeProductImageInput,
|
||||
normalizeProductImagesInput,
|
||||
productImageErrorResponse,
|
||||
ProductImageConflictError,
|
||||
ProductImagePayloadError,
|
||||
} from "../lib/productImages";
|
||||
import {
|
||||
normalizeProductTranslationCreate,
|
||||
normalizeProductTranslationUpdate,
|
||||
productTranslationErrorResponse,
|
||||
ProductTranslationPayloadError,
|
||||
syncProductTranslations,
|
||||
} from "../lib/productTranslations";
|
||||
|
||||
function summarizeValidationIssues(issues: any[]) {
|
||||
return issues.map((issue) => ({
|
||||
@@ -72,11 +87,11 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Normalize Name, Description, Details
|
||||
const rawName = payload.name;
|
||||
const pName = typeof rawName === 'string' ? rawName : (rawName?.th || rawName?.en || "New Product");
|
||||
const pDesc = typeof payload.description === 'string' ? payload.description : (payload.description?.th || payload.description?.en || "");
|
||||
const pDetails = typeof payload.details === 'string' ? payload.details : (payload.details?.th || payload.details?.en || "");
|
||||
// 3. Normalize Name, Description, Details and translation rows
|
||||
const translationPlan = normalizeProductTranslationCreate(payload);
|
||||
const pName = translationPlan.base.name;
|
||||
const pDesc = translationPlan.base.description;
|
||||
const pDetails = translationPlan.base.details;
|
||||
|
||||
// 4. Resolve Variants and Options
|
||||
let variants = Array.isArray(payload.variants) ? payload.variants : [];
|
||||
@@ -110,46 +125,14 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
|
||||
});
|
||||
|
||||
// 6. Handle Translations
|
||||
const transObj = payload.translations && typeof payload.translations === 'object' ? { ...payload.translations } : {};
|
||||
if (typeof rawName === 'object' && rawName !== null) {
|
||||
for (const [lang, val] of Object.entries(rawName)) {
|
||||
if (!transObj[lang]) transObj[lang] = {};
|
||||
transObj[lang].name = val;
|
||||
}
|
||||
}
|
||||
if (typeof payload.description === 'object' && payload.description !== null) {
|
||||
for (const [lang, val] of Object.entries(payload.description)) {
|
||||
if (!transObj[lang]) transObj[lang] = {};
|
||||
transObj[lang].description = val;
|
||||
}
|
||||
}
|
||||
if (typeof payload.details === 'object' && payload.details !== null) {
|
||||
for (const [lang, val] of Object.entries(payload.details)) {
|
||||
if (!transObj[lang]) transObj[lang] = {};
|
||||
transObj[lang].details = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(transObj).length > 0) {
|
||||
for (const [lang, trans] of Object.entries(transObj)) {
|
||||
await tx.productTranslation.create({
|
||||
data: {
|
||||
productId: prod.id,
|
||||
langCode: lang,
|
||||
name: (trans as any).name || pName,
|
||||
description: (trans as any).description || pDesc,
|
||||
details: (trans as any).details || pDetails,
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const [lang, trans] of Object.entries(translationPlan.byLanguage)) {
|
||||
await tx.productTranslation.create({
|
||||
data: {
|
||||
productId: prod.id,
|
||||
langCode: 'th',
|
||||
name: pName,
|
||||
description: pDesc,
|
||||
details: pDetails,
|
||||
langCode: lang,
|
||||
name: trans.name,
|
||||
description: trans.description,
|
||||
details: trans.details,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -171,18 +154,17 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
|
||||
}
|
||||
|
||||
// 8. Create Images (support both ["http..."] and [{ url: "http..." }])
|
||||
if (payload.images && Array.isArray(payload.images)) {
|
||||
for (const [idx, img] of payload.images.entries()) {
|
||||
const url = typeof img === 'string' ? img : img?.url;
|
||||
if (url) {
|
||||
await tx.productImage.create({
|
||||
data: {
|
||||
productId: prod.id,
|
||||
url,
|
||||
order: typeof img === 'object' && typeof img?.order === 'number' ? img.order : idx,
|
||||
}
|
||||
});
|
||||
}
|
||||
if (payload.images !== undefined) {
|
||||
const images = normalizeProductImagesInput(payload.images);
|
||||
for (const [idx, img] of images.entries()) {
|
||||
await tx.productImage.create({
|
||||
data: {
|
||||
productId: prod.id,
|
||||
url: img.url,
|
||||
mobileUrl: img.mobileUrl ?? null,
|
||||
order: img.order ?? idx,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,10 +651,15 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
const { categoryId, description, details, variants, images } = body as any;
|
||||
let options = (body as any).options;
|
||||
const payload = body as any;
|
||||
const { categoryId, variants, images } = payload;
|
||||
let options = payload.options;
|
||||
|
||||
try {
|
||||
const normalizedImages = images === undefined
|
||||
? undefined
|
||||
: normalizeProductImagesInput(images);
|
||||
const translationPlan = normalizeProductTranslationUpdate(payload);
|
||||
if (variants && Array.isArray(variants)) {
|
||||
// If options is not passed but variants are, we might need to fetch existing options
|
||||
if (options === undefined) {
|
||||
@@ -683,7 +670,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
}
|
||||
const updatedProduct = await prisma.$transaction(async (tx) => {
|
||||
// 1. Update basic product info
|
||||
const updateData: any = {};
|
||||
const updateData: any = { ...translationPlan.base };
|
||||
if (categoryId !== undefined) {
|
||||
await assertActiveCategory(categoryId, tx);
|
||||
updateData.categoryId = categoryId;
|
||||
@@ -694,6 +681,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
await syncProductTranslations(tx, id, translationPlan, prod.name);
|
||||
|
||||
// 2. Sync Variants
|
||||
if (variants && Array.isArray(variants)) {
|
||||
@@ -757,9 +745,10 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
}
|
||||
|
||||
// 3. Sync Images
|
||||
if (images && Array.isArray(images)) {
|
||||
if (normalizedImages) {
|
||||
const existingImages = await tx.productImage.findMany({ where: { productId: id } });
|
||||
const incomingImgIds = images.map((img: any) => img.id).filter(Boolean);
|
||||
const incomingImgIds = normalizedImages.map((img) => img.id).filter((imageId): imageId is number => imageId !== undefined);
|
||||
assertProductImageIdsBelongToProduct(existingImages.map((image) => image.id), incomingImgIds);
|
||||
|
||||
const imagesToDelete = existingImages.filter(ei => !incomingImgIds.includes(ei.id));
|
||||
if (imagesToDelete.length > 0) {
|
||||
@@ -768,7 +757,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
});
|
||||
}
|
||||
|
||||
for (const [idx, img] of images.entries()) {
|
||||
for (const [idx, img] of normalizedImages.entries()) {
|
||||
if (img.id) {
|
||||
const exists = existingImages.find(ei => ei.id === img.id);
|
||||
if (exists) {
|
||||
@@ -776,7 +765,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
where: { id: img.id },
|
||||
data: {
|
||||
url: img.url,
|
||||
order: img.order ?? (typeof img.order === 'number' ? img.order : idx),
|
||||
mobileUrl: img.mobileUrl,
|
||||
order: img.order ?? idx,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -785,7 +775,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
data: {
|
||||
productId: id,
|
||||
url: img.url,
|
||||
order: img.order ?? (typeof img.order === 'number' ? img.order : idx),
|
||||
mobileUrl: img.mobileUrl,
|
||||
order: img.order ?? idx,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -797,6 +788,15 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
|
||||
return { success: true, product: updatedProduct };
|
||||
} catch (err: any) {
|
||||
if (err instanceof ProductTranslationPayloadError) {
|
||||
return status(400, productTranslationErrorResponse(err));
|
||||
}
|
||||
if (err instanceof ProductImageConflictError) {
|
||||
return status(409, { success: false, code: err.code, message: err.message });
|
||||
}
|
||||
if (err instanceof ProductImagePayloadError) {
|
||||
return status(400, productImageErrorResponse(err));
|
||||
}
|
||||
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||
return status(400, { success: false, message: "Category not found" });
|
||||
}
|
||||
@@ -817,10 +817,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
const { url } = body as any;
|
||||
if (!url) return status(400, { message: "URL is required" });
|
||||
|
||||
try {
|
||||
const image = normalizeProductImageInput(body);
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: { images: true }
|
||||
@@ -830,20 +828,25 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const newImage = await prisma.productImage.create({
|
||||
data: {
|
||||
productId: id,
|
||||
url,
|
||||
url: image.url,
|
||||
mobileUrl: image.mobileUrl ?? null,
|
||||
order: product.images.length
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, image: newImage };
|
||||
} catch (err: any) {
|
||||
if (err instanceof ProductImagePayloadError) {
|
||||
return status(400, productImageErrorResponse(err));
|
||||
}
|
||||
console.error("Add product image error:", err);
|
||||
return status(500, { success: false, message: err?.message || "Failed to add product image" });
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
url: t.String()
|
||||
url: t.String({ minLength: 1 }),
|
||||
mobileUrl: t.Optional(t.Union([t.String(), t.Null()]))
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -858,6 +861,12 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
|
||||
return { success: true, product };
|
||||
} catch (err: any) {
|
||||
if (err instanceof ProductTranslationPayloadError) {
|
||||
return status(400, productTranslationErrorResponse(err));
|
||||
}
|
||||
if (err instanceof ProductImagePayloadError) {
|
||||
return status(400, productImageErrorResponse(err));
|
||||
}
|
||||
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||
return status(400, { success: false, message: "Category not found" });
|
||||
}
|
||||
@@ -893,6 +902,12 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
|
||||
return { success: true, count: createdProducts.length, products: createdProducts };
|
||||
} catch (err: any) {
|
||||
if (err instanceof ProductTranslationPayloadError) {
|
||||
return status(400, productTranslationErrorResponse(err));
|
||||
}
|
||||
if (err instanceof ProductImagePayloadError) {
|
||||
return status(400, productImageErrorResponse(err));
|
||||
}
|
||||
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||
return status(400, { success: false, message: "Category not found" });
|
||||
}
|
||||
@@ -916,64 +931,26 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
|
||||
try {
|
||||
const payload = body as any;
|
||||
const prodData: any = {};
|
||||
const translationPlan = normalizeProductTranslationUpdate(payload);
|
||||
const prodData: any = { ...translationPlan.base };
|
||||
if (payload.categoryId !== undefined) prodData.categoryId = payload.categoryId;
|
||||
if (payload.brandId !== undefined) prodData.brandId = payload.brandId;
|
||||
if (payload.options !== undefined) prodData.options = payload.options;
|
||||
|
||||
const prod = await prisma.product.update({
|
||||
where: { id },
|
||||
data: prodData,
|
||||
const prod = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.product.update({
|
||||
where: { id },
|
||||
data: prodData,
|
||||
});
|
||||
await syncProductTranslations(tx, id, translationPlan, updated.name);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Update translations
|
||||
// If name, description, details are strings, treat as 'th'
|
||||
// If they are objects, iterate over them
|
||||
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
|
||||
for (const lang of languages) {
|
||||
const updateData: any = {};
|
||||
let shouldUpdate = false;
|
||||
|
||||
if (payload.name) {
|
||||
const val = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name[lang];
|
||||
if (val !== undefined && val !== null) { updateData.name = val; shouldUpdate = true; }
|
||||
}
|
||||
if (payload.description) {
|
||||
const val = typeof payload.description === 'string' ? (lang === 'th' ? payload.description : null) : payload.description[lang];
|
||||
if (val !== undefined && val !== null) { updateData.description = val; shouldUpdate = true; }
|
||||
}
|
||||
if (payload.details) {
|
||||
const val = typeof payload.details === 'string' ? (lang === 'th' ? payload.details : null) : payload.details[lang];
|
||||
if (val !== undefined && val !== null) { updateData.details = val; shouldUpdate = true; }
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
// First check if translation exists to create or update
|
||||
const existing = await prisma.productTranslation.findUnique({
|
||||
where: { productId_langCode: { productId: id, langCode: lang } }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.productTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: updateData
|
||||
});
|
||||
} else if (updateData.name) { // name is required for creation
|
||||
await prisma.productTranslation.create({
|
||||
data: {
|
||||
productId: id,
|
||||
langCode: lang,
|
||||
name: updateData.name,
|
||||
description: updateData.description,
|
||||
details: updateData.details
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, product: prod };
|
||||
} catch (err: any) {
|
||||
if (err instanceof ProductTranslationPayloadError) {
|
||||
return status(400, productTranslationErrorResponse(err));
|
||||
}
|
||||
console.error("Patch product error:", err);
|
||||
return status(500, { success: false, message: err?.message || "Failed to patch product" });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user