Compare commits

..

No commits in common. "8f4cddee1adaec2c787b5a6e156c59fa8dac1f10" and "b38ff13950a8e151e2f0f325f4e8422c153be3b7" have entirely different histories.

24 changed files with 728 additions and 886 deletions

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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'] });
},
});
};

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, 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

View file

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

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, 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({

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

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

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

View file

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

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, 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,

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

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, 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[]) || []),
})) }))
); );

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, 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,
}; };

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

View file

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

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