diff --git a/apps/admin-ui/app/(drawer)/product-tags/add.tsx b/apps/admin-ui/app/(drawer)/product-tags/add.tsx index c362b4e..38e24f6 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/add.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/add.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { View, Alert } from 'react-native'; import { useRouter } from 'expo-router'; -import { AppContainer, MyText, tw } from 'common-ui'; +import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui'; import TagForm from '@/src/components/TagForm'; -import { useCreateTag } from '@/src/api-hooks/tag.api'; import { trpc } from '@/src/trpc-client'; +import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'; interface TagFormData { tagName: string; @@ -15,50 +15,47 @@ interface TagFormData { export default function AddTag() { const router = useRouter(); - const { mutate: createTag, isPending: isCreating } = useCreateTag(); + const createTag = trpc.admin.product.createProductTag.useMutation(); const { data: storesData } = trpc.admin.store.getStores.useQuery(); + const { upload, isUploading } = useUploadToObjectStorage(); - const handleSubmit = (values: TagFormData, image?: { uri?: string }) => { - const formData = new FormData(); + const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], _removedExisting: boolean) => { + try { + let imageUrl: string | null | undefined; + let uploadUrls: string[] = [] - // Add text fields - formData.append('tagName', values.tagName); - if (values.tagDescription) { - formData.append('tagDescription', values.tagDescription); + const newImage = images.find((image) => image.mimeType !== null) + if (newImage) { + const response = await fetch(newImage.imgUrl) + const blob = await response.blob() + const result = await upload({ + images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }], + contextString: 'tags', + }) + imageUrl = result.keys[0] + uploadUrls = result.presignedUrls + } + + await createTag.mutateAsync({ + tagName: values.tagName, + tagDescription: values.tagDescription || undefined, + imageUrl, + isDashboardTag: values.isDashboardTag, + relatedStores: values.relatedStores, + uploadUrls, + }) + + Alert.alert('Success', 'Tag created successfully', [ + { + text: 'OK', + onPress: () => router.back(), + }, + ]) + } catch (error: any) { + const errorMessage = error.message || 'Failed to create tag' + Alert.alert('Error', errorMessage) } - formData.append('isDashboardTag', values.isDashboardTag.toString()); - - // Add related stores - formData.append('relatedStores', JSON.stringify(values.relatedStores)); - - // Add image if uploaded - if (image?.uri) { - const filename = image.uri.split('/').pop() || 'image.jpg'; - const match = /\.(\w+)$/.exec(filename); - const type = match ? `image/${match[1]}` : 'image/jpeg'; - - formData.append('image', { - uri: image.uri, - name: filename, - type, - } as any); - } - - createTag(formData, { - onSuccess: (data) => { - Alert.alert('Success', 'Tag created successfully', [ - { - text: 'OK', - onPress: () => router.back(), - }, - ]); - }, - onError: (error: any) => { - const errorMessage = error.message || 'Failed to create tag'; - Alert.alert('Error', errorMessage); - }, - }); - }; + } const initialValues: TagFormData = { tagName: '', @@ -76,10 +73,10 @@ export default function AddTag() { mode="create" initialValues={initialValues} onSubmit={handleSubmit} - isLoading={isCreating} - stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []} + isLoading={createTag.isPending || isUploading} + stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []} /> ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx b/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx index 9c91b46..693c62f 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/edit/index.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { View, Alert } from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; -import { AppContainer, MyText, tw } from 'common-ui'; +import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui'; import TagForm from '@/src/components/TagForm'; -import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api'; import { trpc } from '@/src/trpc-client'; +import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'; interface TagFormData { tagName: string; @@ -19,53 +19,56 @@ export default function EditTag() { const { tagId } = useLocalSearchParams<{ tagId: string }>(); const tagIdNum = tagId ? parseInt(tagId) : null; - const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!); - const { mutate: updateTag, isPending: isUpdating } = useUpdateTag(); + const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.product.getProductTagById.useQuery( + { id: tagIdNum || 0 }, + { enabled: !!tagIdNum } + ) + const updateTag = trpc.admin.product.updateProductTag.useMutation(); const { data: storesData } = trpc.admin.store.getStores.useQuery(); + const { upload, isUploading } = useUploadToObjectStorage(); - const handleSubmit = (values: TagFormData, image?: { uri?: string }) => { + const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => { if (!tagIdNum) return; - const formData = new FormData(); + try { + let imageUrl: string | null | undefined + let uploadUrls: string[] = [] - // Add text fields - formData.append('tagName', values.tagName); - if (values.tagDescription) { - formData.append('tagDescription', values.tagDescription); + const newImage = images.find((image) => image.mimeType !== null) + if (newImage) { + const response = await fetch(newImage.imgUrl) + const blob = await response.blob() + const result = await upload({ + images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }], + contextString: 'tags', + }) + imageUrl = result.keys[0] + uploadUrls = result.presignedUrls + } else if (removedExisting) { + imageUrl = null + } + + await updateTag.mutateAsync({ + id: tagIdNum, + tagName: values.tagName, + tagDescription: values.tagDescription || undefined, + imageUrl, + isDashboardTag: values.isDashboardTag, + relatedStores: values.relatedStores, + uploadUrls, + }) + + Alert.alert('Success', 'Tag updated successfully', [ + { + text: 'OK', + onPress: () => router.back(), + }, + ]) + } catch (error: any) { + const errorMessage = error.message || 'Failed to update tag' + Alert.alert('Error', errorMessage) } - formData.append('isDashboardTag', values.isDashboardTag.toString()); - - // Add related stores - formData.append('relatedStores', JSON.stringify(values.relatedStores)); - - // Add image if uploaded - if (image?.uri) { - const filename = image.uri.split('/').pop() || 'image.jpg'; - const match = /\.(\w+)$/.exec(filename); - const type = match ? `image/${match[1]}` : 'image/jpeg'; - - formData.append('image', { - uri: image.uri, - name: filename, - type, - } as any); - } - - updateTag({ id: tagIdNum, formData }, { - onSuccess: (data) => { - Alert.alert('Success', 'Tag updated successfully', [ - { - text: 'OK', - onPress: () => router.back(), - }, - ]); - }, - onError: (error: any) => { - const errorMessage = error.message || 'Failed to update tag'; - Alert.alert('Error', errorMessage); - }, - }); - }; + } if (isLoadingTag) { return ( @@ -92,7 +95,7 @@ export default function EditTag() { tagName: tag.tagName, tagDescription: tag.tagDescription || '', isDashboardTag: tag.isDashboardTag, - relatedStores: tag.relatedStores || [], + relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [], existingImageUrl: tag.imageUrl || undefined, }; @@ -106,10 +109,10 @@ export default function EditTag() { initialValues={initialValues} existingImageUrl={tag.imageUrl || undefined} onSubmit={handleSubmit} - isLoading={isUpdating} - stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []} + isLoading={updateTag.isPending || isUploading} + stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []} /> ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/app/(drawer)/product-tags/index.tsx b/apps/admin-ui/app/(drawer)/product-tags/index.tsx index 27b74ce..d2b6d8f 100644 --- a/apps/admin-ui/app/(drawer)/product-tags/index.tsx +++ b/apps/admin-ui/app/(drawer)/product-tags/index.tsx @@ -5,10 +5,20 @@ import { useRouter } from 'expo-router'; import { MaterialIcons } from '@expo/vector-icons'; import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui'; import { TagMenu } from '@/src/components/TagMenu'; -import { useGetTags, Tag } from '@/src/api-hooks/tag.api'; +import { trpc } from '@/src/trpc-client'; + +interface TagItemData { + id: number; + tagName: string; + tagDescription: string | null; + imageUrl: string | null; + isDashboardTag: boolean; + relatedStores?: unknown; + createdAt: string | Date; +} interface TagItemProps { - item: Tag; + item: TagItemData; onDeleteSuccess: () => void; } @@ -60,7 +70,7 @@ const TagHeader: React.FC = ({ onAddNewTag }) => ( export default function ProductTags() { const router = useRouter(); - const { data: tagsData, isLoading, error, refetch } = useGetTags(); + const { data: tagsData, isLoading, error, refetch } = trpc.admin.product.getProductTags.useQuery(); const [refreshing, setRefreshing] = useState(false); const tags = tagsData?.tags || []; @@ -129,4 +139,4 @@ export default function ProductTags() { /> ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/hooks/useUploadToObjectStore.ts b/apps/admin-ui/hooks/useUploadToObjectStore.ts index 1e62ee0..febfbcd 100644 --- a/apps/admin-ui/hooks/useUploadToObjectStore.ts +++ b/apps/admin-ui/hooks/useUploadToObjectStore.ts @@ -1,7 +1,7 @@ import { useState } from 'react'; import { trpc } from '../src/trpc-client'; -type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'complaint' | 'profile'; +type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'complaint' | 'profile' | 'tags'; interface UploadInput { blob: Blob; diff --git a/apps/admin-ui/src/api-hooks/tag.api.ts b/apps/admin-ui/src/api-hooks/tag.api.ts deleted file mode 100644 index cd84dc9..0000000 --- a/apps/admin-ui/src/api-hooks/tag.api.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import axios from '../../services/axios-admin-ui'; - -// Types -export interface CreateTagPayload { - tagName: string; - tagDescription?: string; - imageUrl?: string; - isDashboardTag: boolean; - relatedStores?: number[]; -} - -export interface UpdateTagPayload { - tagName: string; - tagDescription?: string; - imageUrl?: string; - isDashboardTag: boolean; - relatedStores?: number[]; -} - -export interface Tag { - id: number; - tagName: string; - tagDescription: string | null; - imageUrl: string | null; - isDashboardTag: boolean; - relatedStores: number[]; - createdAt?: string; -} - -export interface CreateTagResponse { - tag: Tag; - message: string; -} - -export interface GetTagsResponse { - tags: Tag[]; - message: string; -} - -// API functions -const createTagApi = async (formData: FormData): Promise => { - const response = await axios.post('/av/product-tags', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - return response.data; -}; - -const updateTagApi = async ({ id, formData }: { id: number; formData: FormData }): Promise => { - const response = await axios.put(`/av/product-tags/${id}`, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - return response.data; -}; - -const deleteTagApi = async (id: number): Promise<{ message: string }> => { - const response = await axios.delete(`/av/product-tags/${id}`); - return response.data; -}; - -const getTagsApi = async (): Promise => { - const response = await axios.get('/av/product-tags'); - return response.data; -}; - -const getTagApi = async (id: number): Promise<{ tag: Tag }> => { - const response = await axios.get(`/av/product-tags/${id}`); - return response.data; -}; - -// Hooks -export const useCreateTag = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: createTagApi, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tags'] }); - }, - }); -}; - -export const useUpdateTag = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: updateTagApi, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tags'] }); - }, - }); -}; - -export const useDeleteTag = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: deleteTagApi, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tags'] }); - }, - }); -}; - -export const useGetTags = () => { - return useQuery({ - queryKey: ['tags'], - queryFn: getTagsApi, - }); -}; - -export const useGetTag = (id: number) => { - return useQuery({ - queryKey: ['tags', id], - queryFn: () => getTagApi(id), - enabled: !!id, - }); -}; \ No newline at end of file diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 07b76b3..eeeaa69 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -5,7 +5,6 @@ import * as Yup from 'yup'; import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { trpc } from '../trpc-client'; -import { useGetTags } from '../api-hooks/tag.api'; interface ProductFormData { name: string; @@ -71,7 +70,7 @@ const ProductForm = forwardRef(({ value: store.id, })) || []; - const { data: tagsData } = useGetTags(); + const { data: tagsData } = trpc.admin.product.getProductTags.useQuery(); const tagOptions = tagsData?.tags.map(tag => ({ label: tag.tagName, value: tag.id.toString(), diff --git a/apps/admin-ui/src/components/TagForm.tsx b/apps/admin-ui/src/components/TagForm.tsx index 8045cd7..b91640e 100644 --- a/apps/admin-ui/src/components/TagForm.tsx +++ b/apps/admin-ui/src/components/TagForm.tsx @@ -1,10 +1,8 @@ import React, { useState, useEffect, forwardRef, useCallback } from 'react'; import { View, TouchableOpacity } from 'react-native'; -import { Image } from 'expo-image'; import { Formik } from 'formik'; import * as Yup from 'yup'; -import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui'; -import usePickImage from 'common-ui/src/components/use-pick-image'; +import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; interface StoreOption { @@ -23,7 +21,7 @@ interface TagFormProps { mode: 'create' | 'edit'; initialValues: TagFormData; existingImageUrl?: string; - onSubmit: (values: TagFormData, image?: { uri?: string }) => void; + onSubmit: (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => void; isLoading: boolean; stores?: StoreOption[]; } @@ -36,22 +34,20 @@ const TagForm = forwardRef(({ isLoading, stores = [], }, ref) => { - const [image, setImage] = useState<{ uri?: string } | null>(null); + const [images, setImages] = useState([]) + const [removedExisting, setRemovedExisting] = useState(false) const [isDashboardTagChecked, setIsDashboardTagChecked] = useState(Boolean(initialValues.isDashboardTag)); // Update checkbox when initial values change useEffect(() => { setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag)); - existingImageUrl && setImage({uri:existingImageUrl}) - }, [initialValues.isDashboardTag]); - - const pickImage = usePickImage({ - setFile: (files) => { - - setImage(files || null) - }, - multiple: false, - }); + if (existingImageUrl) { + setImages([{ imgUrl: existingImageUrl, mimeType: null }]) + } else { + setImages([]) + } + setRemovedExisting(false) + }, [existingImageUrl, initialValues.isDashboardTag]); const validationSchema = Yup.object().shape({ @@ -67,17 +63,17 @@ const TagForm = forwardRef(({ onSubmit(values, image || undefined)} + onSubmit={(values) => onSubmit(values, images, removedExisting)} enableReinitialize > {({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => { // Clear form when screen comes into focus - const clearForm = useCallback(() => { - setImage(null); - - setIsDashboardTagChecked(false); - resetForm(); - }, [resetForm]); + const clearForm = useCallback(() => { + setImages([]) + setRemovedExisting(false) + setIsDashboardTagChecked(false); + resetForm(); + }, [resetForm]); useFocusCallback(clearForm); @@ -108,10 +104,21 @@ const TagForm = forwardRef(({ - setImage(null)} + { + setImages((prev) => [...prev, ...payload.map((img) => ({ + imgUrl: img.url, + mimeType: img.mimeType, + }))]) + }} + onImageRemove={(payload) => { + if (payload.mimeType === null) { + setRemovedExisting(true) + } + setImages((prev) => prev.filter((item) => item.imgUrl !== payload.url)) + }} + allowMultiple={false} /> @@ -167,4 +174,4 @@ const TagForm = forwardRef(({ TagForm.displayName = 'TagForm'; -export default TagForm; \ No newline at end of file +export default TagForm; diff --git a/apps/admin-ui/src/components/TagMenu.tsx b/apps/admin-ui/src/components/TagMenu.tsx index 2bd9d8a..5961d5d 100644 --- a/apps/admin-ui/src/components/TagMenu.tsx +++ b/apps/admin-ui/src/components/TagMenu.tsx @@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native'; import { Entypo } from '@expo/vector-icons'; import { MyText, tw, BottomDialog } from 'common-ui'; import { useRouter } from 'expo-router'; -import { useDeleteTag } from '../api-hooks/tag.api'; +import { trpc } from '@/src/trpc-client'; export interface TagMenuProps { tagId: number; @@ -22,7 +22,7 @@ export const TagMenu: React.FC = ({ }) => { const [isOpen, setIsOpen] = useState(false); const router = useRouter(); - const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag(); + const deleteTag = trpc.admin.product.deleteProductTag.useMutation(); const handleOpenMenu = () => { setIsOpen(true); @@ -54,7 +54,7 @@ export const TagMenu: React.FC = ({ }; const performDelete = () => { - deleteTag(tagId, { + deleteTag.mutate({ id: tagId }, { onSuccess: () => { Alert.alert('Success', 'Tag deleted successfully'); onDeleteSuccess?.(); @@ -63,7 +63,7 @@ export const TagMenu: React.FC = ({ const errorMessage = error.message || 'Failed to delete tag'; Alert.alert('Error', errorMessage); }, - }); + }) }; const options = [ @@ -116,4 +116,4 @@ export const TagMenu: React.FC = ({ ); -}; \ No newline at end of file +}; 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..1f24bac 100755 --- a/apps/backend/src/apis/admin-apis/apis/av-router.ts +++ b/apps/backend/src/apis/admin-apis/apis/av-router.ts @@ -1,7 +1,6 @@ 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(); @@ -11,9 +10,6 @@ router.use(authenticateStaff); // Product routes router.use("/products", productRouter); -// Tag routes -router.use("/product-tags", tagRouter); - const avRouter = router; -export default avRouter; \ No newline at end of file +export default avRouter; diff --git a/apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts b/apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts deleted file mode 100644 index cc23a52..0000000 --- a/apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { Request, Response } from "express"; -import { - checkProductTagExistsByName, - createProductTag, - deleteProductTag, - getAllProductTagInfos, - getProductTagInfoById, - updateProductTag, -} from '@/src/dbService' -import { ApiError } from "@/src/lib/api-error"; -import { imageUploadS3, generateSignedUrlFromS3Url } from "@/src/lib/s3-client"; -import { deleteS3Image } from "@/src/lib/delete-image"; -import { scheduleStoreInitialization } from '@/src/stores/store-initializer'; - - -/** - * Create a new product tag - */ -export const createTag = async (req: Request, res: Response) => { - const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body; - - if (!tagName) { - throw new ApiError("Tag name is required", 400); - } - - // Check for duplicate tag name - const existingTag = await checkProductTagExistsByName(tagName.trim()) - - if (existingTag) { - throw new ApiError("A tag with this name already exists", 400); - } - - let imageUrl: string | null = null; - - // Handle image upload if file is provided - if (req.file) { - const key = `tags/${Date.now()}-${req.file.originalname}`; - imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key); - } - - // Parse relatedStores if it's a string (from FormData) - let parsedRelatedStores: number[] = []; - if (relatedStores) { - try { - parsedRelatedStores = typeof relatedStores === 'string' - ? JSON.parse(relatedStores) - : relatedStores; - } catch (e) { - parsedRelatedStores = []; - } - } - - const createdTag = await createProductTag({ - tagName: tagName.trim(), - tagDescription, - imageUrl, - isDashboardTag: isDashboardTag || false, - relatedStores: parsedRelatedStores, - }) - - const { products, ...newTag } = createdTag - - // Reinitialize stores to reflect changes in cache - scheduleStoreInitialization() - - // Send response first - res.status(201).json({ - tag: newTag, - message: "Tag created successfully", - }); -}; - -/** - * Get all product tags - */ -export const getAllTags = async (req: Request, res: Response) => { - const tags = await getAllProductTagInfos() - - // Generate signed URLs for tag images - const tagsWithSignedUrls = await Promise.all( - tags.map(async (tag) => ({ - ...tag, - imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null, - })) - ); - - return res.status(200).json({ - tags: tagsWithSignedUrls, - message: "Tags retrieved successfully", - }); -}; - -/** - * Get a single product tag by ID - */ -export const getTagById = async (req: Request, res: Response) => { - const id = req.params.id as string - - const tag = await getProductTagInfoById(parseInt(id)) - - if (!tag) { - throw new ApiError("Tag not found", 404); - } - - // Generate signed URL for tag image - const tagWithSignedUrl = { - ...tag, - imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null, - }; - - return res.status(200).json({ - tag: tagWithSignedUrl, - message: "Tag retrieved successfully", - }); -}; - -/** - * Update a product tag - */ -export const updateTag = async (req: Request, res: Response) => { - const id = req.params.id as string - const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body; - - // Get the current tag to check for existing image - const currentTag = await getProductTagInfoById(parseInt(id)) - - if (!currentTag) { - throw new ApiError("Tag not found", 404); - } - - let imageUrl = currentTag.imageUrl; - - // Handle image upload if new file is provided - if (req.file) { - // Delete old image if it exists - if (currentTag.imageUrl) { - try { - await deleteS3Image(currentTag.imageUrl); - } catch (error) { - console.error("Failed to delete old image:", error); - // Continue with update even if delete fails - } - } - - - // Upload new image - const key = `tags/${Date.now()}-${req.file.originalname}`; - console.log('file', key) - imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key); - } - - // Parse relatedStores if it's a string (from FormData) - let parsedRelatedStores: number[] | undefined; - if (relatedStores !== undefined) { - try { - parsedRelatedStores = typeof relatedStores === 'string' - ? JSON.parse(relatedStores) - : relatedStores; - } catch (e) { - parsedRelatedStores = []; - } - } - - const updatedTag = await updateProductTag(parseInt(id), { - tagName: tagName?.trim(), - tagDescription, - imageUrl, - isDashboardTag, - relatedStores: parsedRelatedStores, - }) - - const { products, ...updatedTagInfo } = updatedTag - - // Reinitialize stores to reflect changes in cache - scheduleStoreInitialization() - - // Send response first - res.status(200).json({ - tag: updatedTagInfo, - message: "Tag updated successfully", - }); -}; - -/** - * Delete a product tag - */ -export const deleteTag = async (req: Request, res: Response) => { - const id = req.params.id as string - - // Check if tag exists - const tag = await getProductTagInfoById(parseInt(id)) - - if (!tag) { - throw new ApiError("Tag not found", 404); - } - - // Delete image from S3 if it exists - if (tag.imageUrl) { - try { - await deleteS3Image(tag.imageUrl); - } catch (error) { - console.error("Failed to delete image from S3:", error); - // Continue with deletion even if image delete fails - } - } - - // Note: This will fail if tag is still assigned to products due to foreign key constraint - await deleteProductTag(parseInt(id)) - - // Reinitialize stores to reflect changes in cache - scheduleStoreInitialization() - - // Send response first - res.status(200).json({ - message: "Tag deleted successfully", - }); -}; diff --git a/apps/backend/src/apis/admin-apis/apis/tag.router.ts b/apps/backend/src/apis/admin-apis/apis/tag.router.ts deleted file mode 100644 index dbcdb0d..0000000 --- a/apps/backend/src/apis/admin-apis/apis/tag.router.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Router } from "express"; -import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "@/src/apis/admin-apis/apis/product-tags.controller" -import uploadHandler from '@/src/lib/upload-handler'; - -const router = Router(); - -// Tag routes -router.post("/", uploadHandler.single('image'), createTag); -router.get("/", getAllTags); -router.get("/:id", getTagById); -router.put("/:id", uploadHandler.single('image'), updateTag); -router.delete("/:id", deleteTag); - -export default router; \ No newline at end of file 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 42c5032..270d0a2 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -1,7 +1,7 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod' import { ApiError } from '@/src/lib/api-error' -import { generateSignedUrlsFromS3Urls, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client' +import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client' import { scheduleStoreInitialization } from '@/src/stores/store-initializer' import { getAllProducts as getAllProductsInDb, @@ -26,6 +26,12 @@ import { getProductImagesById, updateProduct as updateProductInDb, updateProductDeals, + checkProductTagExistsByName, + createProductTag as createProductTagInDb, + updateProductTag as updateProductTagInDb, + deleteProductTag as deleteProductTagInDb, + getAllProductTagInfos as getAllProductTagInfosInDb, + getProductTagInfoById as getProductTagInfoByIdInDb, } from '@/src/dbService' import type { AdminProduct, @@ -818,18 +824,18 @@ export const productRouter = router({ } }), - updateProductPrices: protectedProcedure - .input(z.object({ - updates: z.array(z.object({ - productId: z.number(), - price: z.number().optional(), - marketPrice: z.number().nullable().optional(), - flashPrice: z.number().nullable().optional(), - isFlashAvailable: z.boolean().optional(), - })), - })) - .mutation(async ({ input }): Promise => { - const { updates } = input; + updateProductPrices: protectedProcedure + .input(z.object({ + updates: z.array(z.object({ + productId: z.number(), + price: z.number().optional(), + marketPrice: z.number().nullable().optional(), + flashPrice: z.number().nullable().optional(), + isFlashAvailable: z.boolean().optional(), + })), + })) + .mutation(async ({ input }): Promise => { + const { updates } = input; if (updates.length === 0) { throw new ApiError('No updates provided', 400) @@ -889,9 +895,158 @@ export const productRouter = router({ scheduleStoreInitialization() - return { - message: `Updated prices for ${result.updatedCount} product(s)`, - updatedCount: result.updatedCount, - } + return { + message: `Updated prices for ${result.updatedCount} product(s)`, + updatedCount: result.updatedCount, + } }), + + getProductTags: protectedProcedure + .query(async (): Promise<{ tags: Array<{ id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }>; message: string }> => { + const tags = await getAllProductTagInfosInDb() + + const tagsWithSignedUrls = await Promise.all( + tags.map(async (tag) => ({ + ...tag, + imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null, + })) + ) + + return { + tags: tagsWithSignedUrls, + message: 'Tags retrieved successfully', + } + }), + + getProductTagById: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => { + const tag = await getProductTagInfoByIdInDb(input.id) + + if (!tag) { + throw new ApiError('Tag not found', 404) + } + + const tagWithSignedUrl = { + ...tag, + imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null, + } + + return { + tag: tagWithSignedUrl, + message: 'Tag retrieved successfully', + } + }), + + createProductTag: protectedProcedure + .input(z.object({ + tagName: z.string().min(1, 'Tag name is required'), + tagDescription: z.string().optional(), + imageUrl: z.string().optional().nullable(), + isDashboardTag: z.boolean().optional().default(false), + relatedStores: z.array(z.number()).optional().default([]), + uploadUrls: z.array(z.string()).optional().default([]), + })) + .mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => { + const { tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input + + const existingTag = await checkProductTagExistsByName(tagName.trim()) + if (existingTag) { + throw new ApiError('A tag with this name already exists', 400) + } + + const createdTag = await createProductTagInDb({ + tagName: tagName.trim(), + tagDescription, + imageUrl: imageUrl ?? null, + isDashboardTag, + relatedStores, + }) + + if (uploadUrls.length > 0) { + await Promise.all(uploadUrls.map((url) => claimUploadUrl(url))) + } + + scheduleStoreInitialization() + + const { products, ...createdTagInfo } = createdTag + + return { + tag: { + ...createdTagInfo, + imageUrl: createdTagInfo.imageUrl ? await generateSignedUrlFromS3Url(createdTagInfo.imageUrl) : null, + }, + message: 'Tag created successfully', + } + }), + + updateProductTag: protectedProcedure + .input(z.object({ + id: z.number(), + tagName: z.string().min(1).optional(), + tagDescription: z.string().optional().nullable(), + imageUrl: z.string().optional().nullable(), + isDashboardTag: z.boolean().optional(), + relatedStores: z.array(z.number()).optional(), + uploadUrls: z.array(z.string()).optional().default([]), + })) + .mutation(async ({ input }): Promise<{ tag: { id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: unknown; createdAt: Date }; message: string }> => { + const { id, tagName, tagDescription, imageUrl, isDashboardTag, relatedStores, uploadUrls } = input + + const currentTag = await getProductTagInfoByIdInDb(id) + + if (!currentTag) { + throw new ApiError('Tag not found', 404) + } + + if (imageUrl !== undefined && imageUrl !== currentTag.imageUrl) { + if (currentTag.imageUrl) { + await deleteImageUtil({ keys: [currentTag.imageUrl] }) + } + } + + const updatedTag = await updateProductTagInDb(id, { + tagName: tagName?.trim(), + tagDescription: tagDescription ?? undefined, + imageUrl: imageUrl ?? undefined, + isDashboardTag, + relatedStores, + }) + + if (uploadUrls.length > 0) { + await Promise.all(uploadUrls.map((url) => claimUploadUrl(url))) + } + + scheduleStoreInitialization() + + const { products, ...updatedTagInfo } = updatedTag + + return { + tag: { + ...updatedTagInfo, + imageUrl: updatedTagInfo.imageUrl ? await generateSignedUrlFromS3Url(updatedTagInfo.imageUrl) : null, + }, + message: 'Tag updated successfully', + } + }), + + deleteProductTag: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }): Promise<{ message: string }> => { + const tag = await getProductTagInfoByIdInDb(input.id) + + if (!tag) { + throw new ApiError('Tag not found', 404) + } + + if (tag.imageUrl) { + await deleteImageUtil({ keys: [tag.imageUrl] }) + } + + await deleteProductTagInDb(input.id) + + scheduleStoreInitialization() + + return { message: 'Tag deleted successfully' } + }), }); diff --git a/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts b/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts index 49cb6df..1ac10e4 100644 --- a/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts +++ b/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts @@ -82,7 +82,7 @@ export const commonApiRouter = router({ generateUploadUrls: protectedProcedure .input(z.object({ - contextString: z.enum(['review', 'review_response', 'product_info', 'notification', 'store', 'complaint', 'profile']), + contextString: z.enum(['review', 'review_response', 'product_info', 'notification', 'store', 'complaint', 'profile', 'tags']), mimeTypes: z.array(z.string()), })) .mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => { @@ -106,6 +106,8 @@ export const commonApiRouter = router({ folder = 'complaint-images'; } else if (contextString === 'profile') { folder = 'profile-images'; + } else if (contextString === 'tags') { + folder = 'tags'; } else { folder = ''; } diff --git a/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts b/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts index 9f6ef4b..f27b6f8 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts @@ -6,7 +6,7 @@ import { ApiError } from '@/src/lib/api-error'; export const fileUploadRouter = router({ generateUploadUrls: protectedProcedure .input(z.object({ - contextString: z.enum(['review', 'product_info', 'notification', 'complaint', 'profile']), + contextString: z.enum(['review', 'product_info', 'notification', 'complaint', 'profile', 'tags']), mimeTypes: z.array(z.string()), })) .mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => { @@ -32,6 +32,8 @@ export const fileUploadRouter = router({ folder = 'complaint-images' } else if (contextString === 'profile') { folder = 'profile-images' + } else if (contextString === 'tags') { + folder = 'tags' } else { folder = ''; }