enh
This commit is contained in:
parent
b38ff13950
commit
77e3eb21d6
15 changed files with 531 additions and 160 deletions
6
apps/admin-ui/.expo/types/router.d.ts
vendored
6
apps/admin-ui/.expo/types/router.d.ts
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
117
apps/admin-ui/hooks/useUploadToObjectStorage.ts
Normal file
117
apps/admin-ui/hooks/useUploadToObjectStorage.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
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);
|
||||||
|
const rawKey = u.pathname.replace(/^\/+/, '');
|
||||||
|
return decodeURIComponent(rawKey);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
@ -294,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: 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[]) || []),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
342
edge_migration.md
Normal 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
|
||||||
|
|
@ -65,8 +65,8 @@ const isDevMode = Constants.executionEnvironment !== "standalone";
|
||||||
// const BASE_API_URL = 'http://192.168.100.101:4000';
|
// const BASE_API_URL = 'http://192.168.100.101:4000';
|
||||||
// const BASE_API_URL = 'http://192.168.1.5:4000';
|
// const BASE_API_URL = 'http://192.168.1.5:4000';
|
||||||
// let BASE_API_URL = "https://mf.freshyo.in";
|
// let BASE_API_URL = "https://mf.freshyo.in";
|
||||||
let BASE_API_URL = "https://freshyo.technocracy.ovh";
|
// let BASE_API_URL = "https://freshyo.technocracy.ovh";
|
||||||
// let BASE_API_URL = 'http://192.168.100.108:4000';
|
let BASE_API_URL = 'http://192.168.100.108:4000';
|
||||||
// let BASE_API_URL = 'http://192.168.29.176:4000';
|
// let BASE_API_URL = 'http://192.168.29.176:4000';
|
||||||
|
|
||||||
// if(isDevMode) {
|
// if(isDevMode) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue