This commit is contained in:
shafi54 2026-03-26 17:36:36 +05:30
parent ca7d8df1c8
commit 128e3b6a58
14 changed files with 326 additions and 505 deletions

View file

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { View, Alert } from 'react-native'; import { View, Alert } from 'react-native';
import { useRouter } from 'expo-router'; 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 TagForm from '@/src/components/TagForm';
import { useCreateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface TagFormData { interface TagFormData {
tagName: string; tagName: string;
@ -15,50 +15,47 @@ interface TagFormData {
export default function AddTag() { export default function AddTag() {
const router = useRouter(); 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 { 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) => {
const formData = new FormData(); try {
let imageUrl: string | null | undefined;
let uploadUrls: string[] = []
// Add text fields const newImage = images.find((image) => image.mimeType !== null)
formData.append('tagName', values.tagName); if (newImage) {
if (values.tagDescription) { const response = await fetch(newImage.imgUrl)
formData.append('tagDescription', values.tagDescription); 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 = { const initialValues: TagFormData = {
tagName: '', tagName: '',
@ -76,8 +73,8 @@ export default function AddTag() {
mode="create" mode="create"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isCreating} isLoading={createTag.isPending || isUploading}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []} stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
/> />
</View> </View>
</AppContainer> </AppContainer>

View file

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { View, Alert } from 'react-native'; import { View, Alert } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router'; 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 TagForm from '@/src/components/TagForm';
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface TagFormData { interface TagFormData {
tagName: string; tagName: string;
@ -19,53 +19,56 @@ export default function EditTag() {
const { tagId } = useLocalSearchParams<{ tagId: string }>(); const { tagId } = useLocalSearchParams<{ tagId: string }>();
const tagIdNum = tagId ? parseInt(tagId) : null; const tagIdNum = tagId ? parseInt(tagId) : null;
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!); const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.product.getProductTagById.useQuery(
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag(); { id: tagIdNum || 0 },
{ enabled: !!tagIdNum }
)
const updateTag = trpc.admin.product.updateProductTag.useMutation();
const { data: storesData } = trpc.admin.store.getStores.useQuery(); 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; if (!tagIdNum) return;
const formData = new FormData(); try {
let imageUrl: string | null | undefined
let uploadUrls: string[] = []
// Add text fields const newImage = images.find((image) => image.mimeType !== null)
formData.append('tagName', values.tagName); if (newImage) {
if (values.tagDescription) { const response = await fetch(newImage.imgUrl)
formData.append('tagDescription', values.tagDescription); 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) { if (isLoadingTag) {
return ( return (
@ -92,7 +95,7 @@ export default function EditTag() {
tagName: tag.tagName, tagName: tag.tagName,
tagDescription: tag.tagDescription || '', tagDescription: tag.tagDescription || '',
isDashboardTag: tag.isDashboardTag, isDashboardTag: tag.isDashboardTag,
relatedStores: tag.relatedStores || [], relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
existingImageUrl: tag.imageUrl || undefined, existingImageUrl: tag.imageUrl || undefined,
}; };
@ -106,8 +109,8 @@ export default function EditTag() {
initialValues={initialValues} initialValues={initialValues}
existingImageUrl={tag.imageUrl || undefined} existingImageUrl={tag.imageUrl || undefined}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isUpdating} isLoading={updateTag.isPending || isUploading}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []} stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
/> />
</View> </View>
</AppContainer> </AppContainer>

View file

@ -5,10 +5,20 @@ import { useRouter } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui'; import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
import { TagMenu } from '@/src/components/TagMenu'; 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 { interface TagItemProps {
item: Tag; item: TagItemData;
onDeleteSuccess: () => void; onDeleteSuccess: () => void;
} }
@ -60,7 +70,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
export default function ProductTags() { export default function ProductTags() {
const router = useRouter(); 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 [refreshing, setRefreshing] = useState(false);
const tags = tagsData?.tags || []; const tags = tagsData?.tags || [];

View file

@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { trpc } from '../src/trpc-client'; 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 { interface UploadInput {
blob: Blob; blob: Blob;

View file

@ -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<CreateTagResponse> => {
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<CreateTagResponse> => {
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<GetTagsResponse> => {
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,
});
};

View file

@ -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 { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { trpc } from '../trpc-client'; import { trpc } from '../trpc-client';
import { useGetTags } from '../api-hooks/tag.api';
interface ProductFormData { interface ProductFormData {
name: string; name: string;
@ -71,7 +70,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
value: store.id, value: store.id,
})) || []; })) || [];
const { data: tagsData } = useGetTags(); const { data: tagsData } = trpc.admin.product.getProductTags.useQuery();
const tagOptions = tagsData?.tags.map(tag => ({ const tagOptions = tagsData?.tags.map(tag => ({
label: tag.tagName, label: tag.tagName,
value: tag.id.toString(), value: tag.id.toString(),

View file

@ -1,10 +1,8 @@
import React, { useState, useEffect, forwardRef, useCallback } from 'react'; import React, { useState, useEffect, forwardRef, useCallback } from 'react';
import { View, TouchableOpacity } from 'react-native'; import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Formik } from 'formik'; import { Formik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui'; import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
interface StoreOption { interface StoreOption {
@ -23,7 +21,7 @@ interface TagFormProps {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
initialValues: TagFormData; initialValues: TagFormData;
existingImageUrl?: string; existingImageUrl?: string;
onSubmit: (values: TagFormData, image?: { uri?: string }) => void; onSubmit: (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => void;
isLoading: boolean; isLoading: boolean;
stores?: StoreOption[]; stores?: StoreOption[];
} }
@ -36,22 +34,20 @@ const TagForm = forwardRef<any, TagFormProps>(({
isLoading, isLoading,
stores = [], stores = [],
}, ref) => { }, ref) => {
const [image, setImage] = useState<{ uri?: string } | null>(null); const [images, setImages] = useState<ImageUploaderNeoItem[]>([])
const [removedExisting, setRemovedExisting] = useState(false)
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag)); const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
// Update checkbox when initial values change // Update checkbox when initial values change
useEffect(() => { useEffect(() => {
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag)); setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
existingImageUrl && setImage({uri:existingImageUrl}) if (existingImageUrl) {
}, [initialValues.isDashboardTag]); setImages([{ imgUrl: existingImageUrl, mimeType: null }])
} else {
const pickImage = usePickImage({ setImages([])
setFile: (files) => { }
setRemovedExisting(false)
setImage(files || null) }, [existingImageUrl, initialValues.isDashboardTag]);
},
multiple: false,
});
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
@ -67,17 +63,17 @@ const TagForm = forwardRef<any, TagFormProps>(({
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
validationSchema={validationSchema} validationSchema={validationSchema}
onSubmit={(values) => onSubmit(values, image || undefined)} onSubmit={(values) => onSubmit(values, images, removedExisting)}
enableReinitialize enableReinitialize
> >
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => { {({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
// Clear form when screen comes into focus // Clear form when screen comes into focus
const clearForm = useCallback(() => { const clearForm = useCallback(() => {
setImage(null); setImages([])
setRemovedExisting(false)
setIsDashboardTagChecked(false); setIsDashboardTagChecked(false);
resetForm(); resetForm();
}, [resetForm]); }, [resetForm]);
useFocusCallback(clearForm); useFocusCallback(clearForm);
@ -108,10 +104,21 @@ const TagForm = forwardRef<any, TagFormProps>(({
</MyText> </MyText>
<ImageUploader <ImageUploaderNeo
images={image ? [image] : []} images={images}
onAddImage={pickImage} onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
onRemoveImage={() => 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}
/> />
</View> </View>

View file

@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native';
import { Entypo } from '@expo/vector-icons'; import { Entypo } from '@expo/vector-icons';
import { MyText, tw, BottomDialog } from 'common-ui'; import { MyText, tw, BottomDialog } from 'common-ui';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useDeleteTag } from '../api-hooks/tag.api'; import { trpc } from '@/src/trpc-client';
export interface TagMenuProps { export interface TagMenuProps {
tagId: number; tagId: number;
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag(); const deleteTag = trpc.admin.product.deleteProductTag.useMutation();
const handleOpenMenu = () => { const handleOpenMenu = () => {
setIsOpen(true); setIsOpen(true);
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
}; };
const performDelete = () => { const performDelete = () => {
deleteTag(tagId, { deleteTag.mutate({ id: tagId }, {
onSuccess: () => { onSuccess: () => {
Alert.alert('Success', 'Tag deleted successfully'); Alert.alert('Success', 'Tag deleted successfully');
onDeleteSuccess?.(); onDeleteSuccess?.();
@ -63,7 +63,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
const errorMessage = error.message || 'Failed to delete tag'; const errorMessage = error.message || 'Failed to delete tag';
Alert.alert('Error', errorMessage); Alert.alert('Error', errorMessage);
}, },
}); })
}; };
const options = [ const options = [

View file

@ -1,7 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import { authenticateStaff } from "@/src/middleware/staff-auth"; import { authenticateStaff } from "@/src/middleware/staff-auth";
import productRouter from "@/src/apis/admin-apis/apis/product.router" import productRouter from "@/src/apis/admin-apis/apis/product.router"
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
const router = Router(); const router = Router();
@ -11,9 +10,6 @@ router.use(authenticateStaff);
// Product routes // Product routes
router.use("/products", productRouter); router.use("/products", productRouter);
// Tag routes
router.use("/product-tags", tagRouter);
const avRouter = router; const avRouter = router;
export default avRouter; export default avRouter;

View file

@ -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",
});
};

View file

@ -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;

View file

@ -1,7 +1,7 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod' import { z } from 'zod'
import { ApiError } from '@/src/lib/api-error' 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 { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { import {
getAllProducts as getAllProductsInDb, getAllProducts as getAllProductsInDb,
@ -26,6 +26,12 @@ import {
getProductImagesById, getProductImagesById,
updateProduct as updateProductInDb, updateProduct as updateProductInDb,
updateProductDeals, updateProductDeals,
checkProductTagExistsByName,
createProductTag as createProductTagInDb,
updateProductTag as updateProductTagInDb,
deleteProductTag as deleteProductTagInDb,
getAllProductTagInfos as getAllProductTagInfosInDb,
getProductTagInfoById as getProductTagInfoByIdInDb,
} from '@/src/dbService' } from '@/src/dbService'
import type { import type {
AdminProduct, AdminProduct,
@ -818,18 +824,18 @@ export const productRouter = router({
} }
}), }),
updateProductPrices: protectedProcedure updateProductPrices: protectedProcedure
.input(z.object({ .input(z.object({
updates: z.array(z.object({ updates: z.array(z.object({
productId: z.number(), productId: z.number(),
price: z.number().optional(), price: z.number().optional(),
marketPrice: z.number().nullable().optional(), marketPrice: z.number().nullable().optional(),
flashPrice: z.number().nullable().optional(), flashPrice: z.number().nullable().optional(),
isFlashAvailable: z.boolean().optional(), isFlashAvailable: z.boolean().optional(),
})), })),
})) }))
.mutation(async ({ input }): Promise<AdminUpdateProductPricesResult> => { .mutation(async ({ input }): Promise<AdminUpdateProductPricesResult> => {
const { updates } = input; const { updates } = input;
if (updates.length === 0) { if (updates.length === 0) {
throw new ApiError('No updates provided', 400) throw new ApiError('No updates provided', 400)
@ -889,9 +895,158 @@ export const productRouter = router({
scheduleStoreInitialization() scheduleStoreInitialization()
return { return {
message: `Updated prices for ${result.updatedCount} product(s)`, message: `Updated prices for ${result.updatedCount} product(s)`,
updatedCount: result.updatedCount, 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' }
}),
}); });

View file

@ -82,7 +82,7 @@ export const commonApiRouter = router({
generateUploadUrls: protectedProcedure generateUploadUrls: protectedProcedure
.input(z.object({ .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()), mimeTypes: z.array(z.string()),
})) }))
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => { .mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
@ -106,6 +106,8 @@ export const commonApiRouter = router({
folder = 'complaint-images'; folder = 'complaint-images';
} else if (contextString === 'profile') { } else if (contextString === 'profile') {
folder = 'profile-images'; folder = 'profile-images';
} else if (contextString === 'tags') {
folder = 'tags';
} else { } else {
folder = ''; folder = '';
} }

View file

@ -6,7 +6,7 @@ import { ApiError } from '@/src/lib/api-error';
export const fileUploadRouter = router({ export const fileUploadRouter = router({
generateUploadUrls: protectedProcedure generateUploadUrls: protectedProcedure
.input(z.object({ .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()), mimeTypes: z.array(z.string()),
})) }))
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => { .mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
@ -32,6 +32,8 @@ export const fileUploadRouter = router({
folder = 'complaint-images' folder = 'complaint-images'
} else if (contextString === 'profile') { } else if (contextString === 'profile') {
folder = 'profile-images' folder = 'profile-images'
} else if (contextString === 'tags') {
folder = 'tags'
} else { } else {
folder = ''; folder = '';
} }