diff --git a/apps/admin-ui/app/(drawer)/products/add.tsx b/apps/admin-ui/app/(drawer)/products/add.tsx index e49930c..7465d22 100644 --- a/apps/admin-ui/app/(drawer)/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/products/add.tsx @@ -1,62 +1,52 @@ import React from 'react'; import { Alert } from 'react-native'; -import { AppContainer } from 'common-ui'; +import { AppContainer, ImageUploaderNeoPayload } from 'common-ui'; import ProductForm from '@/src/components/ProductForm'; -import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api'; +import { trpc } from '@/src/trpc-client'; +import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'; export default function AddProduct() { - const { mutate: createProduct, isPending: isCreating } = useCreateProduct(); + const createProduct = trpc.admin.product.createProduct.useMutation(); + const { upload, isUploading } = useUploadToObjectStorage(); - const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => { - const payload: CreateProductPayload = { - name: values.name, - shortDescription: values.shortDescription, - longDescription: values.longDescription, - unitId: parseInt(values.unitId), - storeId: parseInt(values.storeId), - price: parseFloat(values.price), - marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, - incrementStep: 1, - productQuantity: values.productQuantity || 1, - }; + const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => { + try { + let uploadUrls: string[] = []; - const formData = new FormData(); - Object.entries(payload).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - formData.append(key, value as string); + if (images.length > 0) { + const blobs = await Promise.all( + images.map(async (img) => { + const response = await fetch(img.url); + const blob = await response.blob(); + return { blob, mimeType: img.mimeType || 'image/jpeg' }; + }) + ); + + const result = await upload({ images: blobs, contextString: 'product_info' }); + uploadUrls = result.presignedUrls; } - }); - // Append tag IDs - if (values.tagIds && values.tagIds.length > 0) { - values.tagIds.forEach((tagId: number) => { - formData.append('tagIds', tagId.toString()); + await createProduct.mutateAsync({ + name: values.name, + shortDescription: values.shortDescription, + longDescription: values.longDescription, + unitId: parseInt(values.unitId), + storeId: parseInt(values.storeId), + price: parseFloat(values.price), + marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, + incrementStep: 1, + productQuantity: values.productQuantity || 1, + isSuspended: values.isSuspended || false, + isFlashAvailable: values.isFlashAvailable || false, + flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined, + uploadUrls, + tagIds: values.tagIds || [], }); - } - // 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); - } - }); + Alert.alert('Success', 'Product created successfully!'); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to create product'); } - - createProduct(formData, { - onSuccess: (data) => { - Alert.alert('Success', 'Product created successfully!'); - // Reset form or navigate - }, - onError: (error: any) => { - Alert.alert('Error', error.message || 'Failed to create product'); - }, - }); }; const initialValues = { @@ -81,9 +71,8 @@ export default function AddProduct() { mode="create" initialValues={initialValues} onSubmit={handleSubmit} - isLoading={isCreating} - existingImages={[]} + isLoading={createProduct.isPending || isUploading} /> ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/app/(drawer)/products/detail/[id].tsx b/apps/admin-ui/app/(drawer)/products/detail/[id].tsx index 90543df..cba7c26 100644 --- a/apps/admin-ui/app/(drawer)/products/detail/[id].tsx +++ b/apps/admin-ui/app/(drawer)/products/detail/[id].tsx @@ -6,6 +6,7 @@ import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploa import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons'; import { trpc } from '@/src/trpc-client'; import usePickImage from 'common-ui/src/components/use-pick-image'; +import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'; import { Formik } from 'formik'; import { LinearGradient } from 'expo-linear-gradient'; import { BlurView } from 'expo-blur'; @@ -23,10 +24,9 @@ const ReviewResponseForm: React.FC = ({ reviewId, onClo const [adminResponse, setAdminResponse] = useState(''); const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]); const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]); - const [uploadUrls, setUploadUrls] = useState([]); const respondToReview = trpc.admin.product.respondToReview.useMutation(); - const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation(); + const { upload } = useUploadToObjectStorage(); const handleImagePick = usePickImage({ setFile: async (assets: any) => { @@ -62,37 +62,16 @@ const ReviewResponseForm: React.FC = ({ reviewId, onClo const handleSubmit = async (adminResponse: string) => { try { - const mimeTypes = selectedImages.map(s => s.mimeType); - const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({ + const { keys, presignedUrls } = await upload({ + images: selectedImages, 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++) { - 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({ reviewId, adminResponse, adminResponseImages: keys, - uploadUrls: generatedUrls, + uploadUrls: presignedUrls, }); Alert.alert('Success', 'Response submitted'); @@ -100,9 +79,7 @@ const ReviewResponseForm: React.FC = ({ reviewId, onClo setAdminResponse(''); setSelectedImages([]); setDisplayImages([]); - setUploadUrls([]); } catch (error:any) { - Alert.alert('Error', error.message || 'Failed to submit response.'); } }; diff --git a/apps/admin-ui/app/(drawer)/products/edit.tsx b/apps/admin-ui/app/(drawer)/products/edit.tsx index e0bea74..722fc8c 100644 --- a/apps/admin-ui/app/(drawer)/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/products/edit.tsx @@ -1,95 +1,69 @@ import React, { useRef } from 'react'; -import { View, Text, Alert } from 'react-native'; +import { View, Alert } from 'react-native'; import { useLocalSearchParams } from 'expo-router'; -import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui'; +import { AppContainer, useManualRefresh, MyText, tw, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui'; import ProductForm, { ProductFormRef } from '@/src/components/ProductForm'; -import { useUpdateProduct } from '@/src/api-hooks/product.api'; import { trpc } from '@/src/trpc-client'; +import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'; export default function EditProduct() { const { id } = useLocalSearchParams(); const productId = Number(id); const productFormRef = useRef(null); - // const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId); const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery( { id: productId }, { enabled: !!productId } ); - // - const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct(); + + const updateProduct = trpc.admin.product.updateProduct.useMutation(); + const { upload, isUploading } = useUploadToObjectStorage(); useManualRefresh(() => refetch()); - const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => { - const payload = { + const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => { + try { + // New images have mimeType !== null, existing images have mimeType === null + const newImages = images.filter(img => img.mimeType !== null); + let uploadUrls: string[] = []; + + if (newImages.length > 0) { + const blobs = await Promise.all( + newImages.map(async (img) => { + const response = await fetch(img.url); + const blob = await response.blob(); + return { blob, mimeType: img.mimeType || 'image/jpeg' }; + }) + ); + + const result = await upload({ images: blobs, contextString: 'product_info' }); + uploadUrls = result.presignedUrls; + } + + await updateProduct.mutateAsync({ + id: productId, name: values.name, shortDescription: values.shortDescription, longDescription: values.longDescription, unitId: parseInt(values.unitId), storeId: parseInt(values.storeId), - price: parseFloat(values.price), - marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, - incrementStep: 1, - productQuantity: values.productQuantity || 1, - deals: values.deals?.filter((deal: any) => - deal.quantity && deal.price && deal.validTill - ).map((deal: any) => ({ - quantity: parseInt(deal.quantity), - price: parseFloat(deal.price), - validTill: deal.validTill instanceof Date - ? deal.validTill.toISOString().split('T')[0] - : deal.validTill, // Convert Date to YYYY-MM-DD string - })), - tagIds: values.tagIds, - }; - - - 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); - } + price: parseFloat(values.price), + marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, + incrementStep: 1, + productQuantity: values.productQuantity || 1, + isSuspended: values.isSuspended || false, + isFlashAvailable: values.isFlashAvailable || false, + flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : null, + uploadUrls, + imagesToDelete, + tagIds: values.tagIds || [], }); - } - // Add images to delete - if (imagesToDelete && imagesToDelete.length > 0) { - formData.append('imagesToDelete', JSON.stringify(imagesToDelete)); + Alert.alert('Success', 'Product updated successfully!'); + productFormRef.current?.clearImages(); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to update product'); } - - updateProduct( - { id: productId, formData }, - { - onSuccess: (data) => { - Alert.alert('Success', 'Product updated successfully!'); - // Clear newly added images after successful update - productFormRef.current?.clearImages(); - }, - onError: (error: any) => { - Alert.alert('Error', error.message || 'Failed to update product'); - }, - } - ); }; if (isFetching) { @@ -112,7 +86,13 @@ export default function EditProduct() { ); } - const productData = product.product; // The API returns { product: Product } + const productData = product.product; + + const existingImages: ImageUploaderNeoItem[] = (productData.images || []).map((url) => ({ + imgUrl: url, + mimeType: null, + })); + const existingImageKeys = productData.imageKeys || []; const initialValues = { name: productData.name, @@ -125,7 +105,7 @@ export default function EditProduct() { deals: productData.deals?.map(deal => ({ quantity: deal.quantity, price: deal.price, - validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object + validTill: deal.validTill ? new Date(deal.validTill) : null, })) || [{ quantity: '', price: '', validTill: null }], tagIds: productData.tags?.map((tag: any) => tag.id) || [], isSuspended: productData.isSuspended || false, @@ -141,9 +121,10 @@ export default function EditProduct() { mode="edit" initialValues={initialValues} onSubmit={handleSubmit} - isLoading={isUpdating} - existingImages={productData.images || []} + isLoading={updateProduct.isPending || isUploading} + existingImages={existingImages} + existingImageKeys={existingImageKeys} /> ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/app/(drawer)/products/index.tsx b/apps/admin-ui/app/(drawer)/products/index.tsx index b5512e0..eea72b4 100644 --- a/apps/admin-ui/app/(drawer)/products/index.tsx +++ b/apps/admin-ui/app/(drawer)/products/index.tsx @@ -6,7 +6,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { AppContainer, MyText, tw, MyButton, useManualRefresh, MyTextInput, SearchBar, useMarkDataFetchers } from 'common-ui'; import { trpc } from '@/src/trpc-client'; -import { Product } from '@/src/api-hooks/product.api'; +import type { AdminProduct } from '@packages/shared'; type FilterType = 'all' | 'in-stock' | 'out-of-stock'; @@ -54,7 +54,7 @@ export default function Products() { // const handleToggleStock = (product: any) => { - const handleToggleStock = (product: Pick) => { + const handleToggleStock = (product: Pick) => { const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock'; Alert.alert( 'Update Stock Status', diff --git a/apps/admin-ui/app/(drawer)/send-notifications/index.tsx b/apps/admin-ui/app/(drawer)/send-notifications/index.tsx index e4673c0..a52ddbb 100644 --- a/apps/admin-ui/app/(drawer)/send-notifications/index.tsx +++ b/apps/admin-ui/app/(drawer)/send-notifications/index.tsx @@ -18,6 +18,7 @@ import { } from 'common-ui'; import { trpc } from '@/src/trpc-client'; import usePickImage from 'common-ui/src/components/use-pick-image'; +import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'; interface User { id: number; @@ -26,12 +27,6 @@ interface User { isEligibleForNotif: boolean; } -const extractKeyFromUrl = (url: string): string => { - const u = new URL(url); - const rawKey = u.pathname.replace(/^\/+/, ''); - return decodeURIComponent(rawKey); -}; - export default function SendNotifications() { const router = useRouter(); const [selectedUserIds, setSelectedUserIds] = useState([]); @@ -46,8 +41,7 @@ export default function SendNotifications() { search: searchQuery, }); - // Generate upload URLs mutation - const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation(); + const { uploadSingle } = useUploadToObjectStorage(); // Send notification mutation const sendNotification = trpc.admin.user.sendNotification.useMutation({ @@ -127,28 +121,8 @@ export default function SendNotifications() { // Upload image if selected if (selectedImage) { - const { uploadUrls } = await generateUploadUrls.mutateAsync({ - 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}`); - } - } + const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification'); + imageUrl = key; } // Send notification diff --git a/apps/admin-ui/components/BannerForm.tsx b/apps/admin-ui/components/BannerForm.tsx index 544f008..8b3258b 100644 --- a/apps/admin-ui/components/BannerForm.tsx +++ b/apps/admin-ui/components/BannerForm.tsx @@ -7,6 +7,7 @@ import { DropdownOption } from 'common-ui/src/components/bottom-dropdown'; import ProductsSelector from './ProductsSelector'; import { trpc } from '../src/trpc-client'; import usePickImage from 'common-ui/src/components/use-pick-image'; +import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; export interface BannerFormData { @@ -52,10 +53,10 @@ export default function BannerForm({ const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]); const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]); - const generateUploadUrls = trpc.common.generateUploadUrls.useMutation(); + const { uploadSingle } = useUploadToObjectStorage(); // Fetch products for dropdown - const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({}); + const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery(); const products = productsData?.products || []; @@ -97,33 +98,11 @@ export default function BannerForm({ let imageUrl: string | undefined; 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 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; + const { presignedUrl } = await uploadSingle(blob, mimeType, 'store'); + imageUrl = presignedUrl; } - // Call onSubmit with form values and imageUrl await onSubmit(values, imageUrl); } catch (error) { console.error('Upload error:', error); @@ -256,4 +235,4 @@ export default function BannerForm({ )} ); -} \ No newline at end of file +} diff --git a/apps/admin-ui/components/StoreForm.tsx b/apps/admin-ui/components/StoreForm.tsx index aaad4e0..da98eb4 100644 --- a/apps/admin-ui/components/StoreForm.tsx +++ b/apps/admin-ui/components/StoreForm.tsx @@ -6,6 +6,7 @@ import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-u import ProductsSelector from './ProductsSelector'; import { trpc } from '../src/trpc-client'; import usePickImage from 'common-ui/src/components/use-pick-image'; +import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore'; export interface StoreFormData { name: string; @@ -66,7 +67,7 @@ const StoreForm = forwardRef((props, ref) => { - const generateUploadUrls = trpc.common.generateUploadUrls.useMutation(); + const { uploadSingle, isUploading } = useUploadToObjectStorage(); const handleImagePick = usePickImage({ setFile: async (assets: any) => { @@ -113,39 +114,11 @@ const StoreForm = forwardRef((props, ref) => { let imageUrl: string | undefined; if (selectedImages.length > 0) { - // Generate upload URLs - const mimeTypes = selectedImages.map(s => s.mimeType); - 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]; + const { blob, mimeType } = selectedImages[0]; + const { presignedUrl } = await uploadSingle(blob, mimeType, 'store'); + imageUrl = presignedUrl; } - // Submit form with imageUrl onSubmit({ ...values, imageUrl }); } catch (error) { console.error('Upload error:', error); @@ -204,11 +177,11 @@ const StoreForm = forwardRef((props, ref) => { - {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')} diff --git a/apps/admin-ui/hooks/useUploadToObjectStore.ts b/apps/admin-ui/hooks/useUploadToObjectStore.ts new file mode 100644 index 0000000..1e62ee0 --- /dev/null +++ b/apps/admin-ui/hooks/useUploadToObjectStore.ts @@ -0,0 +1,118 @@ + import { useState } from 'react'; +import { trpc } from '../src/trpc-client'; + +type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'complaint' | 'profile'; + +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(null); + const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null); + + const generateUploadUrls = trpc.common.generateUploadUrls.useMutation(); + + const upload = async (input: UploadBatchInput): Promise => { + 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); +} diff --git a/apps/admin-ui/src/api-hooks/product.api.ts b/apps/admin-ui/src/api-hooks/product.api.ts deleted file mode 100644 index bdb42ed..0000000 --- a/apps/admin-ui/src/api-hooks/product.api.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import axios from '../../services/axios-admin-ui'; - -// Types -export interface CreateProductPayload { - name: string; - shortDescription?: string; - longDescription?: string; - unitId: number; - storeId: number; - price: number; - marketPrice?: number; - incrementStep?: number; - productQuantity?: number; - isOutOfStock?: boolean; - deals?: { - quantity: number; - price: number; - validTill: string; - }[]; -} - -export interface UpdateProductPayload { - name: string; - shortDescription?: string; - longDescription?: string; - unitId: number; - storeId: number; - price: number; - marketPrice?: number; - incrementStep?: number; - productQuantity?: number; - isOutOfStock?: boolean; - deals?: { - quantity: number; - price: number; - validTill: string; - }[]; -} - -export interface Product { - id: number; - name: string; - shortDescription?: string | null; - longDescription?: string; - unitId: number; - storeId: number; - price: number; - marketPrice?: number; - productQuantity?: number; - isOutOfStock?: boolean; - images?: string[]; - createdAt: string; - unit?: { - id: number; - shortNotation: string; - fullName: string; - }; - deals?: { - id: number; - quantity: string; - price: string; - validTill: string; - }[]; -} - -export interface CreateProductResponse { - product: Product; - deals?: any[]; - message: string; -} - -// API functions -const createProductApi = async (formData: FormData): Promise => { - 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 => { - 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'] }); - }, - }); -}; diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index 1502214..07b76b3 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -1,10 +1,8 @@ -import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; +import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react'; import { View, TouchableOpacity } from 'react-native'; -import { Image } from 'expo-image'; import { Formik, FieldArray } from 'formik'; import * as Yup from 'yup'; -import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDelete, useTheme, DatePicker, tw, useFocusCallback, Checkbox } from 'common-ui'; -import usePickImage from 'common-ui/src/components/use-pick-image'; +import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { trpc } from '../trpc-client'; import { useGetTags } from '../api-hooks/tag.api'; @@ -38,9 +36,10 @@ export interface ProductFormRef { interface ProductFormProps { mode: 'create' | 'edit'; initialValues: ProductFormData; - onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void; + onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void; isLoading: boolean; - existingImages?: string[]; + existingImages?: ImageUploaderNeoItem[]; + existingImageKeys?: string[]; } const unitOptions = [ @@ -50,18 +49,21 @@ const unitOptions = [ { label: 'Unit Piece', value: 4 }, ]; - - const ProductForm = forwardRef(({ mode, initialValues, onSubmit, isLoading, - existingImages = [] + existingImages = [], + existingImageKeys = [], }, ref) => { const { theme } = useTheme(); - const [images, setImages] = useState<{ uri?: string }[]>([]); - const [existingImagesState, setExistingImagesState] = useState(existingImages); + const [images, setImages] = useState(existingImages); + + // Sync images state when existingImages prop changes (e.g., when async query data arrives) + useEffect(() => { + setImages(existingImages); + }, [existingImages]); const { data: storesData } = trpc.common.getStoresSummary.useQuery(); const storeOptions = storesData?.stores.map(store => ({ @@ -75,38 +77,44 @@ const ProductForm = forwardRef(({ value: tag.id.toString(), })) || []; - // Initialize existing images state when existingImages prop changes - useEffect(() => { - console.log('changing existing imaes statte') - - setExistingImagesState(existingImages); - }, [existingImages]); - - const pickImage = usePickImage({ - setFile: (files) => setImages(prev => [...prev, ...files]), - multiple: true, - }); - - // Calculate which existing images were deleted - const deletedImages = existingImages.filter(img => !existingImagesState.includes(img)); + // Build signed URL -> S3 key mapping for existing images + const signedUrlToKey = useMemo(() => { + const map: Record = {}; + existingImages.forEach((img, i) => { + if (existingImageKeys[i]) { + map[img.imgUrl] = existingImageKeys[i]; + } + }); + return map; + }, [existingImages, existingImageKeys]); return ( onSubmit(values, images, deletedImages)} + onSubmit={(values) => { + // New images have mimeType set, existing images have mimeType === null + const newImages = images.filter(img => img.mimeType !== null); + const deletedImageKeys = existingImages + .filter(existing => !images.some(current => current.imgUrl === existing.imgUrl)) + .map(deleted => signedUrlToKey[deleted.imgUrl]) + .filter(Boolean); + + onSubmit( + values, + newImages.map(img => ({ url: img.imgUrl, mimeType: img.mimeType })), + deletedImageKeys, + ); + }} enableReinitialize > {({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => { - // Clear form when screen comes into focus const clearForm = useCallback(() => { setImages([]); - setExistingImagesState([]); resetForm(); }, [resetForm]); useFocusCallback(clearForm); - // Update ref with current clearForm function useImperativeHandle(ref, () => ({ clearImages: clearForm, }), [clearForm]); @@ -141,44 +149,18 @@ const ProductForm = forwardRef(({ style={{ marginBottom: 16 }} /> - {mode === 'create' && ( - setImages(prev => prev.filter(img => img.uri !== uri))} - /> - )} - - {mode === 'edit' && existingImagesState.length > 0 && ( - - Current Images - - - )} - - {mode === 'edit' && ( - - Add New Images - setImages(prev => prev.filter(img => img.uri !== uri))} - /> - - )} + setImages(prev => [...prev, ...payloads.map(p => ({ imgUrl: p.url, mimeType: p.mimeType }))])} + onImageRemove={(payload) => setImages(prev => prev.filter(img => img.imgUrl !== payload.url))} + allowMultiple={true} + /> handleChange('unitId')(value+'')} onValueChange={(value) => setFieldValue('unitId', value)} placeholder="Select unit" style={{ marginBottom: 16 }} @@ -188,18 +170,7 @@ const ProductForm = forwardRef(({ placeholder="Enter product quantity" keyboardType="numeric" value={values.productQuantity.toString()} - onChangeText={(text) => { - // if(text) - // setFieldValue('productQuantity', text); - // else - setFieldValue('productQuantity', text); - // if (text === '' || text === null || text === undefined) { - // setFieldValue('productQuantity', 1); - // } else { - // const num = parseFloat(text); - // setFieldValue('productQuantity', isNaN(num) ? 1 : num); - // } - }} + onChangeText={(text) => setFieldValue('productQuantity', text)} style={{ marginBottom: 16 }} /> (({ style={{ marginBottom: 16 }} /> - - (({ checked={values.isFlashAvailable} onPress={() => { setFieldValue('isFlashAvailable', !values.isFlashAvailable); - if (values.isFlashAvailable) setFieldValue('flashPrice', ''); // Clear price when disabled + if (values.isFlashAvailable) setFieldValue('flashPrice', ''); }} style={tw`mr-3`} /> @@ -272,87 +241,6 @@ const ProductForm = forwardRef(({ /> )} - {/* - {({ push, remove, form }) => ( - - - - - Special Package Deals - - (Optional) - - {(form.values.deals || []).map((deal: any, index: number) => ( - - - - - - - - - - - - - form.setFieldValue(`deals.${index}.validTill`, date)} - showLabel={true} - placeholder="Valid Till" - /> - - - remove(index)} - style={tw`bg-red-500 p-3 rounded-lg shadow-md flex-row items-center justify-center`} - > - - Remove - - - - - - ))} - - {(form.values.deals || []).length === 0 && ( - - - - No package deals added yet - - - Add special pricing for bulk purchases - - - )} - - push({ quantity: '', price: '', validTill: null })} - style={tw`bg-green-500 px-4 py-2 rounded-lg shadow-lg flex-row items-center justify-center mt-4`} - > - - Add Package Deal - - - )} - */} - (({ ProductForm.displayName = 'ProductForm'; -export default ProductForm; \ No newline at end of file +export default ProductForm; diff --git a/apps/backend/index.ts b/apps/backend/index.ts index a8254fb..1b0e039 100755 --- a/apps/backend/index.ts +++ b/apps/backend/index.ts @@ -2,7 +2,6 @@ import 'dotenv/config'; import express, { NextFunction, Request, Response } from "express"; import cors from "cors"; // import bodyParser from "body-parser"; -import multer from "multer"; import path from "path"; import fs from "fs"; import { getStaffUserById, getUserDetailsByUserId, isUserSuspended } from '@/src/dbService'; diff --git a/apps/backend/src/main-router.ts b/apps/backend/src/main-router.ts index 25086e6..f8dd753 100755 --- a/apps/backend/src/main-router.ts +++ b/apps/backend/src/main-router.ts @@ -4,8 +4,6 @@ import { ApiError } from "@/src/lib/api-error" import v1Router from "@/src/v1-router" import testController from "@/src/test-controller" import { authenticateUser } from "@/src/middleware/auth.middleware" -import { raiseComplaint } from "@/src/uv-apis/user-rest.controller" -import uploadHandler from "@/src/lib/upload-handler" const router = Router(); @@ -34,12 +32,6 @@ router.use('/v1', v1Router); // router.use('/av', avRouter); router.use('/test', testController); -// User REST APIs -router.post('/uv/complaints/raise', - uploadHandler.array('images', 5), - raiseComplaint -); - // Global error handling middleware router.use((err: Error, req: Request, res: Response, next: NextFunction) => { console.error('Error:', err); @@ -62,4 +54,4 @@ router.use((err: Error, req: Request, res: Response, next: NextFunction) => { const mainRouter = router; -export default mainRouter; \ No newline at end of file +export default mainRouter; diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index febce94..42c5032 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -1,7 +1,7 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod' import { ApiError } from '@/src/lib/api-error' -import { generateSignedUrlsFromS3Urls, claimUploadUrl } from '@/src/lib/s3-client' +import { generateSignedUrlsFromS3Urls, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client' import { scheduleStoreInitialization } from '@/src/stores/store-initializer' import { getAllProducts as getAllProductsInDb, @@ -18,8 +18,18 @@ import { updateProductGroup as updateProductGroupInDb, deleteProductGroup as deleteProductGroupInDb, updateProductPrices as updateProductPricesInDb, + checkProductExistsByName, + checkUnitExists, + createProduct as createProductInDb, + createSpecialDealsForProduct, + replaceProductTags, + getProductImagesById, + updateProduct as updateProductInDb, + updateProductDeals, } from '@/src/dbService' import type { + AdminProduct, + AdminSpecialDeal, AdminProductGroupsResult, AdminProductGroupResponse, AdminProductReviewsResult, @@ -200,6 +210,168 @@ export const productRouter = router({ } }), + createProduct: protectedProcedure + .input(z.object({ + name: z.string().min(1, 'Name is required'), + shortDescription: z.string().optional(), + longDescription: z.string().optional(), + unitId: z.number().min(1, 'Unit is required'), + storeId: z.number().min(1, 'Store is required'), + price: z.number().positive('Price must be positive'), + marketPrice: z.number().optional(), + incrementStep: z.number().optional().default(1), + productQuantity: z.number().optional().default(1), + isSuspended: z.boolean().optional().default(false), + isFlashAvailable: z.boolean().optional().default(false), + flashPrice: z.number().optional(), + uploadUrls: z.array(z.string()).optional().default([]), + deals: z.array(z.object({ + quantity: z.number(), + price: z.number(), + validTill: z.string(), + })).optional(), + tagIds: z.array(z.number()).optional().default([]), + })) + .mutation(async ({ input }): Promise<{ product: AdminProduct; deals: AdminSpecialDeal[]; message: string }> => { + const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, uploadUrls, deals, tagIds } = input + + const existingProduct = await checkProductExistsByName(name.trim()) + if (existingProduct) { + throw new ApiError('A product with this name already exists', 400) + } + + const unitExists = await checkUnitExists(unitId) + if (!unitExists) { + throw new ApiError('Invalid unit ID', 400) + } + + const imageKeys = uploadUrls.map(url => extractKeyFromPresignedUrl(url)) + + const newProduct = await createProductInDb({ + name, + shortDescription, + longDescription, + unitId, + storeId, + price: price.toString(), + marketPrice: marketPrice?.toString(), + incrementStep, + productQuantity, + isSuspended, + isFlashAvailable, + flashPrice: flashPrice?.toString(), + images: imageKeys, + }) + + let createdDeals: AdminSpecialDeal[] = [] + if (deals && deals.length > 0) { + createdDeals = await createSpecialDealsForProduct(newProduct.id, deals) + } + + if (tagIds.length > 0) { + await replaceProductTags(newProduct.id, tagIds) + } + + if (uploadUrls.length > 0) { + await Promise.all(uploadUrls.map(url => claimUploadUrl(url))) + } + + scheduleStoreInitialization() + + return { + product: newProduct, + deals: createdDeals, + message: 'Product created successfully', + } + }), + + updateProduct: protectedProcedure + .input(z.object({ + id: z.number(), + name: z.string().min(1, 'Name is required'), + shortDescription: z.string().optional(), + longDescription: z.string().optional(), + unitId: z.number().min(1, 'Unit is required'), + storeId: z.number().min(1, 'Store is required'), + price: z.number().positive('Price must be positive'), + marketPrice: z.number().optional(), + incrementStep: z.number().optional().default(1), + productQuantity: z.number().optional().default(1), + isSuspended: z.boolean().optional().default(false), + isFlashAvailable: z.boolean().optional().default(false), + flashPrice: z.number().nullable().optional(), + uploadUrls: z.array(z.string()).optional().default([]), + imagesToDelete: z.array(z.string()).optional().default([]), + deals: z.array(z.object({ + quantity: z.number(), + price: z.number(), + validTill: z.string(), + })).optional(), + tagIds: z.array(z.number()).optional().default([]), + })) + .mutation(async ({ input }): Promise<{ product: AdminProduct; message: string }> => { + const { id, name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, uploadUrls, imagesToDelete, deals, tagIds } = input + + const unitExists = await checkUnitExists(unitId) + if (!unitExists) { + throw new ApiError('Invalid unit ID', 400) + } + + const currentImages = await getProductImagesById(id) + if (!currentImages) { + throw new ApiError('Product not found', 404) + } + + let updatedImages = currentImages || [] + if (imagesToDelete.length > 0) { + const imagesToRemove = updatedImages.filter(img => imagesToDelete.includes(img)) + await deleteImageUtil({ keys: imagesToRemove }) + updatedImages = updatedImages.filter(img => !imagesToRemove.includes(img)) + } + + const newImageKeys = uploadUrls.map(url => extractKeyFromPresignedUrl(url)) + const finalImages = [...updatedImages, ...newImageKeys] + + const updatedProduct = await updateProductInDb(id, { + name, + shortDescription, + longDescription, + unitId, + storeId, + price: price.toString(), + marketPrice: marketPrice?.toString(), + incrementStep, + productQuantity, + isSuspended, + isFlashAvailable, + flashPrice: flashPrice?.toString() ?? null, + images: finalImages, + }) + + if (!updatedProduct) { + throw new ApiError('Product not found', 404) + } + + if (deals && deals.length > 0) { + await updateProductDeals(id, deals) + } + + if (tagIds.length > 0) { + await replaceProductTags(id, tagIds) + } + + if (uploadUrls.length > 0) { + await Promise.all(uploadUrls.map(url => claimUploadUrl(url))) + } + + scheduleStoreInitialization() + + return { + product: updatedProduct, + message: 'Product updated successfully', + } + }), + updateSlotProducts: protectedProcedure .input(z.object({ slotId: z.string(), @@ -484,7 +656,7 @@ export const productRouter = router({ groups: groups.map(group => ({ ...group, products: group.memberships.map(m => ({ - ...m.product, + ...(m.product as AdminProduct), images: (m.product.images as string[]) || null, })), productCount: group.memberships.length, diff --git a/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts b/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts index 9b6c5e1..49cb6df 100644 --- a/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts +++ b/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts @@ -82,7 +82,7 @@ export const commonApiRouter = router({ generateUploadUrls: protectedProcedure .input(z.object({ - contextString: z.enum(['review', 'product_info', 'store']), + contextString: z.enum(['review', 'review_response', 'product_info', 'notification', 'store', 'complaint', 'profile']), mimeTypes: z.array(z.string()), })) .mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => { @@ -102,6 +102,10 @@ export const commonApiRouter = router({ folder = 'store-images'; } else if (contextString === 'review_response') { folder = 'review-response-images'; + } else if (contextString === 'complaint') { + folder = 'complaint-images'; + } else if (contextString === 'profile') { + folder = 'profile-images'; } else { folder = ''; } diff --git a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts index c028e3d..3193979 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts @@ -12,10 +12,12 @@ import { getUserAuthById as getUserAuthByIdInDb, getUserAuthCreds as getUserAuthCredsInDb, getUserAuthDetails as getUserAuthDetailsInDb, - createUserAuthWithCreds as createUserAuthWithCredsInDb, createUserAuthWithMobile as createUserAuthWithMobileInDb, upsertUserAuthPassword as upsertUserAuthPasswordInDb, deleteUserAuthAccount as deleteUserAuthAccountInDb, + createUserWithProfile as createUserWithProfileInDb, + updateUserProfile as updateUserProfileInDb, + getUserDetailsByUserId as getUserDetailsByUserIdInDb, } from '@/src/dbService' import type { UserAuthResult, @@ -36,6 +38,7 @@ interface RegisterRequest { email: string; mobile: string; password: string; + profileImageUrl?: string | null; } const generateToken = (userId: number): string => { @@ -127,9 +130,10 @@ export const authRouter = router({ email: z.string().email('Invalid email format'), mobile: z.string().min(1, 'Mobile is required'), password: z.string().min(1, 'Password is required'), + profileImageUrl: z.string().nullable().optional(), })) .mutation(async ({ input }): Promise => { - const { name, email, mobile, password }: RegisterRequest = input; + const { name, email, mobile, password, profileImageUrl }: RegisterRequest = input; if (!name || !email || !mobile || !password) { throw new ApiError('All fields are required', 400); @@ -165,15 +169,20 @@ export const authRouter = router({ const hashedPassword = await bcrypt.hash(password, 12); // Create user and credentials in a transaction - const newUser = await createUserAuthWithCredsInDb({ + const newUser = await createUserWithProfileInDb({ name: name.trim(), email: email.toLowerCase().trim(), mobile: cleanMobile, hashedPassword, + profileImage: profileImageUrl ?? null, }) const token = generateToken(newUser.id); + const profileImageSignedUrl = profileImageUrl + ? await generateSignedUrlFromS3Url(profileImageUrl) + : null + const response: UserAuthResponse = { token, user: { @@ -182,7 +191,7 @@ export const authRouter = router({ email: newUser.email, mobile: newUser.mobile, createdAt: newUser.createdAt.toISOString(), - profileImage: null, + profileImage: profileImageSignedUrl, }, }; @@ -278,6 +287,102 @@ export const authRouter = router({ return { success: true, message: 'Password updated successfully' } }), + updateProfile: protectedProcedure + .input(z.object({ + name: z.string().min(1).optional(), + email: z.string().email('Invalid email format').optional(), + mobile: z.string().min(1).optional(), + password: z.string().min(6, 'Password must be at least 6 characters').optional(), + bio: z.string().optional().nullable(), + dateOfBirth: z.string().optional().nullable(), + gender: z.string().optional().nullable(), + occupation: z.string().optional().nullable(), + profileImageUrl: z.string().optional().nullable(), + })) + .mutation(async ({ input, ctx }): Promise => { + const userId = ctx.user.userId; + + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + const { name, email, mobile, password, bio, dateOfBirth, gender, occupation, profileImageUrl } = input + + if (email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + throw new ApiError('Invalid email format', 400); + } + } + + if (mobile) { + const cleanMobile = mobile.replace(/\D/g, ''); + if (cleanMobile.length !== 10 || !/^[6-9]/.test(cleanMobile)) { + throw new ApiError('Invalid mobile number', 400); + } + } + + if (email) { + const existingEmail = await getUserAuthByEmailInDb(email.toLowerCase()) + if (existingEmail && existingEmail.id !== userId) { + throw new ApiError('Email already registered', 409) + } + } + + if (mobile) { + const cleanMobile = mobile.replace(/\D/g, '') + const existingMobile = await getUserAuthByMobileInDb(cleanMobile) + if (existingMobile && existingMobile.id !== userId) { + throw new ApiError('Mobile number already registered', 409) + } + } + + let hashedPassword: string | undefined; + if (password) { + hashedPassword = await bcrypt.hash(password, 12) + } + + const updatedUser = await updateUserProfileInDb(userId, { + name: name?.trim(), + email: email?.toLowerCase().trim(), + mobile: mobile?.replace(/\D/g, ''), + hashedPassword, + profileImage: profileImageUrl ?? undefined, + bio: bio ?? undefined, + dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, + gender: gender ?? undefined, + occupation: occupation ?? undefined, + }) + + const userDetail = await getUserDetailsByUserIdInDb(userId) + const profileImageSignedUrl = userDetail?.profileImage + ? await generateSignedUrlFromS3Url(userDetail.profileImage) + : null + + const token = ctx.req.headers.authorization?.replace('Bearer ', '') || '' + + const response: UserAuthResponse = { + token, + user: { + id: updatedUser.id, + name: updatedUser.name, + email: updatedUser.email, + mobile: updatedUser.mobile, + createdAt: updatedUser.createdAt?.toISOString?.() || new Date().toISOString(), + profileImage: profileImageSignedUrl, + bio: userDetail?.bio || null, + dateOfBirth: userDetail?.dateOfBirth || null, + gender: userDetail?.gender || null, + occupation: userDetail?.occupation || null, + }, + } + + return { + success: true, + data: response, + } + }), + getProfile: protectedProcedure .query(async ({ ctx }): Promise => { const userId = ctx.user.userId; diff --git a/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts b/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts index 9ce9c9b..3ca3790 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts @@ -49,10 +49,11 @@ export const complaintRouter = router({ .input(z.object({ orderId: z.string().optional(), complaintBody: z.string().min(1, 'Complaint body is required'), + imageUrls: z.array(z.string()).optional(), })) .mutation(async ({ input, ctx }): Promise => { const userId = ctx.user.userId; - const { orderId, complaintBody } = input; + const { orderId, complaintBody, imageUrls } = input; let orderIdNum: number | null = null; @@ -63,7 +64,12 @@ export const complaintRouter = router({ } } - await createUserComplaintInDb(userId, orderIdNum, complaintBody.trim()) + await createUserComplaintInDb( + userId, + orderIdNum, + complaintBody.trim(), + imageUrls && imageUrls.length > 0 ? imageUrls : null + ) /* // Old implementation - direct DB query: diff --git a/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts b/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts index bacf1f7..9f6ef4b 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/file-upload.ts @@ -6,7 +6,7 @@ import { ApiError } from '@/src/lib/api-error'; export const fileUploadRouter = router({ generateUploadUrls: protectedProcedure .input(z.object({ - contextString: z.enum(['review', 'product_info', 'notification']), + contextString: z.enum(['review', 'product_info', 'notification', 'complaint', 'profile']), mimeTypes: z.array(z.string()), })) .mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => { @@ -28,6 +28,10 @@ export const fileUploadRouter = router({ // } else if(contextString === 'notification') { folder = 'notification-images' + } else if (contextString === 'complaint') { + folder = 'complaint-images' + } else if (contextString === 'profile') { + folder = 'profile-images' } else { folder = ''; } diff --git a/apps/backend/src/uv-apis/auth.controller.ts b/apps/backend/src/uv-apis/auth.controller.ts deleted file mode 100644 index 02c103b..0000000 --- a/apps/backend/src/uv-apis/auth.controller.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import bcrypt from 'bcryptjs'; -import jwt from 'jsonwebtoken'; -import { - getUserAuthByEmail, - getUserAuthByMobile, - createUserWithProfile, - getUserAuthById, - getUserDetailsByUserId, - updateUserProfile, -} from '@/src/dbService'; -import { ApiError } from '@/src/lib/api-error' -import catchAsync from '@/src/lib/catch-async' -import { jwtSecret } from '@/src/lib/env-exporter'; -import uploadHandler from '@/src/lib/upload-handler' -import { imageUploadS3, generateSignedUrlFromS3Url } from '@/src/lib/s3-client' - -interface RegisterRequest { - name: string; - email: string; - mobile: string; - password: string; - profileImage?: string; -} - -interface UpdateProfileRequest { - name?: string; - email?: string; - mobile?: string; - password?: string; - bio?: string; - dateOfBirth?: string; - gender?: string; - occupation?: string; -} - -interface AuthResponse { - token: string; - user: { - id: number; - name: string | null; - email: string | null; - mobile: string | null; - profileImage?: string | null; - bio?: string | null; - dateOfBirth?: string | null; - gender?: string | null; - occupation?: string | null; - }; -} - -const generateToken = (userId: number): string => { - const secret = jwtSecret; - if (!secret) { - throw new ApiError('JWT secret not configured', 500); - } - - return jwt.sign({ userId }, secret, { expiresIn: '7d' }); -}; - -export const register = catchAsync(async (req: Request, res: Response, next: NextFunction) => { - const { name, email, mobile, password }: RegisterRequest = req.body; - - // Handle profile image upload - let profileImageUrl: string | undefined; - if (req.file) { - const key = `profile-images/${Date.now()}-${req.file.originalname}`; - profileImageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key); - } - - if (!name || !email || !mobile || !password) { - throw new ApiError('All fields are required', 400); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - throw new ApiError('Invalid email format', 400); - } - - // Validate mobile format (Indian mobile numbers) - const cleanMobile = mobile.replace(/\D/g, ''); - if (cleanMobile.length !== 10 || !/^[6-9]/.test(cleanMobile)) { - throw new ApiError('Invalid mobile number', 400); - } - - /* - // Old implementation - direct DB queries: - import { db } from '@/src/db/db_index' - import { users } from '@/src/db/schema' - import { eq } from 'drizzle-orm'; - - const [existingEmail] = await db - .select() - .from(users) - .where(eq(users.email, email.toLowerCase())) - .limit(1); - */ - - // Check if email already exists - const existingEmail = await getUserAuthByEmail(email.toLowerCase()); - if (existingEmail) { - throw new ApiError('Email already registered', 409); - } - - /* - // Old implementation - direct DB queries: - const [existingMobile] = await db - .select() - .from(users) - .where(eq(users.mobile, cleanMobile)) - .limit(1); - */ - - // Check if mobile already exists - const existingMobile = await getUserAuthByMobile(cleanMobile); - if (existingMobile) { - throw new ApiError('Mobile number already registered', 409); - } - - // Hash password - const hashedPassword = await bcrypt.hash(password, 12); - - /* - // Old implementation - direct DB queries: - import { userCreds, userDetails } from '@/src/db/schema' - - const newUser = await db.transaction(async (tx) => { - const [user] = await tx - .insert(users) - .values({ - name: name.trim(), - email: email.toLowerCase().trim(), - mobile: cleanMobile, - }) - .returning(); - - await tx.insert(userCreds).values({ - userId: user.id, - userPassword: hashedPassword, - }); - - await tx.insert(userDetails).values({ - userId: user.id, - profileImage: profileImageUrl, - }); - - return user; - }); - */ - - // Create user with profile in transaction - const newUser = await createUserWithProfile({ - name: name.trim(), - email: email.toLowerCase().trim(), - mobile: cleanMobile, - hashedPassword, - profileImage: profileImageUrl, - }); - - const token = generateToken(newUser.id); - - // Generate signed URL for profile image if it was uploaded - const profileImageSignedUrl = profileImageUrl - ? await generateSignedUrlFromS3Url(profileImageUrl) - : null; - - const response: AuthResponse = { - token, - user: { - id: newUser.id, - name: newUser.name, - email: newUser.email, - mobile: newUser.mobile, - profileImage: profileImageSignedUrl, - bio: null, - dateOfBirth: null, - gender: null, - occupation: null, - }, - }; - - res.status(201).json({ - success: true, - data: response, - }); -}); - -export const updateProfile = catchAsync(async (req: Request, res: Response, next: NextFunction) => { - const userId = req.user?.userId; - - if (!userId) { - throw new ApiError('User not authenticated', 401); - } - - const { name, email, mobile, password, bio, dateOfBirth, gender, occupation }: UpdateProfileRequest = req.body; - - // Handle profile image upload - let profileImageUrl: string | undefined; - if (req.file) { - const key = `profile-images/${Date.now()}-${req.file.originalname}`; - profileImageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key); - } - - // Validate email format if provided - if (email) { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - throw new ApiError('Invalid email format', 400); - } - } - - // Validate mobile format if provided - if (mobile) { - const cleanMobile = mobile.replace(/\D/g, ''); - if (cleanMobile.length !== 10 || !/^[6-9]/.test(cleanMobile)) { - throw new ApiError('Invalid mobile number', 400); - } - } - - /* - // Old implementation - direct DB queries: - import { db } from '@/src/db/db_index' - import { users, userCreds, userDetails } from '@/src/db/schema' - import { eq } from 'drizzle-orm'; - - if (email) { - const [existingEmail] = await db - .select() - .from(users) - .where(eq(users.email, email.toLowerCase())) - .limit(1); - - if (existingEmail && existingEmail.id !== userId) { - throw new ApiError('Email already registered', 409); - } - } - */ - - // Check if email already exists (if changing email) - if (email) { - const existingEmail = await getUserAuthByEmail(email.toLowerCase()); - if (existingEmail && existingEmail.id !== userId) { - throw new ApiError('Email already registered', 409); - } - } - - /* - // Old implementation - direct DB queries: - if (mobile) { - const cleanMobile = mobile.replace(/\D/g, ''); - const [existingMobile] = await db - .select() - .from(users) - .where(eq(users.mobile, cleanMobile)) - .limit(1); - - if (existingMobile && existingMobile.id !== userId) { - throw new ApiError('Mobile number already registered', 409); - } - } - */ - - // Check if mobile already exists (if changing mobile) - if (mobile) { - const cleanMobile = mobile.replace(/\D/g, ''); - const existingMobile = await getUserAuthByMobile(cleanMobile); - if (existingMobile && existingMobile.id !== userId) { - throw new ApiError('Mobile number already registered', 409); - } - } - - // Hash password if provided - let hashedPassword: string | undefined; - if (password) { - hashedPassword = await bcrypt.hash(password, 12); - } - - /* - // Old implementation - direct DB queries: - const updatedUser = await db.transaction(async (tx) => { - // Update user table - const updateData: any = {}; - if (name) updateData.name = name.trim(); - if (email) updateData.email = email.toLowerCase().trim(); - if (mobile) updateData.mobile = mobile.replace(/\D/g, ''); - - if (Object.keys(updateData).length > 0) { - await tx.update(users).set(updateData).where(eq(users.id, userId)); - } - - // Update password if provided - if (password) { - const hashedPassword = await bcrypt.hash(password, 12); - await tx.update(userCreds).set({ userPassword: hashedPassword }).where(eq(userCreds.userId, userId)); - } - - // Update or insert user details - const userDetailsUpdate: any = {}; - if (bio !== undefined) userDetailsUpdate.bio = bio; - if (dateOfBirth !== undefined) userDetailsUpdate.dateOfBirth = dateOfBirth ? new Date(dateOfBirth) : null; - if (gender !== undefined) userDetailsUpdate.gender = gender; - if (occupation !== undefined) userDetailsUpdate.occupation = occupation; - if (profileImageUrl) userDetailsUpdate.profileImage = profileImageUrl; - userDetailsUpdate.updatedAt = new Date(); - - const [existingDetails] = await tx.select().from(userDetails).where(eq(userDetails.userId, userId)).limit(1); - - if (existingDetails) { - await tx.update(userDetails).set(userDetailsUpdate).where(eq(userDetails.userId, userId)); - } else { - userDetailsUpdate.userId = userId; - userDetailsUpdate.createdAt = new Date(); - await tx.insert(userDetails).values(userDetailsUpdate); - } - - const [user] = await tx.select().from(users).where(eq(users.id, userId)).limit(1); - return user; - }); - */ - - // Update user profile in transaction - const updatedUser = await updateUserProfile(userId, { - name: name?.trim(), - email: email?.toLowerCase().trim(), - mobile: mobile?.replace(/\D/g, ''), - hashedPassword, - profileImage: profileImageUrl, - bio, - dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, - gender, - occupation, - }); - - /* - // Old implementation - direct DB queries: - const [userDetail] = await db.select().from(userDetails).where(eq(userDetails.userId, userId)).limit(1); - */ - - // Get updated user details for response - const userDetail = await getUserDetailsByUserId(userId); - - // Generate signed URL for profile image if it exists - const profileImageSignedUrl = userDetail?.profileImage - ? await generateSignedUrlFromS3Url(userDetail.profileImage) - : null; - - const response: AuthResponse = { - token: req.headers.authorization?.replace('Bearer ', '') || '', // Keep existing token - user: { - id: updatedUser.id, - name: updatedUser.name, - email: updatedUser.email, - mobile: updatedUser.mobile, - profileImage: profileImageSignedUrl, - bio: userDetail?.bio || null, - dateOfBirth: userDetail?.dateOfBirth || null, - gender: userDetail?.gender || null, - occupation: userDetail?.occupation || null, - }, - }; - - res.status(200).json({ - success: true, - data: response, - }); -}); - -/* -// Old implementation - direct DB queries: -import { db } from '@/src/db/db_index' -import { users, userCreds, userDetails } from '@/src/db/schema' -import { eq } from 'drizzle-orm'; -*/ diff --git a/apps/backend/src/uv-apis/auth.router.ts b/apps/backend/src/uv-apis/auth.router.ts deleted file mode 100644 index 63ceff5..0000000 --- a/apps/backend/src/uv-apis/auth.router.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Router } from 'express'; -import { register, updateProfile } from '@/src/uv-apis/auth.controller' -import { verifyToken } from '@/src/middleware/auth' -import uploadHandler from '@/src/lib/upload-handler' - -const router = Router(); - -router.post('/register', uploadHandler.single('profileImage'), register); -router.put('/profile', verifyToken, uploadHandler.single('profileImage'), updateProfile); - -const authRouter = router; -export default authRouter; \ No newline at end of file diff --git a/apps/backend/src/uv-apis/user-rest.controller.ts b/apps/backend/src/uv-apis/user-rest.controller.ts deleted file mode 100644 index 4cb8c16..0000000 --- a/apps/backend/src/uv-apis/user-rest.controller.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { createUserComplaint } from '@/src/dbService'; -import { ApiError } from '@/src/lib/api-error' -import catchAsync from '@/src/lib/catch-async' -import { imageUploadS3 } from '@/src/lib/s3-client' - -interface RaiseComplaintRequest { - orderId?: string; - complaintBody: string; -} - -export const raiseComplaint = catchAsync(async (req: Request, res: Response, next: NextFunction) => { - console.log('raising complaint') - - const userId = req.user?.userId; - - if (!userId) { - throw new ApiError('User not authenticated', 401); - } - - const { orderId, complaintBody }: RaiseComplaintRequest = req.body; - - let orderIdNum: number | null = null; - - if (orderId) { - const readableIdMatch = orderId.match(/^ORD(\d+)$/); - if (readableIdMatch) { - orderIdNum = parseInt(readableIdMatch[1]); - } - } - - // Handle image uploads - 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 = `complaint-images/${Date.now()}-${index}`; - return imageUploadS3(file.buffer, file.mimetype, key); - }); - - uploadedImageUrls = await Promise.all(imageUploadPromises); - } - - /* - // Old implementation - direct DB queries: - import { db } from '@/src/db/db_index' - import { complaints } from '@/src/db/schema' - - await db.insert(complaints).values({ - userId, - orderId: orderIdNum, - complaintBody: complaintBody.trim(), - images: uploadedImageUrls.length > 0 ? uploadedImageUrls : null, - }); - */ - - await createUserComplaint( - userId, - orderIdNum, - complaintBody.trim(), - uploadedImageUrls.length > 0 ? uploadedImageUrls : null - ); - - res.status(200).json({ - success: true, - message: 'Complaint raised successfully' - }); -}); - -/* -// Old implementation - direct DB queries: -import { db } from '@/src/db/db_index' -import { complaints } from '@/src/db/schema' -*/ diff --git a/apps/backend/src/uv-apis/uv-router.ts b/apps/backend/src/uv-apis/uv-router.ts deleted file mode 100644 index e205b76..0000000 --- a/apps/backend/src/uv-apis/uv-router.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Router } from "express"; -import authRouter from "@/src/uv-apis/auth.router" -import { raiseComplaint } from "@/src/uv-apis/user-rest.controller" -import uploadHandler from "@/src/lib/upload-handler"; - -const router = Router(); - -router.use("/auth", authRouter); -router.use("/complaints/raise", uploadHandler.array('images'),raiseComplaint) - -const uvRouter = router; -export default uvRouter; diff --git a/apps/backend/src/v1-router.ts b/apps/backend/src/v1-router.ts index 91a2e71..3808c3a 100644 --- a/apps/backend/src/v1-router.ts +++ b/apps/backend/src/v1-router.ts @@ -1,15 +1,13 @@ import { Router } from "express"; - import avRouter from "@/src/apis/admin-apis/apis/av-router" - import commonRouter from "@/src/apis/common-apis/apis/common.router" - import uvRouter from "@/src/uv-apis/uv-router" +import avRouter from "@/src/apis/admin-apis/apis/av-router" +import commonRouter from "@/src/apis/common-apis/apis/common.router" const router = Router(); - router.use('/av', avRouter); - router.use('/cm', commonRouter); - router.use('/uv', uvRouter); +router.use('/av', avRouter); +router.use('/cm', commonRouter); const v1Router = router; -export default v1Router; \ No newline at end of file +export default v1Router; diff --git a/apps/user-ui/app/(auth)/register.tsx b/apps/user-ui/app/(auth)/register.tsx index 157e069..1adce2c 100644 --- a/apps/user-ui/app/(auth)/register.tsx +++ b/apps/user-ui/app/(auth)/register.tsx @@ -5,16 +5,17 @@ import { useRouter } from "expo-router"; import { MyText, tw, MyTouchableOpacity } from "common-ui"; import { useAuth } from "@/src/contexts/AuthContext"; import RegistrationForm from "@/components/registration-form"; +import type { RegisterData, UpdateProfileData } from "@/src/types/auth"; function Register() { const router = useRouter(); const { register } = useAuth(); const [isLoading, setIsLoading] = useState(false); - const handleRegister = async (formData: FormData) => { + const handleRegister = async (formData: RegisterData | UpdateProfileData) => { setIsLoading(true); try { - await register(formData); + await register(formData as RegisterData); // Auth context will handle navigation on successful registration } catch (error: any) { Alert.alert( @@ -56,4 +57,4 @@ function Register() { ); } -export default Register; \ No newline at end of file +export default Register; diff --git a/apps/user-ui/app/(drawer)/(tabs)/me/edit-profile/index.tsx b/apps/user-ui/app/(drawer)/(tabs)/me/edit-profile/index.tsx index 2253fa1..d017743 100644 --- a/apps/user-ui/app/(drawer)/(tabs)/me/edit-profile/index.tsx +++ b/apps/user-ui/app/(drawer)/(tabs)/me/edit-profile/index.tsx @@ -4,6 +4,7 @@ import { AppContainer, MyButton, MyText, tw , BottomDialog } from "common-ui"; import RegistrationForm from "@/components/registration-form"; import { useUserDetails, useAuth } from "@/src/contexts/AuthContext"; import { useUpdateProfile } from "@/src/api-hooks/auth.api"; +import type { RegisterData, UpdateProfileData } from "@/src/types/auth"; import { router } from "expo-router"; import { trpc } from '@/src/trpc-client'; @@ -20,9 +21,9 @@ function EditProfile() { // Prevent unnecessary re-renders const mobileInputValue = useMemo(() => enteredMobile, [enteredMobile]); - const handleUpdate = async (data: FormData) => { + const handleUpdate = async (data: RegisterData | UpdateProfileData) => { try { - const response = await updateProfileMutation.mutateAsync(data); + const response = await updateProfileMutation.mutateAsync(data as UpdateProfileData); // Update the context with new user details if (response.user) { @@ -179,4 +180,4 @@ function EditProfile() { ); } -export default EditProfile; \ No newline at end of file +export default EditProfile; diff --git a/apps/user-ui/components/ComplaintForm.tsx b/apps/user-ui/components/ComplaintForm.tsx index 040d06b..638ea2a 100644 --- a/apps/user-ui/components/ComplaintForm.tsx +++ b/apps/user-ui/components/ComplaintForm.tsx @@ -1,11 +1,9 @@ import React, { useState } from 'react'; import { View, TextInput, ActivityIndicator, KeyboardAvoidingView, Platform, Alert } from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { useMutation } from "@tanstack/react-query"; -import { MyText, ImageUploader, tw, MyTouchableOpacity } from 'common-ui'; -import usePickImage from 'common-ui/src/components/use-pick-image'; -import axios from '../services/axios-user-ui'; -// import axios from 'common-ui/src/services/axios'; +import { MaterialIcons } from '@expo/vector-icons' +import { MyText, ImageUploaderNeo, tw, MyTouchableOpacity, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui' +import { trpc } from '@/src/trpc-client' +import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore' interface ComplaintFormProps { open: boolean; @@ -15,71 +13,66 @@ interface ComplaintFormProps { export default function ComplaintForm({ open, onClose, orderId }: ComplaintFormProps) { const [complaintBody, setComplaintBody] = useState(''); - const [complaintImages, setComplaintImages] = useState<{ uri?: string }[]>([]); + const [complaintImages, setComplaintImages] = useState([]) - // API function - const raiseComplaintApi = async (payload: { complaintBody: string; images: { uri?: string }[] }) => { - const formData = new FormData(); + const raiseComplaintMutation = trpc.user.complaint.raise.useMutation() - formData.append('orderId', orderId.toString()); - formData.append('complaintBody', payload.complaintBody); + const { upload, isUploading } = useUploadToObjectStorage() - // Add images if provided - if (payload.images && payload.images.length > 0) { - payload.images.forEach((image, index) => { - if (image.uri) { - const fileName = `complaint-image-${index}.jpg`; - formData.append('images', { - uri: image.uri, - name: fileName, - type: 'image/jpeg', - } as any); - } - }); - } + const handleAddImages = (images: ImageUploaderNeoPayload[]) => { + setComplaintImages((prev) => [ + ...prev, + ...images.map((image) => ({ + imgUrl: image.url, + mimeType: image.mimeType, + })), + ]) + } - const response = await axios.post('/uv/complaints/raise', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - return response.data; - }; + const handleRemoveImage = (image: ImageUploaderNeoPayload) => { + setComplaintImages((prev) => prev.filter((item) => item.imgUrl !== image.url)) + } - // Hook - const raiseComplaintMutation = useMutation({ - mutationFn: raiseComplaintApi, - }); - - const pickComplaintImage = usePickImage({ - setFile: (files) => setComplaintImages(prev => [...prev, ...files]), - multiple: true, - }); - - const handleSubmit = () => { + const handleSubmit = async () => { if (!complaintBody.trim()) { Alert.alert('Error', 'Please enter complaint details'); return; } - raiseComplaintMutation.mutate( - { - complaintBody: complaintBody.trim(), - images: complaintImages, - }, - { - onSuccess: () => { - Alert.alert('Success', 'Complaint raised successfully'); - setComplaintBody(''); - setComplaintImages([]); - onClose(); - }, - onError: (error: any) => { - Alert.alert('Error', error.message || 'Failed to raise complaint'); - }, + try { + let imageUrls: string[] = [] + + if (complaintImages.length > 0) { + const uploadImages = await Promise.all( + complaintImages.map(async (image) => { + const response = await fetch(image.imgUrl) + const blob = await response.blob() + return { blob, mimeType: image.mimeType || 'image/jpeg' } + }) + ) + + const { keys } = await upload({ + images: uploadImages, + contextString: 'complaint', + }) + + imageUrls = keys } - ); - }; + + await raiseComplaintMutation.mutateAsync({ + orderId: orderId.toString(), + complaintBody: complaintBody.trim(), + imageUrls, + }) + + Alert.alert('Success', 'Complaint raised successfully') + setComplaintBody('') + setComplaintImages([]) + onClose() + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to raise complaint') + } + } if (!open) return null; @@ -105,18 +98,18 @@ export default function ComplaintForm({ open, onClose, orderId }: ComplaintFormP textAlignVertical="top" /> - setComplaintImages(prev => prev.filter(img => img.uri !== uri))} + onImageAdd={handleAddImages} + onImageRemove={handleRemoveImage} /> - {raiseComplaintMutation.isPending ? ( + {raiseComplaintMutation.isPending || isUploading ? ( ) : ( Submit Complaint @@ -125,4 +118,4 @@ export default function ComplaintForm({ open, onClose, orderId }: ComplaintFormP ); -} \ No newline at end of file +} diff --git a/apps/user-ui/components/registration-form.tsx b/apps/user-ui/components/registration-form.tsx index 3f98b1f..edda2b9 100644 --- a/apps/user-ui/components/registration-form.tsx +++ b/apps/user-ui/components/registration-form.tsx @@ -2,8 +2,10 @@ import React, { useState } from "react"; import { View, TextInput, Alert } from "react-native"; import { useForm, Controller } from "react-hook-form"; -import { MyButton, MyText, MyTextInput, ProfileImage, tw, BottomDialog } from "common-ui"; +import { MyButton, MyText, MyTextInput, ProfileImage, tw, BottomDialog, MyTouchableOpacity } from "common-ui"; import { trpc } from "@/src/trpc-client"; +import { useUploadToObjectStorage } from "../hooks/useUploadToObjectStore"; +import type { RegisterData, UpdateProfileData } from "@/src/types/auth"; interface RegisterFormInputs { name: string; @@ -16,7 +18,7 @@ interface RegisterFormInputs { } interface RegistrationFormProps { - onSubmit: (data: FormData) => void | Promise; + onSubmit: (data: RegisterData | UpdateProfileData) => void | Promise; isLoading?: boolean; initialValues?: Partial; isEdit?: boolean; @@ -29,6 +31,7 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit = const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const updatePasswordMutation = trpc.user.auth.updatePassword.useMutation(); + const { uploadSingle, isUploading } = useUploadToObjectStorage() // Set initial profile image URI for edit mode React.useEffect(() => { @@ -161,27 +164,39 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit = return; } - // Create FormData - const formData = new FormData(); - formData.append('name', data.name.trim()); - formData.append('email', data.email.trim().toLowerCase()); - formData.append('mobile', data.mobile.replace(/\D/g, '')); - - // Only include password if provided (for edit mode) - if (data.password) { - formData.append('password', data.password); + let profileImageUrl: string | undefined; + if (profileImageFile?.uri) { + const response = await fetch(profileImageFile.uri) + const blob = await response.blob() + const mimeType = profileImageFile.mimeType || 'image/jpeg' + const { key } = await uploadSingle(blob, mimeType, 'profile') + profileImageUrl = key } - if (profileImageFile) { - - formData.append('profileImage', { - uri: profileImageFile.uri, - type: profileImageFile.mimeType || 'image/jpeg', - name: profileImageFile.name || 'profile.jpg', - } as any); + const basePayload = { + name: data.name.trim(), + email: data.email.trim().toLowerCase(), + mobile: data.mobile.replace(/\D/g, ''), } - await onSubmit(formData); + if (isEdit) { + const updatePayload: UpdateProfileData = { + ...basePayload, + ...(data.password ? { password: data.password } : {}), + ...(profileImageUrl ? { profileImageUrl } : {}), + } + + await onSubmit(updatePayload) + return + } + + const registerPayload: RegisterData = { + ...basePayload, + password: data.password, + ...(profileImageUrl ? { profileImageUrl } : {}), + } + + await onSubmit(registerPayload) }; const handleUpdatePassword = async () => { @@ -407,10 +422,14 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit = fillColor="brand500" textColor="white1" fullWidth - disabled={isLoading} + disabled={isLoading || isUploading} style={tw` rounded-lg`} > - {isLoading ? (isEdit ? "Updating..." : "Creating Account...") : (isEdit ? "Update Profile" : "Create Account")} + {isUploading + ? 'Uploading...' + : isLoading + ? (isEdit ? "Updating..." : "Creating Account...") + : (isEdit ? "Update Profile" : "Create Account")} {isEdit && ( @@ -482,4 +501,4 @@ function RegistrationForm({ onSubmit, isLoading = false, initialValues, isEdit = ); } -export default RegistrationForm; \ No newline at end of file +export default RegistrationForm; diff --git a/apps/user-ui/hooks/useUploadToObjectStore.ts b/apps/user-ui/hooks/useUploadToObjectStore.ts new file mode 100644 index 0000000..36dfabb --- /dev/null +++ b/apps/user-ui/hooks/useUploadToObjectStore.ts @@ -0,0 +1,119 @@ +import { useState } from 'react' +import { trpc } from '@/src/trpc-client' + +type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'complaint' | 'profile' + +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(null) + const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null) + + const generateUploadUrls = trpc.common.generateUploadUrls.useMutation() + + const upload = async (input: UploadBatchInput): Promise => { + setIsUploading(true) + setError(null) + setProgress({ completed: 0, total: input.images.length }) + + try { + const { images, contextString } = input + + if (images.length === 0) { + return { keys: [], presignedUrls: [] } + } + + const mimeTypes = images.map((img) => img.mimeType) + const { uploadUrls } = await generateUploadUrls.mutateAsync({ + contextString: contextString as any, + mimeTypes, + }) + + if (uploadUrls.length !== images.length) { + throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`) + } + + 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}`) + } + + setProgress((prev) => (prev ? { ...prev, completed: prev.completed + 1 } : null)) + + return { + key: extractKeyFromPresignedUrl(presignedUrl), + presignedUrl, + } + }) + + const results = await Promise.all(uploadPromises) + + return { + keys: results.map((result) => result.key), + presignedUrls: results.map((result) => result.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 parsedUrl = new URL(url) + let rawKey = parsedUrl.pathname.replace(/^\/+/, '') + rawKey = rawKey.split('/').slice(1).join('/') + return decodeURIComponent(rawKey) +} diff --git a/apps/user-ui/src/api-hooks/auth.api.ts b/apps/user-ui/src/api-hooks/auth.api.ts index 99dc48a..b2972e6 100644 --- a/apps/user-ui/src/api-hooks/auth.api.ts +++ b/apps/user-ui/src/api-hooks/auth.api.ts @@ -1,16 +1,15 @@ -import { useMutation } from "@tanstack/react-query"; -import axios from 'common-ui/src/services/axios'; -import { LoginCredentials, RegisterData } from '@/src/types/auth'; +import { trpc } from '@/src/trpc-client' +import { LoginCredentials, RegisterData, UpdateProfileData } from '@/src/types/auth' // API response types interface RegisterResponse { token: string; user: { id: number; - name: string; - email: string; - mobile: string; - profileImage?: string; + name?: string | null; + email: string | null; + mobile: string | null; + profileImage?: string | null; createdAt: string; }; } @@ -19,7 +18,7 @@ interface UpdateProfileResponse { token: string; user: { id: number; - name: string; + name?: string | null; email: string | null; mobile: string | null; profileImage?: string | null; @@ -30,35 +29,27 @@ interface UpdateProfileResponse { }; } -// API functions -const registerApi = async (data: FormData): Promise => { - const response = await axios.post('/uv/auth/register', data, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - return response.data.data; // response.data is {success, data}, we want the inner data -}; - -const updateProfileApi = async (data: FormData): Promise => { - const response = await axios.put('/uv/auth/profile', data, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - return response.data.data; // response.data is {success, data}, we want the inner data -}; - // React Query hooks export const useRegister = () => { - return useMutation({ - mutationFn: registerApi, - }); + const mutation = trpc.user.auth.register.useMutation() + + return { + ...mutation, + mutateAsync: async (data: RegisterData): Promise => { + const response = await mutation.mutateAsync(data) + return response.data + }, + } }; export const useUpdateProfile = () => { - return useMutation({ - mutationFn: updateProfileApi, - onError: () => {} - }); -}; \ No newline at end of file + const mutation = trpc.user.auth.updateProfile.useMutation() + + return { + ...mutation, + mutateAsync: async (data: UpdateProfileData): Promise => { + const response = await mutation.mutateAsync(data) + return response.data + }, + } +}; diff --git a/apps/user-ui/src/contexts/AuthContext.tsx b/apps/user-ui/src/contexts/AuthContext.tsx index 1226ec3..4b56add 100644 --- a/apps/user-ui/src/contexts/AuthContext.tsx +++ b/apps/user-ui/src/contexts/AuthContext.tsx @@ -228,7 +228,7 @@ export const AuthProvider: React.FC = ({ children }) => { }; - const register = async (data: FormData): Promise => { + const register = async (data: RegisterData): Promise => { try { setAuthState(prev => ({ ...prev, isLoading: true })); @@ -328,4 +328,4 @@ export const useUserDetails = (): UserDetails | null => { throw new Error('useUserDetails must be used within an AuthProvider'); } return context.userDetails; -}; \ No newline at end of file +}; diff --git a/apps/user-ui/src/types/auth.ts b/apps/user-ui/src/types/auth.ts index 892a062..7b92a0a 100644 --- a/apps/user-ui/src/types/auth.ts +++ b/apps/user-ui/src/types/auth.ts @@ -37,14 +37,26 @@ export interface RegisterData { email: string; mobile: string; password: string; - profileImage?: string; + profileImageUrl?: string | null; +} + +export interface UpdateProfileData { + name?: string; + email?: string; + mobile?: string; + password?: string; + bio?: string | null; + dateOfBirth?: string | null; + gender?: string | null; + occupation?: string | null; + profileImageUrl?: string | null; } export interface AuthContextType extends AuthState { login: (credentials: LoginCredentials) => Promise; loginWithToken: (token: string, user: User) => Promise; - register: (data: FormData) => Promise; + register: (data: RegisterData) => Promise; logout: () => Promise; updateUser: (user: Partial) => void; updateUserDetails: (userDetails: Partial) => void; -} \ No newline at end of file +} diff --git a/packages/db_helper_postgres/src/admin-apis/product.ts b/packages/db_helper_postgres/src/admin-apis/product.ts index 3d6b5d0..f3e10f6 100644 --- a/packages/db_helper_postgres/src/admin-apis/product.ts +++ b/packages/db_helper_postgres/src/admin-apis/product.ts @@ -68,6 +68,7 @@ const mapProduct = (product: ProductRow): AdminProduct => ({ price: product.price.toString(), marketPrice: product.marketPrice ? product.marketPrice.toString() : null, images: getStringArray(product.images), + imageKeys: getStringArray(product.images), isOutOfStock: product.isOutOfStock, isSuspended: product.isSuspended, isFlashAvailable: product.isFlashAvailable, diff --git a/packages/db_helper_sqlite/.env.example b/packages/db_helper_sqlite/.env.example new file mode 100644 index 0000000..4e60d83 --- /dev/null +++ b/packages/db_helper_sqlite/.env.example @@ -0,0 +1,2 @@ +# Copy this to .env and fill in your D1 database URL or local path +DATABASE_URL=file:./dev.db diff --git a/packages/db_helper_sqlite/README.md b/packages/db_helper_sqlite/README.md new file mode 100644 index 0000000..8c89ed3 --- /dev/null +++ b/packages/db_helper_sqlite/README.md @@ -0,0 +1,43 @@ +# Database Helper - SQLite (Cloudflare D1) + +This package contains database helpers and schema definitions for Cloudflare D1. + +## Structure + +- `src/db/` - Database source files + - `schema.ts` - Drizzle ORM schema definitions (SQLite) + - `db_index.ts` - D1 database initializer and client + - `types.ts` - Database types + - `seed.ts` - Database seeding script + - `porter.ts` - Data migration utilities +- `drizzle.config.ts` - Drizzle Kit configuration + +## Environment Variables + +Create a `.env` file with: + +``` +DATABASE_URL=file:./dev.db +``` + +## Initialization (Workers) + +Use `initDb` with your D1 binding before calling helpers: + +```typescript +import { initDb } from '@packages/db_helper_sqlite' + +export default { + async fetch(request: Request, env: Env) { + initDb(env.DB) + // ... call helper methods + }, +} +``` + +## Scripts + +- `npm run migrate` - Generate new migration files (SQLite) +- `npm run db:push` - Push schema changes to database +- `npm run db:seed` - Run database seeding +- `npm run db:studio` - Open Drizzle Studio diff --git a/packages/db_helper_sqlite/drizzle.config.ts b/packages/db_helper_sqlite/drizzle.config.ts new file mode 100644 index 0000000..2618fbb --- /dev/null +++ b/packages/db_helper_sqlite/drizzle.config.ts @@ -0,0 +1,11 @@ +import 'dotenv/config' +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + out: './drizzle', + schema: './src/db/schema.ts', + dialect: 'sqlite', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}) diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts new file mode 100644 index 0000000..9a1c3b1 --- /dev/null +++ b/packages/db_helper_sqlite/index.ts @@ -0,0 +1,393 @@ +// Database Helper - SQLite (Cloudflare D1) +// Main entry point for the package + +// Re-export database connection +export { db, initDb } from './src/db/db_index' + +// Re-export schema +export * from './src/db/schema' + +// Export enum types for type safety +export { staffRoleEnum, staffPermissionEnum } from './src/db/schema' + +// Admin API helpers - explicitly namespaced exports to avoid duplicates +export { + // Banner + getBanners, + getBannerById, + createBanner, + updateBanner, + deleteBanner, +} from './src/admin-apis/banner' + +export { + // Complaint + getComplaints, + resolveComplaint, +} from './src/admin-apis/complaint' + +export { + // Constants + getAllConstants, + upsertConstants, +} from './src/admin-apis/const' + +export { + // Coupon + getAllCoupons, + getCouponById, + invalidateCoupon, + validateCoupon, + getReservedCoupons, + getUsersForCoupon, + createCouponWithRelations, + updateCouponWithRelations, + generateCancellationCoupon, + createReservedCouponWithProducts, + createCouponForUser, + checkUsersExist, + checkCouponExists, + checkReservedCouponExists, + getOrderWithUser, +} from './src/admin-apis/coupon' + +export { + // Order + updateOrderNotes, + getOrderDetails, + updateOrderPackaged, + updateOrderDelivered, + updateOrderItemPackaging, + removeDeliveryCharge, + getSlotOrders, + updateAddressCoords, + getAllOrders, + rebalanceSlots, + cancelOrder, + deleteOrderById, +} from './src/admin-apis/order' + +export { + // Product + getAllProducts, + getProductById, + deleteProduct, + createProduct, + updateProduct, + checkProductExistsByName, + checkUnitExists, + getProductImagesById, + createSpecialDealsForProduct, + updateProductDeals, + replaceProductTags, + toggleProductOutOfStock, + updateSlotProducts, + getSlotProductIds, + getSlotsProductIds, + getAllUnits, + getAllProductTags, + getAllProductTagInfos, + getProductTagInfoById, + createProductTag, + getProductTagById, + updateProductTag, + deleteProductTag, + checkProductTagExistsByName, + getProductReviews, + respondToReview, + getAllProductGroups, + createProductGroup, + updateProductGroup, + deleteProductGroup, + addProductToGroup, + removeProductFromGroup, + updateProductPrices, +} from './src/admin-apis/product' + +export { + // Slots + getActiveSlotsWithProducts, + getActiveSlots, + getSlotsAfterDate, + getSlotByIdWithRelations, + createSlotWithRelations, + updateSlotWithRelations, + deleteSlotById, + updateSlotCapacity, + getSlotDeliverySequence, + updateSlotDeliverySequence, +} from './src/admin-apis/slots' + +export { + // Staff User + getStaffUserByName, + getStaffUserById, + getAllStaff, + getAllUsers, + getUserWithDetails, + updateUserSuspensionStatus, + checkStaffUserExists, + checkStaffRoleExists, + createStaffUser, + getAllRoles, +} from './src/admin-apis/staff-user' + +export { + // Store + getAllStores, + getStoreById, + createStore, + updateStore, + deleteStore, +} from './src/admin-apis/store' + +export { + // User + createUserByMobile, + getUserByMobile, + getUnresolvedComplaintsCount, + getAllUsersWithFilters, + getOrderCountsByUserIds, + getLastOrdersByUserIds, + getSuspensionStatusesByUserIds, + getUserBasicInfo, + getUserSuspensionStatus, + getUserOrders, + getOrderStatusesByOrderIds, + getItemCountsByOrderIds, + upsertUserSuspension, + searchUsers, + getAllNotifCreds, + getAllUnloggedTokens, + getNotifTokensByUserIds, + getUserIncidentsWithRelations, + createUserIncident, +} from './src/admin-apis/user' + +export { + // Vendor Snippets + checkVendorSnippetExists, + getVendorSnippetById, + getVendorSnippetByCode, + getAllVendorSnippets, + createVendorSnippet, + updateVendorSnippet, + deleteVendorSnippet, + getProductsByIds, + getVendorSlotById, + getVendorOrdersBySlotId, + getOrderItemsByOrderIds, + getOrderStatusByOrderIds, + updateVendorOrderItemPackaging, + getVendorOrders, +} from './src/admin-apis/vendor-snippets' + +export { + // User Address + getDefaultAddress as getUserDefaultAddress, + getUserAddresses, + getUserAddressById, + clearDefaultAddress as clearUserDefaultAddress, + createUserAddress, + updateUserAddress, + deleteUserAddress, + hasOngoingOrdersForAddress, +} from './src/user-apis/address' + +export { + // User Banners + getActiveBanners as getUserActiveBanners, +} from './src/user-apis/banners' + +export { + // User Cart + getCartItemsWithProducts as getUserCartItemsWithProducts, + getProductById as getUserProductById, + getCartItemByUserProduct as getUserCartItemByUserProduct, + incrementCartItemQuantity as incrementUserCartItemQuantity, + insertCartItem as insertUserCartItem, + updateCartItemQuantity as updateUserCartItemQuantity, + deleteCartItem as deleteUserCartItem, + clearUserCart, +} from './src/user-apis/cart' + +export { + // User Complaint + getUserComplaints as getUserComplaints, + createComplaint as createUserComplaint, +} from './src/user-apis/complaint' + +export { + // User Stores + getStoreSummaries as getUserStoreSummaries, + getStoreDetail as getUserStoreDetail, +} from './src/user-apis/stores' + +export { + // User Product + getProductDetailById as getUserProductDetailById, + getProductReviews as getUserProductReviews, + getProductById as getUserProductByIdBasic, + createProductReview as createUserProductReview, + getAllProductsWithUnits, + type ProductSummaryData, +} from './src/user-apis/product' + +export { + // User Slots + getActiveSlotsList as getUserActiveSlotsList, + getProductAvailability as getUserProductAvailability, +} from './src/user-apis/slots' + +export { + // User Payments + getOrderById as getUserPaymentOrderById, + getPaymentByOrderId as getUserPaymentByOrderId, + getPaymentByMerchantOrderId as getUserPaymentByMerchantOrderId, + updatePaymentSuccess as updateUserPaymentSuccess, + updateOrderPaymentStatus as updateUserOrderPaymentStatus, + markPaymentFailed as markUserPaymentFailed, +} from './src/user-apis/payments' + +export { + // User Auth + getUserByEmail as getUserAuthByEmail, + getUserByMobile as getUserAuthByMobile, + getUserById as getUserAuthById, + getUserCreds as getUserAuthCreds, + getUserDetails as getUserAuthDetails, + isUserSuspended, + createUserWithCreds as createUserAuthWithCreds, + createUserWithMobile as createUserAuthWithMobile, + upsertUserPassword as upsertUserAuthPassword, + deleteUserAccount as deleteUserAuthAccount, + // UV API helpers + createUserWithProfile, + getUserDetailsByUserId, + updateUserProfile, +} from './src/user-apis/auth' + +export { + // User Coupon + getActiveCouponsWithRelations as getUserActiveCouponsWithRelations, + getAllCouponsWithRelations as getUserAllCouponsWithRelations, + getReservedCouponByCode as getUserReservedCouponByCode, + redeemReservedCoupon as redeemUserReservedCoupon, +} from './src/user-apis/coupon' + +export { + // User Profile + getUserById as getUserProfileById, + getUserDetailByUserId as getUserProfileDetailById, + getUserWithCreds as getUserWithCreds, + getNotifCred as getUserNotifCred, + upsertNotifCred as upsertUserNotifCred, + deleteUnloggedToken as deleteUserUnloggedToken, + getUnloggedToken as getUserUnloggedToken, + upsertUnloggedToken as upsertUserUnloggedToken, +} from './src/user-apis/user' + +export { + // User Order + validateAndGetCoupon as validateAndGetUserCoupon, + applyDiscountToOrder as applyDiscountToUserOrder, + getAddressByIdAndUser as getUserAddressByIdAndUser, + getProductById as getOrderProductById, + checkUserSuspended, + getSlotCapacityStatus as getUserSlotCapacityStatus, + placeOrderTransaction as placeUserOrderTransaction, + deleteCartItemsForOrder as deleteUserCartItemsForOrder, + recordCouponUsage as recordUserCouponUsage, + getOrdersWithRelations as getUserOrdersWithRelations, + getOrderCount as getUserOrderCount, + getOrderByIdWithRelations as getUserOrderByIdWithRelations, + getCouponUsageForOrder as getUserCouponUsageForOrder, + getOrderBasic as getUserOrderBasic, + cancelOrderTransaction as cancelUserOrderTransaction, + updateOrderNotes as updateUserOrderNotes, + getRecentlyDeliveredOrderIds as getUserRecentlyDeliveredOrderIds, + getProductIdsFromOrders as getUserProductIdsFromOrders, + getProductsForRecentOrders as getUserProductsForRecentOrders, + // Post-order handler helpers + getOrdersByIdsWithFullData, + getOrderByIdWithFullData, + type OrderWithFullData, + type OrderWithCancellationData, +} from './src/user-apis/order' + +// Store Helpers (for cache initialization) +export { + // Banner Store + getAllBannersForCache, + type BannerData, + // Product Store + getAllProductsForCache, + getAllStoresForCache, + getAllDeliverySlotsForCache, + getAllSpecialDealsForCache, + getAllProductTagsForCache, + type ProductBasicData, + type StoreBasicData, + type DeliverySlotData, + type SpecialDealData, + type ProductTagData, + // Product Tag Store + getAllTagsForCache, + getAllTagProductMappings, + type TagBasicData, + type TagProductMapping, + // Slot Store + getAllSlotsWithProductsForCache, + type SlotWithProductsData, + // User Negativity Store + getAllUserNegativityScores, + getUserNegativityScore, + type UserNegativityData, +} from './src/stores/store-helpers' + +// Automated Jobs Helpers +export { + toggleFlashDeliveryForItems, + toggleKeyVal, + getAllKeyValStore, +} from './src/lib/automated-jobs' + +// Health Check +export { + healthCheck, +} from './src/lib/health-check' + +// Common API Helpers +export { + getSuspendedProductIds, + getNextDeliveryDateWithCapacity, +} from './src/user-apis/product' + +export { + getStoresSummary, +} from './src/user-apis/stores' + +// Delete Orders Helper +export { + deleteOrdersWithRelations, +} from './src/lib/delete-orders' + +// Upload URL Helpers +export { + createUploadUrlStatus, + claimUploadUrlStatus, +} from './src/helper_methods/upload-url' + +// Seed Helpers +export { + seedUnits, + seedStaffRoles, + seedStaffPermissions, + seedRolePermissions, + seedKeyValStore, + type UnitSeedData, + type RolePermissionAssignment, + type KeyValSeedData, + type StaffRoleName, + type StaffPermissionName, +} from './src/lib/seed' diff --git a/packages/db_helper_sqlite/package.json b/packages/db_helper_sqlite/package.json new file mode 100644 index 0000000..3699451 --- /dev/null +++ b/packages/db_helper_sqlite/package.json @@ -0,0 +1,24 @@ +{ + "name": "@packages/db_helper_sqlite", + "version": "1.0.0", + "main": "index.ts", + "types": "index.ts", + "private": true, + "scripts": { + "migrate": "drizzle-kit generate:sqlite", + "db:push": "drizzle-kit push:sqlite", + "db:seed": "tsx src/db/seed.ts", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "dotenv": "^17.2.1", + "drizzle-orm": "^0.44.5" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260304.0", + "@types/node": "^24.5.2", + "drizzle-kit": "^0.31.4", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + } +} diff --git a/packages/db_helper_sqlite/src/admin-apis/banner.ts b/packages/db_helper_sqlite/src/admin-apis/banner.ts new file mode 100644 index 0000000..5f0e797 --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/banner.ts @@ -0,0 +1,114 @@ +import { db } from '../db/db_index' +import { homeBanners } from '../db/schema' +import { eq, desc } from 'drizzle-orm' + +export interface Banner { + id: number + name: string + imageUrl: string + description: string | null + productIds: number[] | null + redirectUrl: string | null + serialNum: number | null + isActive: boolean + createdAt: Date + lastUpdated: Date +} + +type BannerRow = typeof homeBanners.$inferSelect + +export async function getBanners(): Promise { + const banners = await db.query.homeBanners.findMany({ + orderBy: desc(homeBanners.createdAt), + }) as BannerRow[] + + return banners.map((banner) => ({ + id: banner.id, + name: banner.name, + imageUrl: banner.imageUrl, + description: banner.description, + productIds: banner.productIds || [], + redirectUrl: banner.redirectUrl, + serialNum: banner.serialNum, + isActive: banner.isActive, + createdAt: banner.createdAt, + lastUpdated: banner.lastUpdated, + })) +} + +export async function getBannerById(id: number): Promise { + const banner = await db.query.homeBanners.findFirst({ + where: eq(homeBanners.id, id), + }) + + if (!banner) return null + + return { + id: banner.id, + name: banner.name, + imageUrl: banner.imageUrl, + description: banner.description, + productIds: banner.productIds || [], + redirectUrl: banner.redirectUrl, + serialNum: banner.serialNum, + isActive: banner.isActive, + createdAt: banner.createdAt, + lastUpdated: banner.lastUpdated, + } +} + +export type CreateBannerInput = Omit + +export async function createBanner(input: CreateBannerInput): Promise { + const [banner] = await db.insert(homeBanners).values({ + name: input.name, + imageUrl: input.imageUrl, + description: input.description, + productIds: input.productIds || [], + redirectUrl: input.redirectUrl, + serialNum: input.serialNum, + isActive: input.isActive, + }).returning() + + return { + id: banner.id, + name: banner.name, + imageUrl: banner.imageUrl, + description: banner.description, + productIds: banner.productIds || [], + redirectUrl: banner.redirectUrl, + serialNum: banner.serialNum, + isActive: banner.isActive, + createdAt: banner.createdAt, + lastUpdated: banner.lastUpdated, + } +} + +export type UpdateBannerInput = Partial> + +export async function updateBanner(id: number, input: UpdateBannerInput): Promise { + const [banner] = await db.update(homeBanners) + .set({ + ...input, + lastUpdated: new Date(), + }) + .where(eq(homeBanners.id, id)) + .returning() + + return { + id: banner.id, + name: banner.name, + imageUrl: banner.imageUrl, + description: banner.description, + productIds: banner.productIds || [], + redirectUrl: banner.redirectUrl, + serialNum: banner.serialNum, + isActive: banner.isActive, + createdAt: banner.createdAt, + lastUpdated: banner.lastUpdated, + } +} + +export async function deleteBanner(id: number): Promise { + await db.delete(homeBanners).where(eq(homeBanners.id, id)) +} diff --git a/packages/db_helper_sqlite/src/admin-apis/complaint.ts b/packages/db_helper_sqlite/src/admin-apis/complaint.ts new file mode 100644 index 0000000..aed3a92 --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/complaint.ts @@ -0,0 +1,74 @@ +import { db } from '../db/db_index' +import { complaints, users } from '../db/schema' +import { eq, desc, lt } from 'drizzle-orm' + +export interface Complaint { + id: number + complaintBody: string + userId: number + orderId: number | null + isResolved: boolean + response: string | null + createdAt: Date + images: string[] | null +} + +export interface ComplaintWithUser extends Complaint { + userName: string | null + userMobile: string | null +} + +export async function getComplaints( + cursor?: number, + limit: number = 20 +): Promise<{ complaints: ComplaintWithUser[]; hasMore: boolean }> { + const whereCondition = cursor ? lt(complaints.id, cursor) : undefined + + const complaintsData = await db + .select({ + id: complaints.id, + complaintBody: complaints.complaintBody, + userId: complaints.userId, + orderId: complaints.orderId, + isResolved: complaints.isResolved, + response: complaints.response, + createdAt: complaints.createdAt, + images: complaints.images, + userName: users.name, + userMobile: users.mobile, + }) + .from(complaints) + .leftJoin(users, eq(complaints.userId, users.id)) + .where(whereCondition) + .orderBy(desc(complaints.id)) + .limit(limit + 1) + + const hasMore = complaintsData.length > limit + const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData + + return { + complaints: complaintsToReturn.map((c) => ({ + id: c.id, + complaintBody: c.complaintBody, + userId: c.userId, + orderId: c.orderId, + isResolved: c.isResolved, + response: c.response, + createdAt: c.createdAt, + images: c.images as string[], + userName: c.userName, + userMobile: c.userMobile, + })), + hasMore, + } +} + +export async function resolveComplaint( + id: number, + response?: string +): Promise { + await db + .update(complaints) + .set({ isResolved: true, response }) + .where(eq(complaints.id, id)) +} diff --git a/packages/db_helper_sqlite/src/admin-apis/const.ts b/packages/db_helper_sqlite/src/admin-apis/const.ts new file mode 100644 index 0000000..603c684 --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/const.ts @@ -0,0 +1,29 @@ +import { db } from '../db/db_index' +import { keyValStore } from '../db/schema' + +export interface Constant { + key: string + value: any +} + +export async function getAllConstants(): Promise { + const constants = await db.select().from(keyValStore) + + return constants.map(c => ({ + key: c.key, + value: c.value, + })) +} + +export async function upsertConstants(constants: Constant[]): Promise { + await db.transaction(async (tx) => { + for (const { key, value } of constants) { + await tx.insert(keyValStore) + .values({ key, value }) + .onConflictDoUpdate({ + target: keyValStore.key, + set: { value }, + }) + } + }) +} diff --git a/packages/db_helper_sqlite/src/admin-apis/coupon.ts b/packages/db_helper_sqlite/src/admin-apis/coupon.ts new file mode 100644 index 0000000..fb2e4b4 --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/coupon.ts @@ -0,0 +1,496 @@ +import { db } from '../db/db_index' +import { coupons, reservedCoupons, users, orders, orderStatus, couponApplicableUsers, couponApplicableProducts } from '../db/schema' +import { eq, and, like, or, inArray, lt, desc, asc } from 'drizzle-orm' + +export interface Coupon { + id: number + couponCode: string + isUserBased: boolean + discountPercent: string | null + flatDiscount: string | null + minOrder: string | null + productIds: number[] | null + maxValue: string | null + isApplyForAll: boolean + validTill: Date | null + maxLimitForUser: number | null + exclusiveApply: boolean + isInvalidated: boolean + createdAt: Date + createdBy: number +} + +export async function getAllCoupons( + cursor?: number, + limit: number = 50, + search?: string +): Promise<{ coupons: any[]; hasMore: boolean }> { + let whereCondition = undefined + const conditions = [] + + if (cursor) { + conditions.push(lt(coupons.id, cursor)) + } + + if (search && search.trim()) { + conditions.push(like(coupons.couponCode, `%${search}%`)) + } + + if (conditions.length > 0) { + whereCondition = and(...conditions) + } + + const result = await db.query.coupons.findMany({ + where: whereCondition, + with: { + creator: true, + applicableUsers: { + with: { + user: true, + }, + }, + applicableProducts: { + with: { + product: true, + }, + }, + }, + orderBy: desc(coupons.createdAt), + limit: limit + 1, + }) + + const hasMore = result.length > limit + const couponsList = hasMore ? result.slice(0, limit) : result + + return { coupons: couponsList, hasMore } +} + +export async function getCouponById(id: number): Promise { + return await db.query.coupons.findFirst({ + where: eq(coupons.id, id), + with: { + creator: true, + applicableUsers: { + with: { + user: true, + }, + }, + applicableProducts: { + with: { + product: true, + }, + }, + }, + }) +} + +export interface CreateCouponInput { + couponCode: string + isUserBased: boolean + discountPercent?: string + flatDiscount?: string + minOrder?: string + productIds?: number[] | null + maxValue?: string + isApplyForAll: boolean + validTill?: Date + maxLimitForUser?: number + exclusiveApply: boolean + createdBy: number +} + +export async function createCouponWithRelations( + input: CreateCouponInput, + applicableUsers?: number[], + applicableProducts?: number[] +): Promise { + return await db.transaction(async (tx) => { + const [coupon] = await tx.insert(coupons).values({ + couponCode: input.couponCode, + isUserBased: input.isUserBased, + discountPercent: input.discountPercent, + flatDiscount: input.flatDiscount, + minOrder: input.minOrder, + productIds: input.productIds, + createdBy: input.createdBy, + maxValue: input.maxValue, + isApplyForAll: input.isApplyForAll, + validTill: input.validTill, + maxLimitForUser: input.maxLimitForUser, + exclusiveApply: input.exclusiveApply, + }).returning() + + if (applicableUsers && applicableUsers.length > 0) { + await tx.insert(couponApplicableUsers).values( + applicableUsers.map(userId => ({ + couponId: coupon.id, + userId, + })) + ) + } + + if (applicableProducts && applicableProducts.length > 0) { + await tx.insert(couponApplicableProducts).values( + applicableProducts.map(productId => ({ + couponId: coupon.id, + productId, + })) + ) + } + + return coupon as Coupon + }) +} + +export interface UpdateCouponInput { + couponCode?: string + isUserBased?: boolean + discountPercent?: string + flatDiscount?: string + minOrder?: string + productIds?: number[] | null + maxValue?: string + isApplyForAll?: boolean + validTill?: Date | null + maxLimitForUser?: number + exclusiveApply?: boolean + isInvalidated?: boolean +} + +export async function updateCouponWithRelations( + id: number, + input: UpdateCouponInput, + applicableUsers?: number[], + applicableProducts?: number[] +): Promise { + return await db.transaction(async (tx) => { + const [coupon] = await tx.update(coupons) + .set({ + ...input, + }) + .where(eq(coupons.id, id)) + .returning() + + if (applicableUsers !== undefined) { + await tx.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id)) + if (applicableUsers.length > 0) { + await tx.insert(couponApplicableUsers).values( + applicableUsers.map(userId => ({ + couponId: id, + userId, + })) + ) + } + } + + if (applicableProducts !== undefined) { + await tx.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id)) + if (applicableProducts.length > 0) { + await tx.insert(couponApplicableProducts).values( + applicableProducts.map(productId => ({ + couponId: id, + productId, + })) + ) + } + } + + return coupon as Coupon + }) +} + +export async function invalidateCoupon(id: number): Promise { + const result = await db.update(coupons) + .set({ isInvalidated: true }) + .where(eq(coupons.id, id)) + .returning() + + return result[0] as Coupon +} + +export interface CouponValidationResult { + valid: boolean + message?: string + discountAmount?: number + coupon?: Partial +} + +export async function validateCoupon( + code: string, + userId: number, + orderAmount: number +): Promise { + const coupon = await db.query.coupons.findFirst({ + where: and( + eq(coupons.couponCode, code.toUpperCase()), + eq(coupons.isInvalidated, false) + ), + }) + + if (!coupon) { + return { valid: false, message: 'Coupon not found or invalidated' } + } + + if (coupon.validTill && new Date(coupon.validTill) < new Date()) { + return { valid: false, message: 'Coupon has expired' } + } + + if (!coupon.isApplyForAll && !coupon.isUserBased) { + return { valid: false, message: 'Coupon is not available for use' } + } + + const minOrderValue = coupon.minOrder ? parseFloat(coupon.minOrder) : 0 + if (minOrderValue > 0 && orderAmount < minOrderValue) { + return { valid: false, message: `Minimum order amount is ${minOrderValue}` } + } + + let discountAmount = 0 + if (coupon.discountPercent) { + const percent = parseFloat(coupon.discountPercent) + discountAmount = (orderAmount * percent) / 100 + } else if (coupon.flatDiscount) { + discountAmount = parseFloat(coupon.flatDiscount) + } + + const maxValueLimit = coupon.maxValue ? parseFloat(coupon.maxValue) : 0 + if (maxValueLimit > 0 && discountAmount > maxValueLimit) { + discountAmount = maxValueLimit + } + + return { + valid: true, + discountAmount, + coupon: { + id: coupon.id, + discountPercent: coupon.discountPercent, + flatDiscount: coupon.flatDiscount, + maxValue: coupon.maxValue, + } + } +} + +export async function getReservedCoupons( + cursor?: number, + limit: number = 50, + search?: string +): Promise<{ coupons: any[]; hasMore: boolean }> { + let whereCondition = undefined + const conditions = [] + + if (cursor) { + conditions.push(lt(reservedCoupons.id, cursor)) + } + + if (search && search.trim()) { + conditions.push(or( + like(reservedCoupons.secretCode, `%${search}%`), + like(reservedCoupons.couponCode, `%${search}%`) + )) + } + + if (conditions.length > 0) { + whereCondition = and(...conditions) + } + + const result = await db.query.reservedCoupons.findMany({ + where: whereCondition, + with: { + redeemedUser: true, + creator: true, + }, + orderBy: desc(reservedCoupons.createdAt), + limit: limit + 1, + }) + + const hasMore = result.length > limit + const couponsList = hasMore ? result.slice(0, limit) : result + + return { coupons: couponsList, hasMore } +} + +export async function createReservedCouponWithProducts( + input: any, + applicableProducts?: number[] +): Promise { + return await db.transaction(async (tx) => { + const [coupon] = await tx.insert(reservedCoupons).values({ + secretCode: input.secretCode, + couponCode: input.couponCode, + discountPercent: input.discountPercent, + flatDiscount: input.flatDiscount, + minOrder: input.minOrder, + productIds: input.productIds, + maxValue: input.maxValue, + validTill: input.validTill, + maxLimitForUser: input.maxLimitForUser, + exclusiveApply: input.exclusiveApply, + createdBy: input.createdBy, + }).returning() + + if (applicableProducts && applicableProducts.length > 0) { + await tx.insert(couponApplicableProducts).values( + applicableProducts.map(productId => ({ + couponId: coupon.id, + productId, + })) + ) + } + + return coupon + }) +} + +export async function checkUsersExist(userIds: number[]): Promise { + const existingUsers = await db.query.users.findMany({ + where: inArray(users.id, userIds), + columns: { id: true }, + }) + return existingUsers.length === userIds.length +} + +export async function checkCouponExists(couponCode: string): Promise { + const existing = await db.query.coupons.findFirst({ + where: eq(coupons.couponCode, couponCode), + }) + return !!existing +} + +export async function checkReservedCouponExists(secretCode: string): Promise { + const existing = await db.query.reservedCoupons.findFirst({ + where: eq(reservedCoupons.secretCode, secretCode), + }) + return !!existing +} + +export async function generateCancellationCoupon( + orderId: number, + staffUserId: number, + userId: number, + orderAmount: number, + couponCode: string +): Promise { + return await db.transaction(async (tx) => { + const expiryDate = new Date() + expiryDate.setDate(expiryDate.getDate() + 30) + + const [coupon] = await tx.insert(coupons).values({ + couponCode, + isUserBased: true, + flatDiscount: orderAmount.toString(), + minOrder: orderAmount.toString(), + maxValue: orderAmount.toString(), + validTill: expiryDate, + maxLimitForUser: 1, + createdBy: staffUserId, + isApplyForAll: false, + }).returning() + + await tx.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId, + }) + + await tx.update(orderStatus) + .set({ refundCouponId: coupon.id }) + .where(eq(orderStatus.orderId, orderId)) + + return coupon as Coupon + }) +} + +export async function getOrderWithUser(orderId: number): Promise { + return await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + }, + }) +} + +export async function createCouponForUser( + mobile: string, + couponCode: string, + staffUserId: number +): Promise<{ coupon: Coupon; user: { id: number; mobile: string; name: string | null } }> { + return await db.transaction(async (tx) => { + let user = await tx.query.users.findFirst({ + where: eq(users.mobile, mobile), + }) + + if (!user) { + const [newUser] = await tx.insert(users).values({ + name: null, + email: null, + mobile, + }).returning() + user = newUser + } + + const [coupon] = await tx.insert(coupons).values({ + couponCode, + isUserBased: true, + discountPercent: '20', + minOrder: '1000', + maxValue: '500', + maxLimitForUser: 1, + isApplyForAll: false, + exclusiveApply: false, + createdBy: staffUserId, + validTill: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + }).returning() + + await tx.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId: user.id, + }) + + return { + coupon: coupon as Coupon, + user: { + id: user.id, + mobile: user.mobile as string, + name: user.name, + }, + } + }) +} + +export interface UserMiniInfo { + id: number + name: string + mobile: string | null +} + +export async function getUsersForCoupon( + search?: string, + limit: number = 20, + offset: number = 0 +): Promise<{ users: UserMiniInfo[] }> { + let whereCondition = undefined + if (search && search.trim()) { + whereCondition = or( + like(users.name, `%${search}%`), + like(users.mobile, `%${search}%`) + ) + } + + const userList = await db.query.users.findMany({ + where: whereCondition, + columns: { + id: true, + name: true, + mobile: true, + }, + limit: limit, + offset: offset, + orderBy: asc(users.name), + }) + + return { + users: userList.map((user: typeof users.$inferSelect) => ({ + id: user.id, + name: user.name || 'Unknown', + mobile: user.mobile, + })) + } +} diff --git a/packages/db_helper_sqlite/src/admin-apis/order.ts b/packages/db_helper_sqlite/src/admin-apis/order.ts new file mode 100644 index 0000000..d990219 --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/order.ts @@ -0,0 +1,710 @@ +import { db } from '../db/db_index' +import { + addresses, + complaints, + couponUsage, + orderItems, + orders, + orderStatus, + payments, + refunds, +} from '../db/schema' +import { and, desc, eq, inArray, lt, SQL } from 'drizzle-orm' +import type { + AdminOrderDetails, + AdminOrderRow, + AdminOrderStatusRecord, + AdminOrderUpdateResult, + AdminOrderItemPackagingResult, + AdminOrderMessageResult, + AdminOrderBasicResult, + AdminGetSlotOrdersResult, + AdminGetAllOrdersResultWithUserId, + AdminRebalanceSlotsResult, + AdminCancelOrderResult, + AdminRefundRecord, + RefundStatus, + PaymentStatus, +} from '@packages/shared' +import type { InferSelectModel } from 'drizzle-orm' + +const isPaymentStatus = (value: string): value is PaymentStatus => + value === 'pending' || value === 'success' || value === 'cod' || value === 'failed' + +const isRefundStatus = (value: string): value is RefundStatus => + value === 'success' || value === 'pending' || value === 'failed' || value === 'none' || value === 'na' || value === 'processed' + +type OrderStatusRow = InferSelectModel + +const mapOrderStatusRecord = (record: OrderStatusRow): AdminOrderStatusRecord => ({ + id: record.id, + orderTime: record.orderTime, + userId: record.userId, + orderId: record.orderId, + isPackaged: record.isPackaged, + isDelivered: record.isDelivered, + isCancelled: record.isCancelled, + cancelReason: record.cancelReason ?? null, + isCancelledByAdmin: record.isCancelledByAdmin ?? null, + paymentStatus: isPaymentStatus(record.paymentStatus) ? record.paymentStatus : 'pending', + cancellationUserNotes: record.cancellationUserNotes ?? null, + cancellationAdminNotes: record.cancellationAdminNotes ?? null, + cancellationReviewed: record.cancellationReviewed, + cancellationReviewedAt: record.cancellationReviewedAt ?? null, + refundCouponId: record.refundCouponId ?? null, +}) + +export async function updateOrderNotes(orderId: number, adminNotes: string | null): Promise { + const [result] = await db + .update(orders) + .set({ adminNotes }) + .where(eq(orders.id, orderId)) + .returning() + return (result || null) as AdminOrderRow | null +} + +export async function updateOrderPackaged(orderId: string, isPackaged: boolean): Promise { + const orderIdNumber = parseInt(orderId) + + await db + .update(orderItems) + .set({ is_packaged: isPackaged }) + .where(eq(orderItems.orderId, orderIdNumber)) + + if (!isPackaged) { + await db + .update(orderStatus) + .set({ isPackaged, isDelivered: false }) + .where(eq(orderStatus.orderId, orderIdNumber)) + } else { + await db + .update(orderStatus) + .set({ isPackaged }) + .where(eq(orderStatus.orderId, orderIdNumber)) + } + + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderIdNumber), + }) + + return { success: true, userId: order?.userId ?? null } +} + +export async function updateOrderDelivered(orderId: string, isDelivered: boolean): Promise { + const orderIdNumber = parseInt(orderId) + + await db + .update(orderStatus) + .set({ isDelivered }) + .where(eq(orderStatus.orderId, orderIdNumber)) + + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderIdNumber), + }) + + return { success: true, userId: order?.userId ?? null } +} + +export async function getOrderDetails(orderId: number): Promise { + // Single optimized query with all relations + const orderData = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + payment: true, + paymentInfo: true, + orderStatus: true, + refunds: true, + }, + }) + + if (!orderData) { + return null + } + + const couponUsageData = await db.query.couponUsage.findMany({ + where: eq(couponUsage.orderId, orderData.id), + with: { + coupon: true, + }, + }) + + let couponData = null + if (couponUsageData.length > 0) { + let totalDiscountAmount = 0 + const orderTotal = parseFloat(orderData.totalAmount.toString()) + + for (const usage of couponUsageData) { + let discountAmount = 0 + + if (usage.coupon.discountPercent) { + discountAmount = + (orderTotal * parseFloat(usage.coupon.discountPercent.toString())) / + 100 + } else if (usage.coupon.flatDiscount) { + discountAmount = parseFloat(usage.coupon.flatDiscount.toString()) + } + + if ( + usage.coupon.maxValue && + discountAmount > parseFloat(usage.coupon.maxValue.toString()) + ) { + discountAmount = parseFloat(usage.coupon.maxValue.toString()) + } + + totalDiscountAmount += discountAmount + } + + couponData = { + couponCode: couponUsageData.map((u: any) => u.coupon.couponCode).join(', '), + couponDescription: `${couponUsageData.length} coupons applied`, + discountAmount: totalDiscountAmount, + } + } + + const statusRecord = orderData.orderStatus?.[0] + const orderStatusRecord = statusRecord ? mapOrderStatusRecord(statusRecord) : null + let status: 'pending' | 'delivered' | 'cancelled' = 'pending' + if (orderStatusRecord?.isCancelled) { + status = 'cancelled' + } else if (orderStatusRecord?.isDelivered) { + status = 'delivered' + } + + const refund = orderData.refunds?.[0] + const refundStatus = refund?.refundStatus && isRefundStatus(refund.refundStatus) + ? refund.refundStatus + : null + const refundRecord: AdminRefundRecord | null = refund + ? { + id: refund.id, + orderId: refund.orderId, + refundAmount: refund.refundAmount, + refundStatus, + merchantRefundId: refund.merchantRefundId, + refundProcessedAt: refund.refundProcessedAt, + createdAt: refund.createdAt, + } + : null + + return { + id: orderData.id, + readableId: orderData.id, + userId: orderData.user.id, + customerName: `${orderData.user.name}`, + customerEmail: orderData.user.email, + customerMobile: orderData.user.mobile, + address: { + name: orderData.address.name, + line1: orderData.address.addressLine1, + line2: orderData.address.addressLine2, + city: orderData.address.city, + state: orderData.address.state, + pincode: orderData.address.pincode, + phone: orderData.address.phone, + }, + slotInfo: orderData.slot + ? { + time: orderData.slot.deliveryTime.toISOString(), + sequence: orderData.slot.deliverySequence, + } + : null, + isCod: orderData.isCod, + isOnlinePayment: orderData.isOnlinePayment, + totalAmount: + parseFloat(orderData.totalAmount?.toString() || '0') - + parseFloat(orderData.deliveryCharge?.toString() || '0'), + deliveryCharge: parseFloat(orderData.deliveryCharge?.toString() || '0'), + adminNotes: orderData.adminNotes, + userNotes: orderData.userNotes, + createdAt: orderData.createdAt, + status, + isPackaged: orderStatusRecord?.isPackaged || false, + isDelivered: orderStatusRecord?.isDelivered || false, + items: orderData.orderItems.map((item: any) => ({ + id: item.id, + name: item.product.name, + quantity: item.quantity, + productSize: item.product.productQuantity, + price: item.price, + unit: item.product.unit?.shortNotation, + amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'), + isPackaged: item.is_packaged, + isPackageVerified: item.is_package_verified, + })), + payment: orderData.payment + ? { + status: orderData.payment.status, + gateway: orderData.payment.gateway, + merchantOrderId: orderData.payment.merchantOrderId, + } + : null, + paymentInfo: orderData.paymentInfo + ? { + status: orderData.paymentInfo.status, + gateway: orderData.paymentInfo.gateway, + merchantOrderId: orderData.paymentInfo.merchantOrderId, + } + : null, + cancelReason: orderStatusRecord?.cancelReason || null, + cancellationReviewed: orderStatusRecord?.cancellationReviewed || false, + isRefundDone: refundStatus === 'processed' || false, + refundStatus, + refundAmount: refund?.refundAmount + ? parseFloat(refund.refundAmount.toString()) + : null, + couponData, + couponCode: couponData?.couponCode || null, + couponDescription: couponData?.couponDescription || null, + discountAmount: couponData?.discountAmount || null, + orderStatus: orderStatusRecord, + refundRecord, + isFlashDelivery: orderData.isFlashDelivery, + } +} + +export async function updateOrderItemPackaging( + orderItemId: number, + isPackaged?: boolean, + isPackageVerified?: boolean +): Promise { + const orderItem = await db.query.orderItems.findFirst({ + where: eq(orderItems.id, orderItemId), + }) + + if (!orderItem) { + return { success: false, updated: false } + } + + const updateData: Partial<{ + is_packaged: boolean + is_package_verified: boolean + }> = {} + + if (isPackaged !== undefined) { + updateData.is_packaged = isPackaged + } + if (isPackageVerified !== undefined) { + updateData.is_package_verified = isPackageVerified + } + + await db + .update(orderItems) + .set(updateData) + .where(eq(orderItems.id, orderItemId)) + + return { success: true, updated: true } +} + +export async function removeDeliveryCharge(orderId: number): Promise { + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + }) + + if (!order) { + return null + } + + const currentDeliveryCharge = parseFloat(order.deliveryCharge?.toString() || '0') + const currentTotalAmount = parseFloat(order.totalAmount?.toString() || '0') + const newTotalAmount = currentTotalAmount - currentDeliveryCharge + + await db + .update(orders) + .set({ + deliveryCharge: '0', + totalAmount: newTotalAmount.toString(), + }) + .where(eq(orders.id, orderId)) + + return { success: true, message: 'Delivery charge removed' } +} + +export async function getSlotOrders(slotId: string): Promise { + const slotOrders = await db.query.orders.findMany({ + where: eq(orders.slotId, parseInt(slotId)), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + }, + }) + + const filteredOrders = slotOrders.filter((order: any) => { + const statusRecord = order.orderStatus[0] + return order.isCod || (statusRecord && statusRecord.paymentStatus === 'success') + }) + + const formattedOrders = filteredOrders.map((order: any) => { + const statusRecord = order.orderStatus[0] + let status: 'pending' | 'delivered' | 'cancelled' = 'pending' + if (statusRecord?.isCancelled) { + status = 'cancelled' + } else if (statusRecord?.isDelivered) { + status = 'delivered' + } + + const items = order.orderItems.map((item: any) => ({ + id: item.id, + name: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || '', + isPackaged: item.is_packaged, + isPackageVerified: item.is_package_verified, + })) + + const paymentMode: 'COD' | 'Online' = order.isCod ? 'COD' : 'Online' + + return { + id: order.id, + readableId: order.id, + customerName: order.user.name || order.user.mobile+'', + address: `${order.address.addressLine1}${ + order.address.addressLine2 ? `, ${order.address.addressLine2}` : '' + }, ${order.address.city}, ${order.address.state} - ${ + order.address.pincode + }, Phone: ${order.address.phone}`, + addressId: order.addressId, + latitude: order.address.adminLatitude ?? order.address.latitude, + longitude: order.address.adminLongitude ?? order.address.longitude, + totalAmount: parseFloat(order.totalAmount), + items, + deliveryTime: order.slot?.deliveryTime.toISOString() || null, + status, + isPackaged: order.orderItems.every((item: any) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + isCod: order.isCod, + paymentMode, + paymentStatus: isPaymentStatus(statusRecord?.paymentStatus || 'pending') + ? statusRecord?.paymentStatus || 'pending' + : 'pending', + slotId: order.slotId, + adminNotes: order.adminNotes, + userNotes: order.userNotes, + } + }) + + return { success: true, data: formattedOrders } +} + +export async function updateAddressCoords( + addressId: number, + latitude: number, + longitude: number +): Promise { + const result = await db + .update(addresses) + .set({ + adminLatitude: latitude, + adminLongitude: longitude, + }) + .where(eq(addresses.id, addressId)) + .returning() + + return { success: result.length > 0 } +} + +type GetAllOrdersInput = { + cursor?: number + limit: number + slotId?: number | null + packagedFilter?: 'all' | 'packaged' | 'not_packaged' + deliveredFilter?: 'all' | 'delivered' | 'not_delivered' + cancellationFilter?: 'all' | 'cancelled' | 'not_cancelled' + flashDeliveryFilter?: 'all' | 'flash' | 'regular' +} + +export async function getAllOrders(input: GetAllOrdersInput): Promise { + const { + cursor, + limit, + slotId, + packagedFilter, + deliveredFilter, + cancellationFilter, + flashDeliveryFilter, + } = input + + let whereCondition: SQL | undefined = eq(orders.id, orders.id) + if (cursor) { + whereCondition = and(whereCondition, lt(orders.id, cursor)) + } + if (slotId) { + whereCondition = and(whereCondition, eq(orders.slotId, slotId)) + } + if (packagedFilter === 'packaged') { + whereCondition = and(whereCondition, eq(orderStatus.isPackaged, true)) + } else if (packagedFilter === 'not_packaged') { + whereCondition = and(whereCondition, eq(orderStatus.isPackaged, false)) + } + if (deliveredFilter === 'delivered') { + whereCondition = and(whereCondition, eq(orderStatus.isDelivered, true)) + } else if (deliveredFilter === 'not_delivered') { + whereCondition = and(whereCondition, eq(orderStatus.isDelivered, false)) + } + if (cancellationFilter === 'cancelled') { + whereCondition = and(whereCondition, eq(orderStatus.isCancelled, true)) + } else if (cancellationFilter === 'not_cancelled') { + whereCondition = and(whereCondition, eq(orderStatus.isCancelled, false)) + } + if (flashDeliveryFilter === 'flash') { + whereCondition = and(whereCondition, eq(orders.isFlashDelivery, true)) + } else if (flashDeliveryFilter === 'regular') { + whereCondition = and(whereCondition, eq(orders.isFlashDelivery, false)) + } + + const allOrders = await db.query.orders.findMany({ + where: whereCondition, + orderBy: desc(orders.createdAt), + limit: limit + 1, + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + }, + }) + + const hasMore = allOrders.length > limit + const ordersToReturn = hasMore ? allOrders.slice(0, limit) : allOrders + + const filteredOrders = ordersToReturn.filter((order: any) => { + const statusRecord = order.orderStatus[0] + return order.isCod || (statusRecord && statusRecord.paymentStatus === 'success') + }) + + const formattedOrders = filteredOrders.map((order: any) => { + const statusRecord = order.orderStatus[0] + let status: 'pending' | 'delivered' | 'cancelled' = 'pending' + if (statusRecord?.isCancelled) { + status = 'cancelled' + } else if (statusRecord?.isDelivered) { + status = 'delivered' + } + + const items = order.orderItems + .map((item: any) => ({ + id: item.id, + name: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || '', + productSize: item.product.productQuantity, + isPackaged: item.is_packaged, + isPackageVerified: item.is_package_verified, + })) + .sort((first: any, second: any) => first.id - second.id) + + return { + id: order.id, + orderId: order.id.toString(), + readableId: order.id, + customerName: order.user.name || order.user.mobile + '', + customerMobile: order.user.mobile, + address: `${order.address.addressLine1}${ + order.address.addressLine2 ? `, ${order.address.addressLine2}` : '' + }, ${order.address.city}, ${order.address.state} - ${ + order.address.pincode + }, Phone: ${order.address.phone}`, + addressId: order.addressId, + latitude: order.address.adminLatitude ?? order.address.latitude, + longitude: order.address.adminLongitude ?? order.address.longitude, + totalAmount: parseFloat(order.totalAmount), + deliveryCharge: parseFloat(order.deliveryCharge || '0'), + items, + createdAt: order.createdAt, + deliveryTime: order.slot?.deliveryTime.toISOString() || null, + status, + isPackaged: order.orderItems.every((item: any) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + isCod: order.isCod, + isFlashDelivery: order.isFlashDelivery, + userNotes: order.userNotes, + adminNotes: order.adminNotes, + userNegativityScore: 0, + userId: order.userId, + } + }) + + return { + orders: formattedOrders, + nextCursor: hasMore ? ordersToReturn[ordersToReturn.length - 1].id : undefined, + } +} + +export async function rebalanceSlots(slotIds: number[]): Promise { + const ordersList = await db.query.orders.findMany({ + where: inArray(orders.slotId, slotIds), + with: { + orderItems: { + with: { + product: true, + }, + }, + couponUsages: { + with: { + coupon: true, + }, + }, + }, + }) + + const processedOrdersData = ordersList.map((order: any) => { + let newTotal = order.orderItems.reduce((acc: number, item: any) => { + const latestPrice = +item.product.price + const amount = latestPrice * Number(item.quantity) + return acc + amount + }, 0) + + order.orderItems.forEach((item: any) => { + item.price = item.product.price + item.discountedPrice = item.product.price + }) + + const coupon = order.couponUsages[0]?.coupon + + let discount = 0 + if (coupon && !coupon.isInvalidated && (!coupon.validTill || new Date(coupon.validTill) > new Date())) { + const proportion = Number(order.orderGroupProportion || 1) + if (coupon.discountPercent) { + const maxDiscount = Number(coupon.maxValue || Infinity) * proportion + discount = Math.min((newTotal * parseFloat(coupon.discountPercent)) / 100, maxDiscount) + } else { + discount = Number(coupon.flatDiscount) * proportion + } + } + newTotal -= discount + + const { couponUsages, orderItems: orderItemsRaw, ...rest } = order + const updatedOrderItems = orderItemsRaw.map((item: any) => { + const { product, ...rawOrderItem } = item + return rawOrderItem + }) + return { order: rest, updatedOrderItems, newTotal } + }) + + const updatedOrderIds: number[] = [] + await db.transaction(async (tx) => { + for (const { order, updatedOrderItems, newTotal } of processedOrdersData) { + await tx.update(orders).set({ totalAmount: newTotal.toString() }).where(eq(orders.id, order.id)) + updatedOrderIds.push(order.id) + + for (const item of updatedOrderItems) { + await tx + .update(orderItems) + .set({ + price: item.price, + discountedPrice: item.discountedPrice, + }) + .where(eq(orderItems.id, item.id)) + } + } + }) + + return { + success: true, + updatedOrders: updatedOrderIds, + message: `Rebalanced ${updatedOrderIds.length} orders.`, + } +} + +export async function cancelOrder(orderId: number, reason: string): Promise { + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + orderStatus: true, + }, + }) + + if (!order) { + return { success: false, message: 'Order not found', error: 'order_not_found' } + } + + const status = order.orderStatus[0] + if (!status) { + return { success: false, message: 'Order status not found', error: 'status_not_found' } + } + + if (status.isCancelled) { + return { success: false, message: 'Order is already cancelled', error: 'already_cancelled' } + } + + if (status.isDelivered) { + return { success: false, message: 'Cannot cancel delivered order', error: 'already_delivered' } + } + + const result = await db.transaction(async (tx) => { + await tx + .update(orderStatus) + .set({ + isCancelled: true, + isCancelledByAdmin: true, + cancelReason: reason, + cancellationAdminNotes: reason, + cancellationReviewed: true, + cancellationReviewedAt: new Date(), + }) + .where(eq(orderStatus.id, status.id)) + + const refundStatus = order.isCod ? 'na' : 'pending' + + await tx.insert(refunds).values({ + orderId: order.id, + refundStatus, + }) + + return { orderId: order.id, userId: order.userId } + }) + + return { + success: true, + message: 'Order cancelled successfully', + orderId: result.orderId, + userId: result.userId, + } +} + +export async function deleteOrderById(orderId: number): Promise { + await db.transaction(async (tx) => { + await tx.delete(orderItems).where(eq(orderItems.orderId, orderId)) + await tx.delete(orderStatus).where(eq(orderStatus.orderId, orderId)) + await tx.delete(payments).where(eq(payments.orderId, orderId)) + await tx.delete(refunds).where(eq(refunds.orderId, orderId)) + await tx.delete(couponUsage).where(eq(couponUsage.orderId, orderId)) + await tx.delete(complaints).where(eq(complaints.orderId, orderId)) + await tx.delete(orders).where(eq(orders.id, orderId)) + }) +} diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts new file mode 100644 index 0000000..4d59201 --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -0,0 +1,827 @@ +import { db } from '../db/db_index' +import { + productInfo, + units, + specialDeals, + productSlots, + productTags, + productReviews, + productGroupInfo, + productGroupMembership, + productTagInfo, + users, + storeInfo, +} from '../db/schema' +import { and, desc, eq, inArray, sql } from 'drizzle-orm' +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' +import type { + AdminProduct, + AdminProductGroupInfo, + AdminProductTagInfo, + AdminProductTagWithProducts, + AdminProductReview, + AdminProductWithDetails, + AdminProductWithRelations, + AdminSpecialDeal, + AdminUnit, + AdminUpdateSlotProductsResult, + Store, +} from '@packages/shared' + +type ProductRow = InferSelectModel +type UnitRow = InferSelectModel +type StoreRow = InferSelectModel +type SpecialDealRow = InferSelectModel +type ProductTagInfoRow = InferSelectModel +type ProductTagRow = InferSelectModel +type ProductGroupRow = InferSelectModel +type ProductGroupMembershipRow = InferSelectModel +type ProductReviewRow = InferSelectModel + +const getStringArray = (value: unknown): string[] | null => { + if (!Array.isArray(value)) return null + return value.map((item) => String(item)) +} + +const mapUnit = (unit: UnitRow): AdminUnit => ({ + id: unit.id, + shortNotation: unit.shortNotation, + fullName: unit.fullName, +}) + +const mapStore = (store: StoreRow): Store => ({ + id: store.id, + name: store.name, + description: store.description, + imageUrl: store.imageUrl, + owner: store.owner, + createdAt: store.createdAt, + // updatedAt: store.createdAt, +}) + +const mapProduct = (product: ProductRow): AdminProduct => ({ + id: product.id, + name: product.name, + shortDescription: product.shortDescription ?? null, + longDescription: product.longDescription ?? null, + unitId: product.unitId, + price: String(product.price ?? '0'), + marketPrice: product.marketPrice ? String(product.marketPrice) : null, + images: getStringArray(product.images), + imageKeys: getStringArray(product.images), + isOutOfStock: product.isOutOfStock, + isSuspended: product.isSuspended, + isFlashAvailable: product.isFlashAvailable, + flashPrice: product.flashPrice ? String(product.flashPrice) : null, + createdAt: product.createdAt, + incrementStep: product.incrementStep, + productQuantity: product.productQuantity, + storeId: product.storeId, +}) + +const mapSpecialDeal = (deal: SpecialDealRow): AdminSpecialDeal => ({ + id: deal.id, + productId: deal.productId, + quantity: String(deal.quantity ?? '0'), + price: String(deal.price ?? '0'), + validTill: deal.validTill, +}) + +const mapTagInfo = (tag: ProductTagInfoRow): AdminProductTagInfo => ({ + id: tag.id, + tagName: tag.tagName, + tagDescription: tag.tagDescription ?? null, + imageUrl: tag.imageUrl ?? null, + isDashboardTag: tag.isDashboardTag, + relatedStores: tag.relatedStores, + createdAt: tag.createdAt, +}) + +export async function getAllProducts(): Promise { + type ProductWithRelationsRow = ProductRow & { unit: UnitRow; store: StoreRow | null } + const products = await db.query.productInfo.findMany({ + orderBy: productInfo.name, + with: { + unit: true, + store: true, + }, + }) as ProductWithRelationsRow[] + + return products.map((product) => ({ + ...mapProduct(product), + unit: mapUnit(product.unit), + store: product.store ? mapStore(product.store) : null, + })) +} + +export async function getProductById(id: number): Promise { + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + with: { + unit: true, + }, + }) + + if (!product) { + return null + } + + const deals = await db.query.specialDeals.findMany({ + where: eq(specialDeals.productId, id), + orderBy: specialDeals.quantity, + }) + + const productTagsData = await db.query.productTags.findMany({ + where: eq(productTags.productId, id), + with: { + tag: true, + }, + }) as Array + + return { + ...mapProduct(product), + unit: mapUnit(product.unit), + deals: deals.map(mapSpecialDeal), + tags: productTagsData.map((tag) => mapTagInfo(tag.tag)), + } +} + +export async function deleteProduct(id: number): Promise { + const [deletedProduct] = await db + .delete(productInfo) + .where(eq(productInfo.id, id)) + .returning() + + if (!deletedProduct) { + return null + } + + return mapProduct(deletedProduct) +} + +type ProductInfoInsert = InferInsertModel +type ProductInfoUpdate = Partial + +export async function createProduct(input: ProductInfoInsert): Promise { + const [product] = await db.insert(productInfo).values(input).returning() + return mapProduct(product) +} + +export async function updateProduct(id: number, updates: ProductInfoUpdate): Promise { + const [product] = await db.update(productInfo) + .set(updates) + .where(eq(productInfo.id, id)) + .returning() + if (!product) { + return null + } + + return mapProduct(product) +} + +export async function toggleProductOutOfStock(id: number): Promise { + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + }) + + if (!product) { + return null + } + + const [updatedProduct] = await db + .update(productInfo) + .set({ + isOutOfStock: !product.isOutOfStock, + }) + .where(eq(productInfo.id, id)) + .returning() + + if (!updatedProduct) { + return null + } + + return mapProduct(updatedProduct) +} + +export async function updateSlotProducts(slotId: string, productIds: string[]): Promise { + const currentAssociations = await db.query.productSlots.findMany({ + where: eq(productSlots.slotId, parseInt(slotId)), + columns: { + productId: true, + }, + }) as Array<{ productId: number }> + + const currentProductIds = currentAssociations.map((assoc: { productId: number }) => assoc.productId) + const newProductIds = productIds.map((id: string) => parseInt(id)) + + const productsToAdd = newProductIds.filter((id: number) => !currentProductIds.includes(id)) + const productsToRemove = currentProductIds.filter((id: number) => !newProductIds.includes(id)) + + if (productsToRemove.length > 0) { + await db.delete(productSlots).where( + and( + eq(productSlots.slotId, parseInt(slotId)), + inArray(productSlots.productId, productsToRemove) + ) + ) + } + + if (productsToAdd.length > 0) { + const newAssociations = productsToAdd.map((productId) => ({ + productId, + slotId: parseInt(slotId), + })) + + await db.insert(productSlots).values(newAssociations) + } + + return { + message: 'Slot products updated successfully', + added: productsToAdd.length, + removed: productsToRemove.length, + } +} + +export async function getSlotProductIds(slotId: string): Promise { + const associations = await db.query.productSlots.findMany({ + where: eq(productSlots.slotId, parseInt(slotId)), + columns: { + productId: true, + }, + }) + + return associations.map((assoc: { productId: number }) => assoc.productId) +} + +export async function getAllUnits(): Promise { + const allUnits = await db.query.units.findMany({ + orderBy: units.shortNotation, + }) + + return allUnits.map(mapUnit) +} + +export async function getAllProductTags(): Promise { + const tags = await db.query.productTagInfo.findMany({ + with: { + products: { + with: { + product: true, + }, + }, + }, + }) as Array }> + + return tags.map((tag: ProductTagInfoRow & { products: Array }) => ({ + ...mapTagInfo(tag), + products: tag.products.map((assignment: ProductTagRow & { product: ProductRow }) => ({ + productId: assignment.productId, + tagId: assignment.tagId, + assignedAt: assignment.assignedAt, + product: mapProduct(assignment.product), + })), + })) +} + +export async function getAllProductTagInfos(): Promise { + const tags = await db.query.productTagInfo.findMany({ + orderBy: productTagInfo.tagName, + }) + + return tags.map(mapTagInfo) +} + +export async function getProductTagInfoById(tagId: number): Promise { + const tag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.id, tagId), + }) + + if (!tag) { + return null + } + + return mapTagInfo(tag) +} + +export interface CreateProductTagInput { + tagName: string + tagDescription?: string | null + imageUrl?: string | null + isDashboardTag?: boolean + relatedStores?: number[] +} + +export async function createProductTag(input: CreateProductTagInput): Promise { + const [tag] = await db.insert(productTagInfo).values({ + tagName: input.tagName, + tagDescription: input.tagDescription || null, + imageUrl: input.imageUrl || null, + isDashboardTag: input.isDashboardTag || false, + relatedStores: input.relatedStores || [], + }).returning() + + return { + ...mapTagInfo(tag), + products: [], + } +} + +export async function getProductTagById(tagId: number): Promise { + const tag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.id, tagId), + with: { + products: { + with: { + product: true, + }, + }, + }, + }) + + if (!tag) { + return null + } + + return { + ...mapTagInfo(tag), + products: tag.products.map((assignment: ProductTagRow & { product: ProductRow }) => ({ + productId: assignment.productId, + tagId: assignment.tagId, + assignedAt: assignment.assignedAt, + product: mapProduct(assignment.product), + })), + } +} + +export interface UpdateProductTagInput { + tagName?: string + tagDescription?: string | null + imageUrl?: string | null + isDashboardTag?: boolean + relatedStores?: number[] +} + +export async function updateProductTag(tagId: number, input: UpdateProductTagInput): Promise { + const [tag] = await db.update(productTagInfo).set({ + ...(input.tagName !== undefined && { tagName: input.tagName }), + ...(input.tagDescription !== undefined && { tagDescription: input.tagDescription }), + ...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }), + ...(input.isDashboardTag !== undefined && { isDashboardTag: input.isDashboardTag }), + ...(input.relatedStores !== undefined && { relatedStores: input.relatedStores }), + }).where(eq(productTagInfo.id, tagId)).returning() + + const fullTag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.id, tagId), + with: { + products: { + with: { + product: true, + }, + }, + }, + }) + + return { + ...mapTagInfo(tag), + products: fullTag?.products.map((assignment: ProductTagRow & { product: ProductRow }) => ({ + productId: assignment.productId, + tagId: assignment.tagId, + assignedAt: assignment.assignedAt, + product: mapProduct(assignment.product), + })) || [], + } +} + +export async function deleteProductTag(tagId: number): Promise { + await db.delete(productTagInfo).where(eq(productTagInfo.id, tagId)) +} + +export async function checkProductTagExistsByName(tagName: string): Promise { + const tag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.tagName, tagName), + }) + return !!tag +} + +export async function getSlotsProductIds(slotIds: number[]): Promise> { + if (slotIds.length === 0) { + return {} + } + + const associations = await db.query.productSlots.findMany({ + where: inArray(productSlots.slotId, slotIds), + columns: { + slotId: true, + productId: true, + }, + }) as Array<{ slotId: number; productId: number }> + + const result: Record = {} + for (const assoc of associations) { + if (!result[assoc.slotId]) { + result[assoc.slotId] = [] + } + result[assoc.slotId].push(assoc.productId) + } + + slotIds.forEach((slotId) => { + if (!result[slotId]) { + result[slotId] = [] + } + }) + + return result +} + +export async function getProductReviews(productId: number, limit: number, offset: number) { + const reviews = await db + .select({ + id: productReviews.id, + reviewBody: productReviews.reviewBody, + ratings: productReviews.ratings, + imageUrls: productReviews.imageUrls, + reviewTime: productReviews.reviewTime, + adminResponse: productReviews.adminResponse, + adminResponseImages: productReviews.adminResponseImages, + userName: users.name, + }) + .from(productReviews) + .innerJoin(users, eq(productReviews.userId, users.id)) + .where(eq(productReviews.productId, productId)) + .orderBy(desc(productReviews.reviewTime)) + .limit(limit) + .offset(offset) + + const totalCountResult = await db + .select({ count: sql`count(*)` }) + .from(productReviews) + .where(eq(productReviews.productId, productId)) + + const totalCount = Number(totalCountResult[0].count) + + const mappedReviews: AdminProductReview[] = reviews.map((review: any) => ({ + id: review.id, + reviewBody: review.reviewBody, + ratings: review.ratings, + imageUrls: review.imageUrls, + reviewTime: review.reviewTime, + adminResponse: review.adminResponse ?? null, + adminResponseImages: review.adminResponseImages, + userName: review.userName ?? null, + })) + + return { + reviews: mappedReviews, + totalCount, + } +} + +export async function respondToReview( + reviewId: number, + adminResponse: string | undefined, + adminResponseImages: string[] +): Promise { + const [updatedReview] = await db + .update(productReviews) + .set({ + adminResponse, + adminResponseImages, + }) + .where(eq(productReviews.id, reviewId)) + .returning() + + if (!updatedReview) { + return null + } + + return { + id: updatedReview.id, + reviewBody: updatedReview.reviewBody, + ratings: updatedReview.ratings, + imageUrls: updatedReview.imageUrls, + reviewTime: updatedReview.reviewTime, + adminResponse: updatedReview.adminResponse ?? null, + adminResponseImages: updatedReview.adminResponseImages, + userName: null, + } +} + +export async function getAllProductGroups() { + const groups = await db.query.productGroupInfo.findMany({ + with: { + memberships: { + with: { + product: true, + }, + }, + }, + orderBy: desc(productGroupInfo.createdAt), + }) + + return groups.map((group: any) => ({ + id: group.id, + groupName: group.groupName, + description: group.description ?? null, + createdAt: group.createdAt, + products: group.memberships.map((membership: any) => mapProduct(membership.product)), + productCount: group.memberships.length, + memberships: group.memberships + })) +} + +export async function createProductGroup( + groupName: string, + description: string | undefined, + productIds: number[] +): Promise { + const [newGroup] = await db + .insert(productGroupInfo) + .values({ + groupName, + description, + }) + .returning() + + if (productIds.length > 0) { + const memberships = productIds.map((productId) => ({ + productId, + groupId: newGroup.id, + })) + + await db.insert(productGroupMembership).values(memberships) + } + + return { + id: newGroup.id, + groupName: newGroup.groupName, + description: newGroup.description ?? null, + createdAt: newGroup.createdAt, + } +} + +export async function updateProductGroup( + id: number, + groupName: string | undefined, + description: string | undefined, + productIds: number[] | undefined +): Promise { + const updateData: Partial<{ + groupName: string + description: string | null + }> = {} + + if (groupName !== undefined) updateData.groupName = groupName + if (description !== undefined) updateData.description = description + + const [updatedGroup] = await db + .update(productGroupInfo) + .set(updateData) + .where(eq(productGroupInfo.id, id)) + .returning() + + if (!updatedGroup) { + return null + } + + if (productIds !== undefined) { + await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id)) + + if (productIds.length > 0) { + const memberships = productIds.map((productId) => ({ + productId, + groupId: id, + })) + + await db.insert(productGroupMembership).values(memberships) + } + } + + return { + id: updatedGroup.id, + groupName: updatedGroup.groupName, + description: updatedGroup.description ?? null, + createdAt: updatedGroup.createdAt, + } +} + +export async function deleteProductGroup(id: number): Promise { + await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id)) + + const [deletedGroup] = await db + .delete(productGroupInfo) + .where(eq(productGroupInfo.id, id)) + .returning() + + if (!deletedGroup) { + return null + } + + return { + id: deletedGroup.id, + groupName: deletedGroup.groupName, + description: deletedGroup.description ?? null, + createdAt: deletedGroup.createdAt, + } +} + +export async function addProductToGroup(groupId: number, productId: number): Promise { + await db.insert(productGroupMembership).values({ groupId, productId }) +} + +export async function removeProductFromGroup(groupId: number, productId: number): Promise { + await db.delete(productGroupMembership) + .where(and( + eq(productGroupMembership.groupId, groupId), + eq(productGroupMembership.productId, productId) + )) +} + +export async function updateProductPrices(updates: Array<{ + productId: number + price?: number + marketPrice?: number | null + flashPrice?: number | null + isFlashAvailable?: boolean +}>) { + if (updates.length === 0) { + return { updatedCount: 0, invalidIds: [] } + } + + const productIds = updates.map((update) => update.productId) + const existingProducts = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, productIds), + columns: { id: true }, + }) as Array<{ id: number }> + + const existingIds = new Set(existingProducts.map((product: { id: number }) => product.id)) + const invalidIds = productIds.filter((id) => !existingIds.has(id)) + + if (invalidIds.length > 0) { + return { updatedCount: 0, invalidIds } + } + + const updatePromises = updates.map((update) => { + const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update + const updateData: Partial> = {} + + if (price !== undefined) updateData.price = price.toString() + if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString() + if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString() + if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable + + return db + .update(productInfo) + .set(updateData) + .where(eq(productInfo.id, productId)) + }) + + await Promise.all(updatePromises) + + return { updatedCount: updates.length, invalidIds: [] } +} + + +// ========================================================================== +// Product Helpers for Admin Controller +// ========================================================================== + +export async function checkProductExistsByName(name: string): Promise { + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.name, name), + columns: { id: true }, + }) + + return !!product +} + +export async function checkUnitExists(unitId: number): Promise { + const unit = await db.query.units.findFirst({ + where: eq(units.id, unitId), + columns: { id: true }, + }) + + return !!unit +} + +export async function getProductImagesById(productId: number): Promise { + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, productId), + columns: { images: true }, + }) + + if (!product) { + return null + } + + return getStringArray(product.images) || [] +} + +export interface CreateSpecialDealInput { + quantity: number + price: number + validTill: string | Date +} + +export async function createSpecialDealsForProduct( + productId: number, + deals: CreateSpecialDealInput[] +): Promise { + if (deals.length === 0) { + return [] + } + + const dealInserts = deals.map((deal) => ({ + productId, + quantity: deal.quantity.toString(), + price: deal.price.toString(), + validTill: new Date(deal.validTill), + })) + + const createdDeals = await db + .insert(specialDeals) + .values(dealInserts) + .returning() + + return createdDeals.map(mapSpecialDeal) +} + +export async function updateProductDeals( + productId: number, + deals: CreateSpecialDealInput[] +): Promise { + if (deals.length === 0) { + await db.delete(specialDeals).where(eq(specialDeals.productId, productId)) + return + } + + const existingDeals = await db.query.specialDeals.findMany({ + where: eq(specialDeals.productId, productId), + }) + + const existingDealsMap = new Map( + existingDeals.map((deal: SpecialDealRow) => [`${deal.quantity}-${deal.price}`, deal]) + ) + const newDealsMap = new Map( + deals.map((deal) => [`${deal.quantity}-${deal.price}`, deal]) + ) + + const dealsToAdd = deals.filter((deal) => { + const key = `${deal.quantity}-${deal.price}` + return !existingDealsMap.has(key) + }) + + const dealsToRemove = existingDeals.filter((deal: SpecialDealRow) => { + const key = `${deal.quantity}-${deal.price}` + return !newDealsMap.has(key) + }) + + const dealsToUpdate = deals.filter((deal: CreateSpecialDealInput) => { + const key = `${deal.quantity}-${deal.price}` + const existing = existingDealsMap.get(key) + const nextValidTill = deal.validTill instanceof Date + ? deal.validTill.toISOString().split('T')[0] + : String(deal.validTill) + return existing && existing.validTill.toISOString().split('T')[0] !== nextValidTill + }) + + if (dealsToRemove.length > 0) { + await db.delete(specialDeals).where( + inArray(specialDeals.id, dealsToRemove.map((deal: SpecialDealRow) => deal.id)) + ) + } + + if (dealsToAdd.length > 0) { + const dealInserts = dealsToAdd.map((deal) => ({ + productId, + quantity: deal.quantity.toString(), + price: deal.price.toString(), + validTill: new Date(deal.validTill), + })) + await db.insert(specialDeals).values(dealInserts) + } + + 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)) + } + } +} + +export async function replaceProductTags(productId: number, tagIds: number[]): Promise { + await db.delete(productTags).where(eq(productTags.productId, productId)) + + if (tagIds.length === 0) { + return + } + + const tagAssociations = tagIds.map((tagId) => ({ + productId, + tagId, + })) + + await db.insert(productTags).values(tagAssociations) +} diff --git a/packages/db_helper_sqlite/src/admin-apis/slots.ts b/packages/db_helper_sqlite/src/admin-apis/slots.ts new file mode 100644 index 0000000..43df92f --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/slots.ts @@ -0,0 +1,351 @@ +import { db } from '../db/db_index' +import { + deliverySlotInfo, + productSlots, + productInfo, + vendorSnippets, + productGroupInfo, +} from '../db/schema' +import { and, asc, desc, eq, gt, inArray } from 'drizzle-orm' +import type { + AdminDeliverySlot, + AdminSlotWithProducts, + AdminSlotWithProductsAndSnippetsBase, + AdminSlotCreateResult, + AdminSlotUpdateResult, + AdminVendorSnippet, + AdminSlotProductSummary, + AdminUpdateSlotCapacityResult, +} from '@packages/shared' + +type SlotSnippetInput = { + name: string + productIds: number[] + validTill?: string +} + +const getStringArray = (value: unknown): string[] | null => { + if (!Array.isArray(value)) return null + return value.map((item) => String(item)) +} + +const getNumberArray = (value: unknown): number[] => { + if (!Array.isArray(value)) return [] + return value.map((item) => Number(item)) +} + +const mapDeliverySlot = (slot: typeof deliverySlotInfo.$inferSelect): AdminDeliverySlot => ({ + id: slot.id, + deliveryTime: slot.deliveryTime, + freezeTime: slot.freezeTime, + isActive: slot.isActive, + isFlash: slot.isFlash, + isCapacityFull: slot.isCapacityFull, + deliverySequence: slot.deliverySequence, + groupIds: slot.groupIds, +}) + +const mapSlotProductSummary = (product: { id: number; name: string; images: unknown }): AdminSlotProductSummary => ({ + id: product.id, + name: product.name, + images: getStringArray(product.images), +}) + +const mapVendorSnippet = (snippet: typeof vendorSnippets.$inferSelect): AdminVendorSnippet => ({ + id: snippet.id, + snippetCode: snippet.snippetCode, + slotId: snippet.slotId ?? null, + productIds: snippet.productIds || [], + isPermanent: snippet.isPermanent, + validTill: snippet.validTill ?? null, + createdAt: snippet.createdAt, +}) + +export async function getActiveSlotsWithProducts(): Promise { + const slots = await db.query.deliverySlotInfo + .findMany({ + where: eq(deliverySlotInfo.isActive, true), + orderBy: desc(deliverySlotInfo.deliveryTime), + with: { + productSlots: { + with: { + product: { + columns: { + id: true, + name: true, + images: true, + }, + }, + }, + }, + }, + }) + + return slots.map((slot: any) => ({ + ...mapDeliverySlot(slot), + deliverySequence: getNumberArray(slot.deliverySequence), + products: slot.productSlots.map((ps: any) => mapSlotProductSummary(ps.product)), + })) +} + +export async function getActiveSlots(): Promise { + const slots = await db.query.deliverySlotInfo.findMany({ + where: eq(deliverySlotInfo.isActive, true), + }) + + return slots.map(mapDeliverySlot) +} + +export async function getSlotsAfterDate(afterDate: Date): Promise { + const slots = await db.query.deliverySlotInfo.findMany({ + where: and( + eq(deliverySlotInfo.isActive, true), + gt(deliverySlotInfo.deliveryTime, afterDate) + ), + orderBy: asc(deliverySlotInfo.deliveryTime), + }) + + return slots.map(mapDeliverySlot) +} + +export async function getSlotByIdWithRelations(id: number): Promise { + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, id), + with: { + productSlots: { + with: { + product: { + columns: { + id: true, + name: true, + images: true, + }, + }, + }, + }, + vendorSnippets: true, + }, + }) + + if (!slot) { + return null + } + + return { + ...mapDeliverySlot(slot), + deliverySequence: getNumberArray(slot.deliverySequence), + groupIds: getNumberArray(slot.groupIds), + products: slot.productSlots.map((ps: any) => mapSlotProductSummary(ps.product)), + vendorSnippets: slot.vendorSnippets.map(mapVendorSnippet), + } +} + +export async function createSlotWithRelations(input: { + deliveryTime: string + freezeTime: string + isActive?: boolean + productIds?: number[] + vendorSnippets?: SlotSnippetInput[] + groupIds?: number[] +}): Promise { + const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input + + const result = await db.transaction(async (tx) => { + const [newSlot] = await tx + .insert(deliverySlotInfo) + .values({ + deliveryTime: new Date(deliveryTime), + freezeTime: new Date(freezeTime), + isActive: isActive !== undefined ? isActive : true, + groupIds: groupIds !== undefined ? groupIds : [], + }) + .returning() + + if (productIds && productIds.length > 0) { + const associations = productIds.map((productId) => ({ + productId, + slotId: newSlot.id, + })) + await tx.insert(productSlots).values(associations) + } + + let createdSnippets: AdminVendorSnippet[] = [] + if (snippets && snippets.length > 0) { + for (const snippet of snippets) { + const products = await tx.query.productInfo.findMany({ + where: inArray(productInfo.id, snippet.productIds), + }) + if (products.length !== snippet.productIds.length) { + throw new Error(`One or more invalid product IDs in snippet "${snippet.name}"`) + } + + const existingSnippet = await tx.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippet.name), + }) + if (existingSnippet) { + throw new Error(`Snippet name "${snippet.name}" already exists`) + } + + const [createdSnippet] = await tx.insert(vendorSnippets).values({ + snippetCode: snippet.name, + slotId: newSlot.id, + productIds: snippet.productIds, + validTill: snippet.validTill ? new Date(snippet.validTill) : undefined, + }).returning() + + createdSnippets.push(mapVendorSnippet(createdSnippet)) + } + } + + return { + slot: mapDeliverySlot(newSlot), + createdSnippets, + message: 'Slot created successfully', + } + }) + + return result +} + +export async function updateSlotWithRelations(input: { + id: number + deliveryTime: string + freezeTime: string + isActive?: boolean + productIds?: number[] + vendorSnippets?: SlotSnippetInput[] + groupIds?: number[] +}): Promise { + const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input + + let validGroupIds = groupIds + if (groupIds && groupIds.length > 0) { + const existingGroups = await db.query.productGroupInfo.findMany({ + where: inArray(productGroupInfo.id, groupIds), + columns: { id: true }, + }) + validGroupIds = existingGroups.map((group: { id: number }) => group.id) + } + + const result = await db.transaction(async (tx) => { + const [updatedSlot] = await tx + .update(deliverySlotInfo) + .set({ + deliveryTime: new Date(deliveryTime), + freezeTime: new Date(freezeTime), + isActive: isActive !== undefined ? isActive : true, + groupIds: validGroupIds !== undefined ? validGroupIds : [], + }) + .where(eq(deliverySlotInfo.id, id)) + .returning() + + if (!updatedSlot) { + return null + } + + if (productIds !== undefined) { + await tx.delete(productSlots).where(eq(productSlots.slotId, id)) + + if (productIds.length > 0) { + const associations = productIds.map((productId) => ({ + productId, + slotId: id, + })) + await tx.insert(productSlots).values(associations) + } + } + + let createdSnippets: AdminVendorSnippet[] = [] + if (snippets && snippets.length > 0) { + for (const snippet of snippets) { + const products = await tx.query.productInfo.findMany({ + where: inArray(productInfo.id, snippet.productIds), + }) + if (products.length !== snippet.productIds.length) { + throw new Error(`One or more invalid product IDs in snippet "${snippet.name}"`) + } + + const existingSnippet = await tx.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippet.name), + }) + if (existingSnippet) { + throw new Error(`Snippet name "${snippet.name}" already exists`) + } + + const [createdSnippet] = await tx.insert(vendorSnippets).values({ + snippetCode: snippet.name, + slotId: id, + productIds: snippet.productIds, + validTill: snippet.validTill ? new Date(snippet.validTill) : undefined, + }).returning() + + createdSnippets.push(mapVendorSnippet(createdSnippet)) + } + } + + return { + slot: mapDeliverySlot(updatedSlot), + createdSnippets, + message: 'Slot updated successfully', + } + }) + + return result +} + +export async function deleteSlotById(id: number): Promise { + const [deletedSlot] = await db + .update(deliverySlotInfo) + .set({ isActive: false }) + .where(eq(deliverySlotInfo.id, id)) + .returning() + + if (!deletedSlot) { + return null + } + + return mapDeliverySlot(deletedSlot) +} + +export async function getSlotDeliverySequence(slotId: number): Promise { + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, slotId), + }) + + if (!slot) { + return null + } + + return mapDeliverySlot(slot) +} + +export async function updateSlotDeliverySequence(slotId: number, sequence: unknown) { + const [updatedSlot] = await db + .update(deliverySlotInfo) + .set({ deliverySequence: sequence as Record }) + .where(eq(deliverySlotInfo.id, slotId)) + .returning({ + id: deliverySlotInfo.id, + deliverySequence: deliverySlotInfo.deliverySequence, + }) + + return updatedSlot || null +} + +export async function updateSlotCapacity(slotId: number, isCapacityFull: boolean): Promise { + const [updatedSlot] = await db + .update(deliverySlotInfo) + .set({ isCapacityFull }) + .where(eq(deliverySlotInfo.id, slotId)) + .returning() + + if (!updatedSlot) { + return null + } + + return { + success: true, + slot: mapDeliverySlot(updatedSlot), + message: `Slot ${isCapacityFull ? 'marked as full capacity' : 'capacity reset'}`, + } +} diff --git a/packages/db_helper_sqlite/src/admin-apis/staff-user.ts b/packages/db_helper_sqlite/src/admin-apis/staff-user.ts new file mode 100644 index 0000000..64005da --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/staff-user.ts @@ -0,0 +1,154 @@ +import { db } from '../db/db_index' +import { staffUsers, staffRoles, users, userDetails, orders } from '../db/schema' +import { eq, or, like, and, lt, desc } from 'drizzle-orm' + +export interface StaffUser { + id: number + name: string + password: string + staffRoleId: number | null + createdAt: Date +} + +export async function getStaffUserByName(name: string): Promise { + const staff = await db.query.staffUsers.findFirst({ + where: eq(staffUsers.name, name), + }) + + return staff || null +} + +export async function getStaffUserById(staffId: number): Promise { + const staff = await db.query.staffUsers.findFirst({ + where: eq(staffUsers.id, staffId), + }) + + return staff || null +} + +export async function getAllStaff(): Promise { + const staff = await db.query.staffUsers.findMany({ + columns: { + id: true, + name: true, + }, + with: { + role: { + with: { + rolePermissions: { + with: { + permission: true, + }, + }, + }, + }, + }, + }) + + return staff +} + +export async function getAllUsers( + cursor?: number, + limit: number = 20, + search?: string +): Promise<{ users: any[]; hasMore: boolean }> { + let whereCondition = undefined + + if (search) { + whereCondition = or( + like(users.name, `%${search}%`), + like(users.email, `%${search}%`), + like(users.mobile, `%${search}%`) + ) + } + + if (cursor) { + const cursorCondition = lt(users.id, cursor) + whereCondition = whereCondition ? and(whereCondition, cursorCondition) : cursorCondition + } + + const allUsers = await db.query.users.findMany({ + where: whereCondition, + with: { + userDetails: true, + }, + orderBy: desc(users.id), + limit: limit + 1, + }) + + const hasMore = allUsers.length > limit + const usersToReturn = hasMore ? allUsers.slice(0, limit) : allUsers + + return { users: usersToReturn, hasMore } +} + +export async function getUserWithDetails(userId: number): Promise { + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + with: { + userDetails: true, + orders: { + orderBy: desc(orders.createdAt), + limit: 1, + }, + }, + }) + + return user || null +} + +export async function updateUserSuspensionStatus(userId: number, isSuspended: boolean): Promise { + await db + .insert(userDetails) + .values({ userId, isSuspended }) + .onConflictDoUpdate({ + target: userDetails.userId, + set: { isSuspended }, + }) +} + +export async function checkStaffUserExists(name: string): Promise { + const existingUser = await db.query.staffUsers.findFirst({ + where: eq(staffUsers.name, name), + }) + return !!existingUser +} + +export async function checkStaffRoleExists(roleId: number): Promise { + const role = await db.query.staffRoles.findFirst({ + where: eq(staffRoles.id, roleId), + }) + return !!role +} + +export async function createStaffUser( + name: string, + password: string, + roleId: number +): Promise { + const [newUser] = await db.insert(staffUsers).values({ + name: name.trim(), + password, + staffRoleId: roleId, + }).returning() + + return { + id: newUser.id, + name: newUser.name, + password: newUser.password, + staffRoleId: newUser.staffRoleId, + createdAt: newUser.createdAt, + } +} + +export async function getAllRoles(): Promise { + const roles = await db.query.staffRoles.findMany({ + columns: { + id: true, + roleName: true, + }, + }) + + return roles +} diff --git a/packages/db_helper_sqlite/src/admin-apis/store.ts b/packages/db_helper_sqlite/src/admin-apis/store.ts new file mode 100644 index 0000000..d4442ec --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/store.ts @@ -0,0 +1,145 @@ +import { db } from '../db/db_index' +import { storeInfo, productInfo } from '../db/schema' +import { eq, inArray } from 'drizzle-orm' + +export interface Store { + id: number + name: string + description: string | null + imageUrl: string | null + owner: number + createdAt: Date + // updatedAt: Date +} + +export async function getAllStores(): Promise { + const stores = await db.query.storeInfo.findMany({ + with: { + owner: true, + }, + }) + + return stores +} + +export async function getStoreById(id: number): Promise { + const store = await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, id), + with: { + owner: true, + }, + }) + + return store || null +} + +export interface CreateStoreInput { + name: string + description?: string + imageUrl?: string + owner: number +} + +export async function createStore( + input: CreateStoreInput, + products?: number[] +): Promise { + const [newStore] = await db + .insert(storeInfo) + .values({ + name: input.name, + description: input.description, + imageUrl: input.imageUrl, + owner: input.owner, + }) + .returning() + + if (products && products.length > 0) { + await db + .update(productInfo) + .set({ storeId: newStore.id }) + .where(inArray(productInfo.id, products)) + } + + return { + id: newStore.id, + name: newStore.name, + description: newStore.description, + imageUrl: newStore.imageUrl, + owner: newStore.owner, + createdAt: newStore.createdAt, + // updatedAt: newStore.updatedAt, + } +} + +export interface UpdateStoreInput { + name?: string + description?: string + imageUrl?: string + owner?: number +} + +export async function updateStore( + id: number, + input: UpdateStoreInput, + products?: number[] +): Promise { + const [updatedStore] = await db + .update(storeInfo) + .set({ + ...input, + // updatedAt: new Date(), + }) + .where(eq(storeInfo.id, id)) + .returning() + + if (!updatedStore) { + throw new Error('Store not found') + } + + if (products !== undefined) { + await db + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, id)) + + if (products.length > 0) { + await db + .update(productInfo) + .set({ storeId: id }) + .where(inArray(productInfo.id, products)) + } + } + + return { + id: updatedStore.id, + name: updatedStore.name, + description: updatedStore.description, + imageUrl: updatedStore.imageUrl, + owner: updatedStore.owner, + createdAt: updatedStore.createdAt, + // updatedAt: updatedStore.updatedAt, + } +} + +export async function deleteStore(id: number): Promise<{ message: string }> { + return await db.transaction(async (tx) => { + await tx + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, id)) + + const [deletedStore] = await tx + .delete(storeInfo) + .where(eq(storeInfo.id, id)) + .returning() + + if (!deletedStore) { + throw new Error('Store not found') + } + + return { + message: 'Store deleted successfully', + } + }) +} diff --git a/packages/db_helper_sqlite/src/admin-apis/user.ts b/packages/db_helper_sqlite/src/admin-apis/user.ts new file mode 100644 index 0000000..29ae11a --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/user.ts @@ -0,0 +1,270 @@ +import { db } from '../db/db_index' +import { users, userDetails, orders, orderItems, complaints, notifCreds, unloggedUserTokens, userIncidents, orderStatus } from '../db/schema' +import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm' + +export async function createUserByMobile(mobile: string): Promise { + const [newUser] = await db + .insert(users) + .values({ + name: null, + email: null, + mobile, + }) + .returning() + + return newUser +} + +export async function getUserByMobile(mobile: string): Promise { + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.mobile, mobile)) + .limit(1) + + return existingUser || null +} + +export async function getUnresolvedComplaintsCount(): Promise { + const result = await db + .select({ count: count(complaints.id) }) + .from(complaints) + .where(eq(complaints.isResolved, false)) + + return result[0]?.count || 0 +} + +export async function getAllUsersWithFilters( + limit: number, + cursor?: number, + search?: string +): Promise<{ users: any[]; hasMore: boolean }> { + const whereConditions = [] + + if (search && search.trim()) { + whereConditions.push(sql`${users.mobile} LIKE ${`%${search.trim()}%`}`) + } + + if (cursor) { + whereConditions.push(sql`${users.id} > ${cursor}`) + } + + const usersList = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + createdAt: users.createdAt, + }) + .from(users) + .where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined) + .orderBy(asc(users.id)) + .limit(limit + 1) + + const hasMore = usersList.length > limit + const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList + + return { users: usersToReturn, hasMore } +} + +export async function getOrderCountsByUserIds(userIds: number[]): Promise<{ userId: number; totalOrders: number }[]> { + if (userIds.length === 0) return [] + + return await db + .select({ + userId: orders.userId, + totalOrders: count(orders.id), + }) + .from(orders) + .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(orders.userId) +} + +export async function getLastOrdersByUserIds(userIds: number[]): Promise<{ userId: number; lastOrderDate: Date | null }[]> { + if (userIds.length === 0) return [] + + return await db + .select({ + userId: orders.userId, + lastOrderDate: max(orders.createdAt), + }) + .from(orders) + .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(orders.userId) +} + +export async function getSuspensionStatusesByUserIds(userIds: number[]): Promise<{ userId: number; isSuspended: boolean }[]> { + if (userIds.length === 0) return [] + + return await db + .select({ + userId: userDetails.userId, + isSuspended: userDetails.isSuspended, + }) + .from(userDetails) + .where(sql`${userDetails.userId} IN (${sql.join(userIds, sql`, `)})`) +} + +export async function getUserBasicInfo(userId: number): Promise { + const user = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1) + + return user[0] || null +} + +export async function getUserSuspensionStatus(userId: number): Promise { + const userDetail = await db + .select({ + isSuspended: userDetails.isSuspended, + }) + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1) + + return userDetail[0]?.isSuspended ?? false +} + +export async function getUserOrders(userId: number): Promise { + return await db + .select({ + id: orders.id, + readableId: orders.readableId, + totalAmount: orders.totalAmount, + createdAt: orders.createdAt, + isFlashDelivery: orders.isFlashDelivery, + }) + .from(orders) + .where(eq(orders.userId, userId)) + .orderBy(desc(orders.createdAt)) +} + +export async function getOrderStatusesByOrderIds(orderIds: number[]): Promise<{ orderId: number; isDelivered: boolean; isCancelled: boolean }[]> { + if (orderIds.length === 0) return [] + + return await db + .select({ + orderId: orderStatus.orderId, + isDelivered: orderStatus.isDelivered, + isCancelled: orderStatus.isCancelled, + }) + .from(orderStatus) + .where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`) +} + +export async function getItemCountsByOrderIds(orderIds: number[]): Promise<{ orderId: number; itemCount: number }[]> { + if (orderIds.length === 0) return [] + + return await db + .select({ + orderId: orderItems.orderId, + itemCount: count(orderItems.id), + }) + .from(orderItems) + .where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`) + .groupBy(orderItems.orderId) +} + +export async function upsertUserSuspension(userId: number, isSuspended: boolean): Promise { + const existingDetail = await db + .select({ id: userDetails.id }) + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1) + + if (existingDetail.length > 0) { + await db + .update(userDetails) + .set({ isSuspended }) + .where(eq(userDetails.userId, userId)) + } else { + await db + .insert(userDetails) + .values({ + userId, + isSuspended, + }) + } +} + +export async function searchUsers(search?: string): Promise { + if (search && search.trim()) { + return await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + }) + .from(users) + .where(sql`${users.mobile} LIKE ${`%${search.trim()}%`} OR ${users.name} LIKE ${`%${search.trim()}%`}`) + } else { + return await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + }) + .from(users) + } +} + +export async function getAllNotifCreds(): Promise<{ userId: number, token: string }[]> { + return await db + .select({ userId: notifCreds.userId, token: notifCreds.token }) + .from(notifCreds) +} + +export async function getAllUnloggedTokens(): Promise<{ token: string }[]> { + return await db + .select({ token: unloggedUserTokens.token }) + .from(unloggedUserTokens) +} + +export async function getNotifTokensByUserIds(userIds: number[]): Promise<{ token: string }[]> { + return await db + .select({ token: notifCreds.token }) + .from(notifCreds) + .where(inArray(notifCreds.userId, userIds)) +} + +export async function getUserIncidentsWithRelations(userId: number): Promise { + return await db.query.userIncidents.findMany({ + where: eq(userIncidents.userId, userId), + with: { + order: { + with: { + orderStatus: true, + }, + }, + addedBy: true, + }, + orderBy: desc(userIncidents.dateAdded), + }) +} + +export async function createUserIncident( + userId: number, + orderId: number | undefined, + adminComment: string | undefined, + adminUserId: number, + negativityScore: number | undefined +): Promise { + const [incident] = await db.insert(userIncidents) + .values({ + userId, + orderId, + adminComment, + addedBy: adminUserId, + negativityScore, + }) + .returning() + + return incident +} diff --git a/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts b/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts new file mode 100644 index 0000000..488e74c --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/vendor-snippets.ts @@ -0,0 +1,250 @@ +import { db } from '../db/db_index' +import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, orderStatus } from '../db/schema' +import { desc, eq, inArray } from 'drizzle-orm' +import type { InferSelectModel } from 'drizzle-orm' +import type { + AdminDeliverySlot, + AdminVendorSnippet, + AdminVendorSnippetWithSlot, + AdminVendorSnippetProduct, + AdminVendorUpdatePackagingResult, +} from '@packages/shared' + +type VendorSnippetRow = InferSelectModel +type DeliverySlotRow = InferSelectModel +type ProductRow = InferSelectModel + +const mapVendorSnippet = (snippet: VendorSnippetRow): AdminVendorSnippet => ({ + id: snippet.id, + snippetCode: snippet.snippetCode, + slotId: snippet.slotId ?? null, + productIds: snippet.productIds || [], + isPermanent: snippet.isPermanent, + validTill: snippet.validTill ?? null, + createdAt: snippet.createdAt, +}) + +const mapDeliverySlot = (slot: DeliverySlotRow): AdminDeliverySlot => ({ + id: slot.id, + deliveryTime: slot.deliveryTime, + freezeTime: slot.freezeTime, + isActive: slot.isActive, + isFlash: slot.isFlash, + isCapacityFull: slot.isCapacityFull, + deliverySequence: slot.deliverySequence, + groupIds: slot.groupIds, +}) + +const mapProductSummary = (product: { id: number; name: string }): AdminVendorSnippetProduct => ({ + id: product.id, + name: product.name, +}) + +export async function checkVendorSnippetExists(snippetCode: string): Promise { + const existingSnippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippetCode), + }) + return !!existingSnippet +} + +export async function getVendorSnippetById(id: number): Promise { + const snippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.id, id), + with: { + slot: true, + }, + }) + + if (!snippet) { + return null + } + + return { + ...mapVendorSnippet(snippet), + slot: snippet.slot ? mapDeliverySlot(snippet.slot) : null, + } +} + +export async function getVendorSnippetByCode(snippetCode: string): Promise { + const snippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippetCode), + }) + + return snippet ? mapVendorSnippet(snippet) : null +} + +export async function getAllVendorSnippets(): Promise { + const snippets = await db.query.vendorSnippets.findMany({ + with: { + slot: true, + }, + orderBy: desc(vendorSnippets.createdAt), + }) + + return snippets.map((snippet: VendorSnippetRow & { slot: DeliverySlotRow | null }) => ({ + ...mapVendorSnippet(snippet), + slot: snippet.slot ? mapDeliverySlot(snippet.slot) : null, + })) +} + +export async function createVendorSnippet(input: { + snippetCode: string + slotId?: number + productIds: number[] + isPermanent: boolean + validTill?: Date +}): Promise { + const [result] = await db.insert(vendorSnippets).values({ + snippetCode: input.snippetCode, + slotId: input.slotId, + productIds: input.productIds, + isPermanent: input.isPermanent, + validTill: input.validTill, + }).returning() + + return mapVendorSnippet(result) +} + +export async function updateVendorSnippet(id: number, updates: { + snippetCode?: string + slotId?: number | null + productIds?: number[] + isPermanent?: boolean + validTill?: Date | null +}): Promise { + const [result] = await db.update(vendorSnippets) + .set(updates) + .where(eq(vendorSnippets.id, id)) + .returning() + + return result ? mapVendorSnippet(result) : null +} + +export async function deleteVendorSnippet(id: number): Promise { + const [result] = await db.delete(vendorSnippets) + .where(eq(vendorSnippets.id, id)) + .returning() + + return result ? mapVendorSnippet(result) : null +} + +export async function getProductsByIds(productIds: number[]): Promise { + const products = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, productIds), + columns: { id: true, name: true }, + }) + + const prods = products.map(mapProductSummary) + return prods +} + +export async function getVendorSlotById(slotId: number): Promise { + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, slotId), + }) + + return slot ? mapDeliverySlot(slot) : null +} + +export async function getVendorOrdersBySlotId(slotId: number) { + return await db.query.orders.findMany({ + where: eq(orders.slotId, slotId), + with: { + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + user: true, + slot: true, + }, + orderBy: desc(orders.createdAt), + }) +} + +export async function getVendorOrders() { + return await db.query.orders.findMany({ + with: { + user: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + }, + orderBy: desc(orders.createdAt), + }) +} + +export async function getOrderItemsByOrderIds(orderIds: number[]) { + return await db.query.orderItems.findMany({ + where: inArray(orderItems.orderId, orderIds), + with: { + product: { + with: { + unit: true, + }, + }, + }, + }) +} + +export async function getOrderStatusByOrderIds(orderIds: number[]) { + return await db.query.orderStatus.findMany({ + where: inArray(orderStatus.orderId, orderIds), + }) +} + +export async function updateVendorOrderItemPackaging( + orderItemId: number, + isPackaged: boolean +): Promise { + const orderItem = await db.query.orderItems.findFirst({ + where: eq(orderItems.id, orderItemId), + with: { + order: { + with: { + slot: true, + }, + }, + }, + }) + + if (!orderItem) { + return { success: false, message: 'Order item not found' } + } + + if (!orderItem.order.slotId) { + return { success: false, message: 'Order item not associated with a vendor slot' } + } + + const snippetExists = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.slotId, orderItem.order.slotId), + }) + + if (!snippetExists) { + return { success: false, message: "No vendor snippet found for this order's slot" } + } + + const [updatedItem] = await db.update(orderItems) + .set({ + is_packaged: isPackaged, + }) + .where(eq(orderItems.id, orderItemId)) + .returning({ id: orderItems.id }) + + if (!updatedItem) { + return { success: false, message: 'Failed to update packaging status' } + } + + return { success: true, orderItemId, is_packaged: isPackaged } +} diff --git a/packages/db_helper_sqlite/src/common-apis/utils.ts b/packages/db_helper_sqlite/src/common-apis/utils.ts new file mode 100644 index 0000000..c733a7f --- /dev/null +++ b/packages/db_helper_sqlite/src/common-apis/utils.ts @@ -0,0 +1,19 @@ +// Common utility functions that can be used by both admin and user APIs + +export function formatDate(date: Date): string { + return date.toISOString() +} + +export function generateCode(prefix: string, length: number = 6): string { + const timestamp = Date.now().toString().slice(-length) + const random = Math.random().toString(36).substring(2, 8).toUpperCase() + return `${prefix}${timestamp}${random}` +} + +export function calculateDiscount(amount: number, percent: number, maxDiscount?: number): number { + let discount = (amount * percent) / 100 + if (maxDiscount && discount > maxDiscount) { + discount = maxDiscount + } + return discount +} diff --git a/packages/db_helper_sqlite/src/db/db_index.ts b/packages/db_helper_sqlite/src/db/db_index.ts new file mode 100644 index 0000000..8dc6f70 --- /dev/null +++ b/packages/db_helper_sqlite/src/db/db_index.ts @@ -0,0 +1,26 @@ +import type { D1Database } from '@cloudflare/workers-types' +import { drizzle, type DrizzleD1Database } from 'drizzle-orm/d1' +import * as schema from './schema' + +type DbClient = DrizzleD1Database + +let dbInstance: DbClient | null = null + +export function initDb(database: D1Database): void { + const base = drizzle(database, { schema }) as DbClient + dbInstance = Object.assign(base, { + transaction: async (handler: (tx: DbClient) => Promise): Promise => { + return handler(base) + }, + }) +} + +export const db = new Proxy({} as DbClient, { + get(_target, prop: keyof DbClient) { + if (!dbInstance) { + throw new Error('D1 database not initialized. Call initDb(env.DB) before using db helpers.') + } + + return dbInstance[prop] + }, +}) diff --git a/packages/db_helper_sqlite/src/db/porter.ts b/packages/db_helper_sqlite/src/db/porter.ts new file mode 100644 index 0000000..fcb94f9 --- /dev/null +++ b/packages/db_helper_sqlite/src/db/porter.ts @@ -0,0 +1,125 @@ +/* +* This was a one time script to change the composition of the signed urls +*/ + +import { db } from '@/src/db/db_index' +import { + userDetails, + productInfo, + productTagInfo, + complaints, +} from '@/src/db/schema' +import { eq, not, isNull } from 'drizzle-orm' + +const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net' + +const cleanImageUrl = (url: string): string => { + if (url.startsWith(S3_DOMAIN)) { + return url.replace(S3_DOMAIN + '/', '') + } + return url +} + +const cleanImageUrls = (urls: string[]): string[] => { + return urls.map(cleanImageUrl) +} + +async function migrateUserDetails() { + console.log('Migrating userDetails...') + const users = await db.select().from(userDetails).where(not(isNull(userDetails.profileImage))) + + console.log(`Found ${users.length} user records with profile images`) + + for (const user of users) { + if (user.profileImage) { + const cleanedUrl = cleanImageUrl(user.profileImage) + await db.update(userDetails) + .set({ profileImage: cleanedUrl }) + .where(eq(userDetails.id, user.id)) + } + } + + console.log('userDetails migration completed') +} + +async function migrateProductInfo() { + console.log('Migrating productInfo...') + const products = await db.select().from(productInfo).where(not(isNull(productInfo.images))) + + console.log(`Found ${products.length} product records with images`) + + for (const product of products) { + if (product.images && Array.isArray(product.images)) { + const cleanedUrls = cleanImageUrls(product.images) + await db.update(productInfo) + .set({ images: cleanedUrls }) + .where(eq(productInfo.id, product.id)) + } + } + + console.log('productInfo migration completed') +} + +async function migrateProductTagInfo() { + console.log('Migrating productTagInfo...') + const tags = await db.select().from(productTagInfo).where(not(isNull(productTagInfo.imageUrl))) + + console.log(`Found ${tags.length} tag records with images`) + + for (const tag of tags) { + if (tag.imageUrl) { + const cleanedUrl = cleanImageUrl(tag.imageUrl) + await db.update(productTagInfo) + .set({ imageUrl: cleanedUrl }) + .where(eq(productTagInfo.id, tag.id)) + } + } + + console.log('productTagInfo migration completed') +} + +async function migrateComplaints() { + console.log('Migrating complaints...') + const complaintRecords = await db.select().from(complaints).where(not(isNull(complaints.images))) + + console.log(`Found ${complaintRecords.length} complaint records with images`) + + for (const complaint of complaintRecords) { + if (complaint.images && Array.isArray(complaint.images)) { + const cleanedUrls = cleanImageUrls(complaint.images) + await db.update(complaints) + .set({ images: cleanedUrls }) + .where(eq(complaints.id, complaint.id)) + } + } + + console.log('complaints migration completed') +} + +async function runMigration() { + console.log('Starting image URL migration...') + console.log(`Removing S3 domain: ${S3_DOMAIN}`) + + try { + await migrateUserDetails() + await migrateProductInfo() + await migrateProductTagInfo() + await migrateComplaints() + + console.log('Migration completed successfully!') + } catch (error) { + console.error('Migration failed:', error) + throw error + } +} + +// Run the migration +runMigration() + .then(() => { + console.log('Process completed successfully') + process.exit(0) + }) + .catch((error) => { + console.error('Process failed:', error) + process.exit(1) + }) diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts new file mode 100644 index 0000000..babc230 --- /dev/null +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -0,0 +1,728 @@ +import { + sqliteTable, + integer, + text, + real, + uniqueIndex, + primaryKey, + check, + customType, +} from 'drizzle-orm/sqlite-core' +import { relations, sql } from 'drizzle-orm' + +const jsonText = (name: string) => + customType<{ data: T | null; driverData: string | null }>({ + dataType() { + return 'text' + }, + toDriver(value) { + if (value === undefined || value === null) return null + return JSON.stringify(value) + }, + fromDriver(value) { + if (value === null || value === undefined) return null + try { + return JSON.parse(String(value)) as T + } catch { + return null + } + }, + })(name) + +const numericText = (name: string) => + customType<{ data: string | null; driverData: string | null }>({ + dataType() { + return 'text' + }, + toDriver(value) { + if (value === undefined || value === null) return null + return String(value) + }, + fromDriver(value) { + if (value === null || value === undefined) return null + return String(value) + }, + })(name) + +const staffRoleValues = ['super_admin', 'admin', 'marketer', 'delivery_staff'] as const +const staffPermissionValues = ['crud_product', 'make_coupon', 'crud_staff_users'] as const +const uploadStatusValues = ['pending', 'claimed'] as const +const paymentStatusValues = ['pending', 'success', 'cod', 'failed'] as const + +export const staffRoleEnum = (name: string) => text(name, { enum: staffRoleValues }) +export const staffPermissionEnum = (name: string) => text(name, { enum: staffPermissionValues }) +export const uploadStatusEnum = (name: string) => text(name, { enum: uploadStatusValues }) +export const paymentStatusEnum = (name: string) => text(name, { enum: paymentStatusValues }) + +export const users = sqliteTable('users', { + id: integer().primaryKey({ autoIncrement: true }), + name: text(), + email: text(), + mobile: text(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}, (t) => ({ + unq_email: uniqueIndex('unique_email').on(t.email), +})) + +export const userDetails = sqliteTable('user_details', { + id: integer().primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id).unique(), + bio: text('bio'), + dateOfBirth: integer('date_of_birth', { mode: 'timestamp' }), + gender: text('gender'), + occupation: text('occupation'), + profileImage: text('profile_image'), + isSuspended: integer('is_suspended', { mode: 'boolean' }).notNull().default(false), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const userCreds = sqliteTable('user_creds', { + id: integer().primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id), + userPassword: text('user_password').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const addressZones = sqliteTable('address_zones', { + id: integer().primaryKey({ autoIncrement: true }), + zoneName: text('zone_name').notNull(), + addedAt: integer('added_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const addressAreas = sqliteTable('address_areas', { + id: integer().primaryKey({ autoIncrement: true }), + placeName: text('place_name').notNull(), + zoneId: integer('zone_id').references(() => addressZones.id), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const addresses = sqliteTable('addresses', { + id: integer().primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id), + name: text('name').notNull(), + phone: text('phone').notNull(), + addressLine1: text('address_line1').notNull(), + addressLine2: text('address_line2'), + city: text('city').notNull(), + state: text('state').notNull(), + pincode: text('pincode').notNull(), + isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false), + latitude: real('latitude'), + longitude: real('longitude'), + googleMapsUrl: text('google_maps_url'), + adminLatitude: real('admin_latitude'), + adminLongitude: real('admin_longitude'), + zoneId: integer('zone_id').references(() => addressZones.id), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const staffRoles = sqliteTable('staff_roles', { + id: integer().primaryKey({ autoIncrement: true }), + roleName: staffRoleEnum('role_name').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}, (t) => ({ + unq_role_name: uniqueIndex('unique_role_name').on(t.roleName), +})) + +export const staffPermissions = sqliteTable('staff_permissions', { + id: integer().primaryKey({ autoIncrement: true }), + permissionName: staffPermissionEnum('permission_name').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}, (t) => ({ + unq_permission_name: uniqueIndex('unique_permission_name').on(t.permissionName), +})) + +export const staffRolePermissions = sqliteTable('staff_role_permissions', { + id: integer().primaryKey({ autoIncrement: true }), + staffRoleId: integer('staff_role_id').notNull().references(() => staffRoles.id), + staffPermissionId: integer('staff_permission_id').notNull().references(() => staffPermissions.id), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}, (t) => ({ + unq_role_permission: uniqueIndex('unique_role_permission').on(t.staffRoleId, t.staffPermissionId), +})) + +export const staffUsers = sqliteTable('staff_users', { + id: integer().primaryKey({ autoIncrement: true }), + name: text().notNull(), + password: text().notNull(), + staffRoleId: integer('staff_role_id').references(() => staffRoles.id), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const storeInfo = sqliteTable('store_info', { + id: integer().primaryKey({ autoIncrement: true }), + name: text().notNull(), + description: text(), + imageUrl: text('image_url'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), + owner: integer('owner').notNull().references(() => staffUsers.id), +}) + +export const units = sqliteTable('units', { + id: integer().primaryKey({ autoIncrement: true }), + shortNotation: text('short_notation').notNull(), + fullName: text('full_name').notNull(), +}, (t) => ({ + unq_short_notation: uniqueIndex('unique_short_notation').on(t.shortNotation), +})) + +export const productInfo = sqliteTable('product_info', { + id: integer().primaryKey({ autoIncrement: true }), + name: text().notNull(), + shortDescription: text('short_description'), + longDescription: text('long_description'), + unitId: integer('unit_id').notNull().references(() => units.id), + price: numericText('price').notNull(), + marketPrice: numericText('market_price'), + images: jsonText('images'), + isOutOfStock: integer('is_out_of_stock', { mode: 'boolean' }).notNull().default(false), + isSuspended: integer('is_suspended', { mode: 'boolean' }).notNull().default(false), + isFlashAvailable: integer('is_flash_available', { mode: 'boolean' }).notNull().default(false), + flashPrice: numericText('flash_price'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), + incrementStep: real('increment_step').notNull().default(1), + productQuantity: real('product_quantity').notNull().default(1), + storeId: integer('store_id').references(() => storeInfo.id), +}) + +export const productGroupInfo = sqliteTable('product_group_info', { + id: integer().primaryKey({ autoIncrement: true }), + groupName: text('group_name').notNull(), + description: text(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const productGroupMembership = sqliteTable('product_group_membership', { + productId: integer('product_id').notNull().references(() => productInfo.id), + groupId: integer('group_id').notNull().references(() => productGroupInfo.id), + addedAt: integer('added_at', { mode: 'timestamp' }).notNull().defaultNow(), +}, (t) => ({ + pk: primaryKey({ columns: [t.productId, t.groupId], name: 'product_group_membership_pk' }), +})) + +export const homeBanners = sqliteTable('home_banners', { + id: integer().primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + imageUrl: text('image_url').notNull(), + description: text('description'), + productIds: jsonText('product_ids'), + redirectUrl: text('redirect_url'), + serialNum: integer('serial_num'), + isActive: integer('is_active', { mode: 'boolean' }).notNull().default(false), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), + lastUpdated: integer('last_updated', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const productReviews = sqliteTable('product_reviews', { + id: integer().primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id), + productId: integer('product_id').notNull().references(() => productInfo.id), + reviewBody: text('review_body').notNull(), + imageUrls: jsonText('image_urls').$defaultFn(() => []), + reviewTime: integer('review_time', { mode: 'timestamp' }).notNull().defaultNow(), + ratings: real('ratings').notNull(), + adminResponse: text('admin_response'), + adminResponseImages: jsonText('admin_response_images').$defaultFn(() => []), +}, (t) => ({ + ratingCheck: check('rating_check', sql`${t.ratings} >= 1 AND ${t.ratings} <= 5`), +})) + +export const uploadUrlStatus = sqliteTable('upload_url_status', { + id: integer().primaryKey({ autoIncrement: true }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), + key: text('key').notNull(), + status: uploadStatusEnum('status').notNull().default('pending'), +}) + +export const productTagInfo = sqliteTable('product_tag_info', { + id: integer().primaryKey({ autoIncrement: true }), + tagName: text('tag_name').notNull().unique(), + tagDescription: text('tag_description'), + imageUrl: text('image_url'), + isDashboardTag: integer('is_dashboard_tag', { mode: 'boolean' }).notNull().default(false), + relatedStores: jsonText('related_stores').$defaultFn(() => []), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const productTags = sqliteTable('product_tags', { + id: integer().primaryKey({ autoIncrement: true }), + productId: integer('product_id').notNull().references(() => productInfo.id), + tagId: integer('tag_id').notNull().references(() => productTagInfo.id), + assignedAt: integer('assigned_at', { mode: 'timestamp' }).notNull().defaultNow(), +}, (t) => ({ + unq_product_tag: uniqueIndex('unique_product_tag').on(t.productId, t.tagId), +})) + +export const deliverySlotInfo = sqliteTable('delivery_slot_info', { + id: integer().primaryKey({ autoIncrement: true }), + deliveryTime: integer('delivery_time', { mode: 'timestamp' }).notNull(), + freezeTime: integer('freeze_time', { mode: 'timestamp' }).notNull(), + isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true), + isFlash: integer('is_flash', { mode: 'boolean' }).notNull().default(false), + isCapacityFull: integer('is_capacity_full', { mode: 'boolean' }).notNull().default(false), + deliverySequence: jsonText>('delivery_sequence').$defaultFn(() => ({})), + groupIds: jsonText('group_ids').$defaultFn(() => []), +}) + +export const vendorSnippets = sqliteTable('vendor_snippets', { + id: integer().primaryKey({ autoIncrement: true }), + snippetCode: text('snippet_code').notNull().unique(), + slotId: integer('slot_id').references(() => deliverySlotInfo.id), + isPermanent: integer('is_permanent', { mode: 'boolean' }).notNull().default(false), + productIds: jsonText('product_ids').notNull(), + validTill: integer('valid_till', { mode: 'timestamp' }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const productSlots = sqliteTable('product_slots', { + productId: integer('product_id').notNull().references(() => productInfo.id), + slotId: integer('slot_id').notNull().references(() => deliverySlotInfo.id), +}, (t) => ({ + pk: primaryKey({ columns: [t.productId, t.slotId], name: 'product_slot_pk' }), +})) + +export const specialDeals = sqliteTable('special_deals', { + id: integer().primaryKey({ autoIncrement: true }), + productId: integer('product_id').notNull().references(() => productInfo.id), + quantity: numericText('quantity').notNull(), + price: numericText('price').notNull(), + validTill: integer('valid_till', { mode: 'timestamp' }).notNull(), +}) + +export const paymentInfoTable = sqliteTable('payment_info', { + id: integer().primaryKey({ autoIncrement: true }), + status: text().notNull(), + gateway: text().notNull(), + orderId: text('order_id'), + token: text('token'), + merchantOrderId: text('merchant_order_id').notNull().unique(), + payload: jsonText('payload'), +}) + +export const orders = sqliteTable('orders', { + id: integer().primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id), + addressId: integer('address_id').notNull().references(() => addresses.id), + slotId: integer('slot_id').references(() => deliverySlotInfo.id), + isCod: integer('is_cod', { mode: 'boolean' }).notNull().default(false), + isOnlinePayment: integer('is_online_payment', { mode: 'boolean' }).notNull().default(false), + paymentInfoId: integer('payment_info_id').references(() => paymentInfoTable.id), + totalAmount: numericText('total_amount').notNull(), + deliveryCharge: numericText('delivery_charge').notNull().default('0'), + readableId: integer('readable_id').notNull(), + adminNotes: text('admin_notes'), + userNotes: text('user_notes'), + orderGroupId: text('order_group_id'), + orderGroupProportion: numericText('order_group_proportion'), + isFlashDelivery: integer('is_flash_delivery', { mode: 'boolean' }).notNull().default(false), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const orderItems = sqliteTable('order_items', { + id: integer().primaryKey({ autoIncrement: true }), + orderId: integer('order_id').notNull().references(() => orders.id), + productId: integer('product_id').notNull().references(() => productInfo.id), + quantity: text('quantity').notNull(), + price: numericText('price').notNull(), + discountedPrice: numericText('discounted_price'), + is_packaged: integer('is_packaged', { mode: 'boolean' }).notNull().default(false), + is_package_verified: integer('is_package_verified', { mode: 'boolean' }).notNull().default(false), +}) + +export const orderStatus = sqliteTable('order_status', { + id: integer().primaryKey({ autoIncrement: true }), + orderTime: integer('order_time', { mode: 'timestamp' }).notNull().defaultNow(), + userId: integer('user_id').notNull().references(() => users.id), + orderId: integer('order_id').notNull().references(() => orders.id), + isPackaged: integer('is_packaged', { mode: 'boolean' }).notNull().default(false), + isDelivered: integer('is_delivered', { mode: 'boolean' }).notNull().default(false), + isCancelled: integer('is_cancelled', { mode: 'boolean' }).notNull().default(false), + cancelReason: text('cancel_reason'), + isCancelledByAdmin: integer('is_cancelled_by_admin', { mode: 'boolean' }), + paymentStatus: paymentStatusEnum('payment_state').notNull().default('pending'), + cancellationUserNotes: text('cancellation_user_notes'), + cancellationAdminNotes: text('cancellation_admin_notes'), + cancellationReviewed: integer('cancellation_reviewed', { mode: 'boolean' }).notNull().default(false), + cancellationReviewedAt: integer('cancellation_reviewed_at', { mode: 'timestamp' }), + refundCouponId: integer('refund_coupon_id').references(() => coupons.id), +}) + +export const payments = sqliteTable('payments', { + id: integer().primaryKey({ autoIncrement: true }), + status: text().notNull(), + gateway: text().notNull(), + orderId: integer('order_id').notNull().references(() => orders.id), + token: text('token'), + merchantOrderId: text('merchant_order_id').notNull().unique(), + payload: jsonText('payload'), +}) + +export const refunds = sqliteTable('refunds', { + id: integer().primaryKey({ autoIncrement: true }), + orderId: integer('order_id').notNull().references(() => orders.id), + refundAmount: numericText('refund_amount'), + refundStatus: text('refund_status').default('none'), + merchantRefundId: text('merchant_refund_id'), + refundProcessedAt: integer('refund_processed_at', { mode: 'timestamp' }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const keyValStore = sqliteTable('key_val_store', { + key: text('key').primaryKey(), + value: jsonText('value'), +}) + +export const notifications = sqliteTable('notifications', { + id: integer().primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id), + title: text().notNull(), + body: text().notNull(), + type: text(), + isRead: integer('is_read', { mode: 'boolean' }).notNull().default(false), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const productCategories = sqliteTable('product_categories', { + id: integer().primaryKey({ autoIncrement: true }), + name: text().notNull(), + description: text(), +}) + +export const cartItems = sqliteTable('cart_items', { + id: integer().primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id), + productId: integer('product_id').notNull().references(() => productInfo.id), + quantity: numericText('quantity').notNull(), + addedAt: integer('added_at', { mode: 'timestamp' }).notNull().defaultNow(), +}, (t) => ({ + unq_user_product: uniqueIndex('unique_user_product').on(t.userId, t.productId), +})) + +export const complaints = sqliteTable('complaints', { + id: integer().primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id), + orderId: integer('order_id').references(() => orders.id), + complaintBody: text('complaint_body').notNull(), + images: jsonText('images'), + response: text('response'), + isResolved: integer('is_resolved', { mode: 'boolean' }).notNull().default(false), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const coupons = sqliteTable('coupons', { + id: integer().primaryKey({ autoIncrement: true }), + couponCode: text('coupon_code').notNull().unique(), + isUserBased: integer('is_user_based', { mode: 'boolean' }).notNull().default(false), + discountPercent: numericText('discount_percent'), + flatDiscount: numericText('flat_discount'), + minOrder: numericText('min_order'), + productIds: jsonText('product_ids'), + createdBy: integer('created_by').notNull().references(() => staffUsers.id), + maxValue: numericText('max_value'), + isApplyForAll: integer('is_apply_for_all', { mode: 'boolean' }).notNull().default(false), + validTill: integer('valid_till', { mode: 'timestamp' }), + maxLimitForUser: integer('max_limit_for_user'), + isInvalidated: integer('is_invalidated', { mode: 'boolean' }).notNull().default(false), + exclusiveApply: integer('exclusive_apply', { mode: 'boolean' }).notNull().default(false), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const couponUsage = sqliteTable('coupon_usage', { + id: integer().primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id), + couponId: integer('coupon_id').notNull().references(() => coupons.id), + orderId: integer('order_id').references(() => orders.id), + orderItemId: integer('order_item_id').references(() => orderItems.id), + usedAt: integer('used_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const couponApplicableUsers = sqliteTable('coupon_applicable_users', { + id: integer().primaryKey({ autoIncrement: true }), + couponId: integer('coupon_id').notNull().references(() => coupons.id), + userId: integer('user_id').notNull().references(() => users.id), +}, (t) => ({ + unq_coupon_user: uniqueIndex('unique_coupon_user').on(t.couponId, t.userId), +})) + +export const couponApplicableProducts = sqliteTable('coupon_applicable_products', { + id: integer().primaryKey({ autoIncrement: true }), + couponId: integer('coupon_id').notNull().references(() => coupons.id), + productId: integer('product_id').notNull().references(() => productInfo.id), +}, (t) => ({ + unq_coupon_product: uniqueIndex('unique_coupon_product').on(t.couponId, t.productId), +})) + +export const userIncidents = sqliteTable('user_incidents', { + id: integer().primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id), + orderId: integer('order_id').references(() => orders.id), + dateAdded: integer('date_added', { mode: 'timestamp' }).notNull().defaultNow(), + adminComment: text('admin_comment'), + addedBy: integer('added_by').references(() => staffUsers.id), + negativityScore: integer('negativity_score'), +}) + +export const reservedCoupons = sqliteTable('reserved_coupons', { + id: integer().primaryKey({ autoIncrement: true }), + secretCode: text('secret_code').notNull().unique(), + couponCode: text('coupon_code').notNull(), + discountPercent: numericText('discount_percent'), + flatDiscount: numericText('flat_discount'), + minOrder: numericText('min_order'), + productIds: jsonText('product_ids'), + maxValue: numericText('max_value'), + validTill: integer('valid_till', { mode: 'timestamp' }), + maxLimitForUser: integer('max_limit_for_user'), + exclusiveApply: integer('exclusive_apply', { mode: 'boolean' }).notNull().default(false), + isRedeemed: integer('is_redeemed', { mode: 'boolean' }).notNull().default(false), + redeemedBy: integer('redeemed_by').references(() => users.id), + redeemedAt: integer('redeemed_at', { mode: 'timestamp' }), + createdBy: integer('created_by').notNull().references(() => staffUsers.id), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), +}) + +export const notifCreds = sqliteTable('notif_creds', { + id: integer().primaryKey({ autoIncrement: true }), + token: text().notNull().unique(), + addedAt: integer('added_at', { mode: 'timestamp' }).notNull().defaultNow(), + userId: integer('user_id').notNull().references(() => users.id), + lastVerified: integer('last_verified', { mode: 'timestamp' }), +}) + +export const unloggedUserTokens = sqliteTable('unlogged_user_tokens', { + id: integer().primaryKey({ autoIncrement: true }), + token: text().notNull().unique(), + addedAt: integer('added_at', { mode: 'timestamp' }).notNull().defaultNow(), + lastVerified: integer('last_verified', { mode: 'timestamp' }), +}) + +export const userNotifications = sqliteTable('user_notifications', { + id: integer().primaryKey({ autoIncrement: true }), + title: text('title').notNull(), + imageUrl: text('image_url'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(), + body: text('body').notNull(), + applicableUsers: jsonText('applicable_users'), +}) + +// Relations +export const usersRelations = relations(users, ({ many, one }) => ({ + addresses: many(addresses), + orders: many(orders), + notifications: many(notifications), + cartItems: many(cartItems), + userCreds: one(userCreds), + coupons: many(coupons), + couponUsages: many(couponUsage), + applicableCoupons: many(couponApplicableUsers), + userDetails: one(userDetails), + notifCreds: many(notifCreds), + userIncidents: many(userIncidents), +})) + +export const userCredsRelations = relations(userCreds, ({ one }) => ({ + user: one(users, { fields: [userCreds.userId], references: [users.id] }), +})) + +export const staffUsersRelations = relations(staffUsers, ({ one, many }) => ({ + role: one(staffRoles, { fields: [staffUsers.staffRoleId], references: [staffRoles.id] }), + coupons: many(coupons), + stores: many(storeInfo), +})) + +export const addressesRelations = relations(addresses, ({ one, many }) => ({ + user: one(users, { fields: [addresses.userId], references: [users.id] }), + orders: many(orders), + zone: one(addressZones, { fields: [addresses.zoneId], references: [addressZones.id] }), +})) + +export const unitsRelations = relations(units, ({ many }) => ({ + products: many(productInfo), +})) + +export const productInfoRelations = relations(productInfo, ({ one, many }) => ({ + unit: one(units, { fields: [productInfo.unitId], references: [units.id] }), + store: one(storeInfo, { fields: [productInfo.storeId], references: [storeInfo.id] }), + productSlots: many(productSlots), + specialDeals: many(specialDeals), + orderItems: many(orderItems), + cartItems: many(cartItems), + tags: many(productTags), + applicableCoupons: many(couponApplicableProducts), + reviews: many(productReviews), + groups: many(productGroupMembership), +})) + +export const productTagInfoRelations = relations(productTagInfo, ({ many }) => ({ + products: many(productTags), +})) + +export const productTagsRelations = relations(productTags, ({ one }) => ({ + product: one(productInfo, { fields: [productTags.productId], references: [productInfo.id] }), + tag: one(productTagInfo, { fields: [productTags.tagId], references: [productTagInfo.id] }), +})) + +export const deliverySlotInfoRelations = relations(deliverySlotInfo, ({ many }) => ({ + productSlots: many(productSlots), + orders: many(orders), + vendorSnippets: many(vendorSnippets), +})) + +export const productSlotsRelations = relations(productSlots, ({ one }) => ({ + product: one(productInfo, { fields: [productSlots.productId], references: [productInfo.id] }), + slot: one(deliverySlotInfo, { fields: [productSlots.slotId], references: [deliverySlotInfo.id] }), +})) + +export const specialDealsRelations = relations(specialDeals, ({ one }) => ({ + product: one(productInfo, { fields: [specialDeals.productId], references: [productInfo.id] }), +})) + +export const ordersRelations = relations(orders, ({ one, many }) => ({ + user: one(users, { fields: [orders.userId], references: [users.id] }), + address: one(addresses, { fields: [orders.addressId], references: [addresses.id] }), + slot: one(deliverySlotInfo, { fields: [orders.slotId], references: [deliverySlotInfo.id] }), + orderItems: many(orderItems), + payment: one(payments), + paymentInfo: one(paymentInfoTable, { fields: [orders.paymentInfoId], references: [paymentInfoTable.id] }), + orderStatus: many(orderStatus), + refunds: many(refunds), + couponUsages: many(couponUsage), + userIncidents: many(userIncidents), +})) + +export const orderItemsRelations = relations(orderItems, ({ one }) => ({ + order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }), + product: one(productInfo, { fields: [orderItems.productId], references: [productInfo.id] }), +})) + +export const orderStatusRelations = relations(orderStatus, ({ one }) => ({ + order: one(orders, { fields: [orderStatus.orderId], references: [orders.id] }), + user: one(users, { fields: [orderStatus.userId], references: [users.id] }), + refundCoupon: one(coupons, { fields: [orderStatus.refundCouponId], references: [coupons.id] }), +})) + +export const paymentInfoRelations = relations(paymentInfoTable, ({ one }) => ({ + order: one(orders, { fields: [paymentInfoTable.id], references: [orders.paymentInfoId] }), +})) + +export const paymentsRelations = relations(payments, ({ one }) => ({ + order: one(orders, { fields: [payments.orderId], references: [orders.id] }), +})) + +export const refundsRelations = relations(refunds, ({ one }) => ({ + order: one(orders, { fields: [refunds.orderId], references: [orders.id] }), +})) + +export const notificationsRelations = relations(notifications, ({ one }) => ({ + user: one(users, { fields: [notifications.userId], references: [users.id] }), +})) + +export const productCategoriesRelations = relations(productCategories, ({}) => ({})) + +export const cartItemsRelations = relations(cartItems, ({ one }) => ({ + user: one(users, { fields: [cartItems.userId], references: [users.id] }), + product: one(productInfo, { fields: [cartItems.productId], references: [productInfo.id] }), +})) + +export const complaintsRelations = relations(complaints, ({ one }) => ({ + user: one(users, { fields: [complaints.userId], references: [users.id] }), + order: one(orders, { fields: [complaints.orderId], references: [orders.id] }), +})) + +export const couponsRelations = relations(coupons, ({ one, many }) => ({ + creator: one(staffUsers, { fields: [coupons.createdBy], references: [staffUsers.id] }), + usages: many(couponUsage), + applicableUsers: many(couponApplicableUsers), + applicableProducts: many(couponApplicableProducts), +})) + +export const couponUsageRelations = relations(couponUsage, ({ one }) => ({ + user: one(users, { fields: [couponUsage.userId], references: [users.id] }), + coupon: one(coupons, { fields: [couponUsage.couponId], references: [coupons.id] }), + order: one(orders, { fields: [couponUsage.orderId], references: [orders.id] }), + orderItem: one(orderItems, { fields: [couponUsage.orderItemId], references: [orderItems.id] }), +})) + +export const userDetailsRelations = relations(userDetails, ({ one }) => ({ + user: one(users, { fields: [userDetails.userId], references: [users.id] }), +})) + +export const notifCredsRelations = relations(notifCreds, ({ one }) => ({ + user: one(users, { fields: [notifCreds.userId], references: [users.id] }), +})) + +export const userNotificationsRelations = relations(userNotifications, ({}) => ({ + // No relations needed for now +})) + +export const storeInfoRelations = relations(storeInfo, ({ one, many }) => ({ + owner: one(staffUsers, { fields: [storeInfo.owner], references: [staffUsers.id] }), + products: many(productInfo), +})) + +export const couponApplicableUsersRelations = relations(couponApplicableUsers, ({ one }) => ({ + coupon: one(coupons, { fields: [couponApplicableUsers.couponId], references: [coupons.id] }), + user: one(users, { fields: [couponApplicableUsers.userId], references: [users.id] }), +})) + +export const couponApplicableProductsRelations = relations(couponApplicableProducts, ({ one }) => ({ + coupon: one(coupons, { fields: [couponApplicableProducts.couponId], references: [coupons.id] }), + product: one(productInfo, { fields: [couponApplicableProducts.productId], references: [productInfo.id] }), +})) + +export const reservedCouponsRelations = relations(reservedCoupons, ({ one }) => ({ + redeemedUser: one(users, { fields: [reservedCoupons.redeemedBy], references: [users.id] }), + creator: one(staffUsers, { fields: [reservedCoupons.createdBy], references: [staffUsers.id] }), +})) + +export const productReviewsRelations = relations(productReviews, ({ one }) => ({ + user: one(users, { fields: [productReviews.userId], references: [users.id] }), + product: one(productInfo, { fields: [productReviews.productId], references: [productInfo.id] }), +})) + +export const addressZonesRelations = relations(addressZones, ({ many }) => ({ + addresses: many(addresses), + areas: many(addressAreas), +})) + +export const addressAreasRelations = relations(addressAreas, ({ one }) => ({ + zone: one(addressZones, { fields: [addressAreas.zoneId], references: [addressZones.id] }), +})) + +export const productGroupInfoRelations = relations(productGroupInfo, ({ many }) => ({ + memberships: many(productGroupMembership), +})) + +export const productGroupMembershipRelations = relations(productGroupMembership, ({ one }) => ({ + product: one(productInfo, { fields: [productGroupMembership.productId], references: [productInfo.id] }), + group: one(productGroupInfo, { fields: [productGroupMembership.groupId], references: [productGroupInfo.id] }), +})) + +export const homeBannersRelations = relations(homeBanners, ({}) => ({ + // Relations for productIds array would be more complex, skipping for now +})) + +export const staffRolesRelations = relations(staffRoles, ({ many }) => ({ + staffUsers: many(staffUsers), + rolePermissions: many(staffRolePermissions), +})) + +export const staffPermissionsRelations = relations(staffPermissions, ({ many }) => ({ + rolePermissions: many(staffRolePermissions), +})) + +export const staffRolePermissionsRelations = relations(staffRolePermissions, ({ one }) => ({ + role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.id] }), + permission: one(staffPermissions, { fields: [staffRolePermissions.staffPermissionId], references: [staffPermissions.id] }), +})) + +export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({ + user: one(users, { fields: [userIncidents.userId], references: [users.id] }), + order: one(orders, { fields: [userIncidents.orderId], references: [orders.id] }), + addedBy: one(staffUsers, { fields: [userIncidents.addedBy], references: [staffUsers.id] }), +})) + +export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({ + slot: one(deliverySlotInfo, { fields: [vendorSnippets.slotId], references: [deliverySlotInfo.id] }), +})) diff --git a/packages/db_helper_sqlite/src/db/seed.ts b/packages/db_helper_sqlite/src/db/seed.ts new file mode 100644 index 0000000..4cbb512 --- /dev/null +++ b/packages/db_helper_sqlite/src/db/seed.ts @@ -0,0 +1,147 @@ +import { db } from '@/src/db/db_index' +import { + units, + productInfo, + deliverySlotInfo, + productSlots, + keyValStore, + staffRoles, + staffPermissions, + staffRolePermissions, +} from '@/src/db/schema' +import { eq } from 'drizzle-orm' +import { minOrderValue, deliveryCharge } from '@/src/lib/env-exporter' +import { CONST_KEYS } from '@/src/lib/const-keys' + +export async function seed() { + console.log('Seeding database...') + + // Seed units individually + const unitsToSeed = [ + { shortNotation: 'Kg', fullName: 'Kilogram' }, + { shortNotation: 'L', fullName: 'Litre' }, + { shortNotation: 'Dz', fullName: 'Dozen' }, + { shortNotation: 'Pc', fullName: 'Unit Piece' }, + ] + + for (const unit of unitsToSeed) { + const existingUnit = await db.query.units.findFirst({ + where: eq(units.shortNotation, unit.shortNotation), + }) + if (!existingUnit) { + await db.insert(units).values(unit) + } + } + + // Seed staff roles individually + const rolesToSeed = ['super_admin', 'admin', 'marketer', 'delivery_staff'] as const + + for (const roleName of rolesToSeed) { + const existingRole = await db.query.staffRoles.findFirst({ + where: eq(staffRoles.roleName, roleName), + }) + if (!existingRole) { + await db.insert(staffRoles).values({ roleName }) + } + } + + // Seed staff permissions individually + const permissionsToSeed = ['crud_product', 'make_coupon', 'crud_staff_users'] as const + + for (const permissionName of permissionsToSeed) { + const existingPermission = await db.query.staffPermissions.findFirst({ + where: eq(staffPermissions.permissionName, permissionName), + }) + if (!existingPermission) { + await db.insert(staffPermissions).values({ permissionName }) + } + } + + // Seed role-permission assignments + await db.transaction(async (tx) => { + // Get role IDs + const superAdminRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'super_admin') }) + const adminRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'admin') }) + const marketerRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'marketer') }) + + // Get permission IDs + const crudProductPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'crud_product') }) + const makeCouponPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'make_coupon') }) + const crudStaffUsersPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'crud_staff_users') }) + + // Assign all permissions to super_admin + ;[crudProductPerm, makeCouponPerm, crudStaffUsersPerm].forEach(async (perm) => { + if (superAdminRole && perm) { + const existingSuperAdminPerm = await tx.query.staffRolePermissions.findFirst({ + where: eq(staffRolePermissions.staffRoleId, superAdminRole.id) && eq(staffRolePermissions.staffPermissionId, perm.id), + }) + if (!existingSuperAdminPerm) { + await tx.insert(staffRolePermissions).values({ + staffRoleId: superAdminRole.id, + staffPermissionId: perm.id, + }) + } + } + }) + + // Assign all permissions to admin + ;[crudProductPerm, makeCouponPerm].forEach(async (perm) => { + if (adminRole && perm) { + const existingAdminPerm = await tx.query.staffRolePermissions.findFirst({ + where: eq(staffRolePermissions.staffRoleId, adminRole.id) && eq(staffRolePermissions.staffPermissionId, perm.id), + }) + if (!existingAdminPerm) { + await tx.insert(staffRolePermissions).values({ + staffRoleId: adminRole.id, + staffPermissionId: perm.id, + }) + } + } + }) + + // Assign make_coupon to marketer + if (marketerRole && makeCouponPerm) { + const existingMarketerCoupon = await tx.query.staffRolePermissions.findFirst({ + where: eq(staffRolePermissions.staffRoleId, marketerRole.id) && eq(staffRolePermissions.staffPermissionId, makeCouponPerm.id), + }) + if (!existingMarketerCoupon) { + await tx.insert(staffRolePermissions).values({ + staffRoleId: marketerRole.id, + staffPermissionId: makeCouponPerm.id, + }) + } + } + }) + + // Seed key-val store constants using CONST_KEYS + const constantsToSeed = [ + { key: CONST_KEYS.readableOrderId, value: 0 }, + { key: CONST_KEYS.minRegularOrderValue, value: minOrderValue }, + { key: CONST_KEYS.freeDeliveryThreshold, value: minOrderValue }, + { key: CONST_KEYS.deliveryCharge, value: deliveryCharge }, + { key: CONST_KEYS.flashFreeDeliveryThreshold, value: 500 }, + { key: CONST_KEYS.flashDeliveryCharge, value: 69 }, + { key: CONST_KEYS.popularItems, value: [] }, + { key: CONST_KEYS.allItemsOrder, value: [] }, + { key: CONST_KEYS.versionNum, value: '1.1.0' }, + { key: CONST_KEYS.playStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' }, + { key: CONST_KEYS.appStoreUrl, value: 'https://apps.apple.com/in/app/freshyo/id6756889077' }, + { key: CONST_KEYS.isFlashDeliveryEnabled, value: false }, + { key: CONST_KEYS.supportMobile, value: '8688182552' }, + { key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' }, + ] + + for (const constant of constantsToSeed) { + const existing = await db.query.keyValStore.findFirst({ + where: eq(keyValStore.key, constant.key), + }) + if (!existing) { + await db.insert(keyValStore).values({ + key: constant.key, + value: constant.value, + }) + } + } + + console.log('Seeding completed.') +} diff --git a/packages/db_helper_sqlite/src/db/types.ts b/packages/db_helper_sqlite/src/db/types.ts new file mode 100644 index 0000000..d6e374e --- /dev/null +++ b/packages/db_helper_sqlite/src/db/types.ts @@ -0,0 +1,47 @@ +import type { InferSelectModel } from 'drizzle-orm' +import type { + users, + addresses, + units, + productInfo, + deliverySlotInfo, + productSlots, + specialDeals, + orders, + orderItems, + payments, + notifications, + productCategories, + cartItems, + coupons, +} from '@/src/db/schema' + +export type User = InferSelectModel +export type Address = InferSelectModel +export type Unit = InferSelectModel +export type ProductInfo = InferSelectModel +export type DeliverySlotInfo = InferSelectModel +export type ProductSlot = InferSelectModel +export type SpecialDeal = InferSelectModel +export type Order = InferSelectModel +export type OrderItem = InferSelectModel +export type Payment = InferSelectModel +export type Notification = InferSelectModel +export type ProductCategory = InferSelectModel +export type CartItem = InferSelectModel +export type Coupon = InferSelectModel + +// Combined types +export type ProductWithUnit = ProductInfo & { + unit: Unit +} + +export type OrderWithItems = Order & { + items: (OrderItem & { product: ProductInfo })[] + address: Address + slot: DeliverySlotInfo +} + +export type CartItemWithProduct = CartItem & { + product: ProductInfo +} diff --git a/packages/db_helper_sqlite/src/helper_methods/banner.ts b/packages/db_helper_sqlite/src/helper_methods/banner.ts new file mode 100644 index 0000000..67d6250 --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/banner.ts @@ -0,0 +1,114 @@ +import { db } from '../db/db_index' +import { homeBanners } from '../db/schema' +import { desc, eq } from 'drizzle-orm' + +export interface Banner { + id: number + name: string + imageUrl: string + description: string | null + productIds: number[] | null + redirectUrl: string | null + serialNum: number | null + isActive: boolean + createdAt: Date + lastUpdated: Date +} + +type BannerRow = typeof homeBanners.$inferSelect + +export async function getBanners(): Promise { + const banners = await db.query.homeBanners.findMany({ + orderBy: desc(homeBanners.createdAt), + }) as BannerRow[] + + return banners.map((banner) => ({ + id: banner.id, + name: banner.name, + imageUrl: banner.imageUrl, + description: banner.description, + productIds: banner.productIds || [], + redirectUrl: banner.redirectUrl, + serialNum: banner.serialNum, + isActive: banner.isActive, + createdAt: banner.createdAt, + lastUpdated: banner.lastUpdated, + })) +} + +export async function getBannerById(id: number): Promise { + const banner = await db.query.homeBanners.findFirst({ + where: eq(homeBanners.id, id), + }) + + if (!banner) return null + + return { + id: banner.id, + name: banner.name, + imageUrl: banner.imageUrl, + description: banner.description, + productIds: banner.productIds || [], + redirectUrl: banner.redirectUrl, + serialNum: banner.serialNum, + isActive: banner.isActive, + createdAt: banner.createdAt, + lastUpdated: banner.lastUpdated, + } +} + +export type CreateBannerInput = Omit + +export async function createBanner(input: CreateBannerInput): Promise { + const [banner] = await db.insert(homeBanners).values({ + name: input.name, + imageUrl: input.imageUrl, + description: input.description, + productIds: input.productIds, + redirectUrl: input.redirectUrl, + serialNum: input.serialNum, + isActive: input.isActive, + }).returning() + + return { + id: banner.id, + name: banner.name, + imageUrl: banner.imageUrl, + description: banner.description, + productIds: banner.productIds || [], + redirectUrl: banner.redirectUrl, + serialNum: banner.serialNum, + isActive: banner.isActive, + createdAt: banner.createdAt, + lastUpdated: banner.lastUpdated, + } +} + +export type UpdateBannerInput = Partial> + +export async function updateBanner(id: number, input: UpdateBannerInput): Promise { + const [banner] = await db.update(homeBanners) + .set({ + ...input, + lastUpdated: new Date(), + }) + .where(eq(homeBanners.id, id)) + .returning() + + return { + id: banner.id, + name: banner.name, + imageUrl: banner.imageUrl, + description: banner.description, + productIds: banner.productIds || [], + redirectUrl: banner.redirectUrl, + serialNum: banner.serialNum, + isActive: banner.isActive, + createdAt: banner.createdAt, + lastUpdated: banner.lastUpdated, + } +} + +export async function deleteBanner(id: number): Promise { + await db.delete(homeBanners).where(eq(homeBanners.id, id)) +} diff --git a/packages/db_helper_sqlite/src/helper_methods/complaint.ts b/packages/db_helper_sqlite/src/helper_methods/complaint.ts new file mode 100644 index 0000000..a58d7b2 --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/complaint.ts @@ -0,0 +1,74 @@ +import { db } from '../db/db_index' +import { complaints, users } from '../db/schema' +import { eq, desc, lt } from 'drizzle-orm' + +export interface Complaint { + id: number + complaintBody: string + userId: number + orderId: number | null + isResolved: boolean + response: string | null + createdAt: Date + images: string[] | null +} + +export interface ComplaintWithUser extends Complaint { + userName: string | null + userMobile: string | null +} + +export async function getComplaints( + cursor?: number, + limit: number = 20 +): Promise<{ complaints: ComplaintWithUser[]; hasMore: boolean }> { + const whereCondition = cursor ? lt(complaints.id, cursor) : undefined + + const complaintsData = await db + .select({ + id: complaints.id, + complaintBody: complaints.complaintBody, + userId: complaints.userId, + orderId: complaints.orderId, + isResolved: complaints.isResolved, + response: complaints.response, + createdAt: complaints.createdAt, + images: complaints.images, + userName: users.name, + userMobile: users.mobile, + }) + .from(complaints) + .leftJoin(users, eq(complaints.userId, users.id)) + .where(whereCondition) + .orderBy(desc(complaints.id)) + .limit(limit + 1) + + const hasMore = complaintsData.length > limit + const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData + + return { + complaints: complaintsToReturn.map((c) => ({ + id: c.id, + complaintBody: c.complaintBody, + userId: c.userId, + orderId: c.orderId, + isResolved: c.isResolved, + response: c.response, + createdAt: c.createdAt, + images: c.images, + userName: c.userName, + userMobile: c.userMobile, + })), + hasMore, + } +} + +export async function resolveComplaint( + id: number, + response?: string +): Promise { + await db + .update(complaints) + .set({ isResolved: true, response }) + .where(eq(complaints.id, id)) +} diff --git a/packages/db_helper_sqlite/src/helper_methods/const.ts b/packages/db_helper_sqlite/src/helper_methods/const.ts new file mode 100644 index 0000000..603c684 --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/const.ts @@ -0,0 +1,29 @@ +import { db } from '../db/db_index' +import { keyValStore } from '../db/schema' + +export interface Constant { + key: string + value: any +} + +export async function getAllConstants(): Promise { + const constants = await db.select().from(keyValStore) + + return constants.map(c => ({ + key: c.key, + value: c.value, + })) +} + +export async function upsertConstants(constants: Constant[]): Promise { + await db.transaction(async (tx) => { + for (const { key, value } of constants) { + await tx.insert(keyValStore) + .values({ key, value }) + .onConflictDoUpdate({ + target: keyValStore.key, + set: { value }, + }) + } + }) +} diff --git a/packages/db_helper_sqlite/src/helper_methods/coupon.ts b/packages/db_helper_sqlite/src/helper_methods/coupon.ts new file mode 100644 index 0000000..4320252 --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/coupon.ts @@ -0,0 +1,632 @@ +import { db } from '../db/db_index'; +import { coupons, reservedCoupons, users } from '../db/schema'; +import { eq, and, like, or, inArray, lt, desc, asc } from 'drizzle-orm'; + +export interface Coupon { + id: number; + couponCode: string; + isUserBased: boolean; + discountPercent: string | null; + flatDiscount: string | null; + minOrder: string | null; + productIds: number[] | null; + maxValue: string | null; + isApplyForAll: boolean; + validTill: Date | null; + maxLimitForUser: number | null; + exclusiveApply: boolean; + isInvalidated: boolean; + createdAt: Date; + createdBy: number; +} + +export async function getAllCoupons( + cursor?: number, + limit: number = 50, + search?: string +): Promise<{ coupons: any[]; hasMore: boolean }> { + let whereCondition = undefined; + const conditions = []; + + if (cursor) { + conditions.push(lt(coupons.id, cursor)); + } + + if (search && search.trim()) { + conditions.push(like(coupons.couponCode, `%${search}%`)); + } + + if (conditions.length > 0) { + whereCondition = and(...conditions); + } + + const result = await db.query.coupons.findMany({ + where: whereCondition, + with: { + creator: true, + applicableUsers: { + with: { + user: true, + }, + }, + applicableProducts: { + with: { + product: true, + }, + }, + }, + orderBy: (couponsTable: typeof coupons) => [desc(couponsTable.createdAt)], + limit: limit + 1, + }); + + const hasMore = result.length > limit; + const couponsList = hasMore ? result.slice(0, limit) : result; + + return { coupons: couponsList, hasMore }; +} + +export async function getCouponById(id: number): Promise { + const result = await db.query.coupons.findFirst({ + where: eq(coupons.id, id), + with: { + creator: true, + applicableUsers: { + with: { + user: true, + }, + }, + applicableProducts: { + with: { + product: true, + }, + }, + }, + }); + + return result || null; +} + +export async function invalidateCoupon(id: number): Promise { + const result = await db.update(coupons) + .set({ isInvalidated: true }) + .where(eq(coupons.id, id)) + .returning(); + + return result[0]; +} + +export interface CouponValidationResult { + valid: boolean; + message?: string; + discountAmount?: number; + coupon?: Partial; +} + +export async function validateCoupon( + code: string, + userId: number, + orderAmount: number +): Promise { + const coupon = await db.query.coupons.findFirst({ + where: and( + eq(coupons.couponCode, code.toUpperCase()), + eq(coupons.isInvalidated, false) + ), + }); + + if (!coupon) { + return { valid: false, message: "Coupon not found or invalidated" }; + } + + // Check expiry date + if (coupon.validTill && new Date(coupon.validTill) < new Date()) { + return { valid: false, message: "Coupon has expired" }; + } + + // Check if coupon applies to all users or specific user + if (!coupon.isApplyForAll && !coupon.isUserBased) { + return { valid: false, message: "Coupon is not available for use" }; + } + + // Check minimum order amount + const minOrderValue = coupon.minOrder ? parseFloat(coupon.minOrder) : 0; + if (minOrderValue > 0 && orderAmount < minOrderValue) { + return { valid: false, message: `Minimum order amount is ${minOrderValue}` }; + } + + // Calculate discount + let discountAmount = 0; + if (coupon.discountPercent) { + const percent = parseFloat(coupon.discountPercent); + discountAmount = (orderAmount * percent) / 100; + } else if (coupon.flatDiscount) { + discountAmount = parseFloat(coupon.flatDiscount); + } + + // Apply max value limit + const maxValueLimit = coupon.maxValue ? parseFloat(coupon.maxValue) : 0; + if (maxValueLimit > 0 && discountAmount > maxValueLimit) { + discountAmount = maxValueLimit; + } + + return { + valid: true, + discountAmount, + coupon: { + id: coupon.id, + discountPercent: coupon.discountPercent, + flatDiscount: coupon.flatDiscount, + maxValue: coupon.maxValue, + } + }; +} + +export async function getReservedCoupons( + cursor?: number, + limit: number = 50, + search?: string +): Promise<{ coupons: any[]; hasMore: boolean }> { + let whereCondition = undefined; + const conditions = []; + + if (cursor) { + conditions.push(lt(reservedCoupons.id, cursor)); + } + + if (search && search.trim()) { + conditions.push(or( + like(reservedCoupons.secretCode, `%${search}%`), + like(reservedCoupons.couponCode, `%${search}%`) + )); + } + + if (conditions.length > 0) { + whereCondition = and(...conditions); + } + + const result = await db.query.reservedCoupons.findMany({ + where: whereCondition, + with: { + redeemedUser: true, + creator: true, + }, + orderBy: (reservedCouponsTable: typeof reservedCoupons) => [desc(reservedCouponsTable.createdAt)], + limit: limit + 1, + }); + + const hasMore = result.length > limit; + const couponsList = hasMore ? result.slice(0, limit) : result; + + return { coupons: couponsList, hasMore }; +} + +export interface UserMiniInfo { + id: number; + name: string; + mobile: string | null; +} + +export async function getUsersForCoupon( + search?: string, + limit: number = 20, + offset: number = 0 +): Promise<{ users: UserMiniInfo[] }> { + let whereCondition = undefined; + if (search && search.trim()) { + whereCondition = or( + like(users.name, `%${search}%`), + like(users.mobile, `%${search}%`) + ); + } + + const userList = await db.query.users.findMany({ + where: whereCondition, + columns: { + id: true, + name: true, + mobile: true, + }, + limit: limit, + offset: offset, + orderBy: (usersTable: typeof users) => [asc(usersTable.name)], + }); + + return { + users: userList.map((user: typeof users.$inferSelect) => ({ + id: user.id, + name: user.name || 'Unknown', + mobile: user.mobile, + })) + }; +} + +// ============================================================================ +// BATCH 2: Transaction Methods +// ============================================================================ + +import { couponApplicableUsers, couponApplicableProducts, orders, orderStatus } from '../db/schema'; + +export interface CreateCouponInput { + couponCode: string; + isUserBased: boolean; + discountPercent?: string; + flatDiscount?: string; + minOrder?: string; + productIds?: number[] | null; + maxValue?: string; + isApplyForAll: boolean; + validTill?: Date; + maxLimitForUser?: number; + exclusiveApply: boolean; + createdBy: number; +} + +export async function createCouponWithRelations( + input: CreateCouponInput, + applicableUsers?: number[], + applicableProducts?: number[] +): Promise { + return await db.transaction(async (tx) => { + // Create the coupon + const [coupon] = await tx.insert(coupons).values({ + couponCode: input.couponCode, + isUserBased: input.isUserBased, + discountPercent: input.discountPercent, + flatDiscount: input.flatDiscount, + minOrder: input.minOrder, + productIds: input.productIds, + createdBy: input.createdBy, + maxValue: input.maxValue, + isApplyForAll: input.isApplyForAll, + validTill: input.validTill, + maxLimitForUser: input.maxLimitForUser, + exclusiveApply: input.exclusiveApply, + }).returning(); + + // Insert applicable users + if (applicableUsers && applicableUsers.length > 0) { + await tx.insert(couponApplicableUsers).values( + applicableUsers.map(userId => ({ + couponId: coupon.id, + userId, + })) + ); + } + + // Insert applicable products + if (applicableProducts && applicableProducts.length > 0) { + await tx.insert(couponApplicableProducts).values( + applicableProducts.map(productId => ({ + couponId: coupon.id, + productId, + })) + ); + } + + return { + id: coupon.id, + couponCode: coupon.couponCode, + isUserBased: coupon.isUserBased, + discountPercent: coupon.discountPercent, + flatDiscount: coupon.flatDiscount, + minOrder: coupon.minOrder, + productIds: coupon.productIds, + maxValue: coupon.maxValue, + isApplyForAll: coupon.isApplyForAll, + validTill: coupon.validTill, + maxLimitForUser: coupon.maxLimitForUser, + exclusiveApply: coupon.exclusiveApply, + isInvalidated: coupon.isInvalidated, + createdAt: coupon.createdAt, + createdBy: coupon.createdBy, + }; + }); +} + +export interface UpdateCouponInput { + couponCode?: string; + isUserBased?: boolean; + discountPercent?: string; + flatDiscount?: string; + minOrder?: string; + productIds?: number[] | null; + maxValue?: string; + isApplyForAll?: boolean; + validTill?: Date | null; + maxLimitForUser?: number; + exclusiveApply?: boolean; + isInvalidated?: boolean; +} + +export async function updateCouponWithRelations( + id: number, + input: UpdateCouponInput, + applicableUsers?: number[], + applicableProducts?: number[] +): Promise { + return await db.transaction(async (tx) => { + // Update the coupon + const [coupon] = await tx.update(coupons) + .set({ + ...input, + }) + .where(eq(coupons.id, id)) + .returning(); + + // Update applicable users: delete existing and insert new + if (applicableUsers !== undefined) { + await tx.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id)); + if (applicableUsers.length > 0) { + await tx.insert(couponApplicableUsers).values( + applicableUsers.map(userId => ({ + couponId: id, + userId, + })) + ); + } + } + + // Update applicable products: delete existing and insert new + if (applicableProducts !== undefined) { + await tx.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id)); + if (applicableProducts.length > 0) { + await tx.insert(couponApplicableProducts).values( + applicableProducts.map(productId => ({ + couponId: id, + productId, + })) + ); + } + } + + return { + id: coupon.id, + couponCode: coupon.couponCode, + isUserBased: coupon.isUserBased, + discountPercent: coupon.discountPercent, + flatDiscount: coupon.flatDiscount, + minOrder: coupon.minOrder, + productIds: coupon.productIds, + maxValue: coupon.maxValue, + isApplyForAll: coupon.isApplyForAll, + validTill: coupon.validTill, + maxLimitForUser: coupon.maxLimitForUser, + exclusiveApply: coupon.exclusiveApply, + isInvalidated: coupon.isInvalidated, + createdAt: coupon.createdAt, + createdBy: coupon.createdBy, + }; + }); +} + +export async function generateCancellationCoupon( + orderId: number, + staffUserId: number, + userId: number, + orderAmount: number, + couponCode: string +): Promise { + return await db.transaction(async (tx) => { + // Calculate expiry date (30 days from now) + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + 30); + + // Create the coupon + const [coupon] = await tx.insert(coupons).values({ + couponCode, + isUserBased: true, + flatDiscount: orderAmount.toString(), + minOrder: orderAmount.toString(), + maxValue: orderAmount.toString(), + validTill: expiryDate, + maxLimitForUser: 1, + createdBy: staffUserId, + isApplyForAll: false, + }).returning(); + + // Insert applicable users + await tx.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId, + }); + + // Update order_status with refund coupon ID + await tx.update(orderStatus) + .set({ refundCouponId: coupon.id }) + .where(eq(orderStatus.orderId, orderId)); + + return { + id: coupon.id, + couponCode: coupon.couponCode, + isUserBased: coupon.isUserBased, + discountPercent: coupon.discountPercent, + flatDiscount: coupon.flatDiscount, + minOrder: coupon.minOrder, + productIds: coupon.productIds, + maxValue: coupon.maxValue, + isApplyForAll: coupon.isApplyForAll, + validTill: coupon.validTill, + maxLimitForUser: coupon.maxLimitForUser, + exclusiveApply: coupon.exclusiveApply, + isInvalidated: coupon.isInvalidated, + createdAt: coupon.createdAt, + createdBy: coupon.createdBy, + }; + }); +} + +export interface CreateReservedCouponInput { + secretCode: string; + couponCode: string; + discountPercent?: string; + flatDiscount?: string; + minOrder?: string; + productIds?: number[] | null; + maxValue?: string; + validTill?: Date; + maxLimitForUser?: number; + exclusiveApply: boolean; + createdBy: number; +} + +export async function createReservedCouponWithProducts( + input: CreateReservedCouponInput, + applicableProducts?: number[] +): Promise { + return await db.transaction(async (tx) => { + const [coupon] = await tx.insert(reservedCoupons).values({ + secretCode: input.secretCode, + couponCode: input.couponCode, + discountPercent: input.discountPercent, + flatDiscount: input.flatDiscount, + minOrder: input.minOrder, + productIds: input.productIds, + maxValue: input.maxValue, + validTill: input.validTill, + maxLimitForUser: input.maxLimitForUser, + exclusiveApply: input.exclusiveApply, + createdBy: input.createdBy, + }).returning(); + + // Insert applicable products if provided + if (applicableProducts && applicableProducts.length > 0) { + await tx.insert(couponApplicableProducts).values( + applicableProducts.map(productId => ({ + couponId: coupon.id, + productId, + })) + ); + } + + return coupon; + }); +} + +export async function getOrCreateUserByMobile( + mobile: string +): Promise<{ id: number; mobile: string; name: string | null }> { + return await db.transaction(async (tx) => { + // Check if user exists + let user = await tx.query.users.findFirst({ + where: eq(users.mobile, mobile), + }); + + if (!user) { + // Create new user + const [newUser] = await tx.insert(users).values({ + name: null, + email: null, + mobile, + }).returning(); + user = newUser; + } + + return { + id: user.id, + mobile: user.mobile, + name: user.name, + }; + }); +} + +export async function createCouponForUser( + mobile: string, + couponCode: string, + staffUserId: number +): Promise<{ coupon: Coupon; user: { id: number; mobile: string; name: string | null } }> { + return await db.transaction(async (tx) => { + // Get or create user + let user = await tx.query.users.findFirst({ + where: eq(users.mobile, mobile), + }); + + if (!user) { + const [newUser] = await tx.insert(users).values({ + name: null, + email: null, + mobile, + }).returning(); + user = newUser; + } + + // Create the coupon + const [coupon] = await tx.insert(coupons).values({ + couponCode, + isUserBased: true, + discountPercent: "20", + minOrder: "1000", + maxValue: "500", + maxLimitForUser: 1, + isApplyForAll: false, + exclusiveApply: false, + createdBy: staffUserId, + validTill: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days from now + }).returning(); + + // Associate coupon with user + await tx.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId: user.id, + }); + + return { + coupon: { + id: coupon.id, + couponCode: coupon.couponCode, + isUserBased: coupon.isUserBased, + discountPercent: coupon.discountPercent, + flatDiscount: coupon.flatDiscount, + minOrder: coupon.minOrder, + productIds: coupon.productIds, + maxValue: coupon.maxValue, + isApplyForAll: coupon.isApplyForAll, + validTill: coupon.validTill, + maxLimitForUser: coupon.maxLimitForUser, + exclusiveApply: coupon.exclusiveApply, + isInvalidated: coupon.isInvalidated, + createdAt: coupon.createdAt, + createdBy: coupon.createdBy, + }, + user: { + id: user.id, + mobile: user.mobile, + name: user.name, + }, + }; + }); +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +export async function checkUsersExist(userIds: number[]): Promise { + const existingUsers = await db.query.users.findMany({ + where: inArray(users.id, userIds), + columns: { id: true }, + }); + return existingUsers.length === userIds.length; +} + +export async function checkCouponExists(couponCode: string): Promise { + const existing = await db.query.coupons.findFirst({ + where: eq(coupons.couponCode, couponCode), + }); + return !!existing; +} + +export async function checkReservedCouponExists(secretCode: string): Promise { + const existing = await db.query.reservedCoupons.findFirst({ + where: eq(reservedCoupons.secretCode, secretCode), + }); + return !!existing; +} + +export async function getOrderWithUser(orderId: number): Promise { + return await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + }, + }); +} diff --git a/packages/db_helper_sqlite/src/helper_methods/order.ts b/packages/db_helper_sqlite/src/helper_methods/order.ts new file mode 100644 index 0000000..abc5130 --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/order.ts @@ -0,0 +1,269 @@ +import { db } from '../db/db_index'; +import { orders, orderItems, orderStatus, users, addresses, refunds, complaints, payments } from '../db/schema'; +import { eq, and, gte, lt, desc, inArray, sql } from 'drizzle-orm'; + +export async function updateOrderNotes(orderId: number, adminNotes: string | null): Promise { + const [result] = await db + .update(orders) + .set({ adminNotes }) + .where(eq(orders.id, orderId)) + .returning(); + return result; +} + +export async function getOrderWithDetails(orderId: number): Promise { + return await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + user: true, + address: true, + orderStatus: true, + slot: true, + payments: true, + refunds: true, + }, + }); +} + +export async function getFullOrder(orderId: number): Promise { + return await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + user: { + with: { + userDetails: true, + }, + }, + address: true, + orderStatus: true, + slot: true, + payments: true, + refunds: true, + complaints: true, + }, + }); +} + +export async function getOrderDetails(orderId: number): Promise { + return await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + user: true, + address: true, + orderStatus: true, + slot: true, + payments: true, + refunds: true, + complaints: true, + }, + }); +} + +export async function getAllOrders( + limit: number, + cursor?: number, + slotId?: number | null, + filters?: any +): Promise<{ orders: any[]; hasMore: boolean }> { + let whereConditions = []; + + if (cursor) { + whereConditions.push(lt(orders.id, cursor)); + } + + if (slotId) { + whereConditions.push(eq(orders.slotId, slotId)); + } + + // Add filter conditions + if (filters) { + if (filters.packagedFilter === 'packaged') { + whereConditions.push( + sql`${orders.id} IN (SELECT ${orderStatus.orderId} FROM ${orderStatus} WHERE ${orderStatus.isPackaged} = 1)` + ); + } else if (filters.packagedFilter === 'not_packaged') { + whereConditions.push( + sql`${orders.id} IN (SELECT ${orderStatus.orderId} FROM ${orderStatus} WHERE ${orderStatus.isPackaged} = 0)` + ); + } + + if (filters.deliveredFilter === 'delivered') { + whereConditions.push( + sql`${orders.id} IN (SELECT ${orderStatus.orderId} FROM ${orderStatus} WHERE ${orderStatus.isDelivered} = 1)` + ); + } else if (filters.deliveredFilter === 'not_delivered') { + whereConditions.push( + sql`${orders.id} IN (SELECT ${orderStatus.orderId} FROM ${orderStatus} WHERE ${orderStatus.isDelivered} = 0)` + ); + } + + if (filters.flashDeliveryFilter === 'flash') { + whereConditions.push(eq(orders.isFlashDelivery, true)); + } else if (filters.flashDeliveryFilter === 'regular') { + whereConditions.push(eq(orders.isFlashDelivery, false)); + } + } + + const ordersList = await db.query.orders.findMany({ + where: whereConditions.length > 0 ? and(...whereConditions) : undefined, + with: { + orderItems: { + with: { + product: true, + }, + }, + user: true, + orderStatus: true, + slot: true, + }, + orderBy: desc(orders.id), + limit: limit + 1, + }); + + const hasMore = ordersList.length > limit; + return { orders: hasMore ? ordersList.slice(0, limit) : ordersList, hasMore }; +} + +export async function getOrdersBySlotId(slotId: number): Promise { + return await db.query.orders.findMany({ + where: eq(orders.slotId, slotId), + with: { + orderItems: { + with: { + product: true, + }, + }, + user: true, + orderStatus: true, + address: true, + }, + orderBy: desc(orders.createdAt), + }); +} + +export async function updateOrderPackaged(orderId: number, isPackaged: boolean): Promise { + const [result] = await db + .update(orderStatus) + .set({ isPackaged }) + .where(eq(orderStatus.orderId, orderId)) + .returning(); + return result; +} + +export async function updateOrderDelivered(orderId: number, isDelivered: boolean): Promise { + const [result] = await db + .update(orderStatus) + .set({ isDelivered }) + .where(eq(orderStatus.orderId, orderId)) + .returning(); + return result; +} + +export async function updateOrderItemPackaging( + orderItemId: number, + isPackaged: boolean, + isPackageVerified: boolean +): Promise { + await db.update(orderItems) + .set({ is_packaged: isPackaged, is_package_verified: isPackageVerified }) + .where(eq(orderItems.id, orderItemId)); +} + +export async function updateAddressCoords(addressId: number, lat: number, lng: number): Promise { + await db.update(addresses) + .set({ adminLatitude: lat, adminLongitude: lng }) + .where(eq(addresses.id, addressId)); +} + +export async function getOrderStatus(orderId: number): Promise { + return await db.query.orderStatus.findFirst({ + where: eq(orderStatus.orderId, orderId), + }); +} + +export async function cancelOrder(orderId: number, reason: string): Promise { + return await db.transaction(async (tx) => { + const order = await tx.query.orders.findFirst({ + where: eq(orders.id, orderId), + }) + + if (!order) { + return null + } + + await tx.update(orderStatus) + .set({ + isCancelled: true, + cancelReason: reason, + }) + .where(eq(orderStatus.orderId, orderId)) + + return order + }); +} + +export async function getTodaysOrders(slotId?: number): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + let whereConditions = [ + gte(orders.createdAt, today), + lt(orders.createdAt, tomorrow), + ]; + + if (slotId) { + whereConditions.push(eq(orders.slotId, slotId)); + } + + return await db.query.orders.findMany({ + where: and(...whereConditions), + with: { + orderItems: { + with: { + product: true, + }, + }, + user: true, + orderStatus: true, + }, + orderBy: desc(orders.createdAt), + }); +} + +export async function removeDeliveryCharge(orderId: number): Promise { + const [result] = await db + .update(orders) + .set({ deliveryCharge: '0' }) + .where(eq(orders.id, orderId)) + .returning(); + return result; +} diff --git a/packages/db_helper_sqlite/src/helper_methods/product.ts b/packages/db_helper_sqlite/src/helper_methods/product.ts new file mode 100644 index 0000000..192f0b6 --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/product.ts @@ -0,0 +1,130 @@ +import { db } from '../db/db_index'; +import { productInfo, units, specialDeals, productSlots, productTags, productReviews, productGroupInfo, productGroupMembership } from '../db/schema'; +import { eq, and, inArray, desc, sql, asc } from 'drizzle-orm'; + +export async function getAllProducts(): Promise { + return await db.query.productInfo.findMany({ + orderBy: productInfo.name, + with: { + unit: true, + store: true, + }, + }); +} + +export async function getProductById(id: number): Promise { + return await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + with: { + unit: true, + store: true, + productSlots: { + with: { + slot: true, + }, + }, + specialDeals: true, + productTags: { + with: { + tag: true, + }, + }, + }, + }); +} + +export async function createProduct(input: any): Promise { + const [product] = await db.insert(productInfo).values(input).returning(); + return product; +} + +export async function updateProduct(id: number, updates: any): Promise { + const [product] = await db.update(productInfo) + .set(updates) + .where(eq(productInfo.id, id)) + .returning(); + return product; +} + +export async function toggleProductOutOfStock(id: number, isOutOfStock: boolean): Promise { + const [product] = await db.update(productInfo) + .set({ isOutOfStock }) + .where(eq(productInfo.id, id)) + .returning(); + return product; +} + +export async function getAllUnits(): Promise { + return await db.query.units.findMany({ + orderBy: units.shortNotation, + }); +} + +export async function getAllProductTags(): Promise { + return await db.query.productTags.findMany({ + with: { + products: { + with: { + product: true, + }, + }, + }, + }); +} + +export async function getProductReviews(productId: number): Promise { + return await db.query.productReviews.findMany({ + where: eq(productReviews.productId, productId), + with: { + user: true, + }, + orderBy: desc(productReviews.reviewTime), + }); +} + +export async function respondToReview(reviewId: number, adminResponse: string): Promise { + await db.update(productReviews) + .set({ adminResponse }) + .where(eq(productReviews.id, reviewId)); +} + +export async function getAllProductGroups(): Promise { + return await db.query.productGroupInfo.findMany({ + with: { + products: { + with: { + product: true, + }, + }, + }, + }); +} + +export async function createProductGroup(name: string): Promise { + const [group] = await db.insert(productGroupInfo).values({ groupName: name }).returning(); + return group; +} + +export async function updateProductGroup(id: number, name: string): Promise { + const [group] = await db.update(productGroupInfo) + .set({ groupName: name }) + .where(eq(productGroupInfo.id, id)) + .returning(); + return group; +} + +export async function deleteProductGroup(id: number): Promise { + await db.delete(productGroupInfo).where(eq(productGroupInfo.id, id)); +} + +export async function addProductToGroup(groupId: number, productId: number): Promise { + await db.insert(productGroupMembership).values({ groupId, productId }); +} + +export async function removeProductFromGroup(groupId: number, productId: number): Promise { + await db.delete(productGroupMembership) + .where(and( + eq(productGroupMembership.groupId, groupId), + eq(productGroupMembership.productId, productId) + )); +} diff --git a/packages/db_helper_sqlite/src/helper_methods/slots.ts b/packages/db_helper_sqlite/src/helper_methods/slots.ts new file mode 100644 index 0000000..f12d63b --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/slots.ts @@ -0,0 +1,101 @@ +import { db } from '../db/db_index'; +import { deliverySlotInfo, productSlots, productInfo, vendorSnippets } from '../db/schema'; +import { eq, and, inArray, desc } from 'drizzle-orm'; + +export async function getAllSlots(): Promise { + return await db.query.deliverySlotInfo.findMany({ + orderBy: desc(deliverySlotInfo.deliveryTime), + with: { + productSlots: { + with: { + product: true, + }, + }, + vendorSnippets: true, + }, + }); +} + +export async function getSlotById(id: number): Promise { + return await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, id), + with: { + productSlots: { + with: { + product: true, + }, + }, + vendorSnippets: { + with: { + slot: true, + }, + }, + }, + }); +} + +export async function createSlot(input: any): Promise { + const [slot] = await db.insert(deliverySlotInfo).values(input).returning(); + return slot; +} + +export async function updateSlot(id: number, updates: any): Promise { + const [slot] = await db.update(deliverySlotInfo) + .set(updates) + .where(eq(deliverySlotInfo.id, id)) + .returning(); + return slot; +} + +export async function deleteSlot(id: number): Promise { + await db.delete(deliverySlotInfo).where(eq(deliverySlotInfo.id, id)); +} + +export async function getSlotProducts(slotId: number): Promise { + return await db.query.productSlots.findMany({ + where: eq(productSlots.slotId, slotId), + with: { + product: true, + }, + }); +} + +export async function addProductToSlot(slotId: number, productId: number): Promise { + await db.insert(productSlots).values({ slotId, productId }); +} + +export async function removeProductFromSlot(slotId: number, productId: number): Promise { + await db.delete(productSlots) + .where(and( + eq(productSlots.slotId, slotId), + eq(productSlots.productId, productId) + )); +} + +export async function clearSlotProducts(slotId: number): Promise { + await db.delete(productSlots).where(eq(productSlots.slotId, slotId)); +} + +export async function updateSlotCapacity(slotId: number, maxCapacity: number): Promise { + const [slot] = await db.update(deliverySlotInfo) + .set({ isCapacityFull: Boolean(maxCapacity) }) + .where(eq(deliverySlotInfo.id, slotId)) + .returning(); + return slot; +} + +export async function getSlotDeliverySequence(slotId: number): Promise { + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, slotId), + columns: { + deliverySequence: true, + }, + }); + return slot?.deliverySequence || null; +} + +export async function updateSlotDeliverySequence(slotId: number, sequence: any): Promise { + await db.update(deliverySlotInfo) + .set({ deliverySequence: sequence }) + .where(eq(deliverySlotInfo.id, slotId)); +} diff --git a/packages/db_helper_sqlite/src/helper_methods/staff-user.ts b/packages/db_helper_sqlite/src/helper_methods/staff-user.ts new file mode 100644 index 0000000..50c3bec --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/staff-user.ts @@ -0,0 +1,153 @@ +import { db } from '../db/db_index'; +import { staffUsers, staffRoles, users, userDetails, orders } from '../db/schema'; +import { eq, or, and, lt, desc, like } from 'drizzle-orm'; + +export interface StaffUser { + id: number; + name: string; + password: string; + staffRoleId: number; + createdAt: Date; +} + +export async function getStaffUserByName(name: string): Promise { + const staff = await db.query.staffUsers.findFirst({ + where: eq(staffUsers.name, name), + }); + + return staff || null; +} + +export async function getAllStaff(): Promise { + const staff = await db.query.staffUsers.findMany({ + columns: { + id: true, + name: true, + }, + with: { + role: { + with: { + rolePermissions: { + with: { + permission: true, + }, + }, + }, + }, + }, + }); + + return staff; +} + +export async function getStaffByName(name: string): Promise { + const staff = await db.query.staffUsers.findFirst({ + where: eq(staffUsers.name, name), + }); + return staff || null; +} + +export async function getAllUsers( + cursor?: number, + limit: number = 20, + search?: string +): Promise<{ users: any[]; hasMore: boolean }> { + let whereCondition = undefined; + + if (search) { + whereCondition = or( + like(users.name, `%${search}%`), + like(users.email, `%${search}%`), + like(users.mobile, `%${search}%`) + ); + } + + if (cursor) { + const cursorCondition = lt(users.id, cursor); + whereCondition = whereCondition ? and(whereCondition, cursorCondition) : cursorCondition; + } + + const allUsers = await db.query.users.findMany({ + where: whereCondition, + with: { + userDetails: true, + }, + orderBy: desc(users.id), + limit: limit + 1, + }); + + const hasMore = allUsers.length > limit; + const usersToReturn = hasMore ? allUsers.slice(0, limit) : allUsers; + + return { users: usersToReturn, hasMore }; +} + +export async function getUserWithDetails(userId: number): Promise { + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + with: { + userDetails: true, + orders: { + orderBy: desc(orders.createdAt), + limit: 1, + }, + }, + }); + + return user || null; +} + +export async function updateUserSuspension(userId: number, isSuspended: boolean): Promise { + await db + .insert(userDetails) + .values({ userId, isSuspended }) + .onConflictDoUpdate({ + target: userDetails.userId, + set: { isSuspended }, + }); +} + +export async function checkStaffUserExists(name: string): Promise { + const existingUser = await db.query.staffUsers.findFirst({ + where: eq(staffUsers.name, name), + }); + return !!existingUser; +} + +export async function checkStaffRoleExists(roleId: number): Promise { + const role = await db.query.staffRoles.findFirst({ + where: eq(staffRoles.id, roleId), + }); + return !!role; +} + +export async function createStaffUser( + name: string, + password: string, + roleId: number +): Promise { + const [newUser] = await db.insert(staffUsers).values({ + name: name.trim(), + password, + staffRoleId: roleId, + }).returning(); + + return { + id: newUser.id, + name: newUser.name, + password: newUser.password, + staffRoleId: newUser.staffRoleId ?? roleId, + createdAt: newUser.createdAt, + }; +} + +export async function getAllRoles(): Promise { + const roles = await db.query.staffRoles.findMany({ + columns: { + id: true, + roleName: true, + }, + }); + + return roles; +} diff --git a/packages/db_helper_sqlite/src/helper_methods/store.ts b/packages/db_helper_sqlite/src/helper_methods/store.ts new file mode 100644 index 0000000..c5ffe44 --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/store.ts @@ -0,0 +1,150 @@ +import { db } from '../db/db_index'; +import { storeInfo, productInfo } from '../db/schema'; +import { eq, inArray } from 'drizzle-orm'; + +export interface Store { + id: number; + name: string; + description: string | null; + imageUrl: string | null; + owner: number; + createdAt: Date; + updatedAt: Date; +} + +export async function getAllStores(): Promise { + const stores = await db.query.storeInfo.findMany({ + with: { + owner: true, + }, + }); + + return stores; +} + +export async function getStoreById(id: number): Promise { + const store = await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, id), + with: { + owner: true, + }, + }); + + return store || null; +} + +export interface CreateStoreInput { + name: string; + description?: string; + imageUrl?: string; + owner: number; +} + +export async function createStore( + input: CreateStoreInput, + products?: number[] +): Promise { + const [newStore] = await db + .insert(storeInfo) + .values({ + name: input.name, + description: input.description, + imageUrl: input.imageUrl, + owner: input.owner, + }) + .returning(); + + // Assign selected products to this store + if (products && products.length > 0) { + await db + .update(productInfo) + .set({ storeId: newStore.id }) + .where(inArray(productInfo.id, products)); + } + + return { + id: newStore.id, + name: newStore.name, + description: newStore.description, + imageUrl: newStore.imageUrl, + owner: newStore.owner, + createdAt: newStore.createdAt, + updatedAt: newStore.createdAt, + }; +} + +export interface UpdateStoreInput { + name?: string; + description?: string; + imageUrl?: string; + owner?: number; +} + +export async function updateStore( + id: number, + input: UpdateStoreInput, + products?: number[] +): Promise { + const [updatedStore] = await db + .update(storeInfo) + .set({ + ...input, + }) + .where(eq(storeInfo.id, id)) + .returning(); + + if (!updatedStore) { + throw new Error("Store not found"); + } + + // Update products if provided + if (products !== undefined) { + // First, set storeId to null for products not in the list but currently assigned to this store + await db + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, id)); + + // Then, assign the selected products to this store + if (products.length > 0) { + await db + .update(productInfo) + .set({ storeId: id }) + .where(inArray(productInfo.id, products)); + } + } + + return { + id: updatedStore.id, + name: updatedStore.name, + description: updatedStore.description, + imageUrl: updatedStore.imageUrl, + owner: updatedStore.owner, + createdAt: updatedStore.createdAt, + updatedAt: updatedStore.createdAt, + }; +} + +export async function deleteStore(id: number): Promise<{ message: string }> { + return await db.transaction(async (tx) => { + // First, update all products of this store to set storeId to null + await tx + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, id)); + + // Then delete the store + const [deletedStore] = await tx + .delete(storeInfo) + .where(eq(storeInfo.id, id)) + .returning(); + + if (!deletedStore) { + throw new Error("Store not found"); + } + + return { + message: "Store deleted successfully", + }; + }); +} diff --git a/packages/db_helper_sqlite/src/helper_methods/upload-url.ts b/packages/db_helper_sqlite/src/helper_methods/upload-url.ts new file mode 100644 index 0000000..7b121a7 --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/upload-url.ts @@ -0,0 +1,20 @@ +import { and, eq } from 'drizzle-orm' +import { db } from '../db/db_index' +import { uploadUrlStatus } from '../db/schema' + +export async function createUploadUrlStatus(key: string): Promise { + await db.insert(uploadUrlStatus).values({ + key, + status: 'pending', + }) +} + +export async function claimUploadUrlStatus(key: string): Promise { + const result = await db + .update(uploadUrlStatus) + .set({ status: 'claimed' }) + .where(and(eq(uploadUrlStatus.key, key), eq(uploadUrlStatus.status, 'pending'))) + .returning() + + return result.length > 0 +} diff --git a/packages/db_helper_sqlite/src/helper_methods/user.ts b/packages/db_helper_sqlite/src/helper_methods/user.ts new file mode 100644 index 0000000..c51ee27 --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/user.ts @@ -0,0 +1,270 @@ +import { db } from '../db/db_index'; +import { users, userDetails, orders, orderItems, complaints, notifCreds, unloggedUserTokens, userIncidents, orderStatus } from '../db/schema'; +import { eq, sql, desc, asc, count, max, inArray, like } from 'drizzle-orm'; + +export async function createUserByMobile(mobile: string): Promise { + const [newUser] = await db + .insert(users) + .values({ + name: null, + email: null, + mobile, + }) + .returning(); + + return newUser; +} + +export async function getUserByMobile(mobile: string): Promise { + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.mobile, mobile)) + .limit(1); + + return existingUser || null; +} + +export async function getUnresolvedComplaintsCount(): Promise { + const result = await db + .select({ count: count(complaints.id) }) + .from(complaints) + .where(eq(complaints.isResolved, false)); + + return result[0]?.count || 0; +} + +export async function getAllUsersWithFilters( + limit: number, + cursor?: number, + search?: string +): Promise<{ users: any[]; hasMore: boolean }> { + const whereConditions = []; + + if (search && search.trim()) { + whereConditions.push(sql`${users.mobile} LIKE ${`%${search.trim()}%`}`); + } + + if (cursor) { + whereConditions.push(sql`${users.id} > ${cursor}`); + } + + const usersList = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + createdAt: users.createdAt, + }) + .from(users) + .where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined) + .orderBy(asc(users.id)) + .limit(limit + 1); + + const hasMore = usersList.length > limit; + const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList; + + return { users: usersToReturn, hasMore }; +} + +export async function getOrderCountsByUserIds(userIds: number[]): Promise<{ userId: number; totalOrders: number }[]> { + if (userIds.length === 0) return []; + + return await db + .select({ + userId: orders.userId, + totalOrders: count(orders.id), + }) + .from(orders) + .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(orders.userId); +} + +export async function getLastOrdersByUserIds(userIds: number[]): Promise<{ userId: number; lastOrderDate: Date | null }[]> { + if (userIds.length === 0) return []; + + return await db + .select({ + userId: orders.userId, + lastOrderDate: max(orders.createdAt), + }) + .from(orders) + .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(orders.userId); +} + +export async function getSuspensionStatusesByUserIds(userIds: number[]): Promise<{ userId: number; isSuspended: boolean }[]> { + if (userIds.length === 0) return []; + + return await db + .select({ + userId: userDetails.userId, + isSuspended: userDetails.isSuspended, + }) + .from(userDetails) + .where(sql`${userDetails.userId} IN (${sql.join(userIds, sql`, `)})`); +} + +export async function getUserBasicInfo(userId: number): Promise { + const user = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user[0] || null; +} + +export async function getUserSuspensionStatus(userId: number): Promise { + const userDetail = await db + .select({ + isSuspended: userDetails.isSuspended, + }) + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1); + + return userDetail[0]?.isSuspended ?? false; +} + +export async function getUserOrders(userId: number): Promise { + return await db + .select({ + id: orders.id, + readableId: orders.readableId, + totalAmount: orders.totalAmount, + createdAt: orders.createdAt, + isFlashDelivery: orders.isFlashDelivery, + }) + .from(orders) + .where(eq(orders.userId, userId)) + .orderBy(desc(orders.createdAt)); +} + +export async function getOrderStatusesByOrderIds(orderIds: number[]): Promise<{ orderId: number; isDelivered: boolean; isCancelled: boolean }[]> { + if (orderIds.length === 0) return []; + + return await db + .select({ + orderId: orderStatus.orderId, + isDelivered: orderStatus.isDelivered, + isCancelled: orderStatus.isCancelled, + }) + .from(orderStatus) + .where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`); +} + +export async function getItemCountsByOrderIds(orderIds: number[]): Promise<{ orderId: number; itemCount: number }[]> { + if (orderIds.length === 0) return []; + + return await db + .select({ + orderId: orderItems.orderId, + itemCount: count(orderItems.id), + }) + .from(orderItems) + .where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`) + .groupBy(orderItems.orderId); +} + +export async function upsertUserSuspension(userId: number, isSuspended: boolean): Promise { + const existingDetail = await db + .select({ id: userDetails.id }) + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1); + + if (existingDetail.length > 0) { + await db + .update(userDetails) + .set({ isSuspended }) + .where(eq(userDetails.userId, userId)); + } else { + await db + .insert(userDetails) + .values({ + userId, + isSuspended, + }); + } +} + +export async function searchUsers(search?: string): Promise { + if (search && search.trim()) { + return await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + }) + .from(users) + .where(sql`${users.mobile} LIKE ${`%${search.trim()}%`} OR ${users.name} LIKE ${`%${search.trim()}%`}`); + } else { + return await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + }) + .from(users); + } +} + +export async function getAllNotifCreds(): Promise<{ userId: number }[]> { + return await db + .select({ userId: notifCreds.userId }) + .from(notifCreds); +} + +export async function getAllUnloggedTokens(): Promise<{ token: string }[]> { + return await db + .select({ token: unloggedUserTokens.token }) + .from(unloggedUserTokens); +} + +export async function getNotifTokensByUserIds(userIds: number[]): Promise<{ token: string }[]> { + return await db + .select({ token: notifCreds.token }) + .from(notifCreds) + .where(inArray(notifCreds.userId, userIds)); +} + +export async function getUserIncidentsWithRelations(userId: number): Promise { + return await db.query.userIncidents.findMany({ + where: eq(userIncidents.userId, userId), + with: { + order: { + with: { + orderStatus: true, + }, + }, + addedBy: true, + }, + orderBy: desc(userIncidents.dateAdded), + }); +} + +export async function createUserIncident( + userId: number, + orderId: number | undefined, + adminComment: string | undefined, + adminUserId: number, + negativityScore: number | undefined +): Promise { + const [incident] = await db.insert(userIncidents) + .values({ + userId, + orderId, + adminComment, + addedBy: adminUserId, + negativityScore, + }) + .returning(); + + return incident; +} diff --git a/packages/db_helper_sqlite/src/helper_methods/vendor-snippets.ts b/packages/db_helper_sqlite/src/helper_methods/vendor-snippets.ts new file mode 100644 index 0000000..20d4722 --- /dev/null +++ b/packages/db_helper_sqlite/src/helper_methods/vendor-snippets.ts @@ -0,0 +1,130 @@ +import { db } from '../db/db_index'; +import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, orderStatus } from '../db/schema'; +import { eq, and, inArray, gt, sql, asc, desc } from 'drizzle-orm'; + +export async function checkVendorSnippetExists(snippetCode: string): Promise { + const existingSnippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippetCode), + }); + return !!existingSnippet; +} + +export async function getVendorSnippetById(id: number): Promise { + return await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.id, id), + with: { + slot: true, + }, + }); +} + +export async function getVendorSnippetByCode(snippetCode: string): Promise { + return await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippetCode), + }); +} + +export async function getAllVendorSnippets(): Promise { + return await db.query.vendorSnippets.findMany({ + with: { + slot: true, + }, + orderBy: desc(vendorSnippets.createdAt), + }); +} + +export interface CreateVendorSnippetInput { + snippetCode: string; + slotId?: number; + productIds: number[]; + isPermanent: boolean; + validTill?: Date; +} + +export async function createVendorSnippet(input: CreateVendorSnippetInput): Promise { + const [result] = await db.insert(vendorSnippets).values({ + snippetCode: input.snippetCode, + slotId: input.slotId, + productIds: input.productIds, + isPermanent: input.isPermanent, + validTill: input.validTill, + }).returning(); + + return result; +} + +export async function updateVendorSnippet(id: number, updates: any): Promise { + const [result] = await db.update(vendorSnippets) + .set(updates) + .where(eq(vendorSnippets.id, id)) + .returning(); + + return result; +} + +export async function deleteVendorSnippet(id: number): Promise { + await db.delete(vendorSnippets) + .where(eq(vendorSnippets.id, id)); +} + +export async function getProductsByIds(productIds: number[]): Promise { + return await db.query.productInfo.findMany({ + where: inArray(productInfo.id, productIds), + columns: { id: true, name: true }, + }); +} + +export async function getVendorSlotById(slotId: number): Promise { + return await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, slotId), + }); +} + +export async function getVendorOrdersBySlotId(slotId: number): Promise { + return await db.query.orders.findMany({ + where: eq(orders.slotId, slotId), + with: { + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + user: true, + slot: true, + }, + orderBy: desc(orders.createdAt), + }); +} + +export async function getOrderItemsByOrderIds(orderIds: number[]): Promise { + return await db.query.orderItems.findMany({ + where: inArray(orderItems.orderId, orderIds), + with: { + product: { + with: { + unit: true, + }, + }, + }, + }); +} + +export async function getOrderStatusByOrderIds(orderIds: number[]): Promise { + return await db.query.orderStatus.findMany({ + where: inArray(orderStatus.orderId, orderIds), + }); +} + +export async function updateVendorOrderItemPackaging(orderItemId: number, isPackaged: boolean, isPackageVerified: boolean): Promise { + await db.update(orderItems) + .set({ + is_packaged: isPackaged, + is_package_verified: isPackageVerified, + }) + .where(eq(orderItems.id, orderItemId)); +} diff --git a/packages/db_helper_sqlite/src/lib/automated-jobs.ts b/packages/db_helper_sqlite/src/lib/automated-jobs.ts new file mode 100644 index 0000000..75f5723 --- /dev/null +++ b/packages/db_helper_sqlite/src/lib/automated-jobs.ts @@ -0,0 +1,41 @@ +import { db } from '../db/db_index' +import { productInfo, keyValStore } from '../db/schema' +import { inArray, eq } from 'drizzle-orm' + +/** + * Toggle flash delivery availability for specific products + * @param isAvailable - Whether flash delivery should be available + * @param productIds - Array of product IDs to update + */ +export async function toggleFlashDeliveryForItems( + isAvailable: boolean, + productIds: number[] +): Promise { + await db + .update(productInfo) + .set({ isFlashAvailable: isAvailable }) + .where(inArray(productInfo.id, productIds)) +} + +/** + * Update key-value store + * @param key - The key to update + * @param value - The boolean value to set + */ +export async function toggleKeyVal( + key: string, + value: boolean +): Promise { + await db + .update(keyValStore) + .set({ value }) + .where(eq(keyValStore.key, key)) +} + +/** + * Get all key-value store constants + * @returns Array of all key-value pairs + */ +export async function getAllKeyValStore(): Promise> { + return db.select().from(keyValStore) +} diff --git a/packages/db_helper_sqlite/src/lib/const-keys.ts b/packages/db_helper_sqlite/src/lib/const-keys.ts new file mode 100644 index 0000000..82fcff6 --- /dev/null +++ b/packages/db_helper_sqlite/src/lib/const-keys.ts @@ -0,0 +1,49 @@ +export const CONST_KEYS = { + minRegularOrderValue: 'minRegularOrderValue', + freeDeliveryThreshold: 'freeDeliveryThreshold', + deliveryCharge: 'deliveryCharge', + flashFreeDeliveryThreshold: 'flashFreeDeliveryThreshold', + flashDeliveryCharge: 'flashDeliveryCharge', + platformFeePercent: 'platformFeePercent', + taxRate: 'taxRate', + tester: 'tester', + minOrderAmountForCoupon: 'minOrderAmountForCoupon', + maxCouponDiscount: 'maxCouponDiscount', + flashDeliverySlotId: 'flashDeliverySlotId', + readableOrderId: 'readableOrderId', + versionNum: 'versionNum', + playStoreUrl: 'playStoreUrl', + appStoreUrl: 'appStoreUrl', + popularItems: 'popularItems', + allItemsOrder: 'allItemsOrder', + isFlashDeliveryEnabled: 'isFlashDeliveryEnabled', + supportMobile: 'supportMobile', + supportEmail: 'supportEmail', +} as const + +export const CONST_LABELS: Record = { + minRegularOrderValue: 'Minimum Regular Order Value', + freeDeliveryThreshold: 'Free Delivery Threshold', + deliveryCharge: 'Delivery Charge', + flashFreeDeliveryThreshold: 'Flash Free Delivery Threshold', + flashDeliveryCharge: 'Flash Delivery Charge', + platformFeePercent: 'Platform Fee Percent', + taxRate: 'Tax Rate', + tester: 'Tester', + minOrderAmountForCoupon: 'Minimum Order Amount for Coupon', + maxCouponDiscount: 'Maximum Coupon Discount', + flashDeliverySlotId: 'Flash Delivery Slot ID', + readableOrderId: 'Readable Order ID', + versionNum: 'Version Number', + playStoreUrl: 'Play Store URL', + appStoreUrl: 'App Store URL', + popularItems: 'Popular Items', + allItemsOrder: 'All Items Order', + isFlashDeliveryEnabled: 'Enable Flash Delivery', + supportMobile: 'Support Mobile', + supportEmail: 'Support Email', +} + +export type ConstKey = (typeof CONST_KEYS)[keyof typeof CONST_KEYS] + +export const CONST_KEYS_ARRAY = Object.values(CONST_KEYS) as ConstKey[] diff --git a/packages/db_helper_sqlite/src/lib/delete-orders.ts b/packages/db_helper_sqlite/src/lib/delete-orders.ts new file mode 100644 index 0000000..b3e91d5 --- /dev/null +++ b/packages/db_helper_sqlite/src/lib/delete-orders.ts @@ -0,0 +1,38 @@ +import { db } from '../db/db_index' +import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema' +import { inArray } from 'drizzle-orm' + +/** + * Delete orders and all their related records + * @param orderIds Array of order IDs to delete + * @returns Promise + * @throws Error if deletion fails + */ +export async function deleteOrdersWithRelations(orderIds: number[]): Promise { + if (orderIds.length === 0) { + return + } + + // Delete child records first (in correct order to avoid FK constraint errors) + + // 1. Delete coupon usage records + await db.delete(couponUsage).where(inArray(couponUsage.orderId, orderIds)) + + // 2. Delete complaints related to these orders + await db.delete(complaints).where(inArray(complaints.orderId, orderIds)) + + // 3. Delete refunds + await db.delete(refunds).where(inArray(refunds.orderId, orderIds)) + + // 4. Delete payments + await db.delete(payments).where(inArray(payments.orderId, orderIds)) + + // 5. Delete order status records + await db.delete(orderStatus).where(inArray(orderStatus.orderId, orderIds)) + + // 6. Delete order items + await db.delete(orderItems).where(inArray(orderItems.orderId, orderIds)) + + // 7. Finally delete the orders themselves + await db.delete(orders).where(inArray(orders.id, orderIds)) +} diff --git a/packages/db_helper_sqlite/src/lib/env-exporter.ts b/packages/db_helper_sqlite/src/lib/env-exporter.ts new file mode 100644 index 0000000..7352689 --- /dev/null +++ b/packages/db_helper_sqlite/src/lib/env-exporter.ts @@ -0,0 +1,55 @@ +export const appUrl = process.env.APP_URL as string + +export const jwtSecret: string = process.env.JWT_SECRET as string + +export const defaultRoleName = 'gen_user' + +export const encodedJwtSecret = new TextEncoder().encode(jwtSecret) + +export const s3AccessKeyId = process.env.S3_ACCESS_KEY_ID as string + +export const s3SecretAccessKey = process.env.S3_SECRET_ACCESS_KEY as string + +export const s3BucketName = process.env.S3_BUCKET_NAME as string + +export const s3Region = process.env.S3_REGION as string + +export const assetsDomain = process.env.ASSETS_DOMAIN as string + +export const apiCacheKey = process.env.API_CACHE_KEY as string + +export const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN as string + +export const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID as string + +export const s3Url = process.env.S3_URL as string + +export const redisUrl = process.env.REDIS_URL as string + +export const expoAccessToken = process.env.EXPO_ACCESS_TOKEN as string + +export const phonePeBaseUrl = process.env.PHONE_PE_BASE_URL as string + +export const phonePeClientId = process.env.PHONE_PE_CLIENT_ID as string + +export const phonePeClientVersion = Number(process.env.PHONE_PE_CLIENT_VERSION as string) + +export const phonePeClientSecret = process.env.PHONE_PE_CLIENT_SECRET as string + +export const phonePeMerchantId = process.env.PHONE_PE_MERCHANT_ID as string + +export const razorpayId = process.env.RAZORPAY_KEY as string + +export const razorpaySecret = process.env.RAZORPAY_SECRET as string + +export const otpSenderAuthToken = process.env.OTP_SENDER_AUTH_TOKEN as string + +export const minOrderValue = Number(process.env.MIN_ORDER_VALUE as string) + +export const deliveryCharge = Number(process.env.DELIVERY_CHARGE as string) + +export const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN as string + +export const telegramChatIds = (process.env.TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || [] + +export const isDevMode = (process.env.ENV_MODE as string) === 'dev' diff --git a/packages/db_helper_sqlite/src/lib/health-check.ts b/packages/db_helper_sqlite/src/lib/health-check.ts new file mode 100644 index 0000000..1dfabfb --- /dev/null +++ b/packages/db_helper_sqlite/src/lib/health-check.ts @@ -0,0 +1,18 @@ +import { db } from '../db/db_index' +import { keyValStore, productInfo } from '../db/schema' + +/** + * Health check - test database connectivity + * Tries to select from keyValStore first, falls back to productInfo + */ +export async function healthCheck(): Promise<{ status: string }> { + try { + // Try keyValStore first (smaller table) + await db.select({ key: keyValStore.key }).from(keyValStore).limit(1) + return { status: 'ok' } + } catch { + // Fallback to productInfo + await db.select({ name: productInfo.name }).from(productInfo).limit(1) + return { status: 'ok' } + } +} diff --git a/packages/db_helper_sqlite/src/lib/seed.ts b/packages/db_helper_sqlite/src/lib/seed.ts new file mode 100644 index 0000000..f3410b5 --- /dev/null +++ b/packages/db_helper_sqlite/src/lib/seed.ts @@ -0,0 +1,127 @@ +import { db } from '../db/db_index' +import { eq, and } from 'drizzle-orm' + +// ============================================================================ +// Unit Seed Helper +// ============================================================================ + +export interface UnitSeedData { + shortNotation: string + fullName: string +} + +export async function seedUnits(unitsToSeed: UnitSeedData[]): Promise { + for (const unit of unitsToSeed) { + const { units: unitsTable } = await import('../db/schema') + const existingUnit = await db.query.units.findFirst({ + where: eq(unitsTable.shortNotation, unit.shortNotation), + }) + if (!existingUnit) { + await db.insert(unitsTable).values(unit) + } + } +} + +// ============================================================================ +// Staff Role Seed Helper +// ============================================================================ + +// Type for staff role names based on the enum values in schema +export type StaffRoleName = 'super_admin' | 'admin' | 'marketer' | 'delivery_staff' + +export async function seedStaffRoles(rolesToSeed: StaffRoleName[]): Promise { + for (const roleName of rolesToSeed) { + const { staffRoles } = await import('../db/schema') + const existingRole = await db.query.staffRoles.findFirst({ + where: eq(staffRoles.roleName, roleName), + }) + if (!existingRole) { + await db.insert(staffRoles).values({ roleName }) + } + } +} + +// ============================================================================ +// Staff Permission Seed Helper +// ============================================================================ + +// Type for staff permission names based on the enum values in schema +export type StaffPermissionName = 'crud_product' | 'make_coupon' | 'crud_staff_users' + +export async function seedStaffPermissions(permissionsToSeed: StaffPermissionName[]): Promise { + for (const permissionName of permissionsToSeed) { + const { staffPermissions } = await import('../db/schema') + const existingPermission = await db.query.staffPermissions.findFirst({ + where: eq(staffPermissions.permissionName, permissionName), + }) + if (!existingPermission) { + await db.insert(staffPermissions).values({ permissionName }) + } + } +} + +// ============================================================================ +// Role-Permission Assignment Helper +// ============================================================================ + +export interface RolePermissionAssignment { + roleName: StaffRoleName + permissionName: StaffPermissionName +} + +export async function seedRolePermissions(assignments: RolePermissionAssignment[]): Promise { + await db.transaction(async (tx) => { + const { staffRoles, staffPermissions, staffRolePermissions } = await import('../db/schema') + + for (const assignment of assignments) { + // Get role ID + const role = await tx.query.staffRoles.findFirst({ + where: eq(staffRoles.roleName, assignment.roleName), + }) + + // Get permission ID + const permission = await tx.query.staffPermissions.findFirst({ + where: eq(staffPermissions.permissionName, assignment.permissionName), + }) + + if (role && permission) { + const existing = await tx.query.staffRolePermissions.findFirst({ + where: and( + eq(staffRolePermissions.staffRoleId, role.id), + eq(staffRolePermissions.staffPermissionId, permission.id) + ), + }) + if (!existing) { + await tx.insert(staffRolePermissions).values({ + staffRoleId: role.id, + staffPermissionId: permission.id, + }) + } + } + } + }) +} + +// ============================================================================ +// Key-Value Store Seed Helper +// ============================================================================ + +export interface KeyValSeedData { + key: string + value: any +} + +export async function seedKeyValStore(constantsToSeed: KeyValSeedData[]): Promise { + for (const constant of constantsToSeed) { + const { keyValStore } = await import('../db/schema') + const existing = await db.query.keyValStore.findFirst({ + where: eq(keyValStore.key, constant.key), + }) + if (!existing) { + await db.insert(keyValStore).values({ + key: constant.key, + value: constant.value, + }) + } + } +} diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts new file mode 100644 index 0000000..61d2b47 --- /dev/null +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -0,0 +1,294 @@ +// Store Helpers - Database operations for cache initialization +// These are used by stores in apps/backend/src/stores/ + +import { db } from '../db/db_index' +import { + homeBanners, + productInfo, + units, + productSlots, + deliverySlotInfo, + specialDeals, + storeInfo, + productTags, + productTagInfo, + userIncidents, +} from '../db/schema' +import { eq, and, gt, sql, isNotNull, asc } from 'drizzle-orm' + +// ============================================================================ +// BANNER STORE HELPERS +// ============================================================================ + +export interface BannerData { + id: number + name: string + imageUrl: string | null + serialNum: number | null + productIds: number[] | null + createdAt: Date +} + +export async function getAllBannersForCache(): Promise { + return db.query.homeBanners.findMany({ + where: isNotNull(homeBanners.serialNum), + orderBy: asc(homeBanners.serialNum), + }) +} + +// ============================================================================ +// PRODUCT STORE HELPERS +// ============================================================================ + +export interface ProductBasicData { + id: number + name: string + shortDescription: string | null + longDescription: string | null + price: string + marketPrice: string | null + images: unknown + isOutOfStock: boolean + storeId: number | null + unitShortNotation: string + incrementStep: number + productQuantity: number + isFlashAvailable: boolean + flashPrice: string | null +} + +export interface StoreBasicData { + id: number + name: string + description: string | null +} + +export interface DeliverySlotData { + productId: number + id: number + deliveryTime: Date + freezeTime: Date + isCapacityFull: boolean +} + +export interface SpecialDealData { + productId: number + quantity: string + price: string + validTill: Date +} + +export interface ProductTagData { + productId: number + tagName: string +} + +export async function getAllProductsForCache(): Promise { + const results = await db + .select({ + id: productInfo.id, + name: productInfo.name, + shortDescription: productInfo.shortDescription, + longDescription: productInfo.longDescription, + price: productInfo.price, + marketPrice: productInfo.marketPrice, + images: productInfo.images, + isOutOfStock: productInfo.isOutOfStock, + storeId: productInfo.storeId, + unitShortNotation: units.shortNotation, + incrementStep: productInfo.incrementStep, + productQuantity: productInfo.productQuantity, + isFlashAvailable: productInfo.isFlashAvailable, + flashPrice: productInfo.flashPrice, + }) + .from(productInfo) + .innerJoin(units, eq(productInfo.unitId, units.id)) + + return results.map((product) => ({ + ...product, + price: String(product.price ?? '0'), + marketPrice: product.marketPrice ? String(product.marketPrice) : null, + flashPrice: product.flashPrice ? String(product.flashPrice) : null, + })) +} + +export async function getAllStoresForCache(): Promise { + return db.query.storeInfo.findMany({ + columns: { id: true, name: true, description: true }, + }) +} + +export async function getAllDeliverySlotsForCache(): Promise { + return db + .select({ + productId: productSlots.productId, + id: deliverySlotInfo.id, + deliveryTime: deliverySlotInfo.deliveryTime, + freezeTime: deliverySlotInfo.freezeTime, + isCapacityFull: deliverySlotInfo.isCapacityFull, + }) + .from(productSlots) + .innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id)) + .where( + and( + eq(deliverySlotInfo.isActive, true), + eq(deliverySlotInfo.isCapacityFull, false), + gt(deliverySlotInfo.deliveryTime, sql`CURRENT_TIMESTAMP`) + ) + ) +} + +export async function getAllSpecialDealsForCache(): Promise { + const results = await db + .select({ + productId: specialDeals.productId, + quantity: specialDeals.quantity, + price: specialDeals.price, + validTill: specialDeals.validTill, + }) + .from(specialDeals) + .where(gt(specialDeals.validTill, sql`CURRENT_TIMESTAMP`)) + + return results.map((deal) => ({ + ...deal, + quantity: String(deal.quantity ?? '0'), + price: String(deal.price ?? '0'), + })) +} + +export async function getAllProductTagsForCache(): Promise { + return db + .select({ + productId: productTags.productId, + tagName: productTagInfo.tagName, + }) + .from(productTags) + .innerJoin(productTagInfo, eq(productTags.tagId, productTagInfo.id)) +} + +// ============================================================================ +// PRODUCT TAG STORE HELPERS +// ============================================================================ + +export interface TagBasicData { + id: number + tagName: string + tagDescription: string | null + imageUrl: string | null + isDashboardTag: boolean + relatedStores: unknown +} + +export interface TagProductMapping { + tagId: number + productId: number +} + +export async function getAllTagsForCache(): Promise { + return db + .select({ + id: productTagInfo.id, + tagName: productTagInfo.tagName, + tagDescription: productTagInfo.tagDescription, + imageUrl: productTagInfo.imageUrl, + isDashboardTag: productTagInfo.isDashboardTag, + relatedStores: productTagInfo.relatedStores, + }) + .from(productTagInfo) +} + +export async function getAllTagProductMappings(): Promise { + return db + .select({ + tagId: productTags.tagId, + productId: productTags.productId, + }) + .from(productTags) +} + +// ============================================================================ +// SLOT STORE HELPERS +// ============================================================================ + +export interface SlotWithProductsData { + id: number + deliveryTime: Date + freezeTime: Date + isActive: boolean + isCapacityFull: boolean + productSlots: Array<{ + product: { + id: number + name: string + productQuantity: number + shortDescription: string | null + price: string + marketPrice: string | null + unit: { shortNotation: string } | null + store: { id: number; name: string; description: string | null } | null + images: unknown + isOutOfStock: boolean + storeId: number | null + } + }> +} + +export async function getAllSlotsWithProductsForCache(): Promise { + const now = new Date() + + return db.query.deliverySlotInfo.findMany({ + where: and( + eq(deliverySlotInfo.isActive, true), + gt(deliverySlotInfo.deliveryTime, now) + ), + with: { + productSlots: { + with: { + product: { + with: { + unit: true, + store: true, + }, + }, + }, + }, + }, + orderBy: asc(deliverySlotInfo.deliveryTime), + }) as Promise +} + +// ============================================================================ +// USER NEGATIVITY STORE HELPERS +// ============================================================================ + +export interface UserNegativityData { + userId: number + totalNegativityScore: number +} + +export async function getAllUserNegativityScores(): Promise { + const results = await db + .select({ + userId: userIncidents.userId, + totalNegativityScore: sql`sum(${userIncidents.negativityScore})`, + }) + .from(userIncidents) + .groupBy(userIncidents.userId) + + return results.map((result) => ({ + userId: result.userId, + totalNegativityScore: Number(result.totalNegativityScore ?? 0), + })) +} + +export async function getUserNegativityScore(userId: number): Promise { + const [result] = await db + .select({ + totalNegativityScore: sql`sum(${userIncidents.negativityScore})`, + }) + .from(userIncidents) + .where(eq(userIncidents.userId, userId)) + .limit(1) + + return Number(result?.totalNegativityScore ?? 0) +} diff --git a/packages/db_helper_sqlite/src/user-apis/address.ts b/packages/db_helper_sqlite/src/user-apis/address.ts new file mode 100644 index 0000000..ee5792e --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/address.ts @@ -0,0 +1,148 @@ +import { db } from '../db/db_index' +import { addresses, deliverySlotInfo, orders, orderStatus } from '../db/schema' +import { and, eq, gte } from 'drizzle-orm' +import type { InferSelectModel } from 'drizzle-orm' +import type { UserAddress } from '@packages/shared' + +type AddressRow = InferSelectModel + +const mapUserAddress = (address: AddressRow): UserAddress => ({ + id: address.id, + userId: address.userId, + name: address.name, + phone: address.phone, + addressLine1: address.addressLine1, + addressLine2: address.addressLine2 ?? null, + city: address.city, + state: address.state, + pincode: address.pincode, + isDefault: address.isDefault, + latitude: address.latitude ?? null, + longitude: address.longitude ?? null, + googleMapsUrl: address.googleMapsUrl ?? null, + adminLatitude: address.adminLatitude ?? null, + adminLongitude: address.adminLongitude ?? null, + zoneId: address.zoneId ?? null, + createdAt: address.createdAt, +}) + +export async function getDefaultAddress(userId: number): Promise { + const [defaultAddress] = await db + .select() + .from(addresses) + .where(and(eq(addresses.userId, userId), eq(addresses.isDefault, true))) + .limit(1) + + return defaultAddress ? mapUserAddress(defaultAddress) : null +} + +export async function getUserAddresses(userId: number): Promise { + const userAddresses = await db.select().from(addresses).where(eq(addresses.userId, userId)) + return userAddresses.map(mapUserAddress) +} + +export async function getUserAddressById(userId: number, addressId: number): Promise { + const [address] = await db + .select() + .from(addresses) + .where(and(eq(addresses.id, addressId), eq(addresses.userId, userId))) + .limit(1) + + return address ? mapUserAddress(address) : null +} + +export async function clearDefaultAddress(userId: number): Promise { + await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId)) +} + +export async function createUserAddress(input: { + userId: number + name: string + phone: string + addressLine1: string + addressLine2?: string + city: string + state: string + pincode: string + isDefault: boolean + latitude?: number + longitude?: number + googleMapsUrl?: string +}): Promise { + const [newAddress] = await db.insert(addresses).values({ + userId: input.userId, + name: input.name, + phone: input.phone, + addressLine1: input.addressLine1, + addressLine2: input.addressLine2, + city: input.city, + state: input.state, + pincode: input.pincode, + isDefault: input.isDefault, + latitude: input.latitude, + longitude: input.longitude, + googleMapsUrl: input.googleMapsUrl, + }).returning() + + return mapUserAddress(newAddress) +} + +export async function updateUserAddress(input: { + userId: number + addressId: number + name: string + phone: string + addressLine1: string + addressLine2?: string + city: string + state: string + pincode: string + isDefault: boolean + latitude?: number + longitude?: number + googleMapsUrl?: string +}): Promise { + const [updatedAddress] = await db.update(addresses) + .set({ + name: input.name, + phone: input.phone, + addressLine1: input.addressLine1, + addressLine2: input.addressLine2, + city: input.city, + state: input.state, + pincode: input.pincode, + isDefault: input.isDefault, + googleMapsUrl: input.googleMapsUrl, + latitude: input.latitude, + longitude: input.longitude, + }) + .where(and(eq(addresses.id, input.addressId), eq(addresses.userId, input.userId))) + .returning() + + return updatedAddress ? mapUserAddress(updatedAddress) : null +} + +export async function deleteUserAddress(userId: number, addressId: number): Promise { + const [deleted] = await db.delete(addresses) + .where(and(eq(addresses.id, addressId), eq(addresses.userId, userId))) + .returning({ id: addresses.id }) + + return !!deleted +} + +export async function hasOngoingOrdersForAddress(addressId: number): Promise { + const ongoingOrders = await db.select({ + orderId: orders.id, + }) + .from(orders) + .innerJoin(orderStatus, eq(orders.id, orderStatus.orderId)) + .innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id)) + .where(and( + eq(orders.addressId, addressId), + eq(orderStatus.isCancelled, false), + gte(deliverySlotInfo.deliveryTime, new Date()) + )) + .limit(1) + + return ongoingOrders.length > 0 +} diff --git a/packages/db_helper_sqlite/src/user-apis/auth.ts b/packages/db_helper_sqlite/src/user-apis/auth.ts new file mode 100644 index 0000000..3347355 --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/auth.ts @@ -0,0 +1,229 @@ +import { db } from '../db/db_index' +import { + users, + userCreds, + userDetails, + addresses, + cartItems, + complaints, + couponApplicableUsers, + couponUsage, + notifCreds, + notifications, + orderItems, + orderStatus, + orders, + payments, + refunds, + productReviews, + reservedCoupons, +} from '../db/schema' +import { eq } from 'drizzle-orm' + +export async function getUserByEmail(email: string) { + const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1) + return user || null +} + +export async function getUserByMobile(mobile: string) { + const [user] = await db.select().from(users).where(eq(users.mobile, mobile)).limit(1) + return user || null +} + +export async function getUserById(userId: number) { + const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1) + return user || null +} + +export async function getUserCreds(userId: number) { + const [creds] = await db.select().from(userCreds).where(eq(userCreds.userId, userId)).limit(1) + return creds || null +} + +export async function getUserDetails(userId: number) { + const [details] = await db.select().from(userDetails).where(eq(userDetails.userId, userId)).limit(1) + return details || null +} + +export async function isUserSuspended(userId: number): Promise { + const details = await getUserDetails(userId) + return details?.isSuspended ?? false +} + +export async function createUserWithProfile(input: { + name: string + email: string + mobile: string + hashedPassword: string + profileImage?: string | null +}) { + return db.transaction(async (tx) => { + // Create user + const [user] = await tx.insert(users).values({ + name: input.name, + email: input.email, + mobile: input.mobile, + }).returning() + + // Create user credentials + await tx.insert(userCreds).values({ + userId: user.id, + userPassword: input.hashedPassword, + }) + + // Create user details with profile image + await tx.insert(userDetails).values({ + userId: user.id, + profileImage: input.profileImage || null, + }) + + return user + }) +} + +export async function getUserDetailsByUserId(userId: number) { + const [details] = await db.select().from(userDetails).where(eq(userDetails.userId, userId)).limit(1) + return details || null +} + +export async function updateUserProfile(userId: number, data: { + name?: string + email?: string + mobile?: string + hashedPassword?: string + profileImage?: string + bio?: string + dateOfBirth?: Date | null + gender?: string + occupation?: string +}) { + return db.transaction(async (tx) => { + // Update user table + const userUpdate: any = {} + if (data.name !== undefined) userUpdate.name = data.name + if (data.email !== undefined) userUpdate.email = data.email + if (data.mobile !== undefined) userUpdate.mobile = data.mobile + + if (Object.keys(userUpdate).length > 0) { + await tx.update(users).set(userUpdate).where(eq(users.id, userId)) + } + + // Update password if provided + if (data.hashedPassword) { + await tx.update(userCreds).set({ + userPassword: data.hashedPassword, + }).where(eq(userCreds.userId, userId)) + } + + // Update or insert user details + const detailsUpdate: any = {} + if (data.bio !== undefined) detailsUpdate.bio = data.bio + if (data.dateOfBirth !== undefined) detailsUpdate.dateOfBirth = data.dateOfBirth + if (data.gender !== undefined) detailsUpdate.gender = data.gender + if (data.occupation !== undefined) detailsUpdate.occupation = data.occupation + if (data.profileImage !== undefined) detailsUpdate.profileImage = data.profileImage + detailsUpdate.updatedAt = new Date() + + const [existingDetails] = await tx.select().from(userDetails).where(eq(userDetails.userId, userId)).limit(1) + + if (existingDetails) { + await tx.update(userDetails).set(detailsUpdate).where(eq(userDetails.userId, userId)) + } else { + await tx.insert(userDetails).values({ + userId, + ...detailsUpdate, + createdAt: new Date(), + }) + } + + // Return updated user + const [user] = await tx.select().from(users).where(eq(users.id, userId)).limit(1) + return user + }) +} + +export async function createUserWithCreds(input: { + name: string + email: string + mobile: string + hashedPassword: string +}) { + return db.transaction(async (tx) => { + const [user] = await tx.insert(users).values({ + name: input.name, + email: input.email, + mobile: input.mobile, + }).returning() + + await tx.insert(userCreds).values({ + userId: user.id, + userPassword: input.hashedPassword, + }) + + return user + }) +} + +export async function createUserWithMobile(mobile: string) { + const [user] = await db.insert(users).values({ + name: null, + email: null, + mobile, + }).returning() + + return user +} + +export async function upsertUserPassword(userId: number, hashedPassword: string) { + try { + await db.insert(userCreds).values({ + userId, + userPassword: hashedPassword, + }) + return + } catch (error: any) { + if (error.code === '23505') { + await db.update(userCreds).set({ + userPassword: hashedPassword, + }).where(eq(userCreds.userId, userId)) + return + } + throw error + } +} + +export async function deleteUserAccount(userId: number) { + await db.transaction(async (tx) => { + await tx.delete(notifCreds).where(eq(notifCreds.userId, userId)) + await tx.delete(couponApplicableUsers).where(eq(couponApplicableUsers.userId, userId)) + await tx.delete(couponUsage).where(eq(couponUsage.userId, userId)) + await tx.delete(complaints).where(eq(complaints.userId, userId)) + await tx.delete(cartItems).where(eq(cartItems.userId, userId)) + await tx.delete(notifications).where(eq(notifications.userId, userId)) + await tx.delete(productReviews).where(eq(productReviews.userId, userId)) + + await tx.update(reservedCoupons) + .set({ redeemedBy: null }) + .where(eq(reservedCoupons.redeemedBy, userId)) + + const userOrders = await tx + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.userId, userId)) + + for (const order of userOrders) { + await tx.delete(orderItems).where(eq(orderItems.orderId, order.id)) + await tx.delete(orderStatus).where(eq(orderStatus.orderId, order.id)) + await tx.delete(payments).where(eq(payments.orderId, order.id)) + await tx.delete(refunds).where(eq(refunds.orderId, order.id)) + await tx.delete(couponUsage).where(eq(couponUsage.orderId, order.id)) + await tx.delete(complaints).where(eq(complaints.orderId, order.id)) + } + + await tx.delete(orders).where(eq(orders.userId, userId)) + await tx.delete(addresses).where(eq(addresses.userId, userId)) + await tx.delete(userDetails).where(eq(userDetails.userId, userId)) + await tx.delete(userCreds).where(eq(userCreds.userId, userId)) + await tx.delete(users).where(eq(users.id, userId)) + }) +} diff --git a/packages/db_helper_sqlite/src/user-apis/banners.ts b/packages/db_helper_sqlite/src/user-apis/banners.ts new file mode 100644 index 0000000..5aeb02b --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/banners.ts @@ -0,0 +1,29 @@ +import { db } from '../db/db_index' +import { homeBanners } from '../db/schema' +import { asc, isNotNull } from 'drizzle-orm' +import type { InferSelectModel } from 'drizzle-orm' +import type { UserBanner } from '@packages/shared' + +type BannerRow = InferSelectModel + +const mapBanner = (banner: BannerRow): UserBanner => ({ + id: banner.id, + name: banner.name, + imageUrl: banner.imageUrl, + description: banner.description ?? null, + productIds: banner.productIds ?? null, + redirectUrl: banner.redirectUrl ?? null, + serialNum: banner.serialNum ?? null, + isActive: banner.isActive, + createdAt: banner.createdAt, + lastUpdated: banner.lastUpdated, +}) + +export async function getActiveBanners(): Promise { + const banners = await db.query.homeBanners.findMany({ + where: isNotNull(homeBanners.serialNum), + orderBy: asc(homeBanners.serialNum), + }) + + return banners.map(mapBanner) +} diff --git a/packages/db_helper_sqlite/src/user-apis/cart.ts b/packages/db_helper_sqlite/src/user-apis/cart.ts new file mode 100644 index 0000000..563122e --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/cart.ts @@ -0,0 +1,99 @@ +import { db } from '../db/db_index' +import { cartItems, productInfo, units } from '../db/schema' +import { and, eq, sql } from 'drizzle-orm' +import type { UserCartItem } from '@packages/shared' + +const getStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) return [] + return value.map((item) => String(item)) +} + +export async function getCartItemsWithProducts(userId: number): Promise { + const cartItemsWithProducts = await db + .select({ + cartId: cartItems.id, + productId: productInfo.id, + productName: productInfo.name, + productPrice: productInfo.price, + productImages: productInfo.images, + productQuantity: productInfo.productQuantity, + isOutOfStock: productInfo.isOutOfStock, + unitShortNotation: units.shortNotation, + quantity: cartItems.quantity, + addedAt: cartItems.addedAt, + }) + .from(cartItems) + .innerJoin(productInfo, eq(cartItems.productId, productInfo.id)) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(eq(cartItems.userId, userId)) + + return cartItemsWithProducts.map((item) => { + const priceValue = item.productPrice ?? '0' + const quantityValue = item.quantity ?? '0' + return { + id: item.cartId, + productId: item.productId, + quantity: parseFloat(quantityValue), + addedAt: item.addedAt, + product: { + id: item.productId, + name: item.productName, + price: priceValue.toString(), + productQuantity: item.productQuantity, + unit: item.unitShortNotation, + isOutOfStock: item.isOutOfStock, + images: getStringArray(item.productImages), + }, + subtotal: parseFloat(priceValue.toString()) * parseFloat(quantityValue), + } + }) +} + +export async function getProductById(productId: number) { + return db.query.productInfo.findFirst({ + where: eq(productInfo.id, productId), + }) +} + +export async function getCartItemByUserProduct(userId: number, productId: number) { + return db.query.cartItems.findFirst({ + where: and(eq(cartItems.userId, userId), eq(cartItems.productId, productId)), + }) +} + +export async function incrementCartItemQuantity(itemId: number, quantity: number): Promise { + await db.update(cartItems) + .set({ + quantity: sql`${cartItems.quantity} + ${quantity}`, + }) + .where(eq(cartItems.id, itemId)) +} + +export async function insertCartItem(userId: number, productId: number, quantity: number): Promise { + await db.insert(cartItems).values({ + userId, + productId, + quantity: quantity.toString(), + }) +} + +export async function updateCartItemQuantity(userId: number, itemId: number, quantity: number) { + const [updatedItem] = await db.update(cartItems) + .set({ quantity: quantity.toString() }) + .where(and(eq(cartItems.id, itemId), eq(cartItems.userId, userId))) + .returning({ id: cartItems.id }) + + return !!updatedItem +} + +export async function deleteCartItem(userId: number, itemId: number): Promise { + const [deletedItem] = await db.delete(cartItems) + .where(and(eq(cartItems.id, itemId), eq(cartItems.userId, userId))) + .returning({ id: cartItems.id }) + + return !!deletedItem +} + +export async function clearUserCart(userId: number): Promise { + await db.delete(cartItems).where(eq(cartItems.userId, userId)) +} diff --git a/packages/db_helper_sqlite/src/user-apis/complaint.ts b/packages/db_helper_sqlite/src/user-apis/complaint.ts new file mode 100644 index 0000000..60391f7 --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/complaint.ts @@ -0,0 +1,45 @@ +import { db } from '../db/db_index' +import { complaints } from '../db/schema' +import { asc, eq } from 'drizzle-orm' +import type { InferSelectModel } from 'drizzle-orm' +import type { UserComplaint } from '@packages/shared' + +type ComplaintRow = InferSelectModel + +export async function getUserComplaints(userId: number): Promise { + const userComplaints = await db + .select({ + id: complaints.id, + complaintBody: complaints.complaintBody, + response: complaints.response, + isResolved: complaints.isResolved, + createdAt: complaints.createdAt, + orderId: complaints.orderId, + }) + .from(complaints) + .where(eq(complaints.userId, userId)) + .orderBy(asc(complaints.createdAt)) + + return userComplaints.map((complaint) => ({ + id: complaint.id, + complaintBody: complaint.complaintBody, + response: complaint.response ?? null, + isResolved: complaint.isResolved, + createdAt: complaint.createdAt, + orderId: complaint.orderId ?? null, + })) +} + +export async function createComplaint( + userId: number, + orderId: number | null, + complaintBody: string, + images?: string[] | null +): Promise { + await db.insert(complaints).values({ + userId, + orderId, + complaintBody, + images: images || null, + }) +} diff --git a/packages/db_helper_sqlite/src/user-apis/coupon.ts b/packages/db_helper_sqlite/src/user-apis/coupon.ts new file mode 100644 index 0000000..61b11a2 --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/coupon.ts @@ -0,0 +1,146 @@ +import { db } from '../db/db_index' +import { + couponApplicableProducts, + couponApplicableUsers, + couponUsage, + coupons, + reservedCoupons, +} from '../db/schema' +import { and, eq, gt, isNull, or } from 'drizzle-orm' +import type { InferSelectModel } from 'drizzle-orm' +import type { UserCoupon, UserCouponApplicableProduct, UserCouponApplicableUser, UserCouponUsage, UserCouponWithRelations } from '@packages/shared' + +type CouponRow = InferSelectModel +type CouponUsageRow = InferSelectModel +type CouponApplicableUserRow = InferSelectModel +type CouponApplicableProductRow = InferSelectModel +type ReservedCouponRow = InferSelectModel + +const mapCoupon = (coupon: CouponRow): UserCoupon => ({ + id: coupon.id, + couponCode: coupon.couponCode, + isUserBased: coupon.isUserBased, + discountPercent: coupon.discountPercent ? coupon.discountPercent.toString() : null, + flatDiscount: coupon.flatDiscount ? coupon.flatDiscount.toString() : null, + minOrder: coupon.minOrder ? coupon.minOrder.toString() : null, + productIds: coupon.productIds, + maxValue: coupon.maxValue ? coupon.maxValue.toString() : null, + isApplyForAll: coupon.isApplyForAll, + validTill: coupon.validTill ?? null, + maxLimitForUser: coupon.maxLimitForUser ?? null, + isInvalidated: coupon.isInvalidated, + exclusiveApply: coupon.exclusiveApply, + createdAt: coupon.createdAt, +}) + +const mapUsage = (usage: CouponUsageRow): UserCouponUsage => ({ + id: usage.id, + userId: usage.userId, + couponId: usage.couponId, + orderId: usage.orderId ?? null, + orderItemId: usage.orderItemId ?? null, + usedAt: usage.usedAt, +}) + +const mapApplicableUser = (applicable: CouponApplicableUserRow): UserCouponApplicableUser => ({ + id: applicable.id, + couponId: applicable.couponId, + userId: applicable.userId, +}) + +const mapApplicableProduct = (applicable: CouponApplicableProductRow): UserCouponApplicableProduct => ({ + id: applicable.id, + couponId: applicable.couponId, + productId: applicable.productId, +}) + +const mapCouponWithRelations = (coupon: CouponRow & { + usages: CouponUsageRow[] + applicableUsers: CouponApplicableUserRow[] + applicableProducts: CouponApplicableProductRow[] +}): UserCouponWithRelations => ({ + ...mapCoupon(coupon), + usages: coupon.usages.map(mapUsage), + applicableUsers: coupon.applicableUsers.map(mapApplicableUser), + applicableProducts: coupon.applicableProducts.map(mapApplicableProduct), +}) + +export async function getActiveCouponsWithRelations(userId: number): Promise { + const allCoupons = await db.query.coupons.findMany({ + where: and( + eq(coupons.isInvalidated, false), + or( + isNull(coupons.validTill), + gt(coupons.validTill, new Date()) + ) + ), + with: { + usages: { + where: eq(couponUsage.userId, userId), + }, + applicableUsers: true, + applicableProducts: true, + }, + }) + + return allCoupons.map(mapCouponWithRelations) +} + +export async function getAllCouponsWithRelations(userId: number): Promise { + const allCoupons = await db.query.coupons.findMany({ + with: { + usages: { + where: eq(couponUsage.userId, userId), + }, + applicableUsers: true, + applicableProducts: true, + }, + }) + + return allCoupons.map(mapCouponWithRelations) +} + +export async function getReservedCouponByCode(secretCode: string): Promise { + const reserved = await db.query.reservedCoupons.findFirst({ + where: and( + eq(reservedCoupons.secretCode, secretCode.toUpperCase()), + eq(reservedCoupons.isRedeemed, false) + ), + }) + + return reserved || null +} + +export async function redeemReservedCoupon(userId: number, reservedCoupon: ReservedCouponRow): Promise { + const couponResult = await db.transaction(async (tx) => { + const [coupon] = await tx.insert(coupons).values({ + couponCode: reservedCoupon.couponCode, + isUserBased: true, + discountPercent: reservedCoupon.discountPercent, + flatDiscount: reservedCoupon.flatDiscount, + minOrder: reservedCoupon.minOrder, + productIds: reservedCoupon.productIds, + maxValue: reservedCoupon.maxValue, + isApplyForAll: false, + validTill: reservedCoupon.validTill, + maxLimitForUser: reservedCoupon.maxLimitForUser, + exclusiveApply: reservedCoupon.exclusiveApply, + createdBy: reservedCoupon.createdBy, + }).returning() + + await tx.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId, + }) + + await tx.update(reservedCoupons).set({ + isRedeemed: true, + redeemedBy: userId, + redeemedAt: new Date(), + }).where(eq(reservedCoupons.id, reservedCoupon.id)) + + return coupon + }) + + return mapCoupon(couponResult) +} diff --git a/packages/db_helper_sqlite/src/user-apis/order.ts b/packages/db_helper_sqlite/src/user-apis/order.ts new file mode 100644 index 0000000..2d248ad --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/order.ts @@ -0,0 +1,738 @@ +import { db } from '../db/db_index' +import { + orders, + orderItems, + orderStatus, + addresses, + productInfo, + paymentInfoTable, + coupons, + couponUsage, + cartItems, + refunds, + units, + userDetails, + deliverySlotInfo, +} from '../db/schema' +import { and, eq, inArray, desc, gte, sql } from 'drizzle-orm' +import type { + UserOrderSummary, + UserOrderDetail, + UserRecentProduct, +} from '@packages/shared' + +export interface OrderItemInput { + productId: number + quantity: number + slotId: number | null +} + +export interface PlaceOrderInput { + userId: number + selectedItems: OrderItemInput[] + addressId: number + paymentMethod: 'online' | 'cod' + couponId?: number + userNotes?: string + isFlash?: boolean +} + +export interface OrderGroupData { + slotId: number | null + items: Array<{ + productId: number + quantity: number + slotId: number | null + product: typeof productInfo.$inferSelect + }> +} + +export interface PlacedOrder { + id: number + userId: number + addressId: number + slotId: number | null + totalAmount: string + deliveryCharge: string + isCod: boolean + isOnlinePayment: boolean + paymentInfoId: number | null + readableId: number + userNotes: string | null + orderGroupId: string + orderGroupProportion: string + isFlashDelivery: boolean + createdAt: Date +} + +export interface OrderWithRelations { + id: number + userId: number + addressId: number + slotId: number | null + totalAmount: string + deliveryCharge: string + isCod: boolean + isOnlinePayment: boolean + isFlashDelivery: boolean + userNotes: string | null + createdAt: Date + orderItems: Array<{ + id: number + productId: number + quantity: string + price: string + discountedPrice: string | null + is_packaged: boolean + product: { + id: number + name: string + images: unknown + } + }> + slot: { + deliveryTime: Date + } | null + paymentInfo: { + id: number + status: string + } | null + orderStatus: Array<{ + id: number + isCancelled: boolean + isDelivered: boolean + paymentStatus: string + cancelReason: string | null + }> + refunds: Array<{ + refundStatus: string + refundAmount: string | null + }> +} + +export interface OrderDetailWithRelations { + id: number + userId: number + addressId: number + slotId: number | null + totalAmount: string + deliveryCharge: string + isCod: boolean + isOnlinePayment: boolean + isFlashDelivery: boolean + userNotes: string | null + createdAt: Date + orderItems: Array<{ + id: number + productId: number + quantity: string + price: string + discountedPrice: string | null + is_packaged: boolean + product: { + id: number + name: string + images: unknown + } + }> + slot: { + deliveryTime: Date + } | null + paymentInfo: { + id: number + status: string + } | null + orderStatus: Array<{ + id: number + isCancelled: boolean + isDelivered: boolean + paymentStatus: string + cancelReason: string | null + }> + refunds: Array<{ + refundStatus: string + refundAmount: string | null + }> +} + +export interface CouponValidationResult { + id: number + couponCode: string + isInvalidated: boolean + validTill: Date | null + maxLimitForUser: number | null + minOrder: string | null + discountPercent: string | null + flatDiscount: string | null + maxValue: string | null + usages: Array<{ + id: number + userId: number + }> +} + +export interface CouponUsageWithCoupon { + id: number + couponId: number + orderId: number | null + coupon: { + id: number + couponCode: string + discountPercent: string | null + flatDiscount: string | null + maxValue: string | null + } +} + +export async function validateAndGetCoupon( + couponId: number | undefined, + userId: number, + totalAmount: number +): Promise { + if (!couponId) return null + + const coupon = await db.query.coupons.findFirst({ + where: eq(coupons.id, couponId), + with: { + usages: { where: eq(couponUsage.userId, userId) }, + }, + }) + + if (!coupon) throw new Error('Invalid coupon') + if (coupon.isInvalidated) throw new Error('Coupon is no longer valid') + if (coupon.validTill && new Date(coupon.validTill) < new Date()) + throw new Error('Coupon has expired') + if ( + coupon.maxLimitForUser && + coupon.usages.length >= coupon.maxLimitForUser + ) + throw new Error('Coupon usage limit exceeded') + if ( + coupon.minOrder && + parseFloat(coupon.minOrder.toString()) > totalAmount + ) + throw new Error('Order amount does not meet coupon minimum requirement') + + return coupon as CouponValidationResult +} + +export function applyDiscountToOrder( + orderTotal: number, + appliedCoupon: CouponValidationResult | null, + proportion: number +): { finalOrderTotal: number; orderGroupProportion: number } { + let finalOrderTotal = orderTotal + + if (appliedCoupon) { + if (appliedCoupon.discountPercent) { + const discount = Math.min( + (orderTotal * + parseFloat(appliedCoupon.discountPercent.toString())) / + 100, + appliedCoupon.maxValue + ? parseFloat(appliedCoupon.maxValue.toString()) * proportion + : Infinity + ) + finalOrderTotal -= discount + } else if (appliedCoupon.flatDiscount) { + const discount = Math.min( + parseFloat(appliedCoupon.flatDiscount.toString()) * proportion, + appliedCoupon.maxValue + ? parseFloat(appliedCoupon.maxValue.toString()) * proportion + : finalOrderTotal + ) + finalOrderTotal -= discount + } + } + + return { finalOrderTotal, orderGroupProportion: proportion } +} + +export async function getAddressByIdAndUser( + addressId: number, + userId: number +) { + return db.query.addresses.findFirst({ + where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)), + }) +} + +export async function getProductById(productId: number) { + return db.query.productInfo.findFirst({ + where: eq(productInfo.id, productId), + }) +} + +export async function checkUserSuspended(userId: number): Promise { + const userDetail = await db.query.userDetails.findFirst({ + where: eq(userDetails.userId, userId), + }) + return userDetail?.isSuspended ?? false +} + +export async function getSlotCapacityStatus(slotId: number): Promise { + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, slotId), + columns: { + isCapacityFull: true, + }, + }) + return slot?.isCapacityFull ?? false +} + +export async function placeOrderTransaction(params: { + userId: number + ordersData: Array<{ + order: Omit + orderItems: Omit[] + orderStatus: Omit + }> + paymentMethod: 'online' | 'cod' + totalWithDelivery: number +}): Promise { + const { userId, ordersData, paymentMethod } = params + + return db.transaction(async (tx) => { + let sharedPaymentInfoId: number | null = null + if (paymentMethod === 'online') { + const [paymentInfo] = await tx + .insert(paymentInfoTable) + .values({ + status: 'pending', + gateway: 'razorpay', + merchantOrderId: `multi_order_${Date.now()}`, + }) + .returning() + sharedPaymentInfoId = paymentInfo.id + } + + const ordersToInsert: Omit[] = + ordersData.map((od) => ({ + ...od.order, + paymentInfoId: sharedPaymentInfoId, + })) + + const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning() + + const allOrderItems: Omit[] = [] + const allOrderStatuses: Omit[] = [] + + insertedOrders.forEach((order, index) => { + const od = ordersData[index] + od.orderItems.forEach((item) => { + allOrderItems.push({ ...item, orderId: order.id }) + }) + allOrderStatuses.push({ + ...od.orderStatus, + orderId: order.id, + }) + }) + + await tx.insert(orderItems).values(allOrderItems) + await tx.insert(orderStatus).values(allOrderStatuses) + + return insertedOrders as PlacedOrder[] + }) +} + +export async function deleteCartItemsForOrder( + userId: number, + productIds: number[] +): Promise { + await db.delete(cartItems).where( + and( + eq(cartItems.userId, userId), + inArray(cartItems.productId, productIds) + ) + ) +} + +export async function recordCouponUsage( + userId: number, + couponId: number, + orderId: number +): Promise { + await db.insert(couponUsage).values({ + userId, + couponId, + orderId, + orderItemId: null, + usedAt: new Date(), + }) +} + +export async function getOrdersWithRelations( + userId: number, + offset: number, + pageSize: number +): Promise { + return db.query.orders.findMany({ + where: eq(orders.userId, userId), + with: { + orderItems: { + with: { + product: { + columns: { + id: true, + name: true, + images: true, + }, + }, + }, + }, + slot: { + columns: { + deliveryTime: true, + }, + }, + paymentInfo: { + columns: { + id: true, + status: true, + }, + }, + orderStatus: { + columns: { + id: true, + isCancelled: true, + isDelivered: true, + paymentStatus: true, + cancelReason: true, + }, + }, + refunds: { + columns: { + refundStatus: true, + refundAmount: true, + }, + }, + }, + orderBy: (ordersTable: typeof orders) => [desc(ordersTable.createdAt)], + limit: pageSize, + offset: offset, + }) as Promise +} + +export async function getOrderCount(userId: number): Promise { + const result = await db + .select({ count: sql`count(*)` }) + .from(orders) + .where(eq(orders.userId, userId)) + + return Number(result[0]?.count ?? 0) +} + +export async function getOrderByIdWithRelations( + orderId: number, + userId: number +): Promise { + const order = await db.query.orders.findFirst({ + where: and(eq(orders.id, orderId), eq(orders.userId, userId)), + with: { + orderItems: { + with: { + product: { + columns: { + id: true, + name: true, + images: true, + }, + }, + }, + }, + slot: { + columns: { + deliveryTime: true, + }, + }, + paymentInfo: { + columns: { + id: true, + status: true, + }, + }, + orderStatus: { + columns: { + id: true, + isCancelled: true, + isDelivered: true, + paymentStatus: true, + cancelReason: true, + }, + with: { + refundCoupon: { + columns: { + id: true, + couponCode: true, + }, + }, + }, + }, + refunds: { + columns: { + refundStatus: true, + refundAmount: true, + }, + }, + }, + }) + + return order as OrderDetailWithRelations | null +} + +export async function getCouponUsageForOrder( + orderId: number +): Promise { + return db.query.couponUsage.findMany({ + where: eq(couponUsage.orderId, orderId), + with: { + coupon: { + columns: { + id: true, + couponCode: true, + discountPercent: true, + flatDiscount: true, + maxValue: true, + }, + }, + }, + }) as Promise +} + +export async function getOrderBasic(orderId: number) { + return db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + orderStatus: { + columns: { + id: true, + isCancelled: true, + isDelivered: true, + }, + }, + }, + }) +} + +export async function cancelOrderTransaction( + orderId: number, + statusId: number, + reason: string, + isCod: boolean +): Promise { + await db.transaction(async (tx) => { + await tx + .update(orderStatus) + .set({ + isCancelled: true, + cancelReason: reason, + cancellationUserNotes: reason, + cancellationReviewed: false, + }) + .where(eq(orderStatus.id, statusId)) + + const refundStatus = isCod ? 'na' : 'pending' + + await tx.insert(refunds).values({ + orderId, + refundStatus, + }) + }) +} + +export async function updateOrderNotes( + orderId: number, + userNotes: string +): Promise { + await db + .update(orders) + .set({ + userNotes: userNotes || null, + }) + .where(eq(orders.id, orderId)) +} + +export async function getRecentlyDeliveredOrderIds( + userId: number, + limit: number, + since: Date +): Promise { + const recentOrders = await db + .select({ id: orders.id }) + .from(orders) + .innerJoin(orderStatus, eq(orders.id, orderStatus.orderId)) + .where( + and( + eq(orders.userId, userId), + eq(orderStatus.isDelivered, true), + gte(orders.createdAt, since) + ) + ) + .orderBy(desc(orders.createdAt)) + .limit(limit) + + return recentOrders.map((order) => order.id) +} + +export async function getProductIdsFromOrders( + orderIds: number[] +): Promise { + const orderItemsResult = await db + .select({ productId: orderItems.productId }) + .from(orderItems) + .where(inArray(orderItems.orderId, orderIds)) + + return [...new Set(orderItemsResult.map((item) => item.productId))] +} + +export interface RecentProductData { + id: number + name: string + shortDescription: string | null + price: string + images: unknown + isOutOfStock: boolean + unitShortNotation: string + incrementStep: number +} + +export async function getProductsForRecentOrders( + productIds: number[], + limit: number +): Promise { + const results = await db + .select({ + id: productInfo.id, + name: productInfo.name, + shortDescription: productInfo.shortDescription, + price: productInfo.price, + images: productInfo.images, + isOutOfStock: productInfo.isOutOfStock, + unitShortNotation: units.shortNotation, + incrementStep: productInfo.incrementStep, + }) + .from(productInfo) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where( + and( + inArray(productInfo.id, productIds), + eq(productInfo.isSuspended, false) + ) + ) + .orderBy(desc(productInfo.createdAt)) + .limit(limit) + + return results.map((product) => ({ + ...product, + price: String(product.price ?? '0'), + })) +} + +// ============================================================================ +// Post-Order Handler Helpers (for Telegram notifications) +// ============================================================================ + +export interface OrderWithFullData { + id: number + totalAmount: string + isFlashDelivery: boolean + address: { + name: string | null + addressLine1: string | null + addressLine2: string | null + city: string | null + state: string | null + pincode: string | null + phone: string | null + } | null + orderItems: Array<{ + quantity: string + product: { + name: string + } | null + }> + slot: { + deliveryTime: Date + } | null +} + +export async function getOrdersByIdsWithFullData( + orderIds: number[] +): Promise { + return db.query.orders.findMany({ + where: inArray(orders.id, orderIds), + with: { + address: { + columns: { + name: true, + addressLine1: true, + addressLine2: true, + city: true, + state: true, + pincode: true, + phone: true, + }, + }, + orderItems: { + with: { + product: { + columns: { + name: true, + }, + }, + }, + }, + slot: { + columns: { + deliveryTime: true, + }, + }, + }, + }) as Promise +} + +export interface OrderWithCancellationData extends OrderWithFullData { + refunds: Array<{ + refundStatus: string + }> +} + +export async function getOrderByIdWithFullData( + orderId: number +): Promise { + return db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + address: { + columns: { + name: true, + addressLine1: true, + addressLine2: true, + city: true, + state: true, + pincode: true, + phone: true, + }, + }, + orderItems: { + with: { + product: { + columns: { + name: true, + }, + }, + }, + }, + slot: { + columns: { + deliveryTime: true, + }, + }, + refunds: { + columns: { + refundStatus: true, + }, + }, + }, + }) as Promise +} diff --git a/packages/db_helper_sqlite/src/user-apis/payments.ts b/packages/db_helper_sqlite/src/user-apis/payments.ts new file mode 100644 index 0000000..5657bba --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/payments.ts @@ -0,0 +1,51 @@ +import { db } from '../db/db_index' +import { orders, payments, orderStatus } from '../db/schema' +import { eq } from 'drizzle-orm' + +export async function getOrderById(orderId: number) { + return db.query.orders.findFirst({ + where: eq(orders.id, orderId), + }) +} + +export async function getPaymentByOrderId(orderId: number) { + return db.query.payments.findFirst({ + where: eq(payments.orderId, orderId), + }) +} + +export async function getPaymentByMerchantOrderId(merchantOrderId: string) { + return db.query.payments.findFirst({ + where: eq(payments.merchantOrderId, merchantOrderId), + }) +} + +export async function updatePaymentSuccess(merchantOrderId: string, payload: unknown) { + const [updatedPayment] = await db + .update(payments) + .set({ + status: 'success', + payload, + }) + .where(eq(payments.merchantOrderId, merchantOrderId)) + .returning({ + id: payments.id, + orderId: payments.orderId, + }) + + return updatedPayment || null +} + +export async function updateOrderPaymentStatus(orderId: number, status: 'pending' | 'success' | 'cod' | 'failed') { + await db + .update(orderStatus) + .set({ paymentStatus: status }) + .where(eq(orderStatus.orderId, orderId)) +} + +export async function markPaymentFailed(paymentId: number) { + await db + .update(payments) + .set({ status: 'failed' }) + .where(eq(payments.id, paymentId)) +} diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts new file mode 100644 index 0000000..4e3f32f --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -0,0 +1,271 @@ +import { db } from '../db/db_index' +import { deliverySlotInfo, productInfo, productReviews, productSlots, productTags, specialDeals, storeInfo, units, users } from '../db/schema' +import { and, desc, eq, gt, inArray, sql } from 'drizzle-orm' +import type { UserProductDetailData, UserProductReview } from '@packages/shared' + +const getStringArray = (value: unknown): string[] | null => { + if (!Array.isArray(value)) return null + return value.map((item) => String(item)) +} + +export async function getProductDetailById(productId: number): Promise { + const productData = await db + .select({ + id: productInfo.id, + name: productInfo.name, + shortDescription: productInfo.shortDescription, + longDescription: productInfo.longDescription, + price: productInfo.price, + marketPrice: productInfo.marketPrice, + images: productInfo.images, + isOutOfStock: productInfo.isOutOfStock, + storeId: productInfo.storeId, + unitShortNotation: units.shortNotation, + incrementStep: productInfo.incrementStep, + productQuantity: productInfo.productQuantity, + isFlashAvailable: productInfo.isFlashAvailable, + flashPrice: productInfo.flashPrice, + }) + .from(productInfo) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(eq(productInfo.id, productId)) + .limit(1) + + if (productData.length === 0) { + return null + } + + const product = productData[0] + + const storeData = product.storeId ? await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, product.storeId), + columns: { id: true, name: true, description: true }, + }) : null + + const deliverySlotsData = await db + .select({ + id: deliverySlotInfo.id, + deliveryTime: deliverySlotInfo.deliveryTime, + freezeTime: deliverySlotInfo.freezeTime, + }) + .from(productSlots) + .innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id)) + .where( + and( + eq(productSlots.productId, productId), + eq(deliverySlotInfo.isActive, true), + eq(deliverySlotInfo.isCapacityFull, false), + gt(deliverySlotInfo.deliveryTime, sql`CURRENT_TIMESTAMP`), + gt(deliverySlotInfo.freezeTime, sql`CURRENT_TIMESTAMP`) + ) + ) + .orderBy(deliverySlotInfo.deliveryTime) + + const specialDealsData = await db + .select({ + quantity: specialDeals.quantity, + price: specialDeals.price, + validTill: specialDeals.validTill, + }) + .from(specialDeals) + .where( + and( + eq(specialDeals.productId, productId), + gt(specialDeals.validTill, sql`CURRENT_TIMESTAMP`) + ) + ) + .orderBy(specialDeals.quantity) + + return { + id: product.id, + name: product.name, + shortDescription: product.shortDescription ?? null, + longDescription: product.longDescription ?? null, + price: String(product.price ?? '0'), + marketPrice: product.marketPrice ? String(product.marketPrice) : null, + unitNotation: product.unitShortNotation, + images: getStringArray(product.images), + isOutOfStock: product.isOutOfStock, + store: storeData ? { + id: storeData.id, + name: storeData.name, + description: storeData.description ?? null, + } : null, + incrementStep: product.incrementStep, + productQuantity: product.productQuantity, + isFlashAvailable: product.isFlashAvailable, + flashPrice: product.flashPrice?.toString() || null, + deliverySlots: deliverySlotsData, + specialDeals: specialDealsData.map((deal) => ({ + quantity: String(deal.quantity ?? '0'), + price: String(deal.price ?? '0'), + validTill: deal.validTill, + })), + } +} + +export async function getProductReviews(productId: number, limit: number, offset: number) { + const reviews = await db + .select({ + id: productReviews.id, + reviewBody: productReviews.reviewBody, + ratings: productReviews.ratings, + imageUrls: productReviews.imageUrls, + reviewTime: productReviews.reviewTime, + userName: users.name, + }) + .from(productReviews) + .innerJoin(users, eq(productReviews.userId, users.id)) + .where(eq(productReviews.productId, productId)) + .orderBy(desc(productReviews.reviewTime)) + .limit(limit) + .offset(offset) + + const totalCountResult = await db + .select({ count: sql`count(*)` }) + .from(productReviews) + .where(eq(productReviews.productId, productId)) + + const totalCount = Number(totalCountResult[0].count) + + const mappedReviews: UserProductReview[] = reviews.map((review) => ({ + id: review.id, + reviewBody: review.reviewBody, + ratings: review.ratings, + imageUrls: getStringArray(review.imageUrls), + reviewTime: review.reviewTime, + userName: review.userName ?? null, + })) + + return { + reviews: mappedReviews, + totalCount, + } +} + +export async function getProductById(productId: number) { + return db.query.productInfo.findFirst({ + where: eq(productInfo.id, productId), + }) +} + +export async function createProductReview( + userId: number, + productId: number, + reviewBody: string, + ratings: number, + imageUrls: string[] +): Promise { + const [newReview] = await db.insert(productReviews).values({ + userId, + productId, + reviewBody, + ratings, + imageUrls, + }).returning({ + id: productReviews.id, + reviewBody: productReviews.reviewBody, + ratings: productReviews.ratings, + imageUrls: productReviews.imageUrls, + reviewTime: productReviews.reviewTime, + }) + + return { + id: newReview.id, + reviewBody: newReview.reviewBody, + ratings: newReview.ratings, + imageUrls: getStringArray(newReview.imageUrls), + reviewTime: newReview.reviewTime, + userName: null, + } +} + +export interface ProductSummaryData { + id: number + name: string + shortDescription: string | null + price: string + marketPrice: string | null + images: unknown + isOutOfStock: boolean + unitShortNotation: string + productQuantity: number +} + +export async function getAllProductsWithUnits(tagId?: number): Promise { + let productIds: number[] | null = null + + // If tagId is provided, get products that have this tag + if (tagId) { + const taggedProducts = await db + .select({ productId: productTags.productId }) + .from(productTags) + .where(eq(productTags.tagId, tagId)) + + productIds = taggedProducts.map(tp => tp.productId) + } + + let whereCondition = undefined + + // Filter by product IDs if tag filtering is applied + if (productIds && productIds.length > 0) { + whereCondition = inArray(productInfo.id, productIds) + } + + const results = await db + .select({ + id: productInfo.id, + name: productInfo.name, + shortDescription: productInfo.shortDescription, + price: productInfo.price, + marketPrice: productInfo.marketPrice, + images: productInfo.images, + isOutOfStock: productInfo.isOutOfStock, + unitShortNotation: units.shortNotation, + productQuantity: productInfo.productQuantity, + }) + .from(productInfo) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(whereCondition) + + return results.map((product) => ({ + ...product, + price: String(product.price ?? '0'), + marketPrice: product.marketPrice ? String(product.marketPrice) : null, + })) +} + +/** + * Get all suspended product IDs + */ +export async function getSuspendedProductIds(): Promise { + const suspendedProducts = await db + .select({ id: productInfo.id }) + .from(productInfo) + .where(eq(productInfo.isSuspended, true)) + + return suspendedProducts.map(sp => sp.id) +} + +/** + * Get next delivery date for a product (with capacity check) + * This version filters by both isActive AND isCapacityFull + */ +export async function getNextDeliveryDateWithCapacity(productId: number): Promise { + const result = await db + .select({ deliveryTime: deliverySlotInfo.deliveryTime }) + .from(productSlots) + .innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id)) + .where( + and( + eq(productSlots.productId, productId), + eq(deliverySlotInfo.isActive, true), + eq(deliverySlotInfo.isCapacityFull, false), + gt(deliverySlotInfo.deliveryTime, sql`CURRENT_TIMESTAMP`) + ) + ) + .orderBy(deliverySlotInfo.deliveryTime) + .limit(1) + + return result[0]?.deliveryTime || null +} diff --git a/packages/db_helper_sqlite/src/user-apis/slots.ts b/packages/db_helper_sqlite/src/user-apis/slots.ts new file mode 100644 index 0000000..eeef416 --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/slots.ts @@ -0,0 +1,46 @@ +import { db } from '../db/db_index' +import { deliverySlotInfo, productInfo } from '../db/schema' +import { asc, eq } from 'drizzle-orm' +import type { InferSelectModel } from 'drizzle-orm' +import type { UserDeliverySlot, UserSlotAvailability } from '@packages/shared' + +type SlotRow = InferSelectModel + +const mapSlot = (slot: SlotRow): UserDeliverySlot => ({ + id: slot.id, + deliveryTime: slot.deliveryTime, + freezeTime: slot.freezeTime, + isActive: slot.isActive, + isFlash: slot.isFlash, + isCapacityFull: slot.isCapacityFull, + deliverySequence: slot.deliverySequence, + groupIds: slot.groupIds, +}) + +export async function getActiveSlotsList(): Promise { + const slots = await db.query.deliverySlotInfo.findMany({ + where: eq(deliverySlotInfo.isActive, true), + orderBy: asc(deliverySlotInfo.deliveryTime), + }) + + return slots.map(mapSlot) +} + +export async function getProductAvailability(): Promise { + const products = await db + .select({ + id: productInfo.id, + name: productInfo.name, + isOutOfStock: productInfo.isOutOfStock, + isFlashAvailable: productInfo.isFlashAvailable, + }) + .from(productInfo) + .where(eq(productInfo.isSuspended, false)) + + return products.map((product) => ({ + id: product.id, + name: product.name, + isOutOfStock: product.isOutOfStock, + isFlashAvailable: product.isFlashAvailable, + })) +} diff --git a/packages/db_helper_sqlite/src/user-apis/stores.ts b/packages/db_helper_sqlite/src/user-apis/stores.ts new file mode 100644 index 0000000..076b4ce --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/stores.ts @@ -0,0 +1,141 @@ +import { db } from '../db/db_index' +import { productInfo, storeInfo, units } from '../db/schema' +import { and, eq, sql } from 'drizzle-orm' +import type { InferSelectModel } from 'drizzle-orm' +import type { UserStoreDetailData, UserStoreProductData, UserStoreSummaryData, StoreSummary } from '@packages/shared' + +type StoreRow = InferSelectModel +type StoreProductRow = { + id: number + name: string + shortDescription: string | null + price: string | null + marketPrice: string | null + images: unknown + isOutOfStock: boolean + incrementStep: number + unitShortNotation: string + productQuantity: number +} + +const getStringArray = (value: unknown): string[] | null => { + if (!Array.isArray(value)) return null + return value.map((item) => String(item)) +} + +export async function getStoreSummaries(): Promise { + const storesData = await db + .select({ + id: storeInfo.id, + name: storeInfo.name, + description: storeInfo.description, + imageUrl: storeInfo.imageUrl, + productCount: sql`count(${productInfo.id})`.as('productCount'), + }) + .from(storeInfo) + .leftJoin( + productInfo, + and(eq(productInfo.storeId, storeInfo.id), eq(productInfo.isSuspended, false)) + ) + .groupBy(storeInfo.id) + + const storesWithDetails = await Promise.all( + storesData.map(async (store) => { + const sampleProducts = await db + .select({ + id: productInfo.id, + name: productInfo.name, + images: productInfo.images, + }) + .from(productInfo) + .where(and(eq(productInfo.storeId, store.id), eq(productInfo.isSuspended, false))) + .limit(3) + + return { + id: store.id, + name: store.name, + description: store.description ?? null, + imageUrl: store.imageUrl ?? null, + productCount: store.productCount || 0, + sampleProducts: sampleProducts.map((product) => ({ + id: product.id, + name: product.name, + images: getStringArray(product.images), + })), + } + }) + ) + + return storesWithDetails +} + +export async function getStoreDetail(storeId: number): Promise { + const storeData = await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, storeId), + columns: { + id: true, + name: true, + description: true, + imageUrl: true, + }, + }) + + if (!storeData) { + return null + } + + const productsData = await db + .select({ + id: productInfo.id, + name: productInfo.name, + shortDescription: productInfo.shortDescription, + price: productInfo.price, + marketPrice: productInfo.marketPrice, + images: productInfo.images, + isOutOfStock: productInfo.isOutOfStock, + incrementStep: productInfo.incrementStep, + unitShortNotation: units.shortNotation, + productQuantity: productInfo.productQuantity, + }) + .from(productInfo) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(and(eq(productInfo.storeId, storeId), eq(productInfo.isSuspended, false))) + + const products = productsData.map((product: StoreProductRow): UserStoreProductData => ({ + id: product.id, + name: product.name, + shortDescription: product.shortDescription ?? null, + price: String(product.price ?? '0'), + marketPrice: product.marketPrice ? String(product.marketPrice) : null, + incrementStep: product.incrementStep, + unit: product.unitShortNotation, + unitNotation: product.unitShortNotation, + images: getStringArray(product.images), + isOutOfStock: product.isOutOfStock, + productQuantity: product.productQuantity, + })) + + return { + store: { + id: storeData.id, + name: storeData.name, + description: storeData.description ?? null, + imageUrl: storeData.imageUrl ?? null, + }, + products, + } +} + +/** + * Get simple store summary (id, name, description only) + * Used for common API endpoints + */ +export async function getStoresSummary(): Promise { + return db.query.storeInfo.findMany({ + columns: { + id: true, + name: true, + description: true, + }, + }) +} diff --git a/packages/db_helper_sqlite/src/user-apis/tags.ts b/packages/db_helper_sqlite/src/user-apis/tags.ts new file mode 100644 index 0000000..2b32f81 --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/tags.ts @@ -0,0 +1,28 @@ +import { db } from '../db/db_index' +import { productTags } from '../db/schema' +import { eq } from 'drizzle-orm' + +export async function getAllTags(): Promise { + return db.query.productTags.findMany({ + with: { + // products: { + // with: { + // product: true, + // }, + // }, + }, + }) +} + +export async function getTagById(id: number): Promise { + return db.query.productTags.findFirst({ + where: eq(productTags.id, id), + with: { + // products: { + // with: { + // product: true, + // }, + // }, + }, + }) +} diff --git a/packages/db_helper_sqlite/src/user-apis/user.ts b/packages/db_helper_sqlite/src/user-apis/user.ts new file mode 100644 index 0000000..9b35803 --- /dev/null +++ b/packages/db_helper_sqlite/src/user-apis/user.ts @@ -0,0 +1,75 @@ +import { db } from '../db/db_index' +import { notifCreds, unloggedUserTokens, userCreds, userDetails, users } from '../db/schema' +import { and, eq } from 'drizzle-orm' + +export async function getUserById(userId: number) { + const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1) + return user || null +} + +export async function getUserDetailByUserId(userId: number) { + const [detail] = await db.select().from(userDetails).where(eq(userDetails.userId, userId)).limit(1) + return detail || null +} + +export async function getUserWithCreds(userId: number) { + const result = await db + .select() + .from(users) + .leftJoin(userCreds, eq(users.id, userCreds.userId)) + .where(eq(users.id, userId)) + .limit(1) + + if (result.length === 0) return null + return { + user: result[0].users, + creds: result[0].user_creds, + } +} + +export async function getNotifCred(userId: number, token: string) { + return db.query.notifCreds.findFirst({ + where: and(eq(notifCreds.userId, userId), eq(notifCreds.token, token)), + }) +} + +export async function upsertNotifCred(userId: number, token: string): Promise { + const existing = await getNotifCred(userId, token) + if (existing) { + await db.update(notifCreds) + .set({ lastVerified: new Date() }) + .where(eq(notifCreds.id, existing.id)) + return + } + + await db.insert(notifCreds).values({ + userId, + token, + lastVerified: new Date(), + }) +} + +export async function deleteUnloggedToken(token: string): Promise { + await db.delete(unloggedUserTokens).where(eq(unloggedUserTokens.token, token)) +} + +export async function getUnloggedToken(token: string) { + return db.query.unloggedUserTokens.findFirst({ + where: eq(unloggedUserTokens.token, token), + }) +} + +export async function upsertUnloggedToken(token: string): Promise { + const existing = await getUnloggedToken(token) + if (existing) { + await db.update(unloggedUserTokens) + .set({ lastVerified: new Date() }) + .where(eq(unloggedUserTokens.id, existing.id)) + return + } + + await db.insert(unloggedUserTokens).values({ + token, + lastVerified: new Date(), + }) +} diff --git a/packages/db_helper_sqlite/tsconfig.json b/packages/db_helper_sqlite/tsconfig.json new file mode 100644 index 0000000..c49f51a --- /dev/null +++ b/packages/db_helper_sqlite/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "paths": { + "@/*": ["./*"] + }, + "resolveJsonModule": true, + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index 3e8cecf..909019b 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -363,6 +363,7 @@ export interface AdminProduct { price: string; marketPrice: string | null; images: string[] | null; + imageKeys: string[] | null; isOutOfStock: boolean; isSuspended: boolean; isFlashAvailable: boolean; diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 45428fc..0e92038 100755 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -25,6 +25,7 @@ import ImageCarousel from "./src/components/ImageCarousel"; import ImageGallery from "./src/components/ImageGallery"; import ImageGalleryWithDelete from "./src/components/ImageGalleryWithDelete"; import ImageUploader from "./src/components/ImageUploader"; +import ImageUploaderNeo, { ImageUploaderNeoItem, ImageUploaderNeoPayload } from "./src/components/ImageUploaderNeo"; import ProfileImage from "./src/components/profile-image"; import Checkbox from "./src/components/checkbox"; import AppContainer from "./src/components/app-container"; @@ -100,6 +101,9 @@ export { ImageGallery, ImageGalleryWithDelete, ImageUploader, + ImageUploaderNeo, + ImageUploaderNeoItem, + ImageUploaderNeoPayload, ProfileImage, Checkbox, AppContainer, diff --git a/packages/ui/src/components/ImageUploaderNeo.tsx b/packages/ui/src/components/ImageUploaderNeo.tsx new file mode 100644 index 0000000..a2a7d12 --- /dev/null +++ b/packages/ui/src/components/ImageUploaderNeo.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { View } from 'react-native' +import { Image } from 'expo-image' +import Ionicons from '@expo/vector-icons/Ionicons' +import { MaterialIcons } from '@expo/vector-icons' +import tw from '../lib/tailwind' +import MyText from './text' +import MyTouchableOpacity from './touchable-opacity' +import usePickImage from './use-pick-image' + +export interface ImageUploaderNeoItem { + imgUrl: string + mimeType: string | null +} + +export interface ImageUploaderNeoPayload { + url: string + mimeType: string | null +} + +interface ImageUploaderNeoProps { + images: ImageUploaderNeoItem[] + onImageAdd: (images: ImageUploaderNeoPayload[]) => void + onImageRemove: (image: ImageUploaderNeoPayload) => void + allowMultiple?: boolean +} + +const ImageUploaderNeo: React.FC = ({ + images, + onImageAdd, + onImageRemove, + allowMultiple = true, +}) => { + const totalImageCount = images.length + + const handleAddImages = (files: any) => { + if (!files) return + + const assets = Array.isArray(files) ? files : [files] + const payload = assets.map((asset) => ({ + url: asset.uri, + mimeType: asset.mimeType ?? null, + })) + + onImageAdd(payload) + } + + const handlePickImage = usePickImage({ + setFile: handleAddImages, + multiple: allowMultiple, + }) + + // console.log({images}) + return ( + + + {images.map((image, index) => ( + + + + onImageRemove({ + url: image.imgUrl, + mimeType: image.mimeType ?? null, + }) + } + style={tw`absolute top-0 right-0 bg-red-500 rounded-full p-1`} + > + + + + ))} + = 1} + onPress={handlePickImage} + style={tw`w-1/3 px-1 mb-2`} + > + + {!allowMultiple && totalImageCount >= 1 ? ( + + + Only one image allowed + + + ) : ( + + )} + + + + + ) +} + +export default ImageUploaderNeo