enh
This commit is contained in:
parent
77e3eb21d6
commit
8f4cddee1a
11 changed files with 357 additions and 570 deletions
|
|
@ -2,53 +2,28 @@ import React from 'react';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
import { AppContainer } from 'common-ui';
|
import { AppContainer } from 'common-ui';
|
||||||
import ProductForm from '@/src/components/ProductForm';
|
import ProductForm from '@/src/components/ProductForm';
|
||||||
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
export default function AddProduct() {
|
export default function AddProduct() {
|
||||||
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
|
const createProduct = trpc.admin.product.createProduct.useMutation();
|
||||||
|
|
||||||
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
|
const handleSubmit = (values: any, imageKeys?: string[]) => {
|
||||||
const payload: CreateProductPayload = {
|
createProduct.mutate({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
shortDescription: values.shortDescription,
|
shortDescription: values.shortDescription,
|
||||||
longDescription: values.longDescription,
|
longDescription: values.longDescription,
|
||||||
unitId: parseInt(values.unitId),
|
unitId: parseInt(values.unitId),
|
||||||
storeId: parseInt(values.storeId),
|
storeId: parseInt(values.storeId),
|
||||||
price: parseFloat(values.price),
|
price: parseFloat(values.price),
|
||||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||||
incrementStep: 1,
|
incrementStep: 1,
|
||||||
productQuantity: values.productQuantity || 1,
|
productQuantity: values.productQuantity || 1,
|
||||||
};
|
isSuspended: values.isSuspended || false,
|
||||||
|
isFlashAvailable: values.isFlashAvailable || false,
|
||||||
const formData = new FormData();
|
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
||||||
Object.entries(payload).forEach(([key, value]) => {
|
tagIds: values.tagIds || [],
|
||||||
if (value !== undefined && value !== null) {
|
imageKeys: imageKeys || [],
|
||||||
formData.append(key, value as string);
|
}, {
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Append tag IDs
|
|
||||||
if (values.tagIds && values.tagIds.length > 0) {
|
|
||||||
values.tagIds.forEach((tagId: number) => {
|
|
||||||
formData.append('tagIds', tagId.toString());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append images
|
|
||||||
if (images) {
|
|
||||||
images.forEach((image, index) => {
|
|
||||||
if (image.uri) {
|
|
||||||
formData.append('images', {
|
|
||||||
uri: image.uri,
|
|
||||||
name: `image-${index}.jpg`,
|
|
||||||
// type: 'image/jpeg',
|
|
||||||
type: image.mimeType as any,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createProduct(formData, {
|
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
Alert.alert('Success', 'Product created successfully!');
|
Alert.alert('Success', 'Product created successfully!');
|
||||||
// Reset form or navigate
|
// Reset form or navigate
|
||||||
|
|
@ -73,7 +48,7 @@ export default function AddProduct() {
|
||||||
isFlashAvailable: false,
|
isFlashAvailable: false,
|
||||||
flashPrice: '',
|
flashPrice: '',
|
||||||
productQuantity: 1,
|
productQuantity: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<AppContainer>
|
||||||
|
|
@ -81,7 +56,7 @@ export default function AddProduct() {
|
||||||
mode="create"
|
mode="create"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={isCreating}
|
isLoading={createProduct.isPending}
|
||||||
existingImages={[]}
|
existingImages={[]}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { View, Text, Alert } from 'react-native';
|
||||||
import { useLocalSearchParams } from 'expo-router';
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
|
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
|
||||||
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
||||||
import { useUpdateProduct } from '@/src/api-hooks/product.api';
|
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
export default function EditProduct() {
|
export default function EditProduct() {
|
||||||
|
|
@ -11,85 +10,52 @@ export default function EditProduct() {
|
||||||
const productId = Number(id);
|
const productId = Number(id);
|
||||||
const productFormRef = useRef<ProductFormRef>(null);
|
const productFormRef = useRef<ProductFormRef>(null);
|
||||||
|
|
||||||
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
|
|
||||||
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||||
{ id: productId },
|
{ id: productId },
|
||||||
{ enabled: !!productId }
|
{ enabled: !!productId }
|
||||||
);
|
);
|
||||||
//
|
|
||||||
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
|
const updateProduct = trpc.admin.product.updateProduct.useMutation();
|
||||||
|
|
||||||
useManualRefresh(() => refetch());
|
useManualRefresh(() => refetch());
|
||||||
|
|
||||||
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
|
const handleSubmit = (values: any, newImageKeys?: string[], imagesToDelete?: string[]) => {
|
||||||
const payload = {
|
updateProduct.mutate({
|
||||||
name: values.name,
|
id: productId,
|
||||||
shortDescription: values.shortDescription,
|
name: values.name,
|
||||||
longDescription: values.longDescription,
|
shortDescription: values.shortDescription,
|
||||||
unitId: parseInt(values.unitId),
|
longDescription: values.longDescription,
|
||||||
storeId: parseInt(values.storeId),
|
unitId: parseInt(values.unitId),
|
||||||
price: parseFloat(values.price),
|
storeId: parseInt(values.storeId),
|
||||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
price: parseFloat(values.price),
|
||||||
incrementStep: 1,
|
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||||
productQuantity: values.productQuantity || 1,
|
incrementStep: 1,
|
||||||
deals: values.deals?.filter((deal: any) =>
|
productQuantity: values.productQuantity || 1,
|
||||||
|
isSuspended: values.isSuspended,
|
||||||
|
isFlashAvailable: values.isFlashAvailable,
|
||||||
|
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
||||||
|
deals: values.deals?.filter((deal: any) =>
|
||||||
deal.quantity && deal.price && deal.validTill
|
deal.quantity && deal.price && deal.validTill
|
||||||
).map((deal: any) => ({
|
).map((deal: any) => ({
|
||||||
quantity: parseInt(deal.quantity),
|
quantity: parseInt(deal.quantity),
|
||||||
price: parseFloat(deal.price),
|
price: parseFloat(deal.price),
|
||||||
validTill: deal.validTill instanceof Date
|
validTill: deal.validTill instanceof Date
|
||||||
? deal.validTill.toISOString().split('T')[0]
|
? deal.validTill.toISOString().split('T')[0]
|
||||||
: deal.validTill, // Convert Date to YYYY-MM-DD string
|
: deal.validTill,
|
||||||
})),
|
})),
|
||||||
tagIds: values.tagIds,
|
tagIds: values.tagIds,
|
||||||
};
|
newImageKeys: newImageKeys || [],
|
||||||
|
imagesToDelete: imagesToDelete || [],
|
||||||
|
}, {
|
||||||
const formData = new FormData();
|
onSuccess: (data) => {
|
||||||
Object.entries(payload).forEach(([key, value]) => {
|
Alert.alert('Success', 'Product updated successfully!');
|
||||||
if (key === 'deals' && Array.isArray(value)) {
|
// Clear newly added images after successful update
|
||||||
formData.append(key, JSON.stringify(value));
|
productFormRef.current?.clearImages();
|
||||||
} else if (key === 'tagIds' && Array.isArray(value)) {
|
},
|
||||||
value.forEach(tagId => {
|
onError: (error: any) => {
|
||||||
formData.append('tagIds', tagId.toString());
|
Alert.alert('Error', error.message || 'Failed to update product');
|
||||||
});
|
},
|
||||||
} else if (value !== undefined && value !== null) {
|
|
||||||
formData.append(key, value as string);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add new images
|
|
||||||
if (newImages && newImages.length > 0) {
|
|
||||||
newImages.forEach((image, index) => {
|
|
||||||
if (image.uri) {
|
|
||||||
const fileName = image.uri.split('/').pop() || `image_${index}.jpg`;
|
|
||||||
formData.append('images', {
|
|
||||||
uri: image.uri,
|
|
||||||
name: fileName,
|
|
||||||
type: 'image/jpeg',
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add images to delete
|
|
||||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
|
||||||
formData.append('imagesToDelete', JSON.stringify(imagesToDelete));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateProduct(
|
|
||||||
{ id: productId, formData },
|
|
||||||
{
|
|
||||||
onSuccess: (data) => {
|
|
||||||
Alert.alert('Success', 'Product updated successfully!');
|
|
||||||
// Clear newly added images after successful update
|
|
||||||
productFormRef.current?.clearImages();
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
Alert.alert('Error', error.message || 'Failed to update product');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
|
|
@ -125,7 +91,7 @@ export default function EditProduct() {
|
||||||
deals: productData.deals?.map(deal => ({
|
deals: productData.deals?.map(deal => ({
|
||||||
quantity: deal.quantity,
|
quantity: deal.quantity,
|
||||||
price: deal.price,
|
price: deal.price,
|
||||||
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
|
validTill: deal.validTill ? new Date(deal.validTill) : null,
|
||||||
})) || [{ quantity: '', price: '', validTill: null }],
|
})) || [{ quantity: '', price: '', validTill: null }],
|
||||||
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
||||||
isSuspended: productData.isSuspended || false,
|
isSuspended: productData.isSuspended || false,
|
||||||
|
|
@ -141,7 +107,7 @@ export default function EditProduct() {
|
||||||
mode="edit"
|
mode="edit"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={isUpdating}
|
isLoading={updateProduct.isPending}
|
||||||
existingImages={productData.images || []}
|
existingImages={productData.images || []}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ export function useUploadToObjectStorage() {
|
||||||
|
|
||||||
function extractKeyFromPresignedUrl(url: string): string {
|
function extractKeyFromPresignedUrl(url: string): string {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
const rawKey = u.pathname.replace(/^\/+/, '');
|
let rawKey = u.pathname.replace(/^\/+/, '');
|
||||||
|
rawKey = rawKey.split('/').slice(1).join('/'); // make meatfarmer/product-images/asdf as product-images/asdf
|
||||||
return decodeURIComponent(rawKey);
|
return decodeURIComponent(rawKey);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import axios from '../../services/axios-admin-ui';
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export interface CreateProductPayload {
|
|
||||||
name: string;
|
|
||||||
shortDescription?: string;
|
|
||||||
longDescription?: string;
|
|
||||||
unitId: number;
|
|
||||||
storeId: number;
|
|
||||||
price: number;
|
|
||||||
marketPrice?: number;
|
|
||||||
incrementStep?: number;
|
|
||||||
productQuantity?: number;
|
|
||||||
isOutOfStock?: boolean;
|
|
||||||
deals?: {
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
validTill: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateProductPayload {
|
|
||||||
name: string;
|
|
||||||
shortDescription?: string;
|
|
||||||
longDescription?: string;
|
|
||||||
unitId: number;
|
|
||||||
storeId: number;
|
|
||||||
price: number;
|
|
||||||
marketPrice?: number;
|
|
||||||
incrementStep?: number;
|
|
||||||
productQuantity?: number;
|
|
||||||
isOutOfStock?: boolean;
|
|
||||||
deals?: {
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
validTill: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Product {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
shortDescription?: string | null;
|
|
||||||
longDescription?: string;
|
|
||||||
unitId: number;
|
|
||||||
storeId: number;
|
|
||||||
price: number;
|
|
||||||
marketPrice?: number;
|
|
||||||
productQuantity?: number;
|
|
||||||
isOutOfStock?: boolean;
|
|
||||||
images?: string[];
|
|
||||||
createdAt: string;
|
|
||||||
unit?: {
|
|
||||||
id: number;
|
|
||||||
shortNotation: string;
|
|
||||||
fullName: string;
|
|
||||||
};
|
|
||||||
deals?: {
|
|
||||||
id: number;
|
|
||||||
quantity: string;
|
|
||||||
price: string;
|
|
||||||
validTill: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateProductResponse {
|
|
||||||
product: Product;
|
|
||||||
deals?: any[];
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API functions
|
|
||||||
const createProductApi = async (formData: FormData): Promise<CreateProductResponse> => {
|
|
||||||
const response = await axios.post('/av/products', formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProductApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateProductResponse> => {
|
|
||||||
const response = await axios.put(`/av/products/${id}`, formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
export const useCreateProduct = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: createProductApi,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdateProduct = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: updateProductApi,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { View, TouchableOpacity } from 'react-native';
|
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Formik, FieldArray } from 'formik';
|
import { Formik, FieldArray } from 'formik';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
@ -8,6 +8,7 @@ import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { trpc } from '../trpc-client';
|
import { trpc } from '../trpc-client';
|
||||||
import { useGetTags } from '../api-hooks/tag.api';
|
import { useGetTags } from '../api-hooks/tag.api';
|
||||||
|
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
||||||
|
|
||||||
interface ProductFormData {
|
interface ProductFormData {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -38,7 +39,7 @@ export interface ProductFormRef {
|
||||||
interface ProductFormProps {
|
interface ProductFormProps {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
initialValues: ProductFormData;
|
initialValues: ProductFormData;
|
||||||
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
|
onSubmit: (values: ProductFormData, imageKeys?: string[], imagesToDelete?: string[]) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
existingImages?: string[];
|
existingImages?: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -60,8 +61,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
existingImages = []
|
existingImages = []
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [images, setImages] = useState<{ uri?: string }[]>([]);
|
const [newImages, setNewImages] = useState<{ blob: Blob; mimeType: string; uri: string }[]>([]);
|
||||||
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
|
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
|
||||||
|
const { upload, isUploading } = useUploadToObjectStorage();
|
||||||
|
|
||||||
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
||||||
const storeOptions = storesData?.stores.map(store => ({
|
const storeOptions = storesData?.stores.map(store => ({
|
||||||
|
|
@ -83,23 +85,62 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
}, [existingImages]);
|
}, [existingImages]);
|
||||||
|
|
||||||
const pickImage = usePickImage({
|
const pickImage = usePickImage({
|
||||||
setFile: (files) => setImages(prev => [...prev, ...files]),
|
setFile: async (assets: any) => {
|
||||||
|
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.isArray(assets) ? assets : [assets];
|
||||||
|
const imageData = await Promise.all(
|
||||||
|
files.map(async (asset) => {
|
||||||
|
const response = await fetch(asset.uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
mimeType: asset.mimeType || 'image/jpeg',
|
||||||
|
uri: asset.uri
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setNewImages(prev => [...prev, ...imageData]);
|
||||||
|
},
|
||||||
multiple: true,
|
multiple: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate which existing images were deleted
|
// Calculate which existing images were deleted
|
||||||
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
||||||
|
|
||||||
|
// Display images for ImageUploader component
|
||||||
|
const displayImages = newImages.map(img => ({ uri: img.uri }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={(values) => onSubmit(values, images, deletedImages)}
|
onSubmit={async (values) => {
|
||||||
|
try {
|
||||||
|
let imageKeys: string[] = [];
|
||||||
|
|
||||||
|
// Upload new images if any
|
||||||
|
if (newImages.length > 0) {
|
||||||
|
const result = await upload({
|
||||||
|
images: newImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
|
||||||
|
contextString: 'product_info',
|
||||||
|
});
|
||||||
|
imageKeys = result.keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(values, imageKeys, deletedImages);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload images');
|
||||||
|
}
|
||||||
|
}}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
>
|
>
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
||||||
// Clear form when screen comes into focus
|
// Clear form when screen comes into focus
|
||||||
const clearForm = useCallback(() => {
|
const clearForm = useCallback(() => {
|
||||||
setImages([]);
|
setNewImages([]);
|
||||||
setExistingImagesState([]);
|
setExistingImagesState([]);
|
||||||
resetForm();
|
resetForm();
|
||||||
}, [resetForm]);
|
}, [resetForm]);
|
||||||
|
|
@ -143,9 +184,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
|
|
||||||
{mode === 'create' && (
|
{mode === 'create' && (
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
images={images}
|
images={displayImages}
|
||||||
onAddImage={pickImage}
|
onAddImage={pickImage}
|
||||||
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -166,9 +207,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: 16 }}>
|
||||||
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
|
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
images={images}
|
images={displayImages}
|
||||||
onAddImage={pickImage}
|
onAddImage={pickImage}
|
||||||
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -355,11 +396,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={isLoading}
|
disabled={isLoading || isUploading}
|
||||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white text-lg font-bold`}>
|
<MyText style={tw`text-white text-lg font-bold`}>
|
||||||
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
{isUploading ? 'Uploading Images...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { authenticateStaff } from "@/src/middleware/staff-auth";
|
import { authenticateStaff } from "@/src/middleware/staff-auth";
|
||||||
import productRouter from "@/src/apis/admin-apis/apis/product.router"
|
|
||||||
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
|
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -8,9 +7,6 @@ const router = Router();
|
||||||
// Apply staff authentication to all admin routes
|
// Apply staff authentication to all admin routes
|
||||||
router.use(authenticateStaff);
|
router.use(authenticateStaff);
|
||||||
|
|
||||||
// Product routes
|
|
||||||
router.use("/products", productRouter);
|
|
||||||
|
|
||||||
// Tag routes
|
// Tag routes
|
||||||
router.use("/product-tags", tagRouter);
|
router.use("/product-tags", tagRouter);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
import { Request, Response } from "express";
|
|
||||||
import { db } from "@/src/db/db_index";
|
|
||||||
import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
|
|
||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
import { ApiError } from "@/src/lib/api-error";
|
|
||||||
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
|
|
||||||
import { deleteS3Image } from "@/src/lib/delete-image";
|
|
||||||
import type { SpecialDeal } from "@/src/db/types";
|
|
||||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer';
|
|
||||||
|
|
||||||
|
|
||||||
type CreateDeal = {
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
validTill: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new product
|
|
||||||
*/
|
|
||||||
export const createProduct = async (req: Request, res: Response) => {
|
|
||||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals, tagIds } = req.body;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!name || !unitId || !storeId || !price) {
|
|
||||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for duplicate name
|
|
||||||
const existingProduct = await db.query.productInfo.findFirst({
|
|
||||||
where: eq(productInfo.name, name.trim()),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingProduct) {
|
|
||||||
throw new ApiError("A product with this name already exists", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if unit exists
|
|
||||||
const unit = await db.query.units.findFirst({
|
|
||||||
where: eq(units.id, unitId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!unit) {
|
|
||||||
throw new ApiError("Invalid unit ID", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract images from req.files
|
|
||||||
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
|
||||||
let uploadedImageUrls: string[] = [];
|
|
||||||
|
|
||||||
if (images && Array.isArray(images)) {
|
|
||||||
const imageUploadPromises = images.map((file, index) => {
|
|
||||||
const key = `product-images/${Date.now()}-${index}`;
|
|
||||||
return imageUploadS3(file.buffer, file.mimetype, key);
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create product
|
|
||||||
const productData: any = {
|
|
||||||
name,
|
|
||||||
shortDescription,
|
|
||||||
longDescription,
|
|
||||||
unitId,
|
|
||||||
storeId,
|
|
||||||
price,
|
|
||||||
marketPrice,
|
|
||||||
incrementStep: incrementStep || 1,
|
|
||||||
productQuantity: productQuantity || 1,
|
|
||||||
isSuspended: isSuspended || false,
|
|
||||||
isFlashAvailable: isFlashAvailable || false,
|
|
||||||
images: uploadedImageUrls,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (flashPrice) {
|
|
||||||
productData.flashPrice = parseFloat(flashPrice);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [newProduct] = await db
|
|
||||||
.insert(productInfo)
|
|
||||||
.values(productData)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Handle deals if provided
|
|
||||||
let createdDeals: SpecialDeal[] = [];
|
|
||||||
if (deals && Array.isArray(deals)) {
|
|
||||||
const dealInserts = deals.map((deal: CreateDeal) => ({
|
|
||||||
productId: newProduct.id,
|
|
||||||
quantity: deal.quantity.toString(),
|
|
||||||
price: deal.price.toString(),
|
|
||||||
validTill: new Date(deal.validTill),
|
|
||||||
}));
|
|
||||||
|
|
||||||
createdDeals = await db
|
|
||||||
.insert(specialDeals)
|
|
||||||
.values(dealInserts)
|
|
||||||
.returning();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tag assignments if provided
|
|
||||||
if (tagIds && Array.isArray(tagIds)) {
|
|
||||||
const tagAssociations = tagIds.map((tagId: number) => ({
|
|
||||||
productId: newProduct.id,
|
|
||||||
tagId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await db.insert(productTags).values(tagAssociations);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reinitialize stores to reflect changes
|
|
||||||
scheduleStoreInitialization()
|
|
||||||
|
|
||||||
// Send response first
|
|
||||||
res.status(201).json({
|
|
||||||
product: newProduct,
|
|
||||||
deals: createdDeals,
|
|
||||||
message: "Product created successfully",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a product
|
|
||||||
*/
|
|
||||||
export const updateProduct = async (req: Request, res: Response) => {
|
|
||||||
const id = req.params.id as string
|
|
||||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body;
|
|
||||||
|
|
||||||
|
|
||||||
const deals = dealsRaw ? JSON.parse(dealsRaw) : null;
|
|
||||||
const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];
|
|
||||||
|
|
||||||
if (!name || !unitId || !storeId || !price) {
|
|
||||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if unit exists
|
|
||||||
const unit = await db.query.units.findFirst({
|
|
||||||
where: eq(units.id, unitId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!unit) {
|
|
||||||
throw new ApiError("Invalid unit ID", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current product to handle image updates
|
|
||||||
const currentProduct = await db.query.productInfo.findFirst({
|
|
||||||
where: eq(productInfo.id, parseInt(id)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentProduct) {
|
|
||||||
throw new ApiError("Product not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle image deletions
|
|
||||||
let currentImages = (currentProduct.images as string[]) || [];
|
|
||||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
|
||||||
// Convert signed URLs to original S3 URLs for comparison
|
|
||||||
const originalUrlsToDelete = imagesToDelete
|
|
||||||
.map((signedUrl: string) => getOriginalUrlFromSignedUrl(signedUrl))
|
|
||||||
.filter(Boolean); // Remove nulls
|
|
||||||
|
|
||||||
// Find which stored images match the ones to delete
|
|
||||||
const imagesToRemoveFromDb = currentImages.filter(storedUrl =>
|
|
||||||
originalUrlsToDelete.includes(storedUrl)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete the matching images from S3
|
|
||||||
const deletePromises = imagesToRemoveFromDb.map(imageUrl => deleteS3Image(imageUrl));
|
|
||||||
await Promise.all(deletePromises);
|
|
||||||
|
|
||||||
// Remove deleted images from current images array
|
|
||||||
currentImages = currentImages.filter(img => !imagesToRemoveFromDb.includes(img));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract new images from req.files
|
|
||||||
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
|
||||||
let uploadedImageUrls: string[] = [];
|
|
||||||
|
|
||||||
if (images && Array.isArray(images)) {
|
|
||||||
const imageUploadPromises = images.map((file, index) => {
|
|
||||||
const key = `product-images/${Date.now()}-${index}`;
|
|
||||||
return imageUploadS3(file.buffer, file.mimetype, key);
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine remaining current images with new uploaded images
|
|
||||||
const finalImages = [...currentImages, ...uploadedImageUrls];
|
|
||||||
|
|
||||||
const updateData: any = {
|
|
||||||
name,
|
|
||||||
shortDescription,
|
|
||||||
longDescription,
|
|
||||||
unitId,
|
|
||||||
storeId,
|
|
||||||
price,
|
|
||||||
marketPrice,
|
|
||||||
incrementStep: incrementStep || 1,
|
|
||||||
productQuantity: productQuantity || 1,
|
|
||||||
isSuspended: isSuspended || false,
|
|
||||||
images: finalImages.length > 0 ? finalImages : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isFlashAvailable !== undefined) {
|
|
||||||
updateData.isFlashAvailable = isFlashAvailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flashPrice !== undefined) {
|
|
||||||
updateData.flashPrice = flashPrice ? parseFloat(flashPrice) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updatedProduct] = await db
|
|
||||||
.update(productInfo)
|
|
||||||
.set(updateData)
|
|
||||||
.where(eq(productInfo.id, parseInt(id)))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updatedProduct) {
|
|
||||||
throw new ApiError("Product not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle deals if provided
|
|
||||||
if (deals && Array.isArray(deals)) {
|
|
||||||
// Get existing deals
|
|
||||||
const existingDeals = await db.query.specialDeals.findMany({
|
|
||||||
where: eq(specialDeals.productId, parseInt(id)),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create maps for comparison
|
|
||||||
const existingDealsMap = new Map(existingDeals.map(deal => [`${deal.quantity}-${deal.price}`, deal]));
|
|
||||||
const newDealsMap = new Map(deals.map((deal: CreateDeal) => [`${deal.quantity}-${deal.price}`, deal]));
|
|
||||||
|
|
||||||
// Find deals to add, update, and remove
|
|
||||||
const dealsToAdd = deals.filter((deal: CreateDeal) => {
|
|
||||||
const key = `${deal.quantity}-${deal.price}`;
|
|
||||||
return !existingDealsMap.has(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
const dealsToRemove = existingDeals.filter(deal => {
|
|
||||||
const key = `${deal.quantity}-${deal.price}`;
|
|
||||||
return !newDealsMap.has(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
const dealsToUpdate = deals.filter((deal: CreateDeal) => {
|
|
||||||
const key = `${deal.quantity}-${deal.price}`;
|
|
||||||
const existing = existingDealsMap.get(key);
|
|
||||||
return existing && existing.validTill.toISOString().split('T')[0] !== deal.validTill;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove old deals
|
|
||||||
if (dealsToRemove.length > 0) {
|
|
||||||
await db.delete(specialDeals).where(
|
|
||||||
inArray(specialDeals.id, dealsToRemove.map(deal => deal.id))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new deals
|
|
||||||
if (dealsToAdd.length > 0) {
|
|
||||||
const dealInserts = dealsToAdd.map((deal: CreateDeal) => ({
|
|
||||||
productId: parseInt(id),
|
|
||||||
quantity: deal.quantity.toString(),
|
|
||||||
price: deal.price.toString(),
|
|
||||||
validTill: new Date(deal.validTill),
|
|
||||||
}));
|
|
||||||
await db.insert(specialDeals).values(dealInserts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing deals
|
|
||||||
for (const deal of dealsToUpdate) {
|
|
||||||
const key = `${deal.quantity}-${deal.price}`;
|
|
||||||
const existingDeal = existingDealsMap.get(key);
|
|
||||||
if (existingDeal) {
|
|
||||||
await db.update(specialDeals)
|
|
||||||
.set({ validTill: new Date(deal.validTill) })
|
|
||||||
.where(eq(specialDeals.id, existingDeal.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tag assignments if provided
|
|
||||||
// if (tagIds && Array.isArray(tagIds)) {
|
|
||||||
if (tagIds && Boolean(tagIds)) {
|
|
||||||
// Remove existing tags
|
|
||||||
await db.delete(productTags).where(eq(productTags.productId, parseInt(id)));
|
|
||||||
|
|
||||||
const tagIdsArray = Array.isArray(tagIds) ? tagIds : [+tagIds]
|
|
||||||
// Add new tags
|
|
||||||
const tagAssociations = tagIdsArray.map((tagId: number) => ({
|
|
||||||
productId: parseInt(id),
|
|
||||||
tagId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await db.insert(productTags).values(tagAssociations);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reinitialize stores to reflect changes
|
|
||||||
scheduleStoreInitialization()
|
|
||||||
|
|
||||||
// Send response first
|
|
||||||
res.status(200).json({
|
|
||||||
product: updatedProduct,
|
|
||||||
message: "Product updated successfully",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import { createProduct, updateProduct } from "@/src/apis/admin-apis/apis/product.controller"
|
|
||||||
import uploadHandler from '@/src/lib/upload-handler';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// Product routes
|
|
||||||
router.post("/", uploadHandler.array('images'), createProduct);
|
|
||||||
router.put("/:id", uploadHandler.array('images'), updateProduct);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@/src/db/db_index"
|
import { db } from "@/src/db/db_index"
|
||||||
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
|
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
|
||||||
import { s3Url } from "@/src/lib/env-exporter"
|
import { assetsDomain, s3Url } from "@/src/lib/env-exporter"
|
||||||
|
|
||||||
function extractS3Key(url: string): string | null {
|
function extractS3Key(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -27,12 +27,22 @@ function extractS3Key(url: string): string | null {
|
||||||
|
|
||||||
export async function deleteS3Image(imageUrl: string) {
|
export async function deleteS3Image(imageUrl: string) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
let key:string | null = '';
|
||||||
|
|
||||||
|
if(imageUrl.includes(assetsDomain)) {
|
||||||
|
key = imageUrl.replace(assetsDomain, '')
|
||||||
|
}
|
||||||
|
else if(imageUrl.startsWith('http')){
|
||||||
// First check if this is a signed URL and get the original if it is
|
// First check if this is a signed URL and get the original if it is
|
||||||
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
|
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
|
||||||
|
|
||||||
const key = extractS3Key(originalUrl || "");
|
|
||||||
|
|
||||||
|
key = extractS3Key(originalUrl || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
else {
|
||||||
|
key = imageUrl;
|
||||||
|
}
|
||||||
if (!key) {
|
if (!key) {
|
||||||
throw new Error("Invalid image URL format");
|
throw new Error("Invalid image URL format");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,9 +201,12 @@ export function extractKeyFromPresignedUrl(url: string): string {
|
||||||
|
|
||||||
export async function claimUploadUrl(url: string): Promise<void> {
|
export async function claimUploadUrl(url: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const semiKey = extractKeyFromPresignedUrl(url);
|
let semiKey:string = ''
|
||||||
const key = s3BucketName+'/'+ semiKey
|
|
||||||
|
|
||||||
|
if(url.startsWith('http'))
|
||||||
|
semiKey = extractKeyFromPresignedUrl(url);
|
||||||
|
else
|
||||||
|
semiKey = url
|
||||||
// Update status to 'claimed' if currently 'pending'
|
// Update status to 'claimed' if currently 'pending'
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(uploadUrlStatus)
|
.update(uploadUrlStatus)
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,229 @@ export const productRouter = router({
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
createProduct: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
shortDescription: z.string().optional(),
|
||||||
|
longDescription: z.string().optional(),
|
||||||
|
unitId: z.number(),
|
||||||
|
storeId: z.number(),
|
||||||
|
price: z.number(),
|
||||||
|
marketPrice: z.number().optional(),
|
||||||
|
incrementStep: z.number().default(1),
|
||||||
|
productQuantity: z.number().default(1),
|
||||||
|
isSuspended: z.boolean().default(false),
|
||||||
|
isFlashAvailable: z.boolean().default(false),
|
||||||
|
flashPrice: z.number().optional(),
|
||||||
|
deals: z.array(z.object({
|
||||||
|
quantity: z.number(),
|
||||||
|
price: z.number(),
|
||||||
|
validTill: z.string(),
|
||||||
|
})).optional(),
|
||||||
|
tagIds: z.array(z.number()).optional(),
|
||||||
|
imageKeys: z.array(z.string()).optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const {
|
||||||
|
name, shortDescription, longDescription, unitId, storeId,
|
||||||
|
price, marketPrice, incrementStep, productQuantity,
|
||||||
|
isSuspended, isFlashAvailable, flashPrice,
|
||||||
|
deals, tagIds, imageKeys
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name || !unitId || !storeId || !price) {
|
||||||
|
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name
|
||||||
|
const existingProduct = await db.query.productInfo.findFirst({
|
||||||
|
where: eq(productInfo.name, name.trim()),
|
||||||
|
});
|
||||||
|
if (existingProduct) {
|
||||||
|
throw new ApiError("A product with this name already exists", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if unit exists
|
||||||
|
const unit = await db.query.units.findFirst({
|
||||||
|
where: eq(units.id, unitId),
|
||||||
|
});
|
||||||
|
if (!unit) {
|
||||||
|
throw new ApiError("Invalid unit ID", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(imageKeys)
|
||||||
|
const [newProduct] = await db
|
||||||
|
.insert(productInfo)
|
||||||
|
.values({
|
||||||
|
name: name.trim(),
|
||||||
|
shortDescription,
|
||||||
|
longDescription,
|
||||||
|
unitId,
|
||||||
|
storeId,
|
||||||
|
price: price.toString(),
|
||||||
|
marketPrice: marketPrice?.toString(),
|
||||||
|
incrementStep,
|
||||||
|
productQuantity,
|
||||||
|
isSuspended,
|
||||||
|
isFlashAvailable,
|
||||||
|
flashPrice: flashPrice?.toString(),
|
||||||
|
images: imageKeys || [],
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Handle deals
|
||||||
|
if (deals && deals.length > 0) {
|
||||||
|
const dealInserts = deals.map(deal => ({
|
||||||
|
productId: newProduct.id,
|
||||||
|
quantity: deal.quantity.toString(),
|
||||||
|
price: deal.price.toString(),
|
||||||
|
validTill: new Date(deal.validTill),
|
||||||
|
}));
|
||||||
|
await db.insert(specialDeals).values(dealInserts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tags
|
||||||
|
if (tagIds && tagIds.length > 0) {
|
||||||
|
const tagAssociations = tagIds.map(tagId => ({
|
||||||
|
productId: newProduct.id,
|
||||||
|
tagId,
|
||||||
|
}));
|
||||||
|
await db.insert(productTags).values(tagAssociations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim upload URLs
|
||||||
|
if (imageKeys && imageKeys.length > 0) {
|
||||||
|
for (const key of imageKeys) {
|
||||||
|
try {
|
||||||
|
await claimUploadUrl(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to claim upload URL for key: ${key}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleStoreInitialization();
|
||||||
|
|
||||||
|
return {
|
||||||
|
product: newProduct,
|
||||||
|
message: "Product created successfully",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateProduct: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
shortDescription: z.string().optional(),
|
||||||
|
longDescription: z.string().optional(),
|
||||||
|
unitId: z.number().optional(),
|
||||||
|
storeId: z.number().optional(),
|
||||||
|
price: z.number().optional(),
|
||||||
|
marketPrice: z.number().optional(),
|
||||||
|
incrementStep: z.number().optional(),
|
||||||
|
productQuantity: z.number().optional(),
|
||||||
|
isSuspended: z.boolean().optional(),
|
||||||
|
isFlashAvailable: z.boolean().optional(),
|
||||||
|
flashPrice: z.number().optional(),
|
||||||
|
deals: z.array(z.object({
|
||||||
|
quantity: z.number(),
|
||||||
|
price: z.number(),
|
||||||
|
validTill: z.string(),
|
||||||
|
})).optional(),
|
||||||
|
tagIds: z.array(z.number()).optional(),
|
||||||
|
newImageKeys: z.array(z.string()).optional(),
|
||||||
|
imagesToDelete: z.array(z.string()).optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input;
|
||||||
|
|
||||||
|
// Get current product
|
||||||
|
const currentProduct = await db.query.productInfo.findFirst({
|
||||||
|
where: eq(productInfo.id, id),
|
||||||
|
});
|
||||||
|
if (!currentProduct) {
|
||||||
|
throw new ApiError("Product not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle image deletions
|
||||||
|
let currentImages = (currentProduct.images as string[]) || [];
|
||||||
|
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||||
|
for (const imageUrl of imagesToDelete) {
|
||||||
|
try {
|
||||||
|
await deleteS3Image(imageUrl);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to delete image: ${imageUrl}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentImages = currentImages.filter(img => {
|
||||||
|
//!imagesToDelete.includes(img)
|
||||||
|
const isRemoved = imagesToDelete.some(item => item.includes(img));
|
||||||
|
return !isRemoved;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new images
|
||||||
|
if (newImageKeys && newImageKeys.length > 0) {
|
||||||
|
currentImages = [...currentImages, ...newImageKeys];
|
||||||
|
|
||||||
|
for (const key of newImageKeys) {
|
||||||
|
try {
|
||||||
|
await claimUploadUrl(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to claim upload URL for key: ${key}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update product - convert numeric fields to strings for PostgreSQL numeric type
|
||||||
|
const { price, marketPrice, flashPrice, ...otherData } = updateData;
|
||||||
|
const [updatedProduct] = await db
|
||||||
|
.update(productInfo)
|
||||||
|
.set({
|
||||||
|
...otherData,
|
||||||
|
...(price !== undefined && { price: price.toString() }),
|
||||||
|
...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }),
|
||||||
|
...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }),
|
||||||
|
images: currentImages,
|
||||||
|
})
|
||||||
|
.where(eq(productInfo.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Handle deals update
|
||||||
|
if (deals !== undefined) {
|
||||||
|
await db.delete(specialDeals).where(eq(specialDeals.productId, id));
|
||||||
|
if (deals.length > 0) {
|
||||||
|
const dealInserts = deals.map(deal => ({
|
||||||
|
productId: id,
|
||||||
|
quantity: deal.quantity.toString(),
|
||||||
|
price: deal.price.toString(),
|
||||||
|
validTill: new Date(deal.validTill),
|
||||||
|
}));
|
||||||
|
await db.insert(specialDeals).values(dealInserts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tags update
|
||||||
|
if (tagIds !== undefined) {
|
||||||
|
await db.delete(productTags).where(eq(productTags.productId, id));
|
||||||
|
if (tagIds.length > 0) {
|
||||||
|
const tagAssociations = tagIds.map(tagId => ({
|
||||||
|
productId: id,
|
||||||
|
tagId,
|
||||||
|
}));
|
||||||
|
await db.insert(productTags).values(tagAssociations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleStoreInitialization();
|
||||||
|
|
||||||
|
return {
|
||||||
|
product: updatedProduct,
|
||||||
|
message: "Product updated successfully",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
toggleOutOfStock: protectedProcedure
|
toggleOutOfStock: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue