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 { AppContainer } from 'common-ui';
|
||||
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() {
|
||||
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
|
||||
const createProduct = trpc.admin.product.createProduct.useMutation();
|
||||
|
||||
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
|
||||
const payload: CreateProductPayload = {
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
unitId: parseInt(values.unitId),
|
||||
storeId: parseInt(values.storeId),
|
||||
price: parseFloat(values.price),
|
||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
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, {
|
||||
const handleSubmit = (values: any, imageKeys?: string[]) => {
|
||||
createProduct.mutate({
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
unitId: parseInt(values.unitId),
|
||||
storeId: parseInt(values.storeId),
|
||||
price: parseFloat(values.price),
|
||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
isSuspended: values.isSuspended || false,
|
||||
isFlashAvailable: values.isFlashAvailable || false,
|
||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
||||
tagIds: values.tagIds || [],
|
||||
imageKeys: imageKeys || [],
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Product created successfully!');
|
||||
// Reset form or navigate
|
||||
|
|
@ -73,7 +48,7 @@ export default function AddProduct() {
|
|||
isFlashAvailable: false,
|
||||
flashPrice: '',
|
||||
productQuantity: 1,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
|
|
@ -81,9 +56,9 @@ export default function AddProduct() {
|
|||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isCreating}
|
||||
isLoading={createProduct.isPending}
|
||||
existingImages={[]}
|
||||
/>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { View, Text, Alert } from 'react-native';
|
|||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
|
||||
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
||||
import { useUpdateProduct } from '@/src/api-hooks/product.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
export default function EditProduct() {
|
||||
|
|
@ -11,85 +10,52 @@ export default function EditProduct() {
|
|||
const productId = Number(id);
|
||||
const productFormRef = useRef<ProductFormRef>(null);
|
||||
|
||||
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
|
||||
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||
{ id: productId },
|
||||
{ enabled: !!productId }
|
||||
);
|
||||
//
|
||||
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
|
||||
|
||||
const updateProduct = trpc.admin.product.updateProduct.useMutation();
|
||||
|
||||
useManualRefresh(() => refetch());
|
||||
|
||||
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
unitId: parseInt(values.unitId),
|
||||
storeId: parseInt(values.storeId),
|
||||
price: parseFloat(values.price),
|
||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
deals: values.deals?.filter((deal: any) =>
|
||||
const handleSubmit = (values: any, newImageKeys?: string[], imagesToDelete?: string[]) => {
|
||||
updateProduct.mutate({
|
||||
id: productId,
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
unitId: parseInt(values.unitId),
|
||||
storeId: parseInt(values.storeId),
|
||||
price: parseFloat(values.price),
|
||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
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
|
||||
).map((deal: any) => ({
|
||||
quantity: parseInt(deal.quantity),
|
||||
price: parseFloat(deal.price),
|
||||
validTill: deal.validTill instanceof Date
|
||||
? deal.validTill.toISOString().split('T')[0]
|
||||
: deal.validTill, // Convert Date to YYYY-MM-DD string
|
||||
: deal.validTill,
|
||||
})),
|
||||
tagIds: values.tagIds,
|
||||
};
|
||||
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (key === 'deals' && Array.isArray(value)) {
|
||||
formData.append(key, JSON.stringify(value));
|
||||
} else if (key === 'tagIds' && Array.isArray(value)) {
|
||||
value.forEach(tagId => {
|
||||
formData.append('tagIds', tagId.toString());
|
||||
});
|
||||
} else if (value !== undefined && value !== null) {
|
||||
formData.append(key, value as string);
|
||||
}
|
||||
newImageKeys: newImageKeys || [],
|
||||
imagesToDelete: imagesToDelete || [],
|
||||
}, {
|
||||
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');
|
||||
},
|
||||
});
|
||||
|
||||
// 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) {
|
||||
|
|
@ -125,7 +91,7 @@ export default function EditProduct() {
|
|||
deals: productData.deals?.map(deal => ({
|
||||
quantity: deal.quantity,
|
||||
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 }],
|
||||
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
||||
isSuspended: productData.isSuspended || false,
|
||||
|
|
@ -141,9 +107,9 @@ export default function EditProduct() {
|
|||
mode="edit"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isUpdating}
|
||||
isLoading={updateProduct.isPending}
|
||||
existingImages={productData.images || []}
|
||||
/>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ export function useUploadToObjectStorage() {
|
|||
|
||||
function extractKeyFromPresignedUrl(url: string): string {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { View, TouchableOpacity } from 'react-native';
|
||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Formik, FieldArray } from 'formik';
|
||||
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 { trpc } from '../trpc-client';
|
||||
import { useGetTags } from '../api-hooks/tag.api';
|
||||
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
||||
|
||||
interface ProductFormData {
|
||||
name: string;
|
||||
|
|
@ -38,7 +39,7 @@ export interface ProductFormRef {
|
|||
interface ProductFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialValues: ProductFormData;
|
||||
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
|
||||
onSubmit: (values: ProductFormData, imageKeys?: string[], imagesToDelete?: string[]) => void;
|
||||
isLoading: boolean;
|
||||
existingImages?: string[];
|
||||
}
|
||||
|
|
@ -60,8 +61,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
existingImages = []
|
||||
}, ref) => {
|
||||
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 { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
||||
const storeOptions = storesData?.stores.map(store => ({
|
||||
|
|
@ -83,23 +85,62 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
}, [existingImages]);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// Calculate which existing images were deleted
|
||||
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
||||
|
||||
// Display images for ImageUploader component
|
||||
const displayImages = newImages.map(img => ({ uri: img.uri }));
|
||||
|
||||
return (
|
||||
<Formik
|
||||
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
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
||||
// Clear form when screen comes into focus
|
||||
const clearForm = useCallback(() => {
|
||||
setImages([]);
|
||||
setNewImages([]);
|
||||
setExistingImagesState([]);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
|
|
@ -143,9 +184,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
|
||||
{mode === 'create' && (
|
||||
<ImageUploader
|
||||
images={images}
|
||||
images={displayImages}
|
||||
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 }}>
|
||||
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
|
||||
<ImageUploader
|
||||
images={images}
|
||||
images={displayImages}
|
||||
onAddImage={pickImage}
|
||||
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -355,11 +396,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
|
||||
<TouchableOpacity
|
||||
onPress={submit}
|
||||
disabled={isLoading}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
disabled={isLoading || isUploading}
|
||||
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`}>
|
||||
{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>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -371,4 +412,4 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
|
||||
ProductForm.displayName = 'ProductForm';
|
||||
|
||||
export default ProductForm;
|
||||
export default ProductForm;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Router } from "express";
|
||||
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"
|
||||
|
||||
const router = Router();
|
||||
|
|
@ -8,9 +7,6 @@ const router = Router();
|
|||
// Apply staff authentication to all admin routes
|
||||
router.use(authenticateStaff);
|
||||
|
||||
// Product routes
|
||||
router.use("/products", productRouter);
|
||||
|
||||
// Tag routes
|
||||
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 { db } from "@/src/db/db_index"
|
||||
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 {
|
||||
try {
|
||||
|
|
@ -27,12 +27,22 @@ function extractS3Key(url: string): string | null {
|
|||
|
||||
export async function deleteS3Image(imageUrl: string) {
|
||||
try {
|
||||
// First check if this is a signed URL and get the original if it is
|
||||
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
|
||||
|
||||
const key = extractS3Key(originalUrl || "");
|
||||
|
||||
|
||||
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
|
||||
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
|
||||
|
||||
key = extractS3Key(originalUrl || "");
|
||||
}
|
||||
|
||||
else {
|
||||
key = imageUrl;
|
||||
}
|
||||
if (!key) {
|
||||
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> {
|
||||
try {
|
||||
const semiKey = extractKeyFromPresignedUrl(url);
|
||||
const key = s3BucketName+'/'+ semiKey
|
||||
|
||||
let semiKey:string = ''
|
||||
|
||||
if(url.startsWith('http'))
|
||||
semiKey = extractKeyFromPresignedUrl(url);
|
||||
else
|
||||
semiKey = url
|
||||
// Update status to 'claimed' if currently 'pending'
|
||||
const result = await db
|
||||
.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
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue