Compare commits
No commits in common. "8f4cddee1adaec2c787b5a6e156c59fa8dac1f10" and "b38ff13950a8e151e2f0f325f4e8422c153be3b7" have entirely different histories.
8f4cddee1a
...
b38ff13950
24 changed files with 728 additions and 886 deletions
6
apps/admin-ui/.expo/types/router.d.ts
vendored
6
apps/admin-ui/.expo/types/router.d.ts
vendored
File diff suppressed because one or more lines are too long
|
|
@ -2,13 +2,13 @@ import React from 'react';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
import { AppContainer } from 'common-ui';
|
import { AppContainer } from 'common-ui';
|
||||||
import ProductForm from '@/src/components/ProductForm';
|
import ProductForm from '@/src/components/ProductForm';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api';
|
||||||
|
|
||||||
export default function AddProduct() {
|
export default function AddProduct() {
|
||||||
const createProduct = trpc.admin.product.createProduct.useMutation();
|
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
|
||||||
|
|
||||||
const handleSubmit = (values: any, imageKeys?: string[]) => {
|
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
|
||||||
createProduct.mutate({
|
const payload: CreateProductPayload = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
shortDescription: values.shortDescription,
|
shortDescription: values.shortDescription,
|
||||||
longDescription: values.longDescription,
|
longDescription: values.longDescription,
|
||||||
|
|
@ -18,12 +18,37 @@ export default function AddProduct() {
|
||||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||||
incrementStep: 1,
|
incrementStep: 1,
|
||||||
productQuantity: values.productQuantity || 1,
|
productQuantity: values.productQuantity || 1,
|
||||||
isSuspended: values.isSuspended || false,
|
};
|
||||||
isFlashAvailable: values.isFlashAvailable || false,
|
|
||||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
const formData = new FormData();
|
||||||
tagIds: values.tagIds || [],
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
imageKeys: imageKeys || [],
|
if (value !== undefined && value !== null) {
|
||||||
}, {
|
formData.append(key, value as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append tag IDs
|
||||||
|
if (values.tagIds && values.tagIds.length > 0) {
|
||||||
|
values.tagIds.forEach((tagId: number) => {
|
||||||
|
formData.append('tagIds', tagId.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append images
|
||||||
|
if (images) {
|
||||||
|
images.forEach((image, index) => {
|
||||||
|
if (image.uri) {
|
||||||
|
formData.append('images', {
|
||||||
|
uri: image.uri,
|
||||||
|
name: `image-${index}.jpg`,
|
||||||
|
// type: 'image/jpeg',
|
||||||
|
type: image.mimeType as any,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createProduct(formData, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
Alert.alert('Success', 'Product created successfully!');
|
Alert.alert('Success', 'Product created successfully!');
|
||||||
// Reset form or navigate
|
// Reset form or navigate
|
||||||
|
|
@ -56,7 +81,7 @@ export default function AddProduct() {
|
||||||
mode="create"
|
mode="create"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={createProduct.isPending}
|
isLoading={isCreating}
|
||||||
existingImages={[]}
|
existingImages={[]}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploa
|
||||||
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import { useUploadToObjectStorage } from '../../../../hooks/useUploadToObjectStorage';
|
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
|
|
@ -27,7 +26,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
|
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
const respondToReview = trpc.admin.product.respondToReview.useMutation();
|
const respondToReview = trpc.admin.product.respondToReview.useMutation();
|
||||||
const { upload, isUploading } = useUploadToObjectStorage();
|
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||||
|
|
||||||
const handleImagePick = usePickImage({
|
const handleImagePick = usePickImage({
|
||||||
setFile: async (assets: any) => {
|
setFile: async (assets: any) => {
|
||||||
|
|
@ -63,16 +62,30 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
|
|
||||||
const handleSubmit = async (adminResponse: string) => {
|
const handleSubmit = async (adminResponse: string) => {
|
||||||
try {
|
try {
|
||||||
let keys: string[] = [];
|
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||||
let generatedUrls: string[] = [];
|
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
|
||||||
|
|
||||||
if (selectedImages.length > 0) {
|
|
||||||
const result = await upload({
|
|
||||||
images: selectedImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
|
|
||||||
contextString: 'review',
|
contextString: 'review',
|
||||||
|
mimeTypes,
|
||||||
});
|
});
|
||||||
keys = result.keys;
|
const keys = generatedUrls.map(url => {
|
||||||
generatedUrls = result.presignedUrls;
|
const u = new URL(url);
|
||||||
|
const rawKey = u.pathname.replace(/^\/+/, "");
|
||||||
|
const decodedKey = decodeURIComponent(rawKey);
|
||||||
|
const parts = decodedKey.split('/');
|
||||||
|
parts.shift();
|
||||||
|
return parts.join('/');
|
||||||
|
});
|
||||||
|
setUploadUrls(generatedUrls);
|
||||||
|
|
||||||
|
for (let i = 0; i < generatedUrls.length; i++) {
|
||||||
|
const uploadUrl = generatedUrls[i];
|
||||||
|
const { blob, mimeType } = selectedImages[i];
|
||||||
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: blob,
|
||||||
|
headers: { 'Content-Type': mimeType },
|
||||||
|
});
|
||||||
|
if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await respondToReview.mutateAsync({
|
await respondToReview.mutateAsync({
|
||||||
|
|
@ -89,6 +102,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
setDisplayImages([]);
|
setDisplayImages([]);
|
||||||
setUploadUrls([]);
|
setUploadUrls([]);
|
||||||
} catch (error:any) {
|
} catch (error:any) {
|
||||||
|
|
||||||
Alert.alert('Error', error.message || 'Failed to submit response.');
|
Alert.alert('Error', error.message || 'Failed to submit response.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -123,7 +137,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => formikSubmit()}
|
onPress={() => formikSubmit()}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
disabled={respondToReview.isPending || isUploading}
|
disabled={respondToReview.isPending}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#2563EB', '#1D4ED8']}
|
colors={['#2563EB', '#1D4ED8']}
|
||||||
|
|
@ -131,9 +145,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
style={tw`py-4 rounded-2xl items-center shadow-lg`}
|
style={tw`py-4 rounded-2xl items-center shadow-lg`}
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{respondToReview.isPending ? (
|
||||||
<ActivityIndicator color="white" />
|
|
||||||
) : respondToReview.isPending ? (
|
|
||||||
<ActivityIndicator color="white" />
|
<ActivityIndicator color="white" />
|
||||||
) : (
|
) : (
|
||||||
<MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText>
|
<MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { View, Text, Alert } from 'react-native';
|
||||||
import { useLocalSearchParams } from 'expo-router';
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
|
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
|
||||||
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
||||||
|
import { useUpdateProduct } from '@/src/api-hooks/product.api';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
export default function EditProduct() {
|
export default function EditProduct() {
|
||||||
|
|
@ -10,18 +11,18 @@ export default function EditProduct() {
|
||||||
const productId = Number(id);
|
const productId = Number(id);
|
||||||
const productFormRef = useRef<ProductFormRef>(null);
|
const productFormRef = useRef<ProductFormRef>(null);
|
||||||
|
|
||||||
|
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
|
||||||
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||||
{ id: productId },
|
{ id: productId },
|
||||||
{ enabled: !!productId }
|
{ enabled: !!productId }
|
||||||
);
|
);
|
||||||
|
//
|
||||||
const updateProduct = trpc.admin.product.updateProduct.useMutation();
|
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
|
||||||
|
|
||||||
useManualRefresh(() => refetch());
|
useManualRefresh(() => refetch());
|
||||||
|
|
||||||
const handleSubmit = (values: any, newImageKeys?: string[], imagesToDelete?: string[]) => {
|
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
|
||||||
updateProduct.mutate({
|
const payload = {
|
||||||
id: productId,
|
|
||||||
name: values.name,
|
name: values.name,
|
||||||
shortDescription: values.shortDescription,
|
shortDescription: values.shortDescription,
|
||||||
longDescription: values.longDescription,
|
longDescription: values.longDescription,
|
||||||
|
|
@ -31,9 +32,6 @@ export default function EditProduct() {
|
||||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||||
incrementStep: 1,
|
incrementStep: 1,
|
||||||
productQuantity: values.productQuantity || 1,
|
productQuantity: values.productQuantity || 1,
|
||||||
isSuspended: values.isSuspended,
|
|
||||||
isFlashAvailable: values.isFlashAvailable,
|
|
||||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
|
||||||
deals: values.deals?.filter((deal: any) =>
|
deals: values.deals?.filter((deal: any) =>
|
||||||
deal.quantity && deal.price && deal.validTill
|
deal.quantity && deal.price && deal.validTill
|
||||||
).map((deal: any) => ({
|
).map((deal: any) => ({
|
||||||
|
|
@ -41,12 +39,47 @@ export default function EditProduct() {
|
||||||
price: parseFloat(deal.price),
|
price: parseFloat(deal.price),
|
||||||
validTill: deal.validTill instanceof Date
|
validTill: deal.validTill instanceof Date
|
||||||
? deal.validTill.toISOString().split('T')[0]
|
? deal.validTill.toISOString().split('T')[0]
|
||||||
: deal.validTill,
|
: deal.validTill, // Convert Date to YYYY-MM-DD string
|
||||||
})),
|
})),
|
||||||
tagIds: values.tagIds,
|
tagIds: values.tagIds,
|
||||||
newImageKeys: newImageKeys || [],
|
};
|
||||||
imagesToDelete: imagesToDelete || [],
|
|
||||||
}, {
|
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
|
if (key === 'deals' && Array.isArray(value)) {
|
||||||
|
formData.append(key, JSON.stringify(value));
|
||||||
|
} else if (key === 'tagIds' && Array.isArray(value)) {
|
||||||
|
value.forEach(tagId => {
|
||||||
|
formData.append('tagIds', tagId.toString());
|
||||||
|
});
|
||||||
|
} else if (value !== undefined && value !== null) {
|
||||||
|
formData.append(key, value as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new images
|
||||||
|
if (newImages && newImages.length > 0) {
|
||||||
|
newImages.forEach((image, index) => {
|
||||||
|
if (image.uri) {
|
||||||
|
const fileName = image.uri.split('/').pop() || `image_${index}.jpg`;
|
||||||
|
formData.append('images', {
|
||||||
|
uri: image.uri,
|
||||||
|
name: fileName,
|
||||||
|
type: 'image/jpeg',
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add images to delete
|
||||||
|
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||||
|
formData.append('imagesToDelete', JSON.stringify(imagesToDelete));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProduct(
|
||||||
|
{ id: productId, formData },
|
||||||
|
{
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
Alert.alert('Success', 'Product updated successfully!');
|
Alert.alert('Success', 'Product updated successfully!');
|
||||||
// Clear newly added images after successful update
|
// Clear newly added images after successful update
|
||||||
|
|
@ -55,7 +88,8 @@ export default function EditProduct() {
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
Alert.alert('Error', error.message || 'Failed to update product');
|
Alert.alert('Error', error.message || 'Failed to update product');
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
|
|
@ -91,7 +125,7 @@ export default function EditProduct() {
|
||||||
deals: productData.deals?.map(deal => ({
|
deals: productData.deals?.map(deal => ({
|
||||||
quantity: deal.quantity,
|
quantity: deal.quantity,
|
||||||
price: deal.price,
|
price: deal.price,
|
||||||
validTill: deal.validTill ? new Date(deal.validTill) : null,
|
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
|
||||||
})) || [{ quantity: '', price: '', validTill: null }],
|
})) || [{ quantity: '', price: '', validTill: null }],
|
||||||
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
||||||
isSuspended: productData.isSuspended || false,
|
isSuspended: productData.isSuspended || false,
|
||||||
|
|
@ -107,7 +141,7 @@ export default function EditProduct() {
|
||||||
mode="edit"
|
mode="edit"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={updateProduct.isPending}
|
isLoading={isUpdating}
|
||||||
existingImages={productData.images || []}
|
existingImages={productData.images || []}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import {
|
||||||
} from 'common-ui';
|
} from 'common-ui';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import { useUploadToObjectStorage } from '../../../hooks/useUploadToObjectStorage';
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -27,6 +26,12 @@ interface User {
|
||||||
isEligibleForNotif: boolean;
|
isEligibleForNotif: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extractKeyFromUrl = (url: string): string => {
|
||||||
|
const u = new URL(url);
|
||||||
|
const rawKey = u.pathname.replace(/^\/+/, '');
|
||||||
|
return decodeURIComponent(rawKey);
|
||||||
|
};
|
||||||
|
|
||||||
export default function SendNotifications() {
|
export default function SendNotifications() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||||
|
|
@ -41,7 +46,8 @@ export default function SendNotifications() {
|
||||||
search: searchQuery,
|
search: searchQuery,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
// Generate upload URLs mutation
|
||||||
|
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||||
|
|
||||||
// Send notification mutation
|
// Send notification mutation
|
||||||
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
||||||
|
|
@ -121,8 +127,28 @@ export default function SendNotifications() {
|
||||||
|
|
||||||
// Upload image if selected
|
// Upload image if selected
|
||||||
if (selectedImage) {
|
if (selectedImage) {
|
||||||
const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
|
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||||
imageUrl = key;
|
contextString: 'notification',
|
||||||
|
mimeTypes: [selectedImage.mimeType],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadUrls.length > 0) {
|
||||||
|
const uploadUrl = uploadUrls[0];
|
||||||
|
imageUrl = extractKeyFromUrl(uploadUrl);
|
||||||
|
|
||||||
|
// Upload image
|
||||||
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: selectedImage.blob,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': selectedImage.mimeType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
|
|
@ -230,15 +256,15 @@ export default function SendNotifications() {
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleSend}
|
onPress={handleSend}
|
||||||
disabled={sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0}
|
disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0}
|
||||||
style={tw`${
|
style={tw`${
|
||||||
sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0
|
sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0
|
||||||
? 'bg-gray-300'
|
? 'bg-gray-300'
|
||||||
: 'bg-blue-600'
|
: 'bg-blue-600'
|
||||||
} rounded-xl py-4 items-center shadow-sm`}
|
} rounded-xl py-4 items-center shadow-sm`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white font-bold text-base`}>
|
<MyText style={tw`text-white font-bold text-base`}>
|
||||||
{isUploading ? 'Uploading...' : sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
|
{sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import ProductsSelector from './ProductsSelector';
|
||||||
import { trpc } from '../src/trpc-client';
|
import { trpc } from '../src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
|
|
||||||
|
|
||||||
export interface BannerFormData {
|
export interface BannerFormData {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -53,7 +52,14 @@ export default function BannerForm({
|
||||||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||||
|
|
||||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||||
|
|
||||||
|
// Fetch products for dropdown
|
||||||
|
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||||
|
const products = productsData?.products || [];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleImagePick = usePickImage({
|
const handleImagePick = usePickImage({
|
||||||
setFile: async (assets: any) => {
|
setFile: async (assets: any) => {
|
||||||
|
|
@ -91,15 +97,37 @@ export default function BannerForm({
|
||||||
let imageUrl: string | undefined;
|
let imageUrl: string | undefined;
|
||||||
|
|
||||||
if (selectedImages.length > 0) {
|
if (selectedImages.length > 0) {
|
||||||
|
// Generate upload URLs
|
||||||
|
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||||
|
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||||
|
contextString: 'store', // Using 'store' for now
|
||||||
|
mimeTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload image
|
||||||
|
const uploadUrl = uploadUrls[0];
|
||||||
const { blob, mimeType } = selectedImages[0];
|
const { blob, mimeType } = selectedImages[0];
|
||||||
const { key, presignedUrl } = await uploadSingle(blob, mimeType, 'store');
|
|
||||||
imageUrl = presignedUrl;
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: blob,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imageUrl = uploadUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call onSubmit with form values and imageUrl
|
||||||
await onSubmit(values, imageUrl);
|
await onSubmit(values, imageUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
Alert.alert('Error', 'Failed to upload image');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -211,15 +239,15 @@ export default function BannerForm({
|
||||||
|
|
||||||
<MyTouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={() => handleSubmit()}
|
onPress={() => handleSubmit()}
|
||||||
disabled={isSubmitting || isUploading || !isValid || !dirty}
|
disabled={isSubmitting || !isValid || !dirty}
|
||||||
style={tw`flex-1 rounded-lg py-4 items-center ${
|
style={tw`flex-1 rounded-lg py-4 items-center ${
|
||||||
isSubmitting || isUploading || !isValid || !dirty
|
isSubmitting || !isValid || !dirty
|
||||||
? 'bg-blue-400'
|
? 'bg-blue-400'
|
||||||
: 'bg-blue-600'
|
: 'bg-blue-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white font-semibold`}>
|
<MyText style={tw`text-white font-semibold`}>
|
||||||
{isUploading ? 'Uploading...' : isSubmitting ? 'Saving...' : submitButtonText}
|
{isSubmitting ? 'Saving...' : submitButtonText}
|
||||||
</MyText>
|
</MyText>
|
||||||
</MyTouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-u
|
||||||
import ProductsSelector from './ProductsSelector';
|
import ProductsSelector from './ProductsSelector';
|
||||||
import { trpc } from '../src/trpc-client';
|
import { trpc } from '../src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
|
|
||||||
|
|
||||||
export interface StoreFormData {
|
export interface StoreFormData {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -67,7 +66,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||||
|
|
||||||
const handleImagePick = usePickImage({
|
const handleImagePick = usePickImage({
|
||||||
setFile: async (assets: any) => {
|
setFile: async (assets: any) => {
|
||||||
|
|
@ -114,16 +113,43 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
let imageUrl: string | undefined;
|
let imageUrl: string | undefined;
|
||||||
|
|
||||||
if (selectedImages.length > 0) {
|
if (selectedImages.length > 0) {
|
||||||
const { blob, mimeType } = selectedImages[0];
|
// Generate upload URLs
|
||||||
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
|
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||||
imageUrl = presignedUrl;
|
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||||
|
contextString: 'store',
|
||||||
|
mimeTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload images
|
||||||
|
for (let i = 0; i < uploadUrls.length; i++) {
|
||||||
|
const uploadUrl = uploadUrls[i];
|
||||||
|
const { blob, mimeType } = selectedImages[i];
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: blob,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key from first upload URL
|
||||||
|
// const u = new URL(uploadUrls[0]);
|
||||||
|
// const rawKey = u.pathname.replace(/^\/+/, "");
|
||||||
|
// imageUrl = decodeURIComponent(rawKey);
|
||||||
|
imageUrl = uploadUrls[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit form with imageUrl
|
// Submit form with imageUrl
|
||||||
onSubmit({ ...values, imageUrl });
|
onSubmit({ ...values, imageUrl });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
Alert.alert('Error', 'Failed to upload image');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -178,11 +204,11 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={isLoading || isUploading}
|
disabled={isLoading || generateUploadUrls.isPending}
|
||||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white text-lg font-bold`}>
|
<MyText style={tw`text-white text-lg font-bold`}>
|
||||||
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { trpc } from '../src/trpc-client';
|
|
||||||
|
|
||||||
type ContextString = 'review' | 'product_info' | 'notification' | 'store';
|
|
||||||
|
|
||||||
interface UploadInput {
|
|
||||||
blob: Blob;
|
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadBatchInput {
|
|
||||||
images: UploadInput[];
|
|
||||||
contextString: ContextString;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadResult {
|
|
||||||
keys: string[];
|
|
||||||
presignedUrls: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUploadToObjectStorage() {
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null);
|
|
||||||
|
|
||||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
|
||||||
|
|
||||||
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
|
|
||||||
setIsUploading(true);
|
|
||||||
setError(null);
|
|
||||||
setProgress({ completed: 0, total: input.images.length });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { images, contextString } = input;
|
|
||||||
|
|
||||||
if (images.length === 0) {
|
|
||||||
return { keys: [], presignedUrls: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Get presigned URLs from backend (one call for all images)
|
|
||||||
const mimeTypes = images.map(img => img.mimeType);
|
|
||||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
|
||||||
contextString,
|
|
||||||
mimeTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (uploadUrls.length !== images.length) {
|
|
||||||
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Upload all images in parallel
|
|
||||||
const uploadPromises = images.map(async (image, index) => {
|
|
||||||
const presignedUrl = uploadUrls[index];
|
|
||||||
const { blob, mimeType } = image;
|
|
||||||
|
|
||||||
const response = await fetch(presignedUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: blob,
|
|
||||||
headers: { 'Content-Type': mimeType },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update progress
|
|
||||||
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: extractKeyFromPresignedUrl(presignedUrl),
|
|
||||||
presignedUrl,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use Promise.all - if any fails, entire batch fails
|
|
||||||
const results = await Promise.all(uploadPromises);
|
|
||||||
|
|
||||||
return {
|
|
||||||
keys: results.map(r => r.key),
|
|
||||||
presignedUrls: results.map(r => r.presignedUrl),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const uploadError = err instanceof Error ? err : new Error('Upload failed');
|
|
||||||
setError(uploadError);
|
|
||||||
throw uploadError;
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
setProgress(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadSingle = async (blob: Blob, mimeType: string, contextString: ContextString): Promise<{ key: string; presignedUrl: string }> => {
|
|
||||||
const result = await upload({
|
|
||||||
images: [{ blob, mimeType }],
|
|
||||||
contextString,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
key: result.keys[0],
|
|
||||||
presignedUrl: result.presignedUrls[0],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
upload,
|
|
||||||
uploadSingle,
|
|
||||||
isUploading,
|
|
||||||
error,
|
|
||||||
progress,
|
|
||||||
isPending: generateUploadUrls.isPending
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractKeyFromPresignedUrl(url: string): string {
|
|
||||||
const u = new URL(url);
|
|
||||||
let rawKey = u.pathname.replace(/^\/+/, '');
|
|
||||||
rawKey = rawKey.split('/').slice(1).join('/'); // make meatfarmer/product-images/asdf as product-images/asdf
|
|
||||||
return decodeURIComponent(rawKey);
|
|
||||||
}
|
|
||||||
111
apps/admin-ui/src/api-hooks/product.api.ts
Normal file
111
apps/admin-ui/src/api-hooks/product.api.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import axios from '../../services/axios-admin-ui';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface CreateProductPayload {
|
||||||
|
name: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
longDescription?: string;
|
||||||
|
unitId: number;
|
||||||
|
storeId: number;
|
||||||
|
price: number;
|
||||||
|
marketPrice?: number;
|
||||||
|
incrementStep?: number;
|
||||||
|
productQuantity?: number;
|
||||||
|
isOutOfStock?: boolean;
|
||||||
|
deals?: {
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
validTill: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductPayload {
|
||||||
|
name: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
longDescription?: string;
|
||||||
|
unitId: number;
|
||||||
|
storeId: number;
|
||||||
|
price: number;
|
||||||
|
marketPrice?: number;
|
||||||
|
incrementStep?: number;
|
||||||
|
productQuantity?: number;
|
||||||
|
isOutOfStock?: boolean;
|
||||||
|
deals?: {
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
validTill: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
shortDescription?: string | null;
|
||||||
|
longDescription?: string;
|
||||||
|
unitId: number;
|
||||||
|
storeId: number;
|
||||||
|
price: number;
|
||||||
|
marketPrice?: number;
|
||||||
|
productQuantity?: number;
|
||||||
|
isOutOfStock?: boolean;
|
||||||
|
images?: string[];
|
||||||
|
createdAt: string;
|
||||||
|
unit?: {
|
||||||
|
id: number;
|
||||||
|
shortNotation: string;
|
||||||
|
fullName: string;
|
||||||
|
};
|
||||||
|
deals?: {
|
||||||
|
id: number;
|
||||||
|
quantity: string;
|
||||||
|
price: string;
|
||||||
|
validTill: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductResponse {
|
||||||
|
product: Product;
|
||||||
|
deals?: any[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API functions
|
||||||
|
const createProductApi = async (formData: FormData): Promise<CreateProductResponse> => {
|
||||||
|
const response = await axios.post('/av/products', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProductApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateProductResponse> => {
|
||||||
|
const response = await axios.put(`/av/products/${id}`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export const useCreateProduct = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createProductApi,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateProduct = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateProductApi,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
import { View, TouchableOpacity } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Formik, FieldArray } from 'formik';
|
import { Formik, FieldArray } from 'formik';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
@ -8,7 +8,6 @@ import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
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';
|
import { useGetTags } from '../api-hooks/tag.api';
|
||||||
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
|
||||||
|
|
||||||
interface ProductFormData {
|
interface ProductFormData {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -39,7 +38,7 @@ export interface ProductFormRef {
|
||||||
interface ProductFormProps {
|
interface ProductFormProps {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
initialValues: ProductFormData;
|
initialValues: ProductFormData;
|
||||||
onSubmit: (values: ProductFormData, imageKeys?: string[], imagesToDelete?: string[]) => void;
|
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
existingImages?: string[];
|
existingImages?: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -61,9 +60,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
existingImages = []
|
existingImages = []
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [newImages, setNewImages] = useState<{ blob: Blob; mimeType: string; uri: string }[]>([]);
|
const [images, setImages] = useState<{ uri?: string }[]>([]);
|
||||||
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
|
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
|
||||||
const { upload, isUploading } = useUploadToObjectStorage();
|
|
||||||
|
|
||||||
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
||||||
const storeOptions = storesData?.stores.map(store => ({
|
const storeOptions = storesData?.stores.map(store => ({
|
||||||
|
|
@ -85,62 +83,23 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
}, [existingImages]);
|
}, [existingImages]);
|
||||||
|
|
||||||
const pickImage = usePickImage({
|
const pickImage = usePickImage({
|
||||||
setFile: async (assets: any) => {
|
setFile: (files) => setImages(prev => [...prev, ...files]),
|
||||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = Array.isArray(assets) ? assets : [assets];
|
|
||||||
const imageData = await Promise.all(
|
|
||||||
files.map(async (asset) => {
|
|
||||||
const response = await fetch(asset.uri);
|
|
||||||
const blob = await response.blob();
|
|
||||||
return {
|
|
||||||
blob,
|
|
||||||
mimeType: asset.mimeType || 'image/jpeg',
|
|
||||||
uri: asset.uri
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setNewImages(prev => [...prev, ...imageData]);
|
|
||||||
},
|
|
||||||
multiple: true,
|
multiple: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate which existing images were deleted
|
// Calculate which existing images were deleted
|
||||||
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
||||||
|
|
||||||
// Display images for ImageUploader component
|
|
||||||
const displayImages = newImages.map(img => ({ uri: img.uri }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={async (values) => {
|
onSubmit={(values) => onSubmit(values, images, deletedImages)}
|
||||||
try {
|
|
||||||
let imageKeys: string[] = [];
|
|
||||||
|
|
||||||
// Upload new images if any
|
|
||||||
if (newImages.length > 0) {
|
|
||||||
const result = await upload({
|
|
||||||
images: newImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
|
|
||||||
contextString: 'product_info',
|
|
||||||
});
|
|
||||||
imageKeys = result.keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit(values, imageKeys, deletedImages);
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload images');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
>
|
>
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
||||||
// Clear form when screen comes into focus
|
// Clear form when screen comes into focus
|
||||||
const clearForm = useCallback(() => {
|
const clearForm = useCallback(() => {
|
||||||
setNewImages([]);
|
setImages([]);
|
||||||
setExistingImagesState([]);
|
setExistingImagesState([]);
|
||||||
resetForm();
|
resetForm();
|
||||||
}, [resetForm]);
|
}, [resetForm]);
|
||||||
|
|
@ -184,9 +143,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
|
|
||||||
{mode === 'create' && (
|
{mode === 'create' && (
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
images={displayImages}
|
images={images}
|
||||||
onAddImage={pickImage}
|
onAddImage={pickImage}
|
||||||
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -207,9 +166,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: 16 }}>
|
||||||
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
|
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
images={displayImages}
|
images={images}
|
||||||
onAddImage={pickImage}
|
onAddImage={pickImage}
|
||||||
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -396,11 +355,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={isLoading || isUploading}
|
disabled={isLoading}
|
||||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white text-lg font-bold`}>
|
<MyText style={tw`text-white text-lg font-bold`}>
|
||||||
{isUploading ? 'Uploading Images...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +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 tagRouter from "@/src/apis/admin-apis/apis/tag.router"
|
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -7,6 +8,9 @@ const router = Router();
|
||||||
// Apply staff authentication to all admin routes
|
// Apply staff authentication to all admin routes
|
||||||
router.use(authenticateStaff);
|
router.use(authenticateStaff);
|
||||||
|
|
||||||
|
// Product routes
|
||||||
|
router.use("/products", productRouter);
|
||||||
|
|
||||||
// Tag routes
|
// Tag routes
|
||||||
router.use("/product-tags", tagRouter);
|
router.use("/product-tags", tagRouter);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { db } from "@/src/db/db_index";
|
||||||
import { productTagInfo } from "@/src/db/schema";
|
import { productTagInfo } from "@/src/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { ApiError } from "@/src/lib/api-error";
|
import { ApiError } from "@/src/lib/api-error";
|
||||||
import { imageUploadS3, scaffoldAssetUrl } from "@/src/lib/s3-client";
|
import { imageUploadS3, generateSignedUrlFromS3Url } from "@/src/lib/s3-client";
|
||||||
import { deleteS3Image } from "@/src/lib/delete-image";
|
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer';
|
import { scheduleStoreInitialization } from '@/src/stores/store-initializer';
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@ export const getAllTags = async (req: Request, res: Response) => {
|
||||||
const tagsWithSignedUrls = await Promise.all(
|
const tagsWithSignedUrls = await Promise.all(
|
||||||
tags.map(async (tag) => ({
|
tags.map(async (tag) => ({
|
||||||
...tag,
|
...tag,
|
||||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
|
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@ export const getTagById = async (req: Request, res: Response) => {
|
||||||
// Generate signed URL for tag image
|
// Generate signed URL for tag image
|
||||||
const tagWithSignedUrl = {
|
const tagWithSignedUrl = {
|
||||||
...tag,
|
...tag,
|
||||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
|
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
|
|
|
||||||
306
apps/backend/src/apis/admin-apis/apis/product.controller.ts
Normal file
306
apps/backend/src/apis/admin-apis/apis/product.controller.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { db } from "@/src/db/db_index";
|
||||||
|
import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { ApiError } from "@/src/lib/api-error";
|
||||||
|
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
|
||||||
|
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||||
|
import type { SpecialDeal } from "@/src/db/types";
|
||||||
|
import { scheduleStoreInitialization } from '@/src/stores/store-initializer';
|
||||||
|
|
||||||
|
|
||||||
|
type CreateDeal = {
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
validTill: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new product
|
||||||
|
*/
|
||||||
|
export const createProduct = async (req: Request, res: Response) => {
|
||||||
|
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals, tagIds } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || !unitId || !storeId || !price) {
|
||||||
|
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name
|
||||||
|
const existingProduct = await db.query.productInfo.findFirst({
|
||||||
|
where: eq(productInfo.name, name.trim()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingProduct) {
|
||||||
|
throw new ApiError("A product with this name already exists", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if unit exists
|
||||||
|
const unit = await db.query.units.findFirst({
|
||||||
|
where: eq(units.id, unitId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
throw new ApiError("Invalid unit ID", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract images from req.files
|
||||||
|
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
||||||
|
let uploadedImageUrls: string[] = [];
|
||||||
|
|
||||||
|
if (images && Array.isArray(images)) {
|
||||||
|
const imageUploadPromises = images.map((file, index) => {
|
||||||
|
const key = `product-images/${Date.now()}-${index}`;
|
||||||
|
return imageUploadS3(file.buffer, file.mimetype, key);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create product
|
||||||
|
const productData: any = {
|
||||||
|
name,
|
||||||
|
shortDescription,
|
||||||
|
longDescription,
|
||||||
|
unitId,
|
||||||
|
storeId,
|
||||||
|
price,
|
||||||
|
marketPrice,
|
||||||
|
incrementStep: incrementStep || 1,
|
||||||
|
productQuantity: productQuantity || 1,
|
||||||
|
isSuspended: isSuspended || false,
|
||||||
|
isFlashAvailable: isFlashAvailable || false,
|
||||||
|
images: uploadedImageUrls,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (flashPrice) {
|
||||||
|
productData.flashPrice = parseFloat(flashPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newProduct] = await db
|
||||||
|
.insert(productInfo)
|
||||||
|
.values(productData)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Handle deals if provided
|
||||||
|
let createdDeals: SpecialDeal[] = [];
|
||||||
|
if (deals && Array.isArray(deals)) {
|
||||||
|
const dealInserts = deals.map((deal: CreateDeal) => ({
|
||||||
|
productId: newProduct.id,
|
||||||
|
quantity: deal.quantity.toString(),
|
||||||
|
price: deal.price.toString(),
|
||||||
|
validTill: new Date(deal.validTill),
|
||||||
|
}));
|
||||||
|
|
||||||
|
createdDeals = await db
|
||||||
|
.insert(specialDeals)
|
||||||
|
.values(dealInserts)
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tag assignments if provided
|
||||||
|
if (tagIds && Array.isArray(tagIds)) {
|
||||||
|
const tagAssociations = tagIds.map((tagId: number) => ({
|
||||||
|
productId: newProduct.id,
|
||||||
|
tagId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.insert(productTags).values(tagAssociations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinitialize stores to reflect changes
|
||||||
|
scheduleStoreInitialization()
|
||||||
|
|
||||||
|
// Send response first
|
||||||
|
res.status(201).json({
|
||||||
|
product: newProduct,
|
||||||
|
deals: createdDeals,
|
||||||
|
message: "Product created successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a product
|
||||||
|
*/
|
||||||
|
export const updateProduct = async (req: Request, res: Response) => {
|
||||||
|
const id = req.params.id as string
|
||||||
|
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body;
|
||||||
|
|
||||||
|
|
||||||
|
const deals = dealsRaw ? JSON.parse(dealsRaw) : null;
|
||||||
|
const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];
|
||||||
|
|
||||||
|
if (!name || !unitId || !storeId || !price) {
|
||||||
|
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if unit exists
|
||||||
|
const unit = await db.query.units.findFirst({
|
||||||
|
where: eq(units.id, unitId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
throw new ApiError("Invalid unit ID", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current product to handle image updates
|
||||||
|
const currentProduct = await db.query.productInfo.findFirst({
|
||||||
|
where: eq(productInfo.id, parseInt(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentProduct) {
|
||||||
|
throw new ApiError("Product not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle image deletions
|
||||||
|
let currentImages = (currentProduct.images as string[]) || [];
|
||||||
|
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||||
|
// Convert signed URLs to original S3 URLs for comparison
|
||||||
|
const originalUrlsToDelete = imagesToDelete
|
||||||
|
.map((signedUrl: string) => getOriginalUrlFromSignedUrl(signedUrl))
|
||||||
|
.filter(Boolean); // Remove nulls
|
||||||
|
|
||||||
|
// Find which stored images match the ones to delete
|
||||||
|
const imagesToRemoveFromDb = currentImages.filter(storedUrl =>
|
||||||
|
originalUrlsToDelete.includes(storedUrl)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete the matching images from S3
|
||||||
|
const deletePromises = imagesToRemoveFromDb.map(imageUrl => deleteS3Image(imageUrl));
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
|
||||||
|
// Remove deleted images from current images array
|
||||||
|
currentImages = currentImages.filter(img => !imagesToRemoveFromDb.includes(img));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract new images from req.files
|
||||||
|
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
||||||
|
let uploadedImageUrls: string[] = [];
|
||||||
|
|
||||||
|
if (images && Array.isArray(images)) {
|
||||||
|
const imageUploadPromises = images.map((file, index) => {
|
||||||
|
const key = `product-images/${Date.now()}-${index}`;
|
||||||
|
return imageUploadS3(file.buffer, file.mimetype, key);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine remaining current images with new uploaded images
|
||||||
|
const finalImages = [...currentImages, ...uploadedImageUrls];
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
name,
|
||||||
|
shortDescription,
|
||||||
|
longDescription,
|
||||||
|
unitId,
|
||||||
|
storeId,
|
||||||
|
price,
|
||||||
|
marketPrice,
|
||||||
|
incrementStep: incrementStep || 1,
|
||||||
|
productQuantity: productQuantity || 1,
|
||||||
|
isSuspended: isSuspended || false,
|
||||||
|
images: finalImages.length > 0 ? finalImages : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFlashAvailable !== undefined) {
|
||||||
|
updateData.isFlashAvailable = isFlashAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flashPrice !== undefined) {
|
||||||
|
updateData.flashPrice = flashPrice ? parseFloat(flashPrice) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedProduct] = await db
|
||||||
|
.update(productInfo)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(productInfo.id, parseInt(id)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedProduct) {
|
||||||
|
throw new ApiError("Product not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deals if provided
|
||||||
|
if (deals && Array.isArray(deals)) {
|
||||||
|
// Get existing deals
|
||||||
|
const existingDeals = await db.query.specialDeals.findMany({
|
||||||
|
where: eq(specialDeals.productId, parseInt(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create maps for comparison
|
||||||
|
const existingDealsMap = new Map(existingDeals.map(deal => [`${deal.quantity}-${deal.price}`, deal]));
|
||||||
|
const newDealsMap = new Map(deals.map((deal: CreateDeal) => [`${deal.quantity}-${deal.price}`, deal]));
|
||||||
|
|
||||||
|
// Find deals to add, update, and remove
|
||||||
|
const dealsToAdd = deals.filter((deal: CreateDeal) => {
|
||||||
|
const key = `${deal.quantity}-${deal.price}`;
|
||||||
|
return !existingDealsMap.has(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dealsToRemove = existingDeals.filter(deal => {
|
||||||
|
const key = `${deal.quantity}-${deal.price}`;
|
||||||
|
return !newDealsMap.has(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dealsToUpdate = deals.filter((deal: CreateDeal) => {
|
||||||
|
const key = `${deal.quantity}-${deal.price}`;
|
||||||
|
const existing = existingDealsMap.get(key);
|
||||||
|
return existing && existing.validTill.toISOString().split('T')[0] !== deal.validTill;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove old deals
|
||||||
|
if (dealsToRemove.length > 0) {
|
||||||
|
await db.delete(specialDeals).where(
|
||||||
|
inArray(specialDeals.id, dealsToRemove.map(deal => deal.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new deals
|
||||||
|
if (dealsToAdd.length > 0) {
|
||||||
|
const dealInserts = dealsToAdd.map((deal: CreateDeal) => ({
|
||||||
|
productId: parseInt(id),
|
||||||
|
quantity: deal.quantity.toString(),
|
||||||
|
price: deal.price.toString(),
|
||||||
|
validTill: new Date(deal.validTill),
|
||||||
|
}));
|
||||||
|
await db.insert(specialDeals).values(dealInserts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing deals
|
||||||
|
for (const deal of dealsToUpdate) {
|
||||||
|
const key = `${deal.quantity}-${deal.price}`;
|
||||||
|
const existingDeal = existingDealsMap.get(key);
|
||||||
|
if (existingDeal) {
|
||||||
|
await db.update(specialDeals)
|
||||||
|
.set({ validTill: new Date(deal.validTill) })
|
||||||
|
.where(eq(specialDeals.id, existingDeal.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tag assignments if provided
|
||||||
|
// if (tagIds && Array.isArray(tagIds)) {
|
||||||
|
if (tagIds && Boolean(tagIds)) {
|
||||||
|
// Remove existing tags
|
||||||
|
await db.delete(productTags).where(eq(productTags.productId, parseInt(id)));
|
||||||
|
|
||||||
|
const tagIdsArray = Array.isArray(tagIds) ? tagIds : [+tagIds]
|
||||||
|
// Add new tags
|
||||||
|
const tagAssociations = tagIdsArray.map((tagId: number) => ({
|
||||||
|
productId: parseInt(id),
|
||||||
|
tagId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.insert(productTags).values(tagAssociations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinitialize stores to reflect changes
|
||||||
|
scheduleStoreInitialization()
|
||||||
|
|
||||||
|
// Send response first
|
||||||
|
res.status(200).json({
|
||||||
|
product: updatedProduct,
|
||||||
|
message: "Product updated successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
11
apps/backend/src/apis/admin-apis/apis/product.router.ts
Normal file
11
apps/backend/src/apis/admin-apis/apis/product.router.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { createProduct, updateProduct } from "@/src/apis/admin-apis/apis/product.controller"
|
||||||
|
import uploadHandler from '@/src/lib/upload-handler';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Product routes
|
||||||
|
router.post("/", uploadHandler.array('images'), createProduct);
|
||||||
|
router.put("/:id", uploadHandler.array('images'), updateProduct);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@/src/db/db_index"
|
import { db } from "@/src/db/db_index"
|
||||||
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
|
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
|
||||||
import { assetsDomain, s3Url } from "@/src/lib/env-exporter"
|
import { s3Url } from "@/src/lib/env-exporter"
|
||||||
|
|
||||||
function extractS3Key(url: string): string | null {
|
function extractS3Key(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -27,22 +27,12 @@ function extractS3Key(url: string): string | null {
|
||||||
|
|
||||||
export async function deleteS3Image(imageUrl: string) {
|
export async function deleteS3Image(imageUrl: string) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
let key:string | null = '';
|
|
||||||
|
|
||||||
if(imageUrl.includes(assetsDomain)) {
|
|
||||||
key = imageUrl.replace(assetsDomain, '')
|
|
||||||
}
|
|
||||||
else if(imageUrl.startsWith('http')){
|
|
||||||
// First check if this is a signed URL and get the original if it is
|
// First check if this is a signed URL and get the original if it is
|
||||||
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
|
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
|
||||||
|
|
||||||
key = extractS3Key(originalUrl || "");
|
const key = extractS3Key(originalUrl || "");
|
||||||
}
|
|
||||||
|
|
||||||
else {
|
|
||||||
key = imageUrl;
|
|
||||||
}
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
throw new Error("Invalid image URL format");
|
throw new Error("Invalid image URL format");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,12 +201,9 @@ export function extractKeyFromPresignedUrl(url: string): string {
|
||||||
|
|
||||||
export async function claimUploadUrl(url: string): Promise<void> {
|
export async function claimUploadUrl(url: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
let semiKey:string = ''
|
const semiKey = extractKeyFromPresignedUrl(url);
|
||||||
|
const key = s3BucketName+'/'+ semiKey
|
||||||
|
|
||||||
if(url.startsWith('http'))
|
|
||||||
semiKey = extractKeyFromPresignedUrl(url);
|
|
||||||
else
|
|
||||||
semiKey = url
|
|
||||||
// Update status to 'claimed' if currently 'pending'
|
// Update status to 'claimed' if currently 'pending'
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(uploadUrlStatus)
|
.update(uploadUrlStatus)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { db } from '@/src/db/db_index'
|
||||||
import { homeBanners } from '@/src/db/schema'
|
import { homeBanners } from '@/src/db/schema'
|
||||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||||
import { protectedProcedure, router } from '@/src/trpc/trpc-index'
|
import { protectedProcedure, router } from '@/src/trpc/trpc-index'
|
||||||
import { extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client'
|
import { extractKeyFromPresignedUrl, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||||
import { ApiError } from '@/src/lib/api-error';
|
import { ApiError } from '@/src/lib/api-error';
|
||||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ export const bannerRouter = router({
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
...banner,
|
...banner,
|
||||||
imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl,
|
imageUrl: banner.imageUrl ? await generateSignedUrlFromS3Url(banner.imageUrl) : banner.imageUrl,
|
||||||
// Ensure productIds is always an array
|
// Ensure productIds is always an array
|
||||||
productIds: banner.productIds || [],
|
productIds: banner.productIds || [],
|
||||||
};
|
};
|
||||||
|
|
@ -65,7 +65,7 @@ export const bannerRouter = router({
|
||||||
try {
|
try {
|
||||||
// Convert S3 key to signed URL for client
|
// Convert S3 key to signed URL for client
|
||||||
if (banner.imageUrl) {
|
if (banner.imageUrl) {
|
||||||
banner.imageUrl = scaffoldAssetUrl(banner.imageUrl);
|
banner.imageUrl = await generateSignedUrlFromS3Url(banner.imageUrl);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
|
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
|
||||||
|
|
@ -85,7 +85,7 @@ export const bannerRouter = router({
|
||||||
createBanner: protectedProcedure
|
createBanner: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
imageUrl: z.string(),
|
imageUrl: z.string().url(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
productIds: z.array(z.number()).optional(),
|
productIds: z.array(z.number()).optional(),
|
||||||
redirectUrl: z.string().url().optional(),
|
redirectUrl: z.string().url().optional(),
|
||||||
|
|
@ -94,7 +94,6 @@ export const bannerRouter = router({
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const imageUrl = extractKeyFromPresignedUrl(input.imageUrl)
|
const imageUrl = extractKeyFromPresignedUrl(input.imageUrl)
|
||||||
// const imageUrl = input.imageUrl
|
|
||||||
const [banner] = await db.insert(homeBanners).values({
|
const [banner] = await db.insert(homeBanners).values({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||||
import { db } from '@/src/db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { complaints, users } from '@/src/db/schema'
|
import { complaints, users } from '@/src/db/schema'
|
||||||
import { eq, desc, lt, and } from 'drizzle-orm';
|
import { eq, desc, lt, and } from 'drizzle-orm';
|
||||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
import { generateSignedUrlsFromS3Urls } from '@/src/lib/s3-client'
|
||||||
|
|
||||||
export const complaintRouter = router({
|
export const complaintRouter = router({
|
||||||
getAll: protectedProcedure
|
getAll: protectedProcedure
|
||||||
|
|
@ -42,7 +42,7 @@ export const complaintRouter = router({
|
||||||
const complaintsWithSignedImages = await Promise.all(
|
const complaintsWithSignedImages = await Promise.all(
|
||||||
complaintsToReturn.map(async (c) => {
|
complaintsToReturn.map(async (c) => {
|
||||||
const signedImages = c.images
|
const signedImages = c.images
|
||||||
? scaffoldAssetUrl(c.images as string[])
|
? await generateSignedUrlsFromS3Urls(c.images as string[])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { db } from '@/src/db/db_index'
|
||||||
import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema'
|
import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema'
|
||||||
import { eq, and, inArray, desc, sql } from 'drizzle-orm';
|
import { eq, and, inArray, desc, sql } from 'drizzle-orm';
|
||||||
import { ApiError } from '@/src/lib/api-error'
|
import { ApiError } from '@/src/lib/api-error'
|
||||||
import { imageUploadS3, scaffoldAssetUrl, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client'
|
import { imageUploadS3, generateSignedUrlsFromS3Urls, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client'
|
||||||
import { deleteS3Image } from '@/src/lib/delete-image'
|
import { deleteS3Image } from '@/src/lib/delete-image'
|
||||||
import type { SpecialDeal } from '@/src/db/types'
|
import type { SpecialDeal } from '@/src/db/types'
|
||||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||||
|
|
@ -31,7 +31,7 @@ export const productRouter = router({
|
||||||
const productsWithSignedUrls = await Promise.all(
|
const productsWithSignedUrls = await Promise.all(
|
||||||
products.map(async (product) => ({
|
products.map(async (product) => ({
|
||||||
...product,
|
...product,
|
||||||
images: scaffoldAssetUrl((product.images as string[]) || []),
|
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ export const productRouter = router({
|
||||||
// Generate signed URLs for product images
|
// Generate signed URLs for product images
|
||||||
const productWithSignedUrls = {
|
const productWithSignedUrls = {
|
||||||
...product,
|
...product,
|
||||||
images: scaffoldAssetUrl((product.images as string[]) || []),
|
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []),
|
||||||
deals,
|
deals,
|
||||||
tags: productTagsData.map(pt => pt.tag),
|
tags: productTagsData.map(pt => pt.tag),
|
||||||
};
|
};
|
||||||
|
|
@ -110,229 +110,6 @@ export const productRouter = router({
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createProduct: protectedProcedure
|
|
||||||
.input(z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
shortDescription: z.string().optional(),
|
|
||||||
longDescription: z.string().optional(),
|
|
||||||
unitId: z.number(),
|
|
||||||
storeId: z.number(),
|
|
||||||
price: z.number(),
|
|
||||||
marketPrice: z.number().optional(),
|
|
||||||
incrementStep: z.number().default(1),
|
|
||||||
productQuantity: z.number().default(1),
|
|
||||||
isSuspended: z.boolean().default(false),
|
|
||||||
isFlashAvailable: z.boolean().default(false),
|
|
||||||
flashPrice: z.number().optional(),
|
|
||||||
deals: z.array(z.object({
|
|
||||||
quantity: z.number(),
|
|
||||||
price: z.number(),
|
|
||||||
validTill: z.string(),
|
|
||||||
})).optional(),
|
|
||||||
tagIds: z.array(z.number()).optional(),
|
|
||||||
imageKeys: z.array(z.string()).optional(),
|
|
||||||
}))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
const {
|
|
||||||
name, shortDescription, longDescription, unitId, storeId,
|
|
||||||
price, marketPrice, incrementStep, productQuantity,
|
|
||||||
isSuspended, isFlashAvailable, flashPrice,
|
|
||||||
deals, tagIds, imageKeys
|
|
||||||
} = input;
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!name || !unitId || !storeId || !price) {
|
|
||||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for duplicate name
|
|
||||||
const existingProduct = await db.query.productInfo.findFirst({
|
|
||||||
where: eq(productInfo.name, name.trim()),
|
|
||||||
});
|
|
||||||
if (existingProduct) {
|
|
||||||
throw new ApiError("A product with this name already exists", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if unit exists
|
|
||||||
const unit = await db.query.units.findFirst({
|
|
||||||
where: eq(units.id, unitId),
|
|
||||||
});
|
|
||||||
if (!unit) {
|
|
||||||
throw new ApiError("Invalid unit ID", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(imageKeys)
|
|
||||||
const [newProduct] = await db
|
|
||||||
.insert(productInfo)
|
|
||||||
.values({
|
|
||||||
name: name.trim(),
|
|
||||||
shortDescription,
|
|
||||||
longDescription,
|
|
||||||
unitId,
|
|
||||||
storeId,
|
|
||||||
price: price.toString(),
|
|
||||||
marketPrice: marketPrice?.toString(),
|
|
||||||
incrementStep,
|
|
||||||
productQuantity,
|
|
||||||
isSuspended,
|
|
||||||
isFlashAvailable,
|
|
||||||
flashPrice: flashPrice?.toString(),
|
|
||||||
images: imageKeys || [],
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Handle deals
|
|
||||||
if (deals && deals.length > 0) {
|
|
||||||
const dealInserts = deals.map(deal => ({
|
|
||||||
productId: newProduct.id,
|
|
||||||
quantity: deal.quantity.toString(),
|
|
||||||
price: deal.price.toString(),
|
|
||||||
validTill: new Date(deal.validTill),
|
|
||||||
}));
|
|
||||||
await db.insert(specialDeals).values(dealInserts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tags
|
|
||||||
if (tagIds && tagIds.length > 0) {
|
|
||||||
const tagAssociations = tagIds.map(tagId => ({
|
|
||||||
productId: newProduct.id,
|
|
||||||
tagId,
|
|
||||||
}));
|
|
||||||
await db.insert(productTags).values(tagAssociations);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claim upload URLs
|
|
||||||
if (imageKeys && imageKeys.length > 0) {
|
|
||||||
for (const key of imageKeys) {
|
|
||||||
try {
|
|
||||||
await claimUploadUrl(key);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Failed to claim upload URL for key: ${key}`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleStoreInitialization();
|
|
||||||
|
|
||||||
return {
|
|
||||||
product: newProduct,
|
|
||||||
message: "Product created successfully",
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateProduct: protectedProcedure
|
|
||||||
.input(z.object({
|
|
||||||
id: z.number(),
|
|
||||||
name: z.string().min(1).optional(),
|
|
||||||
shortDescription: z.string().optional(),
|
|
||||||
longDescription: z.string().optional(),
|
|
||||||
unitId: z.number().optional(),
|
|
||||||
storeId: z.number().optional(),
|
|
||||||
price: z.number().optional(),
|
|
||||||
marketPrice: z.number().optional(),
|
|
||||||
incrementStep: z.number().optional(),
|
|
||||||
productQuantity: z.number().optional(),
|
|
||||||
isSuspended: z.boolean().optional(),
|
|
||||||
isFlashAvailable: z.boolean().optional(),
|
|
||||||
flashPrice: z.number().optional(),
|
|
||||||
deals: z.array(z.object({
|
|
||||||
quantity: z.number(),
|
|
||||||
price: z.number(),
|
|
||||||
validTill: z.string(),
|
|
||||||
})).optional(),
|
|
||||||
tagIds: z.array(z.number()).optional(),
|
|
||||||
newImageKeys: z.array(z.string()).optional(),
|
|
||||||
imagesToDelete: z.array(z.string()).optional(),
|
|
||||||
}))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input;
|
|
||||||
|
|
||||||
// Get current product
|
|
||||||
const currentProduct = await db.query.productInfo.findFirst({
|
|
||||||
where: eq(productInfo.id, id),
|
|
||||||
});
|
|
||||||
if (!currentProduct) {
|
|
||||||
throw new ApiError("Product not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle image deletions
|
|
||||||
let currentImages = (currentProduct.images as string[]) || [];
|
|
||||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
|
||||||
for (const imageUrl of imagesToDelete) {
|
|
||||||
try {
|
|
||||||
await deleteS3Image(imageUrl);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to delete image: ${imageUrl}`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentImages = currentImages.filter(img => {
|
|
||||||
//!imagesToDelete.includes(img)
|
|
||||||
const isRemoved = imagesToDelete.some(item => item.includes(img));
|
|
||||||
return !isRemoved;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new images
|
|
||||||
if (newImageKeys && newImageKeys.length > 0) {
|
|
||||||
currentImages = [...currentImages, ...newImageKeys];
|
|
||||||
|
|
||||||
for (const key of newImageKeys) {
|
|
||||||
try {
|
|
||||||
await claimUploadUrl(key);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Failed to claim upload URL for key: ${key}`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update product - convert numeric fields to strings for PostgreSQL numeric type
|
|
||||||
const { price, marketPrice, flashPrice, ...otherData } = updateData;
|
|
||||||
const [updatedProduct] = await db
|
|
||||||
.update(productInfo)
|
|
||||||
.set({
|
|
||||||
...otherData,
|
|
||||||
...(price !== undefined && { price: price.toString() }),
|
|
||||||
...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }),
|
|
||||||
...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }),
|
|
||||||
images: currentImages,
|
|
||||||
})
|
|
||||||
.where(eq(productInfo.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Handle deals update
|
|
||||||
if (deals !== undefined) {
|
|
||||||
await db.delete(specialDeals).where(eq(specialDeals.productId, id));
|
|
||||||
if (deals.length > 0) {
|
|
||||||
const dealInserts = deals.map(deal => ({
|
|
||||||
productId: id,
|
|
||||||
quantity: deal.quantity.toString(),
|
|
||||||
price: deal.price.toString(),
|
|
||||||
validTill: new Date(deal.validTill),
|
|
||||||
}));
|
|
||||||
await db.insert(specialDeals).values(dealInserts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tags update
|
|
||||||
if (tagIds !== undefined) {
|
|
||||||
await db.delete(productTags).where(eq(productTags.productId, id));
|
|
||||||
if (tagIds.length > 0) {
|
|
||||||
const tagAssociations = tagIds.map(tagId => ({
|
|
||||||
productId: id,
|
|
||||||
tagId,
|
|
||||||
}));
|
|
||||||
await db.insert(productTags).values(tagAssociations);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleStoreInitialization();
|
|
||||||
|
|
||||||
return {
|
|
||||||
product: updatedProduct,
|
|
||||||
message: "Product updated successfully",
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
toggleOutOfStock: protectedProcedure
|
toggleOutOfStock: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
|
|
@ -517,8 +294,8 @@ export const productRouter = router({
|
||||||
const reviewsWithSignedUrls = await Promise.all(
|
const reviewsWithSignedUrls = await Promise.all(
|
||||||
reviews.map(async (review) => ({
|
reviews.map(async (review) => ({
|
||||||
...review,
|
...review,
|
||||||
signedImageUrls: scaffoldAssetUrl((review.imageUrls as string[]) || []),
|
signedImageUrls: await generateSignedUrlsFromS3Urls((review.imageUrls as string[]) || []),
|
||||||
signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages as string[]) || []),
|
signedAdminImageUrls: await generateSignedUrlsFromS3Urls((review.adminResponseImages as string[]) || []),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { db } from '@/src/db/db_index'
|
||||||
import { storeInfo, productInfo } from '@/src/db/schema'
|
import { storeInfo, productInfo } from '@/src/db/schema'
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { ApiError } from '@/src/lib/api-error'
|
import { ApiError } from '@/src/lib/api-error'
|
||||||
import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } from '@/src/lib/s3-client'
|
import { extractKeyFromPresignedUrl, deleteImageUtil, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ export const storeRouter = router({
|
||||||
|
|
||||||
Promise.all(stores.map(async store => {
|
Promise.all(stores.map(async store => {
|
||||||
if(store.imageUrl)
|
if(store.imageUrl)
|
||||||
store.imageUrl = scaffoldAssetUrl(store.imageUrl)
|
store.imageUrl = await generateSignedUrlFromS3Url(store.imageUrl)
|
||||||
})).catch((e) => {
|
})).catch((e) => {
|
||||||
throw new ApiError("Unable to find store image urls")
|
throw new ApiError("Unable to find store image urls")
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +48,7 @@ export const storeRouter = router({
|
||||||
if (!store) {
|
if (!store) {
|
||||||
throw new ApiError("Store not found", 404);
|
throw new ApiError("Store not found", 404);
|
||||||
}
|
}
|
||||||
store.imageUrl = scaffoldAssetUrl(store.imageUrl);
|
store.imageUrl = await generateSignedUrlFromS3Url(store.imageUrl);
|
||||||
return {
|
return {
|
||||||
store,
|
store,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export const commonApiRouter = router({
|
||||||
|
|
||||||
generateUploadUrls: protectedProcedure
|
generateUploadUrls: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
contextString: z.enum(['review', 'product_info', 'store', 'notification']),
|
contextString: z.enum(['review', 'product_info', 'store']),
|
||||||
mimeTypes: z.array(z.string()),
|
mimeTypes: z.array(z.string()),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
|
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
|
||||||
|
|
@ -87,12 +87,9 @@ export const commonApiRouter = router({
|
||||||
folder = 'product-images';
|
folder = 'product-images';
|
||||||
} else if (contextString === 'store') {
|
} else if (contextString === 'store') {
|
||||||
folder = 'store-images';
|
folder = 'store-images';
|
||||||
}
|
} else if (contextString === 'review_response') {
|
||||||
// else if (contextString === 'review_response') {
|
folder = 'review-response-images';
|
||||||
//
|
} else {
|
||||||
// folder = 'review-response-images';
|
|
||||||
// }
|
|
||||||
else {
|
|
||||||
folder = '';
|
folder = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,342 +0,0 @@
|
||||||
# Edge Migration Plan: Node.js → Cloudflare Workers
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Migrating the backend from Node.js (Express, CommonJS) to Cloudflare Workers (V8 isolates, ESM, Web Standard APIs).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## BLOCKERS (Must fix, won't work at all)
|
|
||||||
|
|
||||||
### 1. Express.js — Complete Replacement Needed
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/index.ts`
|
|
||||||
- `apps/backend/src/main-router.ts`
|
|
||||||
- `apps/backend/src/v1-router.ts`
|
|
||||||
- All files in `apps/backend/src/apis/`
|
|
||||||
- All files in `apps/backend/src/uv-apis/`
|
|
||||||
|
|
||||||
**Problem:** Express uses Node.js `http` module internally — unavailable in Workers.
|
|
||||||
|
|
||||||
**Solution:** Replace with **Hono** (lightweight, Workers-native, similar middleware pattern) or use Cloudflare's `export default { fetch }` handler. Switch tRPC from `createExpressMiddleware` to `createFetchMiddleware` (Fetch adapter).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. `node-postgres` (`pg`) Driver — TCP Sockets Unavailable
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/db/db_index.ts:1` — `drizzle-orm/node-postgres`
|
|
||||||
|
|
||||||
**Problem:** `node-postgres` uses TCP connections to Postgres. Workers have no TCP socket support.
|
|
||||||
|
|
||||||
**Solution:** Replace with `drizzle-orm/neon-http` (Neon serverless driver over HTTP) or use **Cloudflare Hyperdrive** (connection pooling proxy) with `@neondatabase/serverless` or fetch-based driver. Schema stays the same, only the driver changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Redis Client (`redis` npm package) — Persistent TCP + Pub/Sub Unavailable
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/redis-client.ts` (full custom RedisClient class)
|
|
||||||
- `apps/backend/src/stores/product-store.ts`
|
|
||||||
- `apps/backend/src/stores/slot-store.ts`
|
|
||||||
- `apps/backend/src/stores/banner-store.ts`
|
|
||||||
- `apps/backend/src/stores/product-tag-store.ts`
|
|
||||||
- `apps/backend/src/stores/user-negativity-store.ts`
|
|
||||||
- `apps/backend/src/lib/const-store.ts`
|
|
||||||
- `apps/backend/src/lib/event-queue.ts`
|
|
||||||
- `apps/backend/src/lib/post-order-handler.ts`
|
|
||||||
|
|
||||||
**Problem:** The `redis` npm package uses Node.js `net`/`tls` modules. `subscribe()` / `publish()` require persistent TCP connections — fundamentally incompatible with Workers.
|
|
||||||
|
|
||||||
**Solution:** Replace with **Upstash Redis** (HTTP-based Redis, drop-in replacement for get/set/del operations) or **Cloudflare KV** (different API, eventual consistency). Pub/Sub must be rearchitected (see #17).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. BullMQ — Depends on Node.js Events + Persistent Redis Connections
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/notif-job.ts:1-41` — `Queue` and `Worker` classes
|
|
||||||
|
|
||||||
**Problem:** `Queue` and `Worker` classes depend on Node.js `EventEmitter` and persistent Redis TCP connections.
|
|
||||||
|
|
||||||
**Solution:** Replace with **Cloudflare Queues** (native message queue service) — different API entirely. Or use **Cloudflare Cron Triggers** + Upstash Redis for simpler job scheduling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. `node-cron` — No Background Process in Workers
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/automatedJobs.ts`
|
|
||||||
- `apps/backend/src/jobs/jobs-index.ts`
|
|
||||||
|
|
||||||
**Problem:** Workers are request-driven — no long-running background processes. `cron.schedule()` is meaningless.
|
|
||||||
|
|
||||||
**Solution:** Replace with **Cloudflare Cron Triggers** (`wrangler.toml` crons config) — each cron invocation is a separate Worker invocation. Rewrite cron logic as fetch handler with `event.type === 'scheduled'`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Multer — Express Middleware, Uses Node.js Streams
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/upload-handler.ts` — multer config (10MB, memory storage)
|
|
||||||
- `apps/backend/src/main-router.ts:39` — complaint upload
|
|
||||||
- `apps/backend/src/uv-apis/uv-router.ts:9` — complaint upload
|
|
||||||
- `apps/backend/src/uv-apis/auth.router.ts:8-9` — profile image
|
|
||||||
- `apps/backend/src/apis/admin-apis/apis/product.router.ts:8-9` — product images
|
|
||||||
- `apps/backend/src/apis/admin-apis/apis/tag.router.ts:8,11` — tag image
|
|
||||||
|
|
||||||
**Problem:** Multer depends on Node.js streams and multipart parsing tied to Express.
|
|
||||||
|
|
||||||
**Solution:** Replace with native `Request.formData()` in Workers (built into the Fetch API) or use a Workers-compatible multipart parser like `@mjackson/multipart-parser`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. `fs` Module — No Filesystem in Workers
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/signed-url-cache.ts:1,22-25,179,196,198` — `fs.existsSync()`, `fs.mkdirSync()`, `fs.writeFileSync()`, `fs.readFileSync()`
|
|
||||||
- `apps/backend/src/lib/disk-persisted-set.ts:1,13-14,18,28` — `fs.existsSync()`, `fs.writeFileSync()`, `fs.readFileSync()`
|
|
||||||
|
|
||||||
**Problem:** No filesystem in Workers. `fs.readFileSync`, `fs.writeFileSync`, `fs.existsSync`, `fs.mkdirSync` all unavailable.
|
|
||||||
|
|
||||||
**Solution:** Replace disk-persisted caches with **Cloudflare KV** or **Durable Objects** (for stateful storage).
|
|
||||||
- `signed-url-cache.ts` → KV with TTL
|
|
||||||
- `disk-persisted-set.ts` → KV or D1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Static File Serving (`express.static`, `res.sendFile`)
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/index.ts:134-173` — fallback UI serving, assets serving
|
|
||||||
|
|
||||||
**Problem:** No filesystem means no `express.static()` or `res.sendFile()`.
|
|
||||||
|
|
||||||
**Solution:** Deploy fallback UI as a separate **Cloudflare Pages** project. Serve assets from **R2** behind Workers. The fallback UI should not be bundled with the backend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MAJOR CHANGES (Significant rewrite required)
|
|
||||||
|
|
||||||
### 9. `process.env` — 47 Usages Across 10+ Files
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/env-exporter.ts` (27 env vars exported)
|
|
||||||
- `apps/backend/index.ts`
|
|
||||||
- `apps/backend/src/db/db_index.ts`
|
|
||||||
- `apps/backend/src/middleware/auth.ts`
|
|
||||||
- `apps/backend/src/middleware/auth.middleware.ts`
|
|
||||||
- `apps/backend/src/middleware/staff-auth.ts`
|
|
||||||
- `apps/backend/src/trpc/trpc-index.ts`
|
|
||||||
- All files importing from `env-exporter.ts`
|
|
||||||
|
|
||||||
**Problem:** Workers expose env vars via the `Env` bindings object passed to `fetch(request, env)`, not `process.env`.
|
|
||||||
|
|
||||||
**Solution:** Every function that reads `process.env.*` needs refactoring to accept `env` as a parameter or use a shared context pattern. Create an `Env` type in `wrangler.toml` and thread it through the app.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. `process.exit()` and Signal Handlers (`SIGTERM`, `SIGINT`)
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/signed-url-cache.ts:254,260` — `process.exit()`
|
|
||||||
- `apps/backend/src/lib/disk-persisted-set.ts:71-72,76` — `process.exit()` and signal handlers
|
|
||||||
- `apps/backend/src/lib/notif-job.ts:163` — `process.on('SIGTERM', ...)`
|
|
||||||
- `apps/backend/src/db/porter.ts:120,124` — `process.exit()`
|
|
||||||
|
|
||||||
**Problem:** Workers don't have `process` object.
|
|
||||||
|
|
||||||
**Solution:** Remove all signal handlers and `process.exit()` calls. Graceful shutdown is handled by the platform.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 11. `Buffer.from()` — 12 Usages
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/cloud_cache.ts:27,52,77,102,127,152,259,266,273,280,287,298`
|
|
||||||
|
|
||||||
**Problem:** Workers don't have Node.js `Buffer`.
|
|
||||||
|
|
||||||
**Solution:** Replace with `new TextEncoder().encode()` or `Uint8Array`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 12. `crypto.createHmac()` — Node.js Crypto API
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/trpc/apis/user-apis/apis/payments.ts:8,72-75` — Razorpay signature verification
|
|
||||||
|
|
||||||
**Problem:** Node.js `crypto` module unavailable.
|
|
||||||
|
|
||||||
**Solution:** Replace with **Web Crypto API** (`crypto.subtle.importKey` + `crypto.subtle.sign`) — available natively in Workers but different async syntax.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 13. `bcryptjs` — May Need WASM Alternative
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/uv-apis/auth.controller.ts:105,242` — `bcrypt.hash()`
|
|
||||||
- `apps/backend/src/trpc/apis/user-apis/apis/auth.ts:118,196,311` — `bcrypt.compare()`, `bcrypt.hash()`
|
|
||||||
|
|
||||||
**Problem:** bcryptjs uses Node.js `crypto.randomBytes` internally.
|
|
||||||
|
|
||||||
**Solution:** Works in Workers with latest bcryptjs, or replace with **`@noble/hashes`** (pure JS, no Node dependencies).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 14. `jsonwebtoken` — Depends on Node.js Crypto
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/index.ts:16,81` — `jwt.verify()`
|
|
||||||
- `apps/backend/src/middleware/auth.ts:2,31` — `jwt.verify()`
|
|
||||||
- `apps/backend/src/middleware/auth.middleware.ts:2,32` — `jwt.verify()`
|
|
||||||
- `apps/backend/src/middleware/staff-auth.ts:2,25` — `jwt.verify()`
|
|
||||||
- `apps/backend/src/uv-apis/auth.controller.ts:3,53` — `jwt.sign()`
|
|
||||||
- `apps/backend/src/trpc/apis/user-apis/apis/auth.ts:4,53` — `jwt.sign()`
|
|
||||||
- `apps/backend/src/trpc/apis/admin-apis/apis/staff-user.ts:38` — `jwt.sign()`
|
|
||||||
|
|
||||||
**Problem:** The `jsonwebtoken` lib uses Node.js `crypto` internally.
|
|
||||||
|
|
||||||
**Solution:** Replace with **`jose`** (JWT library designed for Web Crypto API, Workers-compatible).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 15. CommonJS → ESM Module System
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/tsconfig.json:29` — `"module": "commonjs"`
|
|
||||||
- `apps/backend/index.ts:135,136,167` — `__dirname` usage
|
|
||||||
|
|
||||||
**Problem:** Workers require ESM. `__dirname` not available in ESM.
|
|
||||||
|
|
||||||
**Solution:** Change `module` to `"ESNext"` or `"ES2022"`, `moduleResolution` to `"bundler"`. Remove all `__dirname` usage (static files handled differently, see #8).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 16. AWS S3 (`@aws-sdk/client-s3`) — Replace with R2
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/s3-client.ts` — S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, getSignedUrl
|
|
||||||
- `apps/backend/src/lib/cloud_cache.ts` — Buffer.from() for S3 uploads
|
|
||||||
- `apps/backend/src/lib/s3-client.ts` — `generateSignedUrlFromS3Url()`
|
|
||||||
|
|
||||||
**Problem:** AWS SDK may have Node.js-specific dependencies.
|
|
||||||
|
|
||||||
**Solution:** R2 is S3-compatible — this is the easiest change. Update the S3Client endpoint to point to your R2 bucket. Or use the native `env.R2_BUCKET` binding for better performance. `@aws-sdk/s3-request-presigner` for upload URLs may need R2-specific handling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MODERATE CHANGES
|
|
||||||
|
|
||||||
### 17. Redis Pub/Sub for Order Notifications
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/post-order-handler.ts` — subscribes to `orders:placed`, `orders:cancelled` channels
|
|
||||||
|
|
||||||
**Problem:** Pub/Sub requires persistent TCP connections.
|
|
||||||
|
|
||||||
**Solution:** Replace with **Durable Objects** with WebSocket broadcasting, or **Cloudflare Queues** for async event processing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 18. `expo-server-sdk` — May Not Work in Workers
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/notif-job.ts`
|
|
||||||
- `apps/backend/src/lib/expo-service.ts`
|
|
||||||
|
|
||||||
**Problem:** SDK may use Node.js internals.
|
|
||||||
|
|
||||||
**Solution:** Check if the SDK uses Node.js internals; if so, replace with direct HTTP calls to Expo's push API via `fetch()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 19. `razorpay` SDK — Check Node.js Dependencies
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/payments-utils.ts` — `RazorpayPaymentService.createOrder()`, `initiateRefund()`, `fetchRefund()`
|
|
||||||
|
|
||||||
**Problem:** The Razorpay Node SDK may use `http`/`https` internally.
|
|
||||||
|
|
||||||
**Solution:** Replace with direct `fetch()` calls to Razorpay's REST API, or check if the SDK works with a polyfill.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 20. `Error.captureStackTrace` — V8-Specific
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/lib/api-error.ts:12`
|
|
||||||
|
|
||||||
**Problem:** V8-specific API.
|
|
||||||
|
|
||||||
**Solution:** Available in Workers (V8 runtime), so this actually works. Verify during migration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 21. `process.uptime()` — Used in Health Checks
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/main-router.ts:18,26`
|
|
||||||
|
|
||||||
**Problem:** No `process` object in Workers.
|
|
||||||
|
|
||||||
**Solution:** Replace with a custom uptime tracker using `Date.now()` at module load.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 22. `@turf/turf` — Check Bundle Size
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts:5` — `turf.booleanPointInPolygon()`
|
|
||||||
|
|
||||||
**Problem:** Workers have a **1MB compressed bundle limit** (free) or 10MB (paid). `@turf/turf` is a large library.
|
|
||||||
|
|
||||||
**Solution:** Import only `@turf/boolean-point-in-polygon` to reduce size. Or implement the point-in-polygon check manually using a lightweight algorithm.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Infrastructure Changes
|
|
||||||
|
|
||||||
| Current | Workers Replacement |
|
|
||||||
|---------|-------------------|
|
|
||||||
| Express on port 4000 | `export default { fetch }` handler (Hono) |
|
|
||||||
| PostgreSQL via `pg` | Hyperdrive + Neon/serverless driver |
|
|
||||||
| Redis (`redis` npm) | Upstash Redis (HTTP) or Cloudflare KV |
|
|
||||||
| BullMQ | Cloudflare Queues |
|
|
||||||
| node-cron | Cloudflare Cron Triggers |
|
|
||||||
| AWS S3 | Cloudflare R2 |
|
|
||||||
| Disk filesystem | KV / D1 / R2 |
|
|
||||||
| Docker deployment | `wrangler deploy` |
|
|
||||||
| `dotenv/config` | `wrangler.toml` `[vars]` + Secrets |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Priority
|
|
||||||
|
|
||||||
### Phase 1 — Foundation
|
|
||||||
1. CommonJS → ESM (#15)
|
|
||||||
2. Replace `dotenv/config` with wrangler env bindings (#9)
|
|
||||||
3. Replace `jsonwebtoken` with `jose` (#14)
|
|
||||||
4. Replace Node.js `crypto.createHmac` with Web Crypto API (#12)
|
|
||||||
|
|
||||||
### Phase 2 — Data Layer
|
|
||||||
5. Replace `node-postgres` driver with Neon/HTTP driver (#2)
|
|
||||||
6. Replace Redis client with Upstash Redis (#3)
|
|
||||||
7. Replace `fs` cache with KV (#7)
|
|
||||||
8. Replace S3 with R2 (#16)
|
|
||||||
|
|
||||||
### Phase 3 — HTTP Layer
|
|
||||||
9. Replace Express with Hono (#1)
|
|
||||||
10. Replace Multer with native FormData (#6)
|
|
||||||
11. Move static files to Pages/R2 (#8)
|
|
||||||
|
|
||||||
### Phase 4 — Background Jobs
|
|
||||||
12. Replace BullMQ with Cloudflare Queues (#4)
|
|
||||||
13. Replace node-cron with Cron Triggers (#5)
|
|
||||||
14. Rearchitect pub/sub (#17)
|
|
||||||
|
|
||||||
### Phase 5 — Polish
|
|
||||||
15. Verify expo-server-sdk / razorpay SDK compatibility (#18, #19)
|
|
||||||
16. Optimize bundle size (#22)
|
|
||||||
17. Remove Dockerfile, update deployment pipeline
|
|
||||||
|
|
@ -65,8 +65,8 @@ const isDevMode = Constants.executionEnvironment !== "standalone";
|
||||||
// const BASE_API_URL = 'http://192.168.100.101:4000';
|
// const BASE_API_URL = 'http://192.168.100.101:4000';
|
||||||
// const BASE_API_URL = 'http://192.168.1.5:4000';
|
// const BASE_API_URL = 'http://192.168.1.5:4000';
|
||||||
// let BASE_API_URL = "https://mf.freshyo.in";
|
// let BASE_API_URL = "https://mf.freshyo.in";
|
||||||
// let BASE_API_URL = "https://freshyo.technocracy.ovh";
|
let BASE_API_URL = "https://freshyo.technocracy.ovh";
|
||||||
let BASE_API_URL = 'http://192.168.100.108:4000';
|
// let BASE_API_URL = 'http://192.168.100.108:4000';
|
||||||
// let BASE_API_URL = 'http://192.168.29.176:4000';
|
// let BASE_API_URL = 'http://192.168.29.176:4000';
|
||||||
|
|
||||||
// if(isDevMode) {
|
// if(isDevMode) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue