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 { 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);
}
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);
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
}
createTag(formData, {
onSuccess: (data) => {
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(),
},
]);
},
onError: (error: any) => {
const errorMessage = error.message || 'Failed to create tag';
Alert.alert('Error', errorMessage);
},
});
};
])
} catch (error: any) {
const errorMessage = error.message || 'Failed to create tag'
Alert.alert('Error', errorMessage)
}
}
const initialValues: TagFormData = {
tagName: '',
@ -76,8 +73,8 @@ 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 })) || []}
/>
</View>
</AppContainer>

View file

@ -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);
}
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);
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
}
updateTag({ id: tagIdNum, formData }, {
onSuccess: (data) => {
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(),
},
]);
},
onError: (error: any) => {
const errorMessage = error.message || 'Failed to update tag';
Alert.alert('Error', errorMessage);
},
});
};
])
} catch (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,8 +109,8 @@ 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 })) || []}
/>
</View>
</AppContainer>

View file

@ -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<TagHeaderProps> = ({ 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 || [];

View file

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

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 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<ProductFormRef, ProductFormProps>(({
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(),

View file

@ -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<any, TagFormProps>(({
isLoading,
stores = [],
}, 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));
// 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,14 +63,14 @@ const TagForm = forwardRef<any, TagFormProps>(({
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values) => 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);
setImages([])
setRemovedExisting(false)
setIsDashboardTagChecked(false);
resetForm();
}, [resetForm]);
@ -108,10 +104,21 @@ const TagForm = forwardRef<any, TagFormProps>(({
</MyText>
<ImageUploader
images={image ? [image] : []}
onAddImage={pickImage}
onRemoveImage={() => setImage(null)}
<ImageUploaderNeo
images={images}
onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
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 file

@ -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<TagMenuProps> = ({
}) => {
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<TagMenuProps> = ({
};
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<TagMenuProps> = ({
const errorMessage = error.message || 'Failed to delete tag';
Alert.alert('Error', errorMessage);
},
});
})
};
const options = [

View file

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

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 { 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,
@ -894,4 +900,153 @@ export const productRouter = router({
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
.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 = '';
}

View file

@ -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 = '';
}