From 8f4cddee1adaec2c787b5a6e156c59fa8dac1f10 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:28:45 +0530 Subject: [PATCH] enh --- apps/admin-ui/app/(drawer)/products/add.tsx | 69 ++-- apps/admin-ui/app/(drawer)/products/edit.tsx | 100 ++---- .../hooks/useUploadToObjectStorage.ts | 3 +- apps/admin-ui/src/api-hooks/product.api.ts | 111 ------- apps/admin-ui/src/components/ProductForm.tsx | 69 +++- .../src/apis/admin-apis/apis/av-router.ts | 4 - .../admin-apis/apis/product.controller.ts | 306 ------------------ .../apis/admin-apis/apis/product.router.ts | 11 - apps/backend/src/lib/delete-image.ts | 22 +- apps/backend/src/lib/s3-client.ts | 9 +- .../src/trpc/apis/admin-apis/apis/product.ts | 223 +++++++++++++ 11 files changed, 357 insertions(+), 570 deletions(-) delete mode 100644 apps/admin-ui/src/api-hooks/product.api.ts delete mode 100644 apps/backend/src/apis/admin-apis/apis/product.controller.ts delete mode 100644 apps/backend/src/apis/admin-apis/apis/product.router.ts diff --git a/apps/admin-ui/app/(drawer)/products/add.tsx b/apps/admin-ui/app/(drawer)/products/add.tsx index e49930c..1a5578f 100644 --- a/apps/admin-ui/app/(drawer)/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/products/add.tsx @@ -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 ( @@ -81,9 +56,9 @@ export default function AddProduct() { mode="create" initialValues={initialValues} onSubmit={handleSubmit} - isLoading={isCreating} + isLoading={createProduct.isPending} existingImages={[]} /> ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/app/(drawer)/products/edit.tsx b/apps/admin-ui/app/(drawer)/products/edit.tsx index e0bea74..e4cbe7a 100644 --- a/apps/admin-ui/app/(drawer)/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/products/edit.tsx @@ -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(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 || []} /> ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/hooks/useUploadToObjectStorage.ts b/apps/admin-ui/hooks/useUploadToObjectStorage.ts index e88250c..abd7d2c 100644 --- a/apps/admin-ui/hooks/useUploadToObjectStorage.ts +++ b/apps/admin-ui/hooks/useUploadToObjectStorage.ts @@ -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); } diff --git a/apps/admin-ui/src/api-hooks/product.api.ts b/apps/admin-ui/src/api-hooks/product.api.ts deleted file mode 100644 index bdb42ed..0000000 --- a/apps/admin-ui/src/api-hooks/product.api.ts +++ /dev/null @@ -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 => { - 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 => { - 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'] }); - }, - }); -}; diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 1502214..92baf32 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -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(({ 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(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(({ }, [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 ( 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(({ {mode === 'create' && ( 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(({ Add New Images setImages(prev => prev.filter(img => img.uri !== uri))} + onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))} /> )} @@ -355,11 +396,11 @@ const ProductForm = forwardRef(({ - {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')} @@ -371,4 +412,4 @@ const ProductForm = forwardRef(({ ProductForm.displayName = 'ProductForm'; -export default ProductForm; \ No newline at end of file +export default ProductForm; diff --git a/apps/backend/src/apis/admin-apis/apis/av-router.ts b/apps/backend/src/apis/admin-apis/apis/av-router.ts index 732e56f..89685a5 100755 --- a/apps/backend/src/apis/admin-apis/apis/av-router.ts +++ b/apps/backend/src/apis/admin-apis/apis/av-router.ts @@ -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); diff --git a/apps/backend/src/apis/admin-apis/apis/product.controller.ts b/apps/backend/src/apis/admin-apis/apis/product.controller.ts deleted file mode 100644 index 1ff6783..0000000 --- a/apps/backend/src/apis/admin-apis/apis/product.controller.ts +++ /dev/null @@ -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", - }); -}; diff --git a/apps/backend/src/apis/admin-apis/apis/product.router.ts b/apps/backend/src/apis/admin-apis/apis/product.router.ts deleted file mode 100644 index fe0bbd1..0000000 --- a/apps/backend/src/apis/admin-apis/apis/product.router.ts +++ /dev/null @@ -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; \ No newline at end of file diff --git a/apps/backend/src/lib/delete-image.ts b/apps/backend/src/lib/delete-image.ts index dd6dfdd..3edcfeb 100644 --- a/apps/backend/src/lib/delete-image.ts +++ b/apps/backend/src/lib/delete-image.ts @@ -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"); } diff --git a/apps/backend/src/lib/s3-client.ts b/apps/backend/src/lib/s3-client.ts index 7862169..58a8c01 100755 --- a/apps/backend/src/lib/s3-client.ts +++ b/apps/backend/src/lib/s3-client.ts @@ -201,9 +201,12 @@ export function extractKeyFromPresignedUrl(url: string): string { export async function claimUploadUrl(url: string): Promise { 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) diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index d72d7bc..4677c40 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -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(),