Compare commits

...

2 commits

Author SHA1 Message Date
shafi54
8f4cddee1a enh 2026-03-21 22:28:45 +05:30
shafi54
77e3eb21d6 enh 2026-03-21 20:59:45 +05:30
24 changed files with 887 additions and 729 deletions

File diff suppressed because one or more lines are too long

View file

@ -2,53 +2,28 @@ 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 { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api'; import { trpc } from '@/src/trpc-client';
export default function AddProduct() { export default function AddProduct() {
const { mutate: createProduct, isPending: isCreating } = useCreateProduct(); const createProduct = trpc.admin.product.createProduct.useMutation();
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => { const handleSubmit = (values: any, imageKeys?: string[]) => {
const payload: CreateProductPayload = { createProduct.mutate({
name: values.name, name: values.name,
shortDescription: values.shortDescription, shortDescription: values.shortDescription,
longDescription: values.longDescription, longDescription: values.longDescription,
unitId: parseInt(values.unitId), unitId: parseInt(values.unitId),
storeId: parseInt(values.storeId), storeId: parseInt(values.storeId),
price: parseFloat(values.price), price: parseFloat(values.price),
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,
const formData = new FormData(); flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
Object.entries(payload).forEach(([key, value]) => { tagIds: values.tagIds || [],
if (value !== undefined && value !== null) { imageKeys: imageKeys || [],
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
@ -73,7 +48,7 @@ export default function AddProduct() {
isFlashAvailable: false, isFlashAvailable: false,
flashPrice: '', flashPrice: '',
productQuantity: 1, productQuantity: 1,
}; };
return ( return (
<AppContainer> <AppContainer>
@ -81,7 +56,7 @@ export default function AddProduct() {
mode="create" mode="create"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isCreating} isLoading={createProduct.isPending}
existingImages={[]} existingImages={[]}
/> />
</AppContainer> </AppContainer>

View file

@ -6,6 +6,7 @@ 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';
@ -26,7 +27,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 generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation(); const { upload, isUploading } = useUploadToObjectStorage();
const handleImagePick = usePickImage({ const handleImagePick = usePickImage({
setFile: async (assets: any) => { setFile: async (assets: any) => {
@ -62,30 +63,16 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const handleSubmit = async (adminResponse: string) => { const handleSubmit = async (adminResponse: string) => {
try { try {
const mimeTypes = selectedImages.map(s => s.mimeType); let keys: string[] = [];
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({ let generatedUrls: string[] = [];
contextString: 'review',
mimeTypes,
});
const keys = generatedUrls.map(url => {
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++) { if (selectedImages.length > 0) {
const uploadUrl = generatedUrls[i]; const result = await upload({
const { blob, mimeType } = selectedImages[i]; images: selectedImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
const uploadResponse = await fetch(uploadUrl, { contextString: 'review',
method: 'PUT',
body: blob,
headers: { 'Content-Type': mimeType },
}); });
if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`); keys = result.keys;
generatedUrls = result.presignedUrls;
} }
await respondToReview.mutateAsync({ await respondToReview.mutateAsync({
@ -101,8 +88,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
setSelectedImages([]); setSelectedImages([]);
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.');
} }
}; };
@ -137,7 +123,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
<TouchableOpacity <TouchableOpacity
onPress={() => formikSubmit()} onPress={() => formikSubmit()}
activeOpacity={0.8} activeOpacity={0.8}
disabled={respondToReview.isPending} disabled={respondToReview.isPending || isUploading}
> >
<LinearGradient <LinearGradient
colors={['#2563EB', '#1D4ED8']} colors={['#2563EB', '#1D4ED8']}
@ -145,7 +131,9 @@ 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`}
> >
{respondToReview.isPending ? ( {isUploading ? (
<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>

View file

@ -3,7 +3,6 @@ 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() {
@ -11,85 +10,52 @@ 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 { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct(); const updateProduct = trpc.admin.product.updateProduct.useMutation();
useManualRefresh(() => refetch()); useManualRefresh(() => refetch());
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => { const handleSubmit = (values: any, newImageKeys?: string[], imagesToDelete?: string[]) => {
const payload = { updateProduct.mutate({
name: values.name, id: productId,
shortDescription: values.shortDescription, name: values.name,
longDescription: values.longDescription, shortDescription: values.shortDescription,
unitId: parseInt(values.unitId), longDescription: values.longDescription,
storeId: parseInt(values.storeId), unitId: parseInt(values.unitId),
price: parseFloat(values.price), storeId: parseInt(values.storeId),
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, price: parseFloat(values.price),
incrementStep: 1, marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
productQuantity: values.productQuantity || 1, incrementStep: 1,
deals: values.deals?.filter((deal: any) => productQuantity: values.productQuantity || 1,
isSuspended: values.isSuspended,
isFlashAvailable: values.isFlashAvailable,
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
deals: values.deals?.filter((deal: any) =>
deal.quantity && deal.price && deal.validTill deal.quantity && deal.price && deal.validTill
).map((deal: any) => ({ ).map((deal: any) => ({
quantity: parseInt(deal.quantity), quantity: parseInt(deal.quantity),
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, // Convert Date to YYYY-MM-DD string : deal.validTill,
})), })),
tagIds: values.tagIds, tagIds: values.tagIds,
}; newImageKeys: newImageKeys || [],
imagesToDelete: imagesToDelete || [],
}, {
const formData = new FormData(); onSuccess: (data) => {
Object.entries(payload).forEach(([key, value]) => { Alert.alert('Success', 'Product updated successfully!');
if (key === 'deals' && Array.isArray(value)) { // Clear newly added images after successful update
formData.append(key, JSON.stringify(value)); productFormRef.current?.clearImages();
} else if (key === 'tagIds' && Array.isArray(value)) { },
value.forEach(tagId => { onError: (error: any) => {
formData.append('tagIds', tagId.toString()); Alert.alert('Error', error.message || 'Failed to update product');
}); },
} 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) => {
Alert.alert('Success', 'Product updated successfully!');
// Clear newly added images after successful update
productFormRef.current?.clearImages();
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to update product');
},
}
);
}; };
if (isFetching) { if (isFetching) {
@ -125,7 +91,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, // Convert to Date object validTill: deal.validTill ? new Date(deal.validTill) : null,
})) || [{ 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,
@ -141,7 +107,7 @@ export default function EditProduct() {
mode="edit" mode="edit"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isUpdating} isLoading={updateProduct.isPending}
existingImages={productData.images || []} existingImages={productData.images || []}
/> />
</AppContainer> </AppContainer>

View file

@ -18,6 +18,7 @@ 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;
@ -26,12 +27,6 @@ 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[]>([]);
@ -46,8 +41,7 @@ export default function SendNotifications() {
search: searchQuery, search: searchQuery,
}); });
// Generate upload URLs mutation const { uploadSingle, isUploading } = useUploadToObjectStorage();
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({
@ -127,28 +121,8 @@ export default function SendNotifications() {
// Upload image if selected // Upload image if selected
if (selectedImage) { if (selectedImage) {
const { uploadUrls } = await generateUploadUrls.mutateAsync({ const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
contextString: 'notification', imageUrl = key;
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
@ -256,15 +230,15 @@ export default function SendNotifications() {
{/* Submit Button */} {/* Submit Button */}
<TouchableOpacity <TouchableOpacity
onPress={handleSend} onPress={handleSend}
disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0} disabled={sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0}
style={tw`${ style={tw`${
sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0 sendNotification.isPending || isUploading || 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`}>
{sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'} {isUploading ? 'Uploading...' : sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>

View file

@ -8,6 +8,7 @@ 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;
@ -52,14 +53,7 @@ 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 generateUploadUrls = trpc.common.generateUploadUrls.useMutation(); const { uploadSingle, isUploading } = useUploadToObjectStorage();
// 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) => {
@ -97,37 +91,15 @@ 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');
const uploadResponse = await fetch(uploadUrl, { imageUrl = presignedUrl;
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', 'Failed to upload image'); Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
} }
}; };
@ -239,15 +211,15 @@ export default function BannerForm({
<MyTouchableOpacity <MyTouchableOpacity
onPress={() => handleSubmit()} onPress={() => handleSubmit()}
disabled={isSubmitting || !isValid || !dirty} disabled={isSubmitting || isUploading || !isValid || !dirty}
style={tw`flex-1 rounded-lg py-4 items-center ${ style={tw`flex-1 rounded-lg py-4 items-center ${
isSubmitting || !isValid || !dirty isSubmitting || isUploading || !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`}>
{isSubmitting ? 'Saving...' : submitButtonText} {isUploading ? 'Uploading...' : isSubmitting ? 'Saving...' : submitButtonText}
</MyText> </MyText>
</MyTouchableOpacity> </MyTouchableOpacity>
</View> </View>

View file

@ -6,6 +6,7 @@ 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;
@ -66,7 +67,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation(); const { uploadSingle, isUploading } = useUploadToObjectStorage();
const handleImagePick = usePickImage({ const handleImagePick = usePickImage({
setFile: async (assets: any) => { setFile: async (assets: any) => {
@ -113,43 +114,16 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
let imageUrl: string | undefined; let imageUrl: string | undefined;
if (selectedImages.length > 0) { if (selectedImages.length > 0) {
// Generate upload URLs const { blob, mimeType } = selectedImages[0];
const mimeTypes = selectedImages.map(s => s.mimeType); const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
const { uploadUrls } = await generateUploadUrls.mutateAsync({ imageUrl = presignedUrl;
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', 'Failed to upload image'); Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
} }
}; };
@ -204,11 +178,11 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
</View> </View>
<TouchableOpacity <TouchableOpacity
onPress={submit} onPress={submit}
disabled={isLoading || generateUploadUrls.isPending} disabled={isLoading || isUploading}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`} style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
> >
<MyText style={tw`text-white text-lg font-bold`}> <MyText style={tw`text-white text-lg font-bold`}>
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')} {isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View file

@ -0,0 +1,118 @@
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);
}

View file

@ -1,111 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from '../../services/axios-admin-ui';
// Types
export interface CreateProductPayload {
name: string;
shortDescription?: string;
longDescription?: string;
unitId: number;
storeId: number;
price: number;
marketPrice?: number;
incrementStep?: number;
productQuantity?: number;
isOutOfStock?: boolean;
deals?: {
quantity: number;
price: number;
validTill: string;
}[];
}
export interface UpdateProductPayload {
name: string;
shortDescription?: string;
longDescription?: string;
unitId: number;
storeId: number;
price: number;
marketPrice?: number;
incrementStep?: number;
productQuantity?: number;
isOutOfStock?: boolean;
deals?: {
quantity: number;
price: number;
validTill: string;
}[];
}
export interface Product {
id: number;
name: string;
shortDescription?: string | null;
longDescription?: string;
unitId: number;
storeId: number;
price: number;
marketPrice?: number;
productQuantity?: number;
isOutOfStock?: boolean;
images?: string[];
createdAt: string;
unit?: {
id: number;
shortNotation: string;
fullName: string;
};
deals?: {
id: number;
quantity: string;
price: string;
validTill: string;
}[];
}
export interface CreateProductResponse {
product: Product;
deals?: any[];
message: string;
}
// API functions
const createProductApi = async (formData: FormData): Promise<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'] });
},
});
};

View file

@ -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 } from 'react-native'; import { View, TouchableOpacity, Alert } 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,6 +8,7 @@ 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;
@ -38,7 +39,7 @@ export interface ProductFormRef {
interface ProductFormProps { interface ProductFormProps {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
initialValues: ProductFormData; initialValues: ProductFormData;
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void; onSubmit: (values: ProductFormData, imageKeys?: string[], imagesToDelete?: string[]) => void;
isLoading: boolean; isLoading: boolean;
existingImages?: string[]; existingImages?: string[];
} }
@ -60,8 +61,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
existingImages = [] existingImages = []
}, ref) => { }, ref) => {
const { theme } = useTheme(); const { theme } = useTheme();
const [images, setImages] = useState<{ uri?: string }[]>([]); const [newImages, setNewImages] = useState<{ blob: Blob; mimeType: string; 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 => ({
@ -83,23 +85,62 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
}, [existingImages]); }, [existingImages]);
const pickImage = usePickImage({ const pickImage = usePickImage({
setFile: (files) => setImages(prev => [...prev, ...files]), setFile: async (assets: any) => {
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
return;
}
const files = Array.isArray(assets) ? assets : [assets];
const imageData = await Promise.all(
files.map(async (asset) => {
const response = await fetch(asset.uri);
const blob = await response.blob();
return {
blob,
mimeType: asset.mimeType || 'image/jpeg',
uri: asset.uri
};
})
);
setNewImages(prev => [...prev, ...imageData]);
},
multiple: true, 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={(values) => onSubmit(values, images, deletedImages)} onSubmit={async (values) => {
try {
let imageKeys: string[] = [];
// Upload new images if any
if (newImages.length > 0) {
const result = await upload({
images: newImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
contextString: 'product_info',
});
imageKeys = result.keys;
}
onSubmit(values, imageKeys, deletedImages);
} catch (error) {
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload images');
}
}}
enableReinitialize 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(() => {
setImages([]); setNewImages([]);
setExistingImagesState([]); setExistingImagesState([]);
resetForm(); resetForm();
}, [resetForm]); }, [resetForm]);
@ -143,9 +184,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
{mode === 'create' && ( {mode === 'create' && (
<ImageUploader <ImageUploader
images={images} images={displayImages}
onAddImage={pickImage} onAddImage={pickImage}
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))} onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
/> />
)} )}
@ -166,9 +207,9 @@ const ProductForm = forwardRef<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={images} images={displayImages}
onAddImage={pickImage} onAddImage={pickImage}
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))} onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
/> />
</View> </View>
)} )}
@ -355,11 +396,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<TouchableOpacity <TouchableOpacity
onPress={submit} onPress={submit}
disabled={isLoading} disabled={isLoading || isUploading}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`} style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
> >
<MyText style={tw`text-white text-lg font-bold`}> <MyText style={tw`text-white text-lg font-bold`}>
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')} {isUploading ? 'Uploading Images...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,5 @@
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();
@ -8,9 +7,6 @@ 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);

View file

@ -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, generateSignedUrlFromS3Url } from "@/src/lib/s3-client"; import { imageUploadS3, scaffoldAssetUrl } 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 ? await generateSignedUrlFromS3Url(tag.imageUrl) : null, imageUrl: tag.imageUrl ? scaffoldAssetUrl(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 ? await generateSignedUrlFromS3Url(tag.imageUrl) : null, imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
}; };
return res.status(200).json({ return res.status(200).json({

View file

@ -1,306 +0,0 @@
import { Request, Response } from "express";
import { db } from "@/src/db/db_index";
import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
import { eq, inArray } from "drizzle-orm";
import { ApiError } from "@/src/lib/api-error";
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
import { deleteS3Image } from "@/src/lib/delete-image";
import type { SpecialDeal } from "@/src/db/types";
import { scheduleStoreInitialization } from '@/src/stores/store-initializer';
type CreateDeal = {
quantity: number;
price: number;
validTill: string;
};
/**
* Create a new product
*/
export const createProduct = async (req: Request, res: Response) => {
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals, tagIds } = req.body;
// Validate required fields
if (!name || !unitId || !storeId || !price) {
throw new ApiError("Name, unitId, storeId, and price are required", 400);
}
// Check for duplicate name
const existingProduct = await db.query.productInfo.findFirst({
where: eq(productInfo.name, name.trim()),
});
if (existingProduct) {
throw new ApiError("A product with this name already exists", 400);
}
// Check if unit exists
const unit = await db.query.units.findFirst({
where: eq(units.id, unitId),
});
if (!unit) {
throw new ApiError("Invalid unit ID", 400);
}
// Extract images from req.files
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
let uploadedImageUrls: string[] = [];
if (images && Array.isArray(images)) {
const imageUploadPromises = images.map((file, index) => {
const key = `product-images/${Date.now()}-${index}`;
return imageUploadS3(file.buffer, file.mimetype, key);
});
uploadedImageUrls = await Promise.all(imageUploadPromises);
}
// Create product
const productData: any = {
name,
shortDescription,
longDescription,
unitId,
storeId,
price,
marketPrice,
incrementStep: incrementStep || 1,
productQuantity: productQuantity || 1,
isSuspended: isSuspended || false,
isFlashAvailable: isFlashAvailable || false,
images: uploadedImageUrls,
};
if (flashPrice) {
productData.flashPrice = parseFloat(flashPrice);
}
const [newProduct] = await db
.insert(productInfo)
.values(productData)
.returning();
// Handle deals if provided
let createdDeals: SpecialDeal[] = [];
if (deals && Array.isArray(deals)) {
const dealInserts = deals.map((deal: CreateDeal) => ({
productId: newProduct.id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
createdDeals = await db
.insert(specialDeals)
.values(dealInserts)
.returning();
}
// Handle tag assignments if provided
if (tagIds && Array.isArray(tagIds)) {
const tagAssociations = tagIds.map((tagId: number) => ({
productId: newProduct.id,
tagId,
}));
await db.insert(productTags).values(tagAssociations);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
// Send response first
res.status(201).json({
product: newProduct,
deals: createdDeals,
message: "Product created successfully",
});
};
/**
* Update a product
*/
export const updateProduct = async (req: Request, res: Response) => {
const id = req.params.id as string
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body;
const deals = dealsRaw ? JSON.parse(dealsRaw) : null;
const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];
if (!name || !unitId || !storeId || !price) {
throw new ApiError("Name, unitId, storeId, and price are required", 400);
}
// Check if unit exists
const unit = await db.query.units.findFirst({
where: eq(units.id, unitId),
});
if (!unit) {
throw new ApiError("Invalid unit ID", 400);
}
// Get current product to handle image updates
const currentProduct = await db.query.productInfo.findFirst({
where: eq(productInfo.id, parseInt(id)),
});
if (!currentProduct) {
throw new ApiError("Product not found", 404);
}
// Handle image deletions
let currentImages = (currentProduct.images as string[]) || [];
if (imagesToDelete && imagesToDelete.length > 0) {
// Convert signed URLs to original S3 URLs for comparison
const originalUrlsToDelete = imagesToDelete
.map((signedUrl: string) => getOriginalUrlFromSignedUrl(signedUrl))
.filter(Boolean); // Remove nulls
// Find which stored images match the ones to delete
const imagesToRemoveFromDb = currentImages.filter(storedUrl =>
originalUrlsToDelete.includes(storedUrl)
);
// Delete the matching images from S3
const deletePromises = imagesToRemoveFromDb.map(imageUrl => deleteS3Image(imageUrl));
await Promise.all(deletePromises);
// Remove deleted images from current images array
currentImages = currentImages.filter(img => !imagesToRemoveFromDb.includes(img));
}
// Extract new images from req.files
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
let uploadedImageUrls: string[] = [];
if (images && Array.isArray(images)) {
const imageUploadPromises = images.map((file, index) => {
const key = `product-images/${Date.now()}-${index}`;
return imageUploadS3(file.buffer, file.mimetype, key);
});
uploadedImageUrls = await Promise.all(imageUploadPromises);
}
// Combine remaining current images with new uploaded images
const finalImages = [...currentImages, ...uploadedImageUrls];
const updateData: any = {
name,
shortDescription,
longDescription,
unitId,
storeId,
price,
marketPrice,
incrementStep: incrementStep || 1,
productQuantity: productQuantity || 1,
isSuspended: isSuspended || false,
images: finalImages.length > 0 ? finalImages : undefined,
};
if (isFlashAvailable !== undefined) {
updateData.isFlashAvailable = isFlashAvailable;
}
if (flashPrice !== undefined) {
updateData.flashPrice = flashPrice ? parseFloat(flashPrice) : null;
}
const [updatedProduct] = await db
.update(productInfo)
.set(updateData)
.where(eq(productInfo.id, parseInt(id)))
.returning();
if (!updatedProduct) {
throw new ApiError("Product not found", 404);
}
// Handle deals if provided
if (deals && Array.isArray(deals)) {
// Get existing deals
const existingDeals = await db.query.specialDeals.findMany({
where: eq(specialDeals.productId, parseInt(id)),
});
// Create maps for comparison
const existingDealsMap = new Map(existingDeals.map(deal => [`${deal.quantity}-${deal.price}`, deal]));
const newDealsMap = new Map(deals.map((deal: CreateDeal) => [`${deal.quantity}-${deal.price}`, deal]));
// Find deals to add, update, and remove
const dealsToAdd = deals.filter((deal: CreateDeal) => {
const key = `${deal.quantity}-${deal.price}`;
return !existingDealsMap.has(key);
});
const dealsToRemove = existingDeals.filter(deal => {
const key = `${deal.quantity}-${deal.price}`;
return !newDealsMap.has(key);
});
const dealsToUpdate = deals.filter((deal: CreateDeal) => {
const key = `${deal.quantity}-${deal.price}`;
const existing = existingDealsMap.get(key);
return existing && existing.validTill.toISOString().split('T')[0] !== deal.validTill;
});
// Remove old deals
if (dealsToRemove.length > 0) {
await db.delete(specialDeals).where(
inArray(specialDeals.id, dealsToRemove.map(deal => deal.id))
);
}
// Add new deals
if (dealsToAdd.length > 0) {
const dealInserts = dealsToAdd.map((deal: CreateDeal) => ({
productId: parseInt(id),
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await db.insert(specialDeals).values(dealInserts);
}
// Update existing deals
for (const deal of dealsToUpdate) {
const key = `${deal.quantity}-${deal.price}`;
const existingDeal = existingDealsMap.get(key);
if (existingDeal) {
await db.update(specialDeals)
.set({ validTill: new Date(deal.validTill) })
.where(eq(specialDeals.id, existingDeal.id));
}
}
}
// Handle tag assignments if provided
// if (tagIds && Array.isArray(tagIds)) {
if (tagIds && Boolean(tagIds)) {
// Remove existing tags
await db.delete(productTags).where(eq(productTags.productId, parseInt(id)));
const tagIdsArray = Array.isArray(tagIds) ? tagIds : [+tagIds]
// Add new tags
const tagAssociations = tagIdsArray.map((tagId: number) => ({
productId: parseInt(id),
tagId,
}));
await db.insert(productTags).values(tagAssociations);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
// Send response first
res.status(200).json({
product: updatedProduct,
message: "Product updated successfully",
});
};

View file

@ -1,11 +0,0 @@
import { Router } from "express";
import { createProduct, updateProduct } from "@/src/apis/admin-apis/apis/product.controller"
import uploadHandler from '@/src/lib/upload-handler';
const router = Router();
// Product routes
router.post("/", uploadHandler.array('images'), createProduct);
router.put("/:id", uploadHandler.array('images'), updateProduct);
export default router;

View file

@ -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 { s3Url } from "@/src/lib/env-exporter" import { assetsDomain, s3Url } from "@/src/lib/env-exporter"
function extractS3Key(url: string): string | null { function extractS3Key(url: string): string | null {
try { try {
@ -27,12 +27,22 @@ 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;
const key = extractS3Key(originalUrl || "");
key = extractS3Key(originalUrl || "");
}
else {
key = imageUrl;
}
if (!key) { if (!key) {
throw new Error("Invalid image URL format"); throw new Error("Invalid image URL format");
} }

View file

@ -201,9 +201,12 @@ export function extractKeyFromPresignedUrl(url: string): string {
export async function claimUploadUrl(url: string): Promise<void> { export async function claimUploadUrl(url: string): Promise<void> {
try { try {
const semiKey = extractKeyFromPresignedUrl(url); let semiKey:string = ''
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)

View file

@ -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, generateSignedUrlFromS3Url } from '@/src/lib/s3-client' import { extractKeyFromPresignedUrl, scaffoldAssetUrl } 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 ? await generateSignedUrlFromS3Url(banner.imageUrl) : banner.imageUrl, imageUrl: banner.imageUrl ? scaffoldAssetUrl(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 = await generateSignedUrlFromS3Url(banner.imageUrl); banner.imageUrl = scaffoldAssetUrl(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().url(), imageUrl: z.string(),
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,6 +94,7 @@ 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,

View file

@ -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 { generateSignedUrlsFromS3Urls } from '@/src/lib/s3-client' import { scaffoldAssetUrl } 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
? await generateSignedUrlsFromS3Urls(c.images as string[]) ? scaffoldAssetUrl(c.images as string[])
: []; : [];
return { return {

View file

@ -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, generateSignedUrlsFromS3Urls, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client' import { imageUploadS3, scaffoldAssetUrl, 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: await generateSignedUrlsFromS3Urls((product.images as string[]) || []), images: scaffoldAssetUrl((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: await generateSignedUrlsFromS3Urls((product.images as string[]) || []), images: scaffoldAssetUrl((product.images as string[]) || []),
deals, deals,
tags: productTagsData.map(pt => pt.tag), tags: productTagsData.map(pt => pt.tag),
}; };
@ -110,6 +110,229 @@ export const productRouter = router({
}; };
}), }),
createProduct: protectedProcedure
.input(z.object({
name: z.string().min(1),
shortDescription: z.string().optional(),
longDescription: z.string().optional(),
unitId: z.number(),
storeId: z.number(),
price: z.number(),
marketPrice: z.number().optional(),
incrementStep: z.number().default(1),
productQuantity: z.number().default(1),
isSuspended: z.boolean().default(false),
isFlashAvailable: z.boolean().default(false),
flashPrice: z.number().optional(),
deals: z.array(z.object({
quantity: z.number(),
price: z.number(),
validTill: z.string(),
})).optional(),
tagIds: z.array(z.number()).optional(),
imageKeys: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const {
name, shortDescription, longDescription, unitId, storeId,
price, marketPrice, incrementStep, productQuantity,
isSuspended, isFlashAvailable, flashPrice,
deals, tagIds, imageKeys
} = input;
// Validation
if (!name || !unitId || !storeId || !price) {
throw new ApiError("Name, unitId, storeId, and price are required", 400);
}
// Check for duplicate name
const existingProduct = await db.query.productInfo.findFirst({
where: eq(productInfo.name, name.trim()),
});
if (existingProduct) {
throw new ApiError("A product with this name already exists", 400);
}
// Check if unit exists
const unit = await db.query.units.findFirst({
where: eq(units.id, unitId),
});
if (!unit) {
throw new ApiError("Invalid unit ID", 400);
}
console.log(imageKeys)
const [newProduct] = await db
.insert(productInfo)
.values({
name: name.trim(),
shortDescription,
longDescription,
unitId,
storeId,
price: price.toString(),
marketPrice: marketPrice?.toString(),
incrementStep,
productQuantity,
isSuspended,
isFlashAvailable,
flashPrice: flashPrice?.toString(),
images: imageKeys || [],
})
.returning();
// Handle deals
if (deals && deals.length > 0) {
const dealInserts = deals.map(deal => ({
productId: newProduct.id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await db.insert(specialDeals).values(dealInserts);
}
// Handle tags
if (tagIds && tagIds.length > 0) {
const tagAssociations = tagIds.map(tagId => ({
productId: newProduct.id,
tagId,
}));
await db.insert(productTags).values(tagAssociations);
}
// Claim upload URLs
if (imageKeys && imageKeys.length > 0) {
for (const key of imageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${key}`, e);
}
}
}
scheduleStoreInitialization();
return {
product: newProduct,
message: "Product created successfully",
};
}),
updateProduct: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).optional(),
shortDescription: z.string().optional(),
longDescription: z.string().optional(),
unitId: z.number().optional(),
storeId: z.number().optional(),
price: z.number().optional(),
marketPrice: z.number().optional(),
incrementStep: z.number().optional(),
productQuantity: z.number().optional(),
isSuspended: z.boolean().optional(),
isFlashAvailable: z.boolean().optional(),
flashPrice: z.number().optional(),
deals: z.array(z.object({
quantity: z.number(),
price: z.number(),
validTill: z.string(),
})).optional(),
tagIds: z.array(z.number()).optional(),
newImageKeys: z.array(z.string()).optional(),
imagesToDelete: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input;
// Get current product
const currentProduct = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
});
if (!currentProduct) {
throw new ApiError("Product not found", 404);
}
// Handle image deletions
let currentImages = (currentProduct.images as string[]) || [];
if (imagesToDelete && imagesToDelete.length > 0) {
for (const imageUrl of imagesToDelete) {
try {
await deleteS3Image(imageUrl);
} catch (e) {
console.error(`Failed to delete image: ${imageUrl}`, e);
}
}
currentImages = currentImages.filter(img => {
//!imagesToDelete.includes(img)
const isRemoved = imagesToDelete.some(item => item.includes(img));
return !isRemoved;
});
}
// Add new images
if (newImageKeys && newImageKeys.length > 0) {
currentImages = [...currentImages, ...newImageKeys];
for (const key of newImageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${key}`, e);
}
}
}
// Update product - convert numeric fields to strings for PostgreSQL numeric type
const { price, marketPrice, flashPrice, ...otherData } = updateData;
const [updatedProduct] = await db
.update(productInfo)
.set({
...otherData,
...(price !== undefined && { price: price.toString() }),
...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }),
...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }),
images: currentImages,
})
.where(eq(productInfo.id, id))
.returning();
// Handle deals update
if (deals !== undefined) {
await db.delete(specialDeals).where(eq(specialDeals.productId, id));
if (deals.length > 0) {
const dealInserts = deals.map(deal => ({
productId: id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await db.insert(specialDeals).values(dealInserts);
}
}
// Handle tags update
if (tagIds !== undefined) {
await db.delete(productTags).where(eq(productTags.productId, id));
if (tagIds.length > 0) {
const tagAssociations = tagIds.map(tagId => ({
productId: id,
tagId,
}));
await db.insert(productTags).values(tagAssociations);
}
}
scheduleStoreInitialization();
return {
product: updatedProduct,
message: "Product updated successfully",
};
}),
toggleOutOfStock: protectedProcedure toggleOutOfStock: protectedProcedure
.input(z.object({ .input(z.object({
id: z.number(), id: z.number(),
@ -294,8 +517,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: await generateSignedUrlsFromS3Urls((review.imageUrls as string[]) || []), signedImageUrls: scaffoldAssetUrl((review.imageUrls as string[]) || []),
signedAdminImageUrls: await generateSignedUrlsFromS3Urls((review.adminResponseImages as string[]) || []), signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages as string[]) || []),
})) }))
); );

View file

@ -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, generateSignedUrlFromS3Url } from '@/src/lib/s3-client' import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } 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 = await generateSignedUrlFromS3Url(store.imageUrl) store.imageUrl = scaffoldAssetUrl(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 = await generateSignedUrlFromS3Url(store.imageUrl); store.imageUrl = scaffoldAssetUrl(store.imageUrl);
return { return {
store, store,
}; };

View file

@ -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']), contextString: z.enum(['review', 'product_info', 'store', 'notification']),
mimeTypes: z.array(z.string()), mimeTypes: z.array(z.string()),
})) }))
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => { .mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
@ -87,9 +87,12 @@ 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') { }
folder = 'review-response-images'; // else if (contextString === 'review_response') {
} else { //
// folder = 'review-response-images';
// }
else {
folder = ''; folder = '';
} }

342
edge_migration.md Normal file
View file

@ -0,0 +1,342 @@
# 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

View file

@ -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) {